Compare commits

...
Author SHA1 Message Date
maxjivi05 8d0b1a42b9 perf: network tables Android refuses are served from the synthesized copy without retrying the open
Wine's nsiproxy reads /proc/net/dev and /sys/class/net/*/carrier for every interface on each
adapter query, and the route tables for gateways. Android refuses those opens, and each refused
open costs 60-150 us under proot before the synthesized table is returned. A refusal is now
remembered for a second per table, so repeat reads go straight to the synthesized copy.

Mortal Kombat XL asks for its adapters from the game thread about 30 times a second:
GetAdaptersAddresses 2.92 -> 2.53 ms, GetIfTable 1.79 -> 1.35 ms, winedevice 9.0 -> 7.6% CPU.
2026-10-21 15:42:43 -04:00
maxjivi05 dafe5f13b5 perf: performance overlay updates ride the next game frame instead of an extra composite 2026-10-21 11:00:55 -04:00
maxjivi05 4e25611f70 perf: games skip Steam's install-script evaluator once it has finished
The client runs a game's install scripts through Proton at every
launch, a whole Wine start before the game's own. When the evaluator
already exited cleanly for the same scripts in this prefix and every
process they name is still marked as run, it is not started again.
Moonlighter launches went from 10.5 to 7.7 s.
2026-10-21 11:00:55 -04:00
maxjivi05 01f2463603 perf: SysV message queues stay mapped instead of reopening per call
gamescope polls mangoapp's queue every frame and each msgsnd/msgrcv
listed the object folder, opened and mapped the queue, then unmapped
it again. Queues are now mapped once per process; removal is marked
in the shared header so cached mappings drop it and blocked waiters
get EIDRM as on Linux.
2026-10-21 11:00:55 -04:00
MaxsTechReview 0e7818049a fix: moving a mouse no longer recomposes the whole session overlay (#1253) 2026-10-21 06:36:30 -04:00
MaxsTechReview 397d196102 fix: clearing a game's compatibility tool in Steam keeps the Linux build (#1250) 2026-10-21 06:07:16 -04:00
MaxsTechReview 1a37a6a28e fix: Google sign-in from a store shows the real device instead of a Galaxy S23 Ultra (#1252) 2026-10-21 05:42:41 -04:00
MaxsTechReview bb64592f5f fix: browsers added as non-Steam games show their window and load pages in Gaming Mode (#1243) 2026-10-21 02:02:18 -04:00
MaxsTechReview 25f392383e fix: a failing image or script on a store sign-in page no longer replaces the page with an SSL error (#1238) 2026-10-21 01:04:03 -04:00
Resul Övür afb9e3e823 fix: a failed Steam client download ends the session with a message instead of silently (#997) 2026-10-21 00:56:05 -04:00
MaxsTechReview 9b0cafa3e9 fix: GOG sign-in starts without the previous account's web session (#1235) 2026-10-21 00:44:40 -04:00
MaxsTechReview 14f23cbb52 fix: game files keep the full game name instead of cutting it at 40 characters (#1230) 2026-10-20 23:24:29 -04:00
Resul Övür b4c56096a4 fix: file manager copy/move keeps symlinks (#1027) 2026-10-20 23:24:25 -04:00
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
Kurt Himebauch 8baa16073f fix: install Decky Loader from upstream repository (#1090) 2026-10-18 19:31:14 +00:00
Resul Övür 8178bf5099 fix: added games are handed to Steam right away and retried only if they never take focus (#994) 2026-10-18 14:59:39 -04:00
Kurt Himebauch a97a7d9058 feat: auto driver picks come from a Drivers-CI manifest with per-GPU fallbacks (#977) 2026-10-18 14:23:41 -04:00
Kurt Himebauch 3acf5db3f6 fix: keep second-screen controls in their own activity (#1041) 2026-10-18 14:23:04 -04:00
Kurt Himebauch cffcbaae4a fix: skip DirectAudio setup for Proton queries (#1044) 2026-10-18 14:21:46 -04:00
MaxsTechReview 3c5e95afa8 chore: bump version to 0.5.0 (14) (#1081) 2026-10-18 11:25:10 -04:00
Kurt Himebauchandmaxjivi05 351d66ed0b docs: draft DroidDeck 0.5.0 release notes (#986)
* docs: draft DroidDeck 0.5.0 release notes

* docs: expand DroidDeck 0.5.0 release notes

* docs: complete 0.5.0 contributor credits

* docs: update the 0.5.0 release notes for everything merged so far, with screenshots and GIFs

Adds the x86-64 Steam client, Linux games under FEX, app-wide glasses tracking, store-game artwork and Adjust logo, and the merged Epic launcher and XR glasses entries. Media is in docs/releases/media/0.5.0.

---------

Co-authored-by: maxjivi05 <[email protected]>
2026-10-18 11:22:00 -04:00
MaxsTechReview 7d149acb00 fix: only start SteamVR for VR-only games from Steam's library (#1076)
Games that merely support VR (No Man's Sky) were held at Play while
DroidDeck downloaded and started SteamVR, so they always opened in VR.
They now launch normally; VR-only games still wait for SteamVR.
2026-10-18 09:26:01 -04:00
MaxsTechReview 69c88aa344 feat: fetch Proton, FEX and the runtimes automatically, and make Linux games run under FEX (#1045)
* fix: x86 Linux games that also ship for Windows run under Proton again

Since #749 the live compatibility helper treated every installed game with
both Windows and x86 Linux builds as a user's native choice, because the
client reports an empty tool for unmapped games. Those games were left on
the Steam Linux Runtime, which cannot start them here (bwrap fails, or FEX
looks for a RootFS that is not shipped), so Valheim, Moonlighter, Half-Life 2,
TF2 and others exited within seconds.

- An unmapped game the helper never managed now gets the default Proton.
  Clearing a mapping the helper set is still kept as a native choice.
- Native choices are stored under "native_choices". The old "native" list
  also held games nobody picked, so it is dropped.

* feat: Linux builds run under FEX by default, with the runtimes fetched automatically

- Steam fetches Proton, FEX, Steam Linux Runtime 3.0 and 4.0 on the first start and keeps them updated instead of leaving them pending.
- Games that ship a Linux build go to FEX once it can run them; Windows-only games and installed Windows builds keep the Proton default.
- FEX runs games on a combined x86 runtime with the newer glibc and libstdc++ the GL and Vulkan thunks need.
- Vulkan display extensions the driver cannot handle are hidden in front of the thunk, without an audit module.
- Mono's incremental garbage collector is turned off under FEX, which stops Unity games crashing.
- x86 Steamworks finds the x86 steamclient.
- 32-bit games read the virtual controller.

* fix: controllers work in Linux Unity games such as Valheim

Joystick ioctls on an event node now fail with EINVAL like the kernel's evdev, so the SDL2 that Unity embeds reads the pad's real ID instead of ignoring it as 0000:0000.

* test: skip the switch script test without node, and the ring tests without gcc

* fix: games with a Linux build keep the Proton default

The Windows build often runs better, so titles that ship both builds stay on the default Proton again. Users can still pick FEX for one, and that choice is kept. Linux-only titles still go to FEX.

* fix: unmapped dual-platform games get Proton even when config.vdf cannot be read
2026-10-18 08:59:54 -04:00
+13 219548f4b6 Add app-wide 3DoF for VITURE, RayNeo and XREAL glasses and 6DoF for Luma Ultra (#1035)
* Add VITURE glasses support: hardware control, 3D, head tracking

Glasses get their own controller over the MCU (permission, 120 Hz default, brightness, volume, sunblock, 2D/3D) and the game moves onto them when plugged in. The IMU drives Cardboard head tracking and the SteamVR driver. 3D falls back to one eye's view on phones that can't send the 3840x1080 signal. Options open from a glasses button in game and on the Steam page.

* Drop the glasses settings like Steam's: Play-style button, Settings lip, tabs

* Hang the glasses Settings lip over the top of the button

* Space glasses MCU commands so the IMU rate sticks, fit fixed resolutions to the glasses

* Keep Steam alive with SteamVR registered, run SteamVR at the glasses' rate, keep the glasses lip on top

* Fit VR games on the glasses: full view, seated origin at the head, pad hands reach with the d-pad

* Let the pad hands reach a metre out so buttons with deep colliders press

* Show every VR frame in order at 120 fps: wait for SteamVR's GPU work, present only finished copies, skip the grace on idle shared counters

* Glasses: SteamVR starts and stops with them, closer view with a field of view setting, Recenter on the pad

* Glasses: SteamVR field of view defaults to 90°

* VR pad hands: sticks move and aim the hands, left stick click switches the sticks to the game, d-pad pulls a held hand

* Glasses: Play SteamVR beside the glasses button instead of starting SteamVR with them, pad hands drawn as Index controllers and off while other VR controllers are connected, glasses sheet tabs sized like Steam's

* Track glasses mode, refresh-aware glasses window, align Play SteamVR with Play Steam

* Fix 3D on the glasses: full-res SteamVR presenter, revert VR 3D at session end, coalesce vsync ticks, no session restart after end

* Address review: glasses output after installs and session end, VR revert retry, display fallback, presenter swapchain retry, agent hex checks, drawer bumpers, glasses translations, docs

* Harden VITURE stereo negotiation and output refresh tracking

* Localize stereo recovery feedback and record Astra reconnect tests

* Improve test failure diagnostics and refresh Astra firmware findings

* Handle VITURE docks separately and reject truncated USB frames

* Glasses: remember a 3D fallback per model, docks as display only, VR timing fixes

- A 3D attempt that ends at the 640x480 fallback is remembered for that glasses
  model, so Play SteamVR no longer blacks out the glasses after every restart;
  Retry clears it and "3D for VR games" stays reachable
- A VITURE Mobile Dock on its own no longer counts as glasses; through a dock
  Play SteamVR is hidden and the sheet shows the output and why there is no control
- HDR is probed on the glasses when the session starts there
- Glasses frames with a mismatched checksum are marked in the status frame log
- SteamVR layer and driver vblank counters stay monotonic across refresh changes;
  one-eye render scale applies from activation and on mono/3D switches
- Compositor binds announce the refresh clients were last told; the presenter
  retries a failed first swapchain
- Dead strings and helpers removed; docs updated

* Glasses: drop USB frames with a bad checksum

* Glasses: count frames dropped for a bad checksum

* Feat/viture xr (#907)

* Add RayNeo Air and GT support to the XR glasses pipeline

* Add RayNeo Air and GT support to the XR glasses pipeline

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Document confirmed XREAL 1S brightness control

* Document confirmed XREAL 1S brightness control

* Fix SteamVR glasses defaults and rendering controls

* Fix SteamVR glasses defaults and rendering controls

* chore: keep only the 3 newest session log folders (#852)

* chore: add agent comment rules (#748)

Co-authored-by: Kurt Himebauch <[email protected]>

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* fix: wireless ADB page names the child-process switch the way Android shows it (#848)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru (#850)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru

- mode_added_art_hint: now mentions Steam then SteamGridDB (all four locales).
- wincomp_needs_installer / wincomp_installers_note: es and ko still described
  the old "coming soon" behaviour; align with the current English text.
- summary_remove_game: was still English in all four locales.

Machine-assisted translation; native-speaker review is welcome.

* l10n(es): say registro, not grabación, for the missing installer record

---------

Co-authored-by: ovurrsl <[email protected]>

* fix: a failed cloud save recovery no longer ends the app (#846)

The cloud-recovery thread started at every app start had nothing to
catch an error, so one went to CrashHandler, which halts the process
with status 2. In unit tests the thread can outlive the test that
started it, fail on the torn-down preferences, and take the whole test
run down. The error is now logged and the thread ends.

Co-authored-by: Kirk <[email protected]>

* fix: an interrupted Linux runtime download resumes instead of starting again (#847)

* fix: an interrupted Linux runtime download resumes instead of starting again

installOnce() asked the downloader to resume but always deleted the archive in its finally block, so the next attempt began at zero. The archive is now named after the release version and checksum, a failed download keeps what it fetched, and archives of other releases (and the old fixed name) are removed. A checksum or extraction failure still deletes the file.

* test: Downloader resumes a cut-off download from the bytes it kept

Starts a socket server that promises the whole file, hangs up part way and then answers a Range request, and checks that the second downloadFile call resumes with the right offset and ends with identical bytes.

* fix: a resumed download discards a 206 that does not start where the kept bytes end

Downloader appended every 206 response to the partial file without
looking at Content-Range. A server or proxy that answers a Range request
with a different offset would leave a corrupt file. Check that the range
starts at the kept length; otherwise delete the partial file and start
again without a Range header.

Adds a test with a server that answers the Range request from offset 0.

---------

Co-authored-by: ovurrsl <[email protected]>

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings (#849)

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings

Adds the strings values-de, values-it and values-pt were missing (14 each) and the six that values-fr, values-es and values-ru were missing. Machine-assisted drafts that follow each file's existing register and terms; native review is welcome.

* l10n: correct missing active swap translations

---------

Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: droiddeckctl reaches variant-package builds (recover #842) (#854)

fix: droiddeckctl reaches builds shipped under a variant package

The provider authority and the start Activity's component were fixed to
com.droiddeck.launcher, so droiddeckctl could not talk to the antutu,
ludashi or pubg builds from tools/release/variants.txt. DROIDDECK_PACKAGE
now picks the package; the Activity's class name stays the same in every
variant.

Co-authored-by: angelrenefm <[email protected]>

* feat: droiddeckctl reports on-screen pacing (recover #843) (#855)

feat: droiddeckctl pacing reports the frames that reached the screen

Reads SurfaceFlinger's timestats for the game's layer (or the session's
SurfaceView) over a capture: frames on screen, fps on screen, the
present-to-present histogram and how many intervals were off the usual
cadence. Records split by a refresh-rate change are summed. A game's own fps
counter counts frames handed over, so it can't show dropped or doubled frames.

Co-authored-by: angelrenefm <[email protected]>

* fix: Steam can restart more than twice in a session (#754)

* fix: Steam can restart more than twice in a session

Every exit 42 counted toward the cap that stops an update which will
not install, and the count was never cleared, so the third restart of
a session ended it however far apart they were. A client that ran for
a minute before asking is not that loop: the count starts again.

* fix: measure the client's run time as it exits

The time spent collecting logs and checking FEX after the client exits
counted toward its run, so a slow cleanup could make a quick exit 42
look like a long one and clear the count.

---------

Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>

* fix: the front end keeps its page when the Fold is folded or unfolded (#832)

* fix: the front end keeps its page when the Fold is folded or unfolded

MainActivity, FileManagerActivity, FilePickerActivity and StoreLoginActivity did not declare the screen-size configuration changes, so folding or unfolding a foldable recreated them and dropped their in-memory state. They now handle orientation, screenSize, screenLayout, smallestScreenSize and density themselves, like SessionActivity already does. uiMode is left out on purpose.

* fix: store login keeps rebuilding its toolbar when the display density changes

StoreLoginActivity builds its toolbar in code with dp-to-pixel values taken at creation time and has no onConfigurationChanged. It no longer declares density in configChanges, so a density change recreates it as before this PR. Size and layout changes are still handled in place.

---------

Co-authored-by: ovurrsl <[email protected]>

* Release pointer buttons without replaying stale motion (#803)

* Fix cursor warping on native touch down

* Preserve input cleanup without replaying stale pointer motion

---------

Co-authored-by: siyuan.qsy <[email protected]>

* l10n: translate the strings added since #284 into zh-CN, zh-HK, zh-TW and ja (#539)

* l10n: translate the 184 strings added since #284 (zh-CN/HK/TW, ja)

The stores, cloud saves, Windows components, Steam touch, desktop
removal and background-downloads strings landed after #284's
translation snapshot, so every one of them fell back to English.
Translate all 184 into Simplified Chinese, Traditional Chinese
(Taiwan and Hong Kong) and Japanese, keeping each locale file's own
vocabulary (工作階段/會話, 解除安裝/卸載, 元件/組件, 設定).

Also fix four Hong Kong strings that kept the simplified 着色器.

* l10n(zh): fix stale and ambiguous strings found in review

Severe: summary_remove_game was untranslated English in all three
files; mode_added_art_hint, wincomp_installers_note and
wincomp_needs_installer still described the pre-#480/#492 English.

Also: distinguish the Setup wizard from Android system settings in
zh-HK/zh-TW (初始設定 / 系統設定 / Android 設定), unify library as
收藏庫, component as 組件 in zh-HK, session as 會話 in zh-HK, account
as 帳戶 in zh-HK, Trending as 趋势/趨勢, Forget as 清除, reword the
re-applied-at-launch, action-failed and already-in-Games strings, and
play as 執行/開啟遊戲 in zh-TW/zh-HK.

* l10n(ja): fix stale and ambiguous strings found in review

---------

Co-authored-by: Dantalian <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* Rebuild and verify Windows VR runtime components

* Rebuild and verify Windows VR runtime components

* Add top, bottom, and centered picture positions for Steam sessions (#871)

* fix: dependency scan ignores folders above the game folder (#859)

* DroidDeck-Proton: get releases from the Droid-Deck org repo

DroidDeck-Proton is now built and released on Droid-Deck/DroidDeck-Proton
(11-9.1 is Latest there), so the Components page, the update check and the
guest installer read releases from the org repo instead of The412Banner's.

* feat: low-latency controller and touch toggles in the session drawer (#808)

* fix: SteamGridDB lookups no longer stick as "no match" after a failure; add a search to the art chooser (#876)

* feat: Steam home wall fills from the signed-in library instead of store picks (#878)

* test: cover GPU family and support classification (#880)

* Fix local builds on Linux failing after the first Comet build (#866)

* feat: sort the store library and installed tabs, and switch between card and list views (#798)

* feat: three-finger tap toggles Steam's on-screen keyboard in game mode (#782)

* fix: keep Quest-patched Turnip as the auto default (#853)

* Fix deploy_local.sh and droiddeckctl not finding a phone connected over wireless debugging (#864)

* fix: keep proot sessions alive when the kernel loses fork events (#457)

* fix(quest): correct desktop and Steam display corruption across sessions (#733)

fix(quest): stabilize desktop and Steam layer presentation

Keep Quest SDR scenes and the black backdrop off the Android Vulkan swapchain regardless of session order or HDR preferences. Only report the first frame after client content is submitted.

Add backdrop lifecycle and first-frame regression coverage. Record the user's successful Quest 3 Test 2 result.

Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: keep SteamVR syscall hooks and report Steam crashes (#905)

* fix: preserve SteamVR syscall hooks

* fix: report Steam crashes through gamescope

* fix: honor glasses pacing and resolution in SteamVR launches

---------

Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>

* Glasses: keep a pending 2D restore and the SteamVR recovery offer, show Play SteamVR only with a picture

* Translate the glasses calibration, XREAL and SteamVR recovery strings

* SteamVR recovery: give a finished teardown time to collect logs before restarting

* Credit the projects the XR glasses support builds on

* Joy-Cons: SteamVR hands from their gyro while SteamVR runs, one combined pad otherwise

* Add experimental Luma tracking and stabilize SteamVR launches

Add native Luma Ultra spatial tracking, optical controllers, and stereo
calibration with DroidDeck styling, imperial/metric measurements, and
pending recenter recovery. Include the pinned hand model and calibration board.

Keep live guest and SDK files outside Android's evictable cache. Filter the
Adreno 840 sysmem override in the shared SteamVR helper, serialize timeline
mirroring, retain invalid optical controllers without event floods, and
prevent an unprivileged scheduling fallback from lowering compositor RTTIME.

Validation: latest APK build and runtime freshness checks pass; 247 affected
glasses/calibration tests pass. Native VR, helper, and packaging regressions
pass. Two earlier phone library launches sustained 60 fps for 308 and 188
seconds without recovery or restart. The newer calibration APK is installed
and checksum-verified; calibrated-hand and sustained gameplay acceptance
remain pending. One existing Android test requires Windows symlink privileges.

* Headsets: settings badge on Play SteamVR instead of a top-right button, both shown only once connected

* Hand tracking: camera-tracked Joy-Cons and bare hands in SteamVR, drawn as white gloves

- Joy-Con movement from the accelerometer (report reordering, per-Joy-Con calibration, zero-velocity updates)
- MediaPipe pose and hand landmarks from the device camera, placement check, per-hand Kalman filter with the Joy-Con
- No controller connected: bare camera hands with Quest-style gestures, 1€ filtered, rendered as a white glove model built from a distance field
- SteamVR safe mode cleared before each start so one crash no longer blocks the driver

* Improve Luma Ultra tracking and isolate device-specific controls

Refine stereo hand and Joy-Con tracking, gesture release, calibration,
recovery UI, diagnostics, display modes, and compositor handling.
Keep Ultra controls and input routing behind a live SDK-confirmed Carina
connection; clear retained spatial mode when other glasses connect.
Add optional UVC transport with pinned dependencies and packaged notices.

Validation: 456 Android tests, ARM64 VR build and native regressions,
47 tooling tests, SDK/UVC-enabled and disabled builds, and 150 packaged
runtime checksums. Two shader compilation tests skipped because the
compiler is unavailable. Hardware interaction gates remain experimental.

* Delay Luma Ultra 6DoF until SteamVR settles

Suspend spatial SDK startup while SteamVR launches, then restore it two seconds
after RUNNING is confirmed. Preserve saved tracking preferences and automatically
recenter same-device restoration, including cancellation and early stop.

Discard superseded launch requests and status replies after session teardown.

Validation: 128 focused JVM tests passed on the isolated commit snapshot; 129
passed with the current local hand-tracking work. Debug build and runtime checks
passed, and the reviewed APK was installed and hash-verified on the phone.

* Clarify glasses and phone camera tracking options

Separate Luma Ultra camera controls from phone-camera tracking so the two
hand-tracking switches have clear sources. Label bare-hand controllers and
Joy-Con position tracking explicitly, explain physical Joy-Con inputs and
camera priority, and update the camera prompt and calibration guidance.

Use fresh phone section and toggle labels so existing translations do not
hide the distinction. Preserve tracking behavior and saved preferences.

Validation: 39 existing UI tests, debug build, and packaged runtime verification
passed. Reviewed changes and installed the APK on the connected phone.

* Fix tracking handoff and shared runtime regressions

Release spatial ownership and controller overrides when output returns to the phone. Preserve USB-loss recovery, keep Vulkan submissions asynchronous, clean persistent guest scratch on runtime removal, and clarify the 6DoF head-tracking control.

* Apply smart-glasses virtual screens across DroidDeck menus

* Finalize Luma hand baseline and document smart-glasses support

Keep raw grayscale-to-RGB Full hand inference, wrist-only Joy-Con reconstruction, bounded visual holds, and the dead-zone rollback. Retain opt-in steady aim, recenter controls, reproducible Lite models, and the JPEG-padding fix.

Remove unsuccessful benchmark-only and USB transport experiments. Document the cross-brand 3DoF matrix, Luma Ultra 6DoF, SDK requirements, and app-wide screen behavior.

* Fix executable permission for SteamVR launcher

* Address hand tracking and VR review findings

* Bound stereo recovery test image comparison memory

* Localize smart-glasses setup and tracking diagnostics

Move calibration controls, status messages, validation errors, distance labels,
and printed-board instructions into Android string resources. Add and update
machine-assisted translations in all 32 supported non-English locale catalogs,
with corrections for recentering, hand tracking, product names, and measurements.

Keep print instructions within the page using text layout that supports wrapping
and right-to-left text. Correct the Lite model toggle description to reflect its
Lite palm detector and Lite landmark model.

Add locale coverage, format-argument, line-break, and measurement checks, plus
French and Arabic calibration UI tests. Preserve tracking calculations and the
shared runtime storage behavior.

Validation: Android resource and Kotlin compilation; 3 localization checks;
68 focused tests; 1,597 JVM tests at a 512 MB heap; French and Arabic UI renders.

* Correct smart-glasses translations from PR review

Fix the 42 localization findings covering hand and head terminology, disabled states, calibration dimensions, camera visibility, capture rejection, and menu references. Correct the repeated capture-error ambiguity in related locales and clarify its English source. Keep Romanian setup instructions consistent with the existing informal voice.

Extend localization measurement validation to distance hints and retry guidance. Verify the recovery screen's palette-dependent text color with one row-sized pixel buffer and guaranteed bitmap cleanup.

Validation: Android resource and Kotlin compilation; 3 localization checks across all supported locales; 68 focused JVM tests at a 512 MB heap.

* Work around SteamVR device loss with Adreno 840 Turnip v0.2.0

* Withdraw ineffective SteamVR flushall workaround

---------

Co-authored-by: maxjivi05 <[email protected]>
Co-authored-by: MaxsTechReview <[email protected]>
Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>
2026-10-18 08:37:09 -04:00
The412Banner cf6cf168b0 feat: x86-64 client follows DroidDeck-Components' fex.json; fuller x86-64 session logs (#1053)
FEX builds can reach devices without an app release: while the x86-64
client is selected the app reads DroidDeck-Components' fex.json (directly,
on its own thread, not in Offline Mode) and a session passes that build to
droiddeck-steam-x64, which installs it only once its sha256 matches and its
programs load, keeping the replaced files aside until the installed copy
loads too; it keeps a working installed build otherwise and falls back to
the build it pins. FEX is built and published by Components' build-fex.yml
on its single fex release; this repository's build-fex.yml only checks the
build. x86-64 session logs name the FEX build and its source, the rootfs and
the client's builds, report the rootfs download in tenths, and say in one
line when the web helper's GPU process keeps exiting, with x86 glxinfo and
vulkaninfo through FEX. Settings shows the FEX build installed.
2026-10-18 03:40:17 -04:00
The412Banner 7d21ba2233 fix: x86-64 Steam client fails without the KDE desktop (FEX r5 bundles libxxhash) (#1050)
FEX r4 links libxxhash, which the Linux runtime does not ship; devices
without the KDE desktop package (where the library came from) exited 127
at the x86-64 client's first FEX call. FEX r5 bundles it beside libFEXCore
with RUNPATH $ORIGIN/../lib, and the FEX build now fails when a program in
the package needs a library that is neither bundled nor listed in
tools/fex/runtime-libs.txt. droiddeck-steam-x64 moves to fex-2610-r5
(DroidDeck-Components #5). Progress log: the org review of #152 and this fix.
2026-10-18 02:00:29 -04:00
The412Banner ae94d9db54 feat: optional x86-64 Steam client (Valve's Linux client under FEX) beside the arm64 one (#152)
An opt-in second Steam client (Steam settings, Steam client: x86-64): Valve's
x86-64 Linux client, unchanged, runs under FEX in its own home with its own
game library and the same sign-in. Linux builds run directly, so titles such
as Team Fortress 2 and Half-Life use their Linux versions; Windows games use
DroidDeck-Proton as on arm64. The arm64 client stays the default.

- droiddeck-steam-x64: FEX and the x86-64 rootfs from DroidDeck-Components
  (sha256-pinned), client install and sync from Valve's CDN, sign-in shared
  with the arm64 client, its web helper on the GPU (ANGLE GL over FEX's GL
  thunk, zink, Turnip), Proton wrappers for the arm64 client's tools,
  DroidDeck direct for Linux builds, SD-card library (x86-64/ beside the
  arm64 one), repair.
- FEX 2610 built by build-fex.yml with thunks and three patches: an openat2
  fallback without openat2 (proot), newer-clang thunkgen, MAP_32BIT
  exhaustion returning ENOMEM. libblsession resolves RESOLVE_IN_ROOT paths
  inside the root.
- Session: x86-64 gating, shortcuts and compatibility tools per client,
  logs from the client that ran.
- App: client setting with the FEX version, an x86-64 chip on the Games
  header, the active client's library, art, storage, downloads, repair,
  Offline Mode, Decky, desktop mode, optional VAC diagnostics; per-client
  state for art, the content log, repair and leftovers.
- Strings in every locale.
- Review fixes: the library cache is saved only by the newest scan; an
  abandoned session's Steam logs come from the client it recorded; distinct
  VAC console names; FEXInterpreter in CPU pinning; clearer VAC hint in
  pl, ru, uk, cs and bg.
2026-10-17 23:57:52 -04:00
MaxsTechReview 45513c90d6 fix: rename the VR recovery task affinity in variant builds (#1038) 2026-10-17 17:24:31 -04:00
MaxsTechReview f3f3ba1a8a fix: rename the VR recovery task affinity in variant builds
The PUBG, AnTuTu and Ludashi signing jobs stopped on
com.droiddeck.launcher.vr_recovery, the task affinity of the VR
recovery activity added with the VITURE XR controls, because the
package rename did not cover it.
2026-10-17 17:14:06 -04:00
MaxsTechReview 97f976ef6c feat: Epic Games Launcher for Linux (#928)
* feat: Epic Games Launcher for Linux

Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's
Steam Runtime 4 and run it under FEX, signed in with the linked Epic account.
Epic games launch through a hidden launcher instance so they get the
launcher's own arguments, with the exchange-code launch as the fallback, and
DroidDeck installs show up in the launcher as installed.

In the launcher, touch scrolls like a phone app and the gamepad drives the UI:
D-pad and left stick move the selection, A opens, B goes back, the right stick
and triggers scroll proportionally, X opens the options menu, Y the keyboard
and LB/RB switch tabs.

* feat: Epic Games Launcher for Linux

Install Epic's official Linux launcher (EGLv2) from Epic's CDN with Valve's
Steam Runtime 4 and run it under FEX, signed in with the linked Epic account.
Epic games launch through a hidden launcher instance so they get the
launcher's own arguments, with the exchange-code launch as the fallback, and
DroidDeck installs show up in the launcher as installed.

In the launcher, touch scrolls like a phone app and the gamepad drives the UI:
D-pad and left stick move the selection, A opens, B goes back, the right stick
and triggers scroll proportionally, X opens the options menu, Y the keyboard
and LB/RB switch tabs.

* Route Epic games through Steam, import launcher installs, offer the second library

- Every Epic launch goes Steam -> Epic Games Launcher -> game; Play inside the open launcher hands the game to Steam
- Games installed from inside the launcher get a Games-tab entry and launch through Steam; the launcher installs to the last Stores target
- Stores install picker offers the Steam second library, with side-by-side choices
- uruntime xattr-only failures no longer fail the launcher unpack (app-extract)

* Hand launcher Play and Steam links to Steam, wait for FEX on first run

Play on a game the launcher just installed is matched to its install
record and handed to Steam. Steam games listed in the launcher open
steam://rungameid links, which now start the game in a Steam session.
Opening the launcher before Steam has downloaded FEX asks Steam for it
and starts Steam instead of failing with no-fex.

* Address review: FEX check, play-in-Steam action, touch route thread, scroll scale

fexReady needs a FEX binary in Steam's FEX tool, not just its folder.
The play-in-Steam action stays until the link is handled, so Run Steam
at startup can't start a session first. The pad touch route reads the
mouse mode without side effects off the main thread and posts the pad
update there. Vertical scroll uses the output height. A status-2 unpack
counts only when AppRun is there. The xdg-open command is tested
end to end for Steam links.

* Open the launcher through Steam, recover dropped shortcuts, sign out of both

The Epic Games Launcher now runs as its own Steam shortcut, so opening
it starts Steam, which opens the launcher, and Play inside it goes to
the running Steam.

Steam dropped a running shortcut from its library when the game exited
after a sleep/wake replay or a server re-logon, and launching it then
waited forever at ShowInterstitials. login1 only replays sleep/wake
when Steam is asleep. The session restarts Steam when a requested
shortcut is missing, and when a game exits with its shortcut gone and
nothing else runs.

Resuming after a long pause no longer reports failure while traced
processes are still waking, so "Steam is paused" goes away on the first
tap.

Signing out of the launcher signs DroidDeck's Epic store out, and
signing out in DroidDeck restarts the launcher signed out and clears
the Epic web cookies.

* Address review: one session-switch poll, Epic setup can't block a session

A new switch confirmation replaces the pending poll instead of adding a
second one that could start the session again. Epic library sync and
install-folder setup at session start are each guarded, so a bad
preference no longer stops the guest from launching.

* Uninstall the Epic Games Launcher from the Games tab

An Uninstall button next to Launch asks first, closes a running
session, and removes the launcher and its runtime. The launcher's
sign-in and install records stay, so its games keep their Games-tab
entries and a reinstall comes back signed in. Its Steam shortcut goes
away on the next Steam start.

* Address review: uninstall only after the session has ended

The launcher's files are removed only once the session is not running
and its phase has ended; a shutdown still in progress after the wait
reports failure instead. The confirmation reads the session state from
the front-end state.

* Find FEX that Steam installed on the second library

Steam's tools on a second library live in DroidDeck's private copy,
which the session binds over the library. The launcher's FEX check
only looked on the shared storage, so with FEX there it always asked
Steam for FEX and only Steam opened. It now checks the private copy
too.

* Start games without Steam, per-game Settings menu with Wine sync

"Start without Steam" now really leaves the Steam client out: the game
runs in its own Proton/FEX through droiddeck-proton-run in a run
session, and the Epic Games Launcher opens on its own with its games
playing in it. Run sessions for these games are not pinned to the big
cores and get DirectAudio and the added-games list like Steam.

The Games tab card is now "Settings", titled "<game> Settings": Start
without Steam, FEX preset, Wine sync (ntsync, fsync, esync, wineserver
or the app default) and Default, which puts everything changed for the
game back to the app's settings. The app-wide Wine sync choice is shown
in the Steam settings Session tab.

* Address review: uninstall guard, routed games, shortcut matching

Opening or reinstalling the Epic Games Launcher waits while it is being
uninstalled. Added games that are Steam installs keep their Start
without Steam setting under their Steam app id and start as that Steam
game. Live shortcut lookups wait for Steam's library to load and match
a same-name shortcut by its executable, so no duplicate is added. The
session treats the open launcher as itself even when it could not tell
its Steam app id.

* Address review: drop a queued launcher switch when it is uninstalled

A confirmed switch to the Epic Games Launcher that is still waiting for
the old session to end is cancelled when the launcher is uninstalled,
and never starts while the uninstall runs. The launcher id test sets
its environment for its own runs only.
2026-10-17 14:40:39 -04:00
+13 83eda433c8 Add VITURE XR controls, head tracking, and SteamVR (#732)
* Add VITURE glasses support: hardware control, 3D, head tracking

Glasses get their own controller over the MCU (permission, 120 Hz default, brightness, volume, sunblock, 2D/3D) and the game moves onto them when plugged in. The IMU drives Cardboard head tracking and the SteamVR driver. 3D falls back to one eye's view on phones that can't send the 3840x1080 signal. Options open from a glasses button in game and on the Steam page.

* Drop the glasses settings like Steam's: Play-style button, Settings lip, tabs

* Hang the glasses Settings lip over the top of the button

* Space glasses MCU commands so the IMU rate sticks, fit fixed resolutions to the glasses

* Keep Steam alive with SteamVR registered, run SteamVR at the glasses' rate, keep the glasses lip on top

* Fit VR games on the glasses: full view, seated origin at the head, pad hands reach with the d-pad

* Let the pad hands reach a metre out so buttons with deep colliders press

* Show every VR frame in order at 120 fps: wait for SteamVR's GPU work, present only finished copies, skip the grace on idle shared counters

* Glasses: SteamVR starts and stops with them, closer view with a field of view setting, Recenter on the pad

* Glasses: SteamVR field of view defaults to 90°

* VR pad hands: sticks move and aim the hands, left stick click switches the sticks to the game, d-pad pulls a held hand

* Glasses: Play SteamVR beside the glasses button instead of starting SteamVR with them, pad hands drawn as Index controllers and off while other VR controllers are connected, glasses sheet tabs sized like Steam's

* Track glasses mode, refresh-aware glasses window, align Play SteamVR with Play Steam

* Fix 3D on the glasses: full-res SteamVR presenter, revert VR 3D at session end, coalesce vsync ticks, no session restart after end

* Address review: glasses output after installs and session end, VR revert retry, display fallback, presenter swapchain retry, agent hex checks, drawer bumpers, glasses translations, docs

* Harden VITURE stereo negotiation and output refresh tracking

* Localize stereo recovery feedback and record Astra reconnect tests

* Improve test failure diagnostics and refresh Astra firmware findings

* Handle VITURE docks separately and reject truncated USB frames

* Glasses: remember a 3D fallback per model, docks as display only, VR timing fixes

- A 3D attempt that ends at the 640x480 fallback is remembered for that glasses
  model, so Play SteamVR no longer blacks out the glasses after every restart;
  Retry clears it and "3D for VR games" stays reachable
- A VITURE Mobile Dock on its own no longer counts as glasses; through a dock
  Play SteamVR is hidden and the sheet shows the output and why there is no control
- HDR is probed on the glasses when the session starts there
- Glasses frames with a mismatched checksum are marked in the status frame log
- SteamVR layer and driver vblank counters stay monotonic across refresh changes;
  one-eye render scale applies from activation and on mono/3D switches
- Compositor binds announce the refresh clients were last told; the presenter
  retries a failed first swapchain
- Dead strings and helpers removed; docs updated

* Glasses: drop USB frames with a bad checksum

* Glasses: count frames dropped for a bad checksum

* Feat/viture xr (#907)

* Add RayNeo Air and GT support to the XR glasses pipeline

* Add RayNeo Air and GT support to the XR glasses pipeline

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Checkpoint glasses tracking, calibration, and SteamVR launch integration

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Add XREAL 1S support and correct GT and Pro 2 tracking

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Fix mirrored glasses tracking and recover SteamVR after device loss

* Document confirmed XREAL 1S brightness control

* Document confirmed XREAL 1S brightness control

* Fix SteamVR glasses defaults and rendering controls

* Fix SteamVR glasses defaults and rendering controls

* chore: keep only the 3 newest session log folders (#852)

* chore: add agent comment rules (#748)

Co-authored-by: Kurt Himebauch <[email protected]>

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* Fix SteamVR polling, VITURE checksums, and fence tracking

Keep manual SteamVR launches polled, discard corrupt VITURE packets, and retire Vulkan fences out of order without blocking submissions at a fixed capacity. Preserve the contiguous completion watermark used by the presenter.

Fix optional Windows launcher configuration and audited helper staging. Add Android, Windows, and native tracker regression coverage.

* fix: wireless ADB page names the child-process switch the way Android shows it (#848)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru (#850)

* l10n: refresh stale and untranslated strings in es, fr, ko, ru

- mode_added_art_hint: now mentions Steam then SteamGridDB (all four locales).
- wincomp_needs_installer / wincomp_installers_note: es and ko still described
  the old "coming soon" behaviour; align with the current English text.
- summary_remove_game: was still English in all four locales.

Machine-assisted translation; native-speaker review is welcome.

* l10n(es): say registro, not grabación, for the missing installer record

---------

Co-authored-by: ovurrsl <[email protected]>

* fix: a failed cloud save recovery no longer ends the app (#846)

The cloud-recovery thread started at every app start had nothing to
catch an error, so one went to CrashHandler, which halts the process
with status 2. In unit tests the thread can outlive the test that
started it, fail on the torn-down preferences, and take the whole test
run down. The error is now logged and the thread ends.

Co-authored-by: Kirk <[email protected]>

* fix: an interrupted Linux runtime download resumes instead of starting again (#847)

* fix: an interrupted Linux runtime download resumes instead of starting again

installOnce() asked the downloader to resume but always deleted the archive in its finally block, so the next attempt began at zero. The archive is now named after the release version and checksum, a failed download keeps what it fetched, and archives of other releases (and the old fixed name) are removed. A checksum or extraction failure still deletes the file.

* test: Downloader resumes a cut-off download from the bytes it kept

Starts a socket server that promises the whole file, hangs up part way and then answers a Range request, and checks that the second downloadFile call resumes with the right offset and ends with identical bytes.

* fix: a resumed download discards a 206 that does not start where the kept bytes end

Downloader appended every 206 response to the partial file without
looking at Content-Range. A server or proxy that answers a Range request
with a different offset would leave a corrupt file. Check that the range
starts at the kept length; otherwise delete the partial file and start
again without a Range header.

Adds a test with a server that answers the Range request from offset 0.

---------

Co-authored-by: ovurrsl <[email protected]>

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings (#849)

* l10n: German, French, Spanish, Italian, Portuguese and Russian for the zRAM, reset-settings and store free-space strings

Adds the strings values-de, values-it and values-pt were missing (14 each) and the six that values-fr, values-es and values-ru were missing. Machine-assisted drafts that follow each file's existing register and terms; native review is welcome.

* l10n: correct missing active swap translations

---------

Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: droiddeckctl reaches variant-package builds (recover #842) (#854)

fix: droiddeckctl reaches builds shipped under a variant package

The provider authority and the start Activity's component were fixed to
com.droiddeck.launcher, so droiddeckctl could not talk to the antutu,
ludashi or pubg builds from tools/release/variants.txt. DROIDDECK_PACKAGE
now picks the package; the Activity's class name stays the same in every
variant.

Co-authored-by: angelrenefm <[email protected]>

* feat: droiddeckctl reports on-screen pacing (recover #843) (#855)

feat: droiddeckctl pacing reports the frames that reached the screen

Reads SurfaceFlinger's timestats for the game's layer (or the session's
SurfaceView) over a capture: frames on screen, fps on screen, the
present-to-present histogram and how many intervals were off the usual
cadence. Records split by a refresh-rate change are summed. A game's own fps
counter counts frames handed over, so it can't show dropped or doubled frames.

Co-authored-by: angelrenefm <[email protected]>

* fix: Steam can restart more than twice in a session (#754)

* fix: Steam can restart more than twice in a session

Every exit 42 counted toward the cap that stops an update which will
not install, and the count was never cleared, so the third restart of
a session ended it however far apart they were. A client that ran for
a minute before asking is not that loop: the count starts again.

* fix: measure the client's run time as it exits

The time spent collecting logs and checking FEX after the client exits
counted toward its run, so a slow cleanup could make a quick exit 42
look like a long one and clear the count.

---------

Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>

* fix: the front end keeps its page when the Fold is folded or unfolded (#832)

* fix: the front end keeps its page when the Fold is folded or unfolded

MainActivity, FileManagerActivity, FilePickerActivity and StoreLoginActivity did not declare the screen-size configuration changes, so folding or unfolding a foldable recreated them and dropped their in-memory state. They now handle orientation, screenSize, screenLayout, smallestScreenSize and density themselves, like SessionActivity already does. uiMode is left out on purpose.

* fix: store login keeps rebuilding its toolbar when the display density changes

StoreLoginActivity builds its toolbar in code with dp-to-pixel values taken at creation time and has no onConfigurationChanged. It no longer declares density in configChanges, so a density change recreates it as before this PR. Size and layout changes are still handled in place.

---------

Co-authored-by: ovurrsl <[email protected]>

* Release pointer buttons without replaying stale motion (#803)

* Fix cursor warping on native touch down

* Preserve input cleanup without replaying stale pointer motion

---------

Co-authored-by: siyuan.qsy <[email protected]>

* l10n: translate the strings added since #284 into zh-CN, zh-HK, zh-TW and ja (#539)

* l10n: translate the 184 strings added since #284 (zh-CN/HK/TW, ja)

The stores, cloud saves, Windows components, Steam touch, desktop
removal and background-downloads strings landed after #284's
translation snapshot, so every one of them fell back to English.
Translate all 184 into Simplified Chinese, Traditional Chinese
(Taiwan and Hong Kong) and Japanese, keeping each locale file's own
vocabulary (工作階段/會話, 解除安裝/卸載, 元件/組件, 設定).

Also fix four Hong Kong strings that kept the simplified 着色器.

* l10n(zh): fix stale and ambiguous strings found in review

Severe: summary_remove_game was untranslated English in all three
files; mode_added_art_hint, wincomp_installers_note and
wincomp_needs_installer still described the pre-#480/#492 English.

Also: distinguish the Setup wizard from Android system settings in
zh-HK/zh-TW (初始設定 / 系統設定 / Android 設定), unify library as
收藏庫, component as 組件 in zh-HK, session as 會話 in zh-HK, account
as 帳戶 in zh-HK, Trending as 趋势/趨勢, Forget as 清除, reword the
re-applied-at-launch, action-failed and already-in-Games strings, and
play as 執行/開啟遊戲 in zh-TW/zh-HK.

* l10n(ja): fix stale and ambiguous strings found in review

---------

Co-authored-by: Dantalian <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>

* Rebuild and verify Windows VR runtime components

* Rebuild and verify Windows VR runtime components

* Add top, bottom, and centered picture positions for Steam sessions (#871)

* fix: dependency scan ignores folders above the game folder (#859)

* DroidDeck-Proton: get releases from the Droid-Deck org repo

DroidDeck-Proton is now built and released on Droid-Deck/DroidDeck-Proton
(11-9.1 is Latest there), so the Components page, the update check and the
guest installer read releases from the org repo instead of The412Banner's.

* feat: low-latency controller and touch toggles in the session drawer (#808)

* fix: SteamGridDB lookups no longer stick as "no match" after a failure; add a search to the art chooser (#876)

* feat: Steam home wall fills from the signed-in library instead of store picks (#878)

* test: cover GPU family and support classification (#880)

* Fix local builds on Linux failing after the first Comet build (#866)

* feat: sort the store library and installed tabs, and switch between card and list views (#798)

* feat: three-finger tap toggles Steam's on-screen keyboard in game mode (#782)

* fix: keep Quest-patched Turnip as the auto default (#853)

* Fix deploy_local.sh and droiddeckctl not finding a phone connected over wireless debugging (#864)

* fix: keep proot sessions alive when the kernel loses fork events (#457)

* fix(quest): correct desktop and Steam display corruption across sessions (#733)

fix(quest): stabilize desktop and Steam layer presentation

Keep Quest SDR scenes and the black backdrop off the Android Vulkan swapchain regardless of session order or HDR preferences. Only report the first frame after client content is submitted.

Add backdrop lifecycle and first-frame regression coverage. Record the user's successful Quest 3 Test 2 result.

Co-authored-by: xXJsonDeruloXx <[email protected]>

* fix: keep SteamVR syscall hooks and report Steam crashes (#905)

* fix: preserve SteamVR syscall hooks

* fix: report Steam crashes through gamescope

* fix: honor glasses pacing and resolution in SteamVR launches

---------

Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>

* Glasses: keep a pending 2D restore and the SteamVR recovery offer, show Play SteamVR only with a picture

* Translate the glasses calibration, XREAL and SteamVR recovery strings

* SteamVR recovery: give a finished teardown time to collect logs before restarting

* Credit the projects the XR glasses support builds on

* Joy-Cons: SteamVR hands from their gyro while SteamVR runs, one combined pad otherwise

* Headsets: settings badge on Play SteamVR instead of a top-right button, both shown only once connected

* Hand tracking: camera-tracked Joy-Cons and bare hands in SteamVR, drawn as white gloves

- Joy-Con movement from the accelerometer (report reordering, per-Joy-Con calibration, zero-velocity updates)
- MediaPipe pose and hand landmarks from the device camera, placement check, per-hand Kalman filter with the Joy-Con
- No controller connected: bare camera hands with Quest-style gestures, 1€ filtered, rendered as a white glove model built from a distance field
- SteamVR safe mode cleared before each start so one crash no longer blocks the driver

---------

Co-authored-by: MaxsTechReview <[email protected]>
Co-authored-by: informalTechCode <[email protected]>
Co-authored-by: connor g <[email protected]>
Co-authored-by: Kurt Himebauch <[email protected]>
Co-authored-by: Yassine Gherbi <[email protected]>
Co-authored-by: Resul Övür <[email protected]>
Co-authored-by: ovurrsl <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: Kirk <[email protected]>
Co-authored-by: xXJsonDeruloXx <[email protected]>
Co-authored-by: angelrenefm <[email protected]>
Co-authored-by: asdf17128 <[email protected]>
Co-authored-by: siyuan.qsy <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: Dantalian <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: The412Banner <[email protected]>
Co-authored-by: FX1922 <[email protected]>
Co-authored-by: Muaz Kadan <[email protected]>
Co-authored-by: kingokksa <[email protected]>
Co-authored-by: Jamie Schenker <[email protected]>
Co-authored-by: Jagger15 <[email protected]>
2026-10-17 13:17:05 -04:00
kingokksa e95a4226eb l10n: translate the missing strings in every locale, and complete es-419 (#1013)
* l10n: translate the missing strings into zh-CN, zh-HK and zh-TW

* l10n: translate the missing strings into ja and ko

* l10n: translate the missing strings into it, pt, pt-PT, ru and tr

* l10n: translate the missing strings into nl, pl, sv, da, nb and fi

* l10n: translate the missing strings into ar, bg, cs and el

* l10n: translate the missing strings into hi, hu, in, ms and ro

* l10n: translate the missing strings into th, uk and vi

* l10n: complete the es-419 translation

* l10n: translate the missing strings into de, es and fr
2026-10-17 11:44:59 -04:00
FX1922 26ca374c47 feat: Adjust a game’s logo position and size in the artwork editors (#1004)
* Add an Adjust logo screen to move and resize a game's logo in Steam

* Report a failed write of the local logo position instead of throwing

* Record a Steam-side logo position change only once it has been taken over here
2026-10-17 10:02:22 -04:00
FX1922 64d78aa812 Feat/store game artwork (#976) 2026-10-17 01:19:02 -04:00
Resul Övür 522e755b37 feat: the session report lists the panel refresh modes, thermal state and hint-session support (#990) 2026-10-17 02:31:17 +00:00
Resul Övür 9c9486400b Launch an added game after a bounded wait for its Steam overview (#973) 2026-10-16 17:21:53 -04:00
Kurt Himebauch 62192203e9 feat: auto drivers update on app open and roll back if a new pair never starts a session (#972) 2026-10-16 16:17:44 -04:00
FX1922 d8df18df82 fix: Keep game art in step with Steam's grid folder; apply review fixes (#948) 2026-10-16 16:14:49 -04:00
Kurt Himebauch 953c11ed48 feat: boot movie settings for 30/60 fps, sound and custom imports (#969) 2026-10-16 15:07:46 -04:00
Kurt Himebauch c796c7cd48 Feat/gpu drivers redesign (#858) 2026-10-16 17:50:45 +00:00
kingokksa 89a0e4d25f feat: downloads resume where they stopped, and fall back to another source (#623) 2026-10-16 13:45:35 -04:00
Kurt Himebauch 59a5afa21d fix: reduce flickering on thor and a7xx: match the A740 TP UBWC flag hint to the system driver so Android UI stops flickering (#963) 2026-10-16 13:37:28 -04:00
TideGear 66529d9026 fix: hold XInput rumble until the game changes it, instead of stopping after a second (#938) 2026-10-16 17:30:21 +00:00
Kurt Himebauch abd89f9954 fix: session achievements page no longer sticks on Reading (#961) 2026-10-16 15:51:57 +00:00
Kurt Himebauch f7ffab1345 feat: merge SteamVR and Cardboard into one VR drawer tab (#959) 2026-10-16 14:59:34 +00:00
angelrenefm 8886304894 fix: the proot fast path finds unix sockets whose host path is longer than sun_path (#841)
* fix: the proot fast path finds unix sockets whose host path is longer than sun_path

When a socket's host path did not fit sun_path, bind() went to proot, which
bound it to a short temporary name the fast path cannot see. The fast path's
own connect, stat and unlink then answered ENOENT, so FEXServer's socket under
compatdata/<app>/fex-emu/Server could never be reached (#839, #579).

Name such a socket through its directory (/proc/self/fd/N/<name>) instead, so
it lives at its real host path for every process, and report the guest path
back from getsockname, getpeername and accept. equiv3.py's long-path section
binds, connects, stats, names and unlinks a socket there.

* fix: key long socket names per socket and reset their lock after fork

Review follow-up. A long name is now looked up by the socket's inode and the
name, separately for its own and its peer's address, so two sockets with the
same file name in different directories no longer mix up; a missing entry is
left as the kernel reports it. The lock is re-initialised in the child after
fork, and the copies use snprintf. equiv3.py binds a second socket with the
same name in a sibling directory and checks both names.
2026-10-16 10:05:12 -04:00
angelrenefm 0b8d55d434 fix: droiddeckctl refuses a DROIDDECK_PACKAGE that is not a package name (#896)
The value goes to the device shell inside `am start -n` and `content call --uri`.
Anything but a dotted Android package name is refused before adb runs.
2026-10-16 10:05:02 -04:00
angelrenefm 99381a8f3e fix: droiddeckctl pacing judges cadence per refresh-rate record and always turns timestats off (#894)
* fix: droiddeckctl pacing judges cadence per refresh-rate record and always turns timestats off

Follow-up to the CodeRabbit reviews of #843/#855:
- off-cadence is counted per layer record against that record's own cadence,
  allowing the neighbouring millisecond bucket (16.7 ms lands in 16 or 17);
- refreshRates comes from the chosen layer records only;
- records with no counted frames are skipped, so an empty game layer no longer
  hides the session's SurfaceView, and a capture with no frames exits NO_FRAMES;
- adb failures while enabling or dumping map to PACING_FAILED, and timestats
  is disabled in every case.

* fix: droiddeckctl pacing reports when timestats could not be turned off
2026-10-16 09:50:43 -04:00
Resul Övürandovurrsl a422698f92 Keep waiting for a shortcut's Steam overview instead of dropping its cold launch (#914)
fix: keep waiting for a shortcut's Steam overview instead of dropping its launch

The cold-launch gate waited once for 45 s and, on a timeout, held the
launch URL for good. A library that loads slower than that (or a client
still at the sign-in screen) left the requested game never started, with
nothing on screen. Retry the wait up to 40 times, 45 s each, and launch
as soon as the overview exists. The no-overview case still does not
forward the CGameID.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:35 -04:00
Resul Övürandovurrsl dce7e7ece6 fix: the store sign-in page keeps its place when the display density changes (#886)
* fix: the store sign-in page keeps its place when the display density changes

StoreLoginActivity sized its toolbar and error panel in code with dp(), so it
was left out of the density change and a fold between two displays of
different density still recreated it and reloaded the sign-in page. Handle
density in the manifest and apply the dp sizes again in onConfigurationChanged.

Checked on the foldable emulator with the GOG sign-in open: wm density
420 -> 560 -> 320 does not relaunch the activity, the page stays, and the top
bar follows the new density.

* fix: re-apply the sign-in text sizes together with the other dp sizes

The title (16sp) and error text (15sp) were set in sp at creation, so after a density change they kept the old scale. Set them again in onConfigurationChanged.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:28 -04:00
Resul Övürandovurrsl e3fe63aee6 fix: the store sign-in WebView cannot read local files or content URIs (#890)
The sign-in pages are ordinary web pages. file access and content access
default to on below targetSdk 30, so switch both off explicitly.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:21 -04:00
Resul Övürandovurrsl 22c85c0cb4 fix: the launcher does not offer its own display as a second screen (#889)
* fix: the launcher does not offer its own display as a second screen

MainActivity asked for the second-screen candidates with the default
display as the primary one. When the launcher is itself shown on another
display (DeX, an external monitor), that display was listed as a second
screen. Pass the Activity's own display id, as SessionActivity already does.

* fix: read the Activity display only on API 30 and newer

minSdk is 26 and Activity.getDisplay needs API 30. Use windowManager.defaultDisplay below that.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:14 -04:00
Resul Övürandovurrsl a548d381b8 fix: store background jobs log a failure instead of ending the app (#898)
* fix: store background jobs log a failure instead of ending the app

refresh, sign-out, uninstall and the EOS overlay cleanup ran on plain threads. An exception there reaches the crash handler, which ends the process. Run them through one helper that logs the failure; a failed uninstall also clears its row and calls onDone so the UI does not stay on uninstalling.

* ci: run the build again

The first run failed while installing packages (archive.debian.org returned 404 for libnsl-dev). Nothing in the change differs.

---------

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:50:06 -04:00
Resul Övürandovurrsl 9abb21e389 l10n: refresh outdated strings, one name for the child-process switch, pt-rPT gaps (#884)
l10n: refresh outdated strings and name the child-process switch the same way everywhere

- hu: ctrl_size_hint showed a literal "%%".
- es, fr, ru: game_env_reset, mode_controller_note and comp_no_proton still
  described older English text.
- fr: the x86_64 AppImage (through FEX) strings were missing the x86_64 case.
- fr: put the spaces back inside the guillemets of the switch name.
- All locales: gate_toggle_steps and phantom_fix_toggle now use the same
  switch name as wadb_restrict_on/off, which is the title Android 17 shows.
- pt-rPT: add the 14 strings it was missing (reset settings, zRAM, store
  free space), as a European Portuguese draft.

Machine-assisted; native review is welcome.

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:49:58 -04:00
Resul Övür 4e4cc9d5e3 feat: the session report lists the memory page size and the last process exits (#897) 2026-10-16 09:46:10 -04:00
Resul Övürandovurrsl a353b15364 fix: read Settings.Global phantom process flag on Android 12 (SDK 31) (#955)
On Android 12 (SDK 31), read() only checked the android12-off preference written by in-app Wireless ADB, so disabling phantom process monitoring over USB ADB left the status as UNREADABLE. Also write settings_enable_monitor_phantom_procs in the SDK 31 shell commands and accept a disabled Settings.Global / fflag value in read().

Co-authored-by: ovurrsl <[email protected]>
2026-10-16 09:37:03 -04:00
Zeyad Saber Refaei Kenawi 86b92c5d0e Add support for android auto (#952)
Add CAR_LAUNCHER intent filter to support android auto
2026-10-16 09:35:39 -04:00
Kurt Himebauch e24e07d0ce Fix Quest CI headers when preloads are cached (#951) 2026-10-16 12:35:55 +00:00
FX1922 71da1c90c5 feat: Change artwork for Steam games; remove “Show more” from the SteamGridDB search. (#939) 2026-10-16 02:08:09 -04:00
kmnkv1 cd4a26b5f1 Sync session logs every 2 s so a hard reset keeps their last minutes (#513) 2026-10-16 01:51:56 -04:00
kmnkv1 66ec776b0b perf: boost the game's busiest threads onto the prime cores on the Odin 3 (#682) 2026-10-16 01:51:00 -04:00
Kurt Himebauch 2847910f22 feat: add immersive Quest SteamVR to the standard app (#910) 2026-10-16 01:48:00 -04:00
Kurt Himebauch d21466be6b update readme (#937)
docs: credit mmap input work
2026-10-15 23:00:15 -04:00
Kurt Himebauch 21a78a1eb2 fix: pace adaptive frame generation on the game's frame rate, not the compositor loop's (#927) 2026-10-15 18:36:40 -04:00
Resul Övür 5aa245cf39 feat: the second-screen Deck controls get the Gamepad tab (#895) 2026-10-15 16:54:43 -04:00
Resul Övür 507ddac39d fix: update zstd-jni so the Linux runtime installs on a 16 KB page-size device (#900) 2026-10-15 16:54:04 -04:00
Kurt Himebauch ef16de6280 feat: storage page to see and free DroidDeck's storage (#913) 2026-10-15 16:45:37 -04:00
881 changed files with 114543 additions and 3509 deletions

No files matched your search

+8
View File
@@ -2,3 +2,11 @@ tools/linuxfs/** text=auto eol=lf
tools/mangoapp/** text=auto eol=lf
tools/comet/LICENSE whitespace=-blank-at-eof
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
# These helpers are packaged verbatim and executed inside the Linux runtime.
tools/linuxfs/overlay/** text eol=lf
tools/linuxfs/desktop/** text eol=lf
tools/mangoapp/mangoapp text eol=lf
app/src/main/assets/desktop-removal/*.json text eol=lf
app/src/main/assets/desktop-removal/*.txt text eol=lf
*.pdf binary
+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
+49
View File
@@ -0,0 +1,49 @@
name: Build FEX (x86-64 Steam client)
# Checks tools/fex: the same build DroidDeck-Components' build-fex.yml runs from this repository to publish FEX
# (its single `fex` release and fex.json, which the app follows). Nothing is published from here.
on:
push:
paths:
- 'tools/fex/**'
- '.github/workflows/build-fex.yml'
workflow_dispatch:
inputs:
thunks:
description: 'Build the host thunks (Vulkan/GL/X11/... to the runtime arm64 libraries)'
required: false
default: 'false'
cxxflags:
description: 'Extra CXXFLAGS for a diagnostic build (e.g. -DDEBUG_STRACE)'
required: false
default: ''
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build in Arch Linux ARM
# Inputs reach the shell as environment variables, never pasted into the script.
env:
FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }}
FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }}
run: |
set -euo pipefail
docker run --rm -e FEX_EXTRA_CXXFLAGS="$FEX_EXTRA_CXXFLAGS" -e FEX_THUNKS="$FEX_THUNKS" -v "$PWD:/work" -w /work menci/archlinuxarm:base-devel bash tools/fex/build-in-arch.sh
sudo chown -R "$(id -u):$(id -g)" .
- uses: actions/upload-artifact@v4
with:
name: fex
path: |
fex.tzst
fex.tzst.sha256
fex-needed.txt
fex-files.txt
+197 -31
View File
@@ -26,10 +26,67 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v3
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,16 +250,15 @@ 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 install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu >/dev/null
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"
aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
@@ -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"
@@ -313,27 +378,32 @@ jobs:
set -euo pipefail
docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
set -euo pipefail
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
apt-get -o Acquire::Check-Valid-Until=false update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
install_compilers() {
apt-get -o Acquire::Check-Valid-Until=false update -qq &&
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
}
printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
if ! install_compilers; then
printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
install_compilers
fi
tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
'
sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
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
@@ -396,6 +466,30 @@ jobs:
set -euo pipefail
bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
- name: Check Quest tracking IPC
run: |
set -euo pipefail
NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
mkdir -p build/vr/include
cp -R "$VK_INCLUDE/vulkan" build/vr/include/
if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
/tmp/test-xr-tracking
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
/tmp/test-xr-vsync
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
/tmp/test-xr-frames
cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
-Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
-ldl -o /tmp/test-presenter-shutdown
timeout 10 /tmp/test-presenter-shutdown
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
@@ -429,10 +523,15 @@ jobs:
path: |
app/.cxx
app/build/intermediates/cxx
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**') }}
key: cxx-${{ env.NDK_VERSION }}-${{ hashFiles('app/src/main/cpp/**', 'tools/vr/src/xr_*.h') }}
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: |
@@ -440,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:
@@ -478,11 +580,33 @@ 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'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
id: unit-tests
run: |
set -o pipefail
mkdir -p app/build
./gradlew testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log
- name: Upload unit test diagnostics
id: unit-test-diagnostics
if: always() && steps.unit-tests.outcome != 'skipped'
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: |
app/build/unit-tests.log
app/build/test-results/testReleaseUnitTest/
app/build/reports/tests/testReleaseUnitTest/
if-no-files-found: ignore
retention-days: 14
- name: Sign with the testkey (v1 + v2 + v3)
run: |
@@ -512,6 +636,23 @@ jobs:
grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \
|| { echo "::error::apk is not signed with the AOSP testkey"; exit 1; }
- uses: actions/upload-artifact@v4
id: apk-upload
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
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
@@ -520,6 +661,9 @@ jobs:
PR: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
BUILD_OUTCOME: ${{ steps.build.outcome }}
UNIT_TEST_OUTCOME: ${{ steps.unit-tests.outcome }}
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
run: |
set -uo pipefail
@@ -530,8 +674,8 @@ jobs:
echo
[ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
echo
case "$BUILD_OUTCOME" in
success)
case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
success:success:success)
echo "### Build passed"
echo
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
@@ -544,7 +688,28 @@ jobs:
echo
echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
;;
failure)
success:success:*)
echo "### APK publication incomplete"
echo
echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
;;
success:failure:*)
echo "### ERROR: Unit tests failed"
echo
echo "The APK compiled, but unit tests failed. No installable artifact was published."
echo
if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
else
echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
fi
;;
success:*)
echo "### Build incomplete"
echo
echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
;;
failure:*)
echo "### ERROR: Build failed"
echo
echo "The compiler's errors ([full log]($RUN_URL)):"
@@ -562,7 +727,7 @@ jobs:
echo '```'
echo "</details>"
;;
skipped|cancelled)
skipped:*|cancelled:*)
echo "### Not built"
echo
echo "The build did not run (see above, or [the run]($RUN_URL))."
@@ -578,14 +743,6 @@ jobs:
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
fi
- uses: actions/upload-artifact@v4
with:
name: droiddeck-apk
path: app/build/outputs/apk/release/*.apk
if-no-files-found: error
retention-days: 30
compression-level: 0
sign:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -598,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:
+20 -2
View File
@@ -15,10 +15,14 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games, browses their catalogs and libraries, downloads games into the Games storage and adds each one to the Steam client as a non-Steam game, so it launches through Proton like everything else; the cog on its chip row sets which tab a store opens on and the download speed tier.
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Adreno 6xx is experimental: DirectX 11 uses DXVK 2 and may run, and DirectX 12 games can still crash. Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/Droid-Deck/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; with additional options to install Appimages and set up scripts. **Stores** (Setup › Stores › "Show Stores in the rail") signs in to GOG, Epic Games and Amazon Games and downloads games into the Games storage. Epic keeps its direct API install path. Opening Epic Stores prepares Epic’s official Linux launcher preview. When installed, Epic games launched from DroidDeck use that client, and games installed in the client are imported into DroidDeck’s library and shortcuts. The x86-64 client runs through FEX on ARM and DroidDeck retains its Proton hooks; this preview and ARM setup are outside Epic’s listed Linux support. The cog on a store’s chip row sets its opening tab and download speed tier.
On Android 14 and newer, before Steam launches, you must turn on **Disable child process restrictions** in Developer options. If this option is not available in developer settings (Android 12 and 13 devices), first launch of Steam will present a "Fix it for me" button, which will help automate the setup process.
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
## Community
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
@@ -29,6 +33,12 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust throu
After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too.
For the prepared Windows checkout, run `./tools/run_windows.ps1` (optionally with `-Serial <adb-serial>`), or select **DroidDeck XR** in Android Studio and run the **app** module on the connected phone. Machine-specific options live in the ignored `local.properties`: `droiddeck.python` selects a Python executable, `droiddeck.skipRust=true` omits the optional store download engines, and `droiddeck.debugApplicationIdSuffix=.xr` installs **DroidDeck XR Dev** alongside the signed release. The development app has its own runtime and Steam login. The local Java selection is in `.gradle/config.properties`.
`python tools/prepare_windows_runtime.py` restores this checkout's audited runtime inputs from the pinned CI APK, three locally rebuilt preload libraries, and the checksum-pinned Gamescope archive in `build/windows-setup/`. Start Docker Desktop's Linux engine first: preparation now rebuilds DroidDeck's ARM64 VR driver and Vulkan layer from current source and runs their native regression tests. The compiler image is built automatically on first use. Vulkan headers come from the NDK beside `--llvm-strip`, or from an explicit `--vk-include <NDK-sysroot-include>` directory.
To reuse that build, pass `--rebuilt-vr build/windows-setup/vr`. Preparation verifies its `vr-build.json` against the current Git revision, source files, Vulkan headers, output checksums, and ARM64 architecture; missing or stale builds fail instead of falling back to the donor's VR libraries. The script checks the actual donor CI merge tree, still rejects unrelated unaudited runtime changes, and records component provenance in `app/build/windows-runtime-provenance.json`. Preserve these ignored preparation files when rebuilding locally; `clean` removes the prepared assets and their verification manifest.
Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers.
## Limits
@@ -37,4 +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. 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.
+102 -6
View File
@@ -4,6 +4,32 @@ plugins {
apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
// Machine-specific tooling and optional CI-prepared inputs stay out of version control.
def localBuildOptions = new Properties()
def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties')
if (localBuildOptionsFile.asFile.exists()) {
localBuildOptions.load(new StringReader(providers.fileContents(localBuildOptionsFile).asText.get()))
}
def pythonExecutable = project.findProperty('pythonExecutable')
?: localBuildOptions.getProperty('droiddeck.python', 'python3')
def vitureSdkDir = project.hasProperty('vitureSdkDir') ? project.property('vitureSdkDir') : localBuildOptions.getProperty('droiddeck.vitureSdk', '')
def uvcDir = project.hasProperty('uvcDir') ? project.property('uvcDir') : localBuildOptions.getProperty('droiddeck.uvcDir', '')
if (uvcDir) {
['arm64-v8a/libusb.so', 'arm64-v8a/libuvc.a', 'include/libusb.h', 'include/libuvc/libuvc.h',
'licenses/libusb-COPYING', 'licenses/libuvc-LICENSE.txt'].each { required ->
if (!new File(uvcDir, required).isFile()) throw new GradleException("Incomplete UVC build: missing ${required}")
}
}
if (vitureSdkDir) {
['android/arm64-v8a/libglasses.so', 'android/arm64-v8a/libcarina_vio.so',
'android/arm64-v8a/libcloud_protocol.so', 'include/viture_glasses_provider.h',
'android/LICENSE', 'android/NOTICE'].each { required ->
if (!new File(vitureSdkDir, required).isFile()) {
throw new GradleException("Incomplete VITURE Android SDK: missing ${required}")
}
}
}
def gitValue = { List<String> arguments ->
try {
def output = providers.exec {
@@ -58,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')
@@ -79,8 +122,8 @@ android {
applicationId "com.droiddeck.launcher"
minSdkVersion 26
targetSdkVersion 28
versionCode 13
versionName "0.4.0"
versionCode 14
versionName "0.5.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -93,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'
@@ -100,6 +147,8 @@ android {
externalNativeBuild {
cmake {
arguments '-DANDROID_STL=c++_shared'
arguments "-DDROIDDECK_VITURE_SDK_DIR=${vitureSdkDir.toString().replace('\\', '/')}"
arguments "-DDROIDDECK_UVC_DIR=${uvcDir.toString().replace('\\', '/')}"
}
}
}
@@ -128,6 +177,11 @@ android {
debug {
minifyEnabled false
signingConfig signingConfigs.testkey
def localSuffix = localBuildOptions.getProperty('droiddeck.debugApplicationIdSuffix')
if (localSuffix) {
applicationIdSuffix localSuffix
resValue 'string', 'app_name', 'DroidDeck XR Dev'
}
}
release {
minifyEnabled false
@@ -139,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']
@@ -146,10 +205,13 @@ 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']
}
}
buildFeatures {
prefab true
compose true
buildConfig true
}
@@ -157,6 +219,12 @@ android {
testOptions {
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
}
}
compileOptions {
@@ -168,6 +236,16 @@ android {
}
}
// Vendor binaries remain in the user's SDK directory; never vendor them into Git.
if (vitureSdkDir) {
android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android'))
def stageVitureNotices = tasks.register('stageVitureNotices', Copy) {
from(new File(vitureSdkDir, 'android')) { include 'LICENSE', 'NOTICE' }
into(layout.buildDirectory.dir('vitureAssets/licenses/viture'))
}
tasks.named('preBuild').configure { dependsOn(stageVitureNotices) }
}
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
from(rootProject.file('tools/linuxfs/overlay')) {
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
@@ -184,12 +262,21 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) {
into(runtimeAssetsDir)
}
android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir])
if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets'))
if (uvcDir) {
def stageUvcNotices = tasks.register('stageUvcNotices', Copy) {
from(new File(uvcDir, 'licenses'))
into(layout.buildDirectory.dir('uvcAssets/licenses/uvc'))
}
tasks.named('preBuild').configure { dependsOn(stageUvcNotices) }
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('uvcAssets'))
}
tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) }
def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) {
mustRunAfter(stageSessionScripts, stageRuntimeAssets)
workingDir rootProject.projectDir
commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify'
commandLine pythonExecutable, 'tools/release/runtime_inputs.py', 'verify'
}
tasks.configureEach { task ->
if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs)
@@ -197,7 +284,8 @@ tasks.configureEach { task ->
def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores')
def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs')
def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean()
def skipRust = (project.findProperty('skipRust')
?: localBuildOptions.getProperty('droiddeck.skipRust', 'false')).toString().toBoolean()
def buildRustStores = tasks.register('buildRustStores', Exec) {
description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.'
onlyIf { !skipRust }
@@ -225,7 +313,9 @@ if (!skipRust) android.sourceSets.main.jniLibs.srcDir(rustJniLibsDir)
tasks.named('preBuild').configure { dependsOn(buildRustStores) }
dependencies {
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
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'
implementation 'org.tukaani:xz:1.10'
implementation platform('androidx.compose:compose-bom:2024.02.00')
@@ -240,9 +330,15 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'com.flyfishxu:kadb-android:1.2.1'
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 'androidx.webkit:webkit:1.12.1'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
testImplementation 'androidx.compose.ui:ui-test-junit4'
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.2-3@jar'
testRuntimeOnly 'com.github.luben:zstd-jni:1.5.7-4@jar'
}
+82 -6
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
@@ -19,13 +20,19 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<uses-feature android:name="android.hardware.vr.headtracking" android:version="1" android:required="false" />
<application
android:name=".App"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -37,20 +44,32 @@
android:isGame="true">
<meta-data android:name="android.game_mode_config" android:resource="@xml/game_mode_config" />
<service android:name=".glasses.LumaRgbCaptureService" android:exported="false"
android:process=":rgb_diagnostic" />
<meta-data android:name="com.oculus.supportedDevices" android:value="quest2|quest3|quest3s" />
<activity android:name=".QuestXrActivity" android:exported="true" android:launchMode="singleTask"
android:permission="android.permission.DUMP"
android:resizeableActivity="false" android:screenOrientation="landscape"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|keyboard|navigation|uiMode"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen">
<meta-data android:name="android.app.lib_name" android:value="droiddeckxr" />
<intent-filter>
<action android:name="com.droiddeck.launcher.XR" />
<category android:name="com.oculus.intent.category.VR" />
<category android:name="org.khronos.openxr.intent.category.IMMERSIVE_HMD" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
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" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
<data android:scheme="droiddeck" android:host="game" />
</intent-filter>
<intent-filter>
@@ -60,9 +79,35 @@
<intent-filter>
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</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"
@@ -102,11 +147,15 @@
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"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
@@ -128,6 +177,25 @@
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".ui.SecondScreenActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@style/SessionTheme"
android:screenOrientation="sensorLandscape"
android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".gpu.SteamVrRestartActivity"
android:exported="false"
android:process=":vr_recovery"
android:taskAffinity="${applicationId}.vr_recovery"
android:excludeFromRecents="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density"
android:theme="@style/SessionTheme" />
<service
android:name=".session.SessionService"
android:exported="false"
@@ -167,5 +235,13 @@
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
<activity android:name=".ui.SpatialTrackingActivity" android:exported="false" />
<service
android:name="com.google.android.datatransport.runtime.backends.TransportBackendDiscovery"
tools:node="merge">
<meta-data
android:name="backend:com.google.android.datatransport.cct.CctBackendFactory"
tools:node="remove" />
</service>
</application>
</manifest>
@@ -0,0 +1,102 @@
{
"version": 1,
"identity": "luma-ultra-base-v1",
"width": 640,
"height": 480,
"fisheye": false,
"error": 1.1209395390660726,
"verified": false,
"k1": {
"rows": 3,
"cols": 3,
"data": [
226.0499636040297,
0,
321.62348950766386,
0,
228.36987999988767,
242.2674105020684,
0,
0,
1
]
},
"d1": {
"rows": 5,
"cols": 1,
"data": [
-0.28622915867944043,
0.1194613930041054,
0.007984341063494766,
-0.0008220922923559441,
-0.025579805296512992
]
},
"k2": {
"rows": 3,
"cols": 3,
"data": [
240.3338202496282,
0,
320.8790079027563,
0,
241.14047924325277,
260.45566640784716,
0,
0,
1
]
},
"d2": {
"rows": 5,
"cols": 1,
"data": [
-0.27073208928390136,
0.08065508519805098,
-0.001460778493930769,
-0.0003694718992635806,
-0.011452505620454932
]
},
"r": {
"rows": 3,
"cols": 3,
"data": [
0.900501479835182,
0.04473903344740378,
-0.43254537762047496,
-0.08255720039597626,
0.9941919046988283,
-0.06904176485356042,
0.4269442510181227,
0.0978819468416306,
0.8989648107712929
]
},
"t": {
"rows": 3,
"cols": 1,
"data": [
-0.14117171913441395,
0.0014051874404824838,
-0.007555740272006347
]
},
"cameraToHead": [
-0.05607918,
0.023585627,
-0.07998472,
0.01024512,
0.9945703,
0.005859665,
-0.10339546
],
"deviceModel": "VITURE Luma Ultra",
"cameraPair": "left0-right0",
"sourceValidation": {
"exportDate": "2026-10-16",
"sourceDeviceVerified": true,
"capturedViews": 25,
"scope": "Starting geometry from one calibrated Luma Ultra; other units have not been individually validated."
}
}
+36 -14
View File
@@ -11,25 +11,26 @@ 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)
add_library(sdnative SHARED sdnative/sdnative.c)
target_link_libraries(sdnative log)
@@ -37,3 +38,24 @@ add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl)
add_library(termux SHARED termux/termux.c)
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
target_compile_features(droiddeck_viture PRIVATE cxx_std_17)
target_include_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/include")
target_link_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/android/${ANDROID_ABI}")
target_link_libraries(droiddeck_viture PRIVATE glasses log)
endif()
if(DROIDDECK_UVC_DIR)
add_library(rgb_usb SHARED IMPORTED)
set_target_properties(rgb_usb PROPERTIES IMPORTED_LOCATION "${DROIDDECK_UVC_DIR}/${ANDROID_ABI}/libusb.so")
add_library(rgb_uvc STATIC IMPORTED)
set_target_properties(rgb_uvc PROPERTIES IMPORTED_LOCATION "${DROIDDECK_UVC_DIR}/${ANDROID_ABI}/libuvc.a")
add_library(droiddeck_uvc SHARED viture/uvc_bridge.cpp)
target_compile_features(droiddeck_uvc PRIVATE cxx_std_17)
target_include_directories(droiddeck_uvc PRIVATE "${DROIDDECK_UVC_DIR}/include")
target_link_libraries(droiddeck_uvc PRIVATE rgb_uvc rgb_usb log)
target_link_options(droiddeck_uvc PRIVATE -Wl,-z,max-page-size=16384)
endif()
+205 -40
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>
@@ -75,7 +78,16 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8;
static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11;
static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252;
static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2;
static constexpr uint32_t FAKE_INPUT_EVENT_SIZE = sizeof(struct input_event);
// Rings hold the 64-bit input_event layout that the arm64 writers use; 32-bit guests convert on read.
struct RingEvent {
int64_t sec;
int64_t usec;
uint16_t type;
uint16_t code;
int32_t value;
};
static_assert(sizeof(RingEvent) == 24, "ring events must stay ABI-stable");
static constexpr uint32_t FAKE_INPUT_EVENT_SIZE = sizeof(RingEvent);
static constexpr uint32_t FAKE_INPUT_RING_CAPACITY = 4096;
static constexpr unsigned int FAKE_INPUT_MAJOR = 13;
static constexpr unsigned int FAKE_INPUT_EVENT_MINOR_BASE = 64;
@@ -371,6 +383,17 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
syscall(SYS_close, sock);
}
// Must match RumbleComponent.HOLD_MS: play until the slot's next effect or stop.
static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff;
__attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) {
uint16_t duration = effect.replay.length ? effect.replay.length : RUMBLE_HOLD_MS;
if (effect.type == FF_RUMBLE)
send_vibration(effect.u.rumble.strong_magnitude, effect.u.rumble.weak_magnitude, duration, slot);
else if (effect.type == FF_PERIODIC)
send_vibration(effect.u.periodic.magnitude, effect.u.periodic.magnitude, duration, slot);
}
__attribute__((visibility("hidden"))) static void
check_ff_event(const struct input_event *ev, uint16_t slot) {
if (ev->type != EV_FF)
@@ -381,15 +404,7 @@ check_ff_event(const struct input_event *ev, uint16_t slot) {
auto it = ff_effects().find(id);
if (it == ff_effects().end())
return;
uint16_t duration = it->second.replay.length;
if (it->second.type == FF_RUMBLE) {
send_vibration(it->second.u.rumble.strong_magnitude,
it->second.u.rumble.weak_magnitude, duration, slot);
} else if (it->second.type == FF_PERIODIC) {
send_vibration(it->second.u.periodic.magnitude,
it->second.u.periodic.magnitude, duration, slot);
}
send_ff_effect(it->second, slot);
} else {
send_vibration(0, 0, 0, slot);
}
@@ -925,14 +940,13 @@ __attribute__((visibility("hidden"))) static void flush_uinput(UinputWriter &w)
return;
}
FakeInputRingHeader *ring = w.ring;
auto *events = reinterpret_cast<struct input_event *>(reinterpret_cast<uint8_t *>(ring) +
FAKE_INPUT_RING_HEADER_SIZE);
auto *events = reinterpret_cast<RingEvent *>(reinterpret_cast<uint8_t *>(ring) +
FAKE_INPUT_RING_HEADER_SIZE);
uint64_t write_seq = __atomic_load_n(&ring->write_seq, __ATOMIC_RELAXED);
struct timeval now = {};
gettimeofday(&now, nullptr);
for (struct input_event ev : w.pending) {
ev.time = now;
events[write_seq % FAKE_INPUT_RING_CAPACITY] = ev;
for (const struct input_event &ev : w.pending) {
events[write_seq % FAKE_INPUT_RING_CAPACITY] = {now.tv_sec, now.tv_usec, ev.type, ev.code, ev.value};
write_seq++;
if (ev.type == EV_KEY) {
for (size_t i = 0; i < kSnapshotButtonCount; i++)
@@ -3469,16 +3483,7 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
if (effect->id == -1)
effect->id = next_ff_id++;
ff_effects()[effect->id] = *effect;
uint16_t duration = effect->replay.length;
uint16_t slot = static_cast<uint16_t>(event_number);
if (effect->type == FF_RUMBLE) {
send_vibration(effect->u.rumble.strong_magnitude,
effect->u.rumble.weak_magnitude, duration, slot);
} else if (effect->type == FF_PERIODIC) {
send_vibration(effect->u.periodic.magnitude, effect->u.periodic.magnitude,
duration, slot);
}
send_ff_effect(*effect, static_cast<uint16_t>(event_number));
return 0;
} else if (type == 0x45 && number == 0x81) {
int id = (intptr_t)argp;
@@ -3517,6 +3522,12 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
} else if (type == 0x45 && number == 0x90) {
Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event);
return 0;
} else if (type == 0x6A && !strncmp(event, "event", 5)) {
// Older SDL2 builds treat any JSIOCGNAME answer as a joydev node and skip EVIOCGID, then drop the pad as 0000:0000.
Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event);
guard.unlock();
errno = EINVAL;
return -1;
} else if (type == 0x6A && number == 0x1) {
Logger::log("Hooking ioctl JSIOCGVERSION for event %s\n", event);
int version = JS_VERSION;
@@ -3569,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);
@@ -3579,7 +3604,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
}
auto handle = controller->second;
FakeController &fake = *handle;
if (count < FAKE_INPUT_EVENT_SIZE) {
if (count < sizeof(struct input_event)) {
errno = EINVAL;
return -1;
}
@@ -3597,7 +3622,7 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
return -1;
}
for (int attempt = 0; attempt < 8; ++attempt) {
size_t requested = count / FAKE_INPUT_EVENT_SIZE;
size_t requested = count / sizeof(struct input_event);
if (fake.keyframe_remaining == 0) {
SnapshotState snap;
if (!read_snapshot(fake.ring, snap)) break;
@@ -3614,38 +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;
struct input_event ev;
memcpy(&ev, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
RingEvent stored;
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
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 * FAKE_INPUT_EVENT_SIZE, &ev,
FAKE_INPUT_EVENT_SIZE);
}
__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 * FAKE_INPUT_EVENT_SIZE);
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 * FAKE_INPUT_EVENT_SIZE,
&ev, FAKE_INPUT_EVENT_SIZE);
--fake.keyframe_remaining;
}
return static_cast<ssize_t>(events * FAKE_INPUT_EVENT_SIZE);
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;
@@ -3661,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"));
@@ -4091,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);
+13
View File
@@ -0,0 +1,13 @@
cmake_minimum_required(VERSION 3.22.1)
project(droiddeckxr C CXX)
find_package(OpenXR REQUIRED CONFIG)
add_library(droiddeckxr_glue STATIC ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
target_include_directories(droiddeckxr_glue PUBLIC ${ANDROID_NDK}/sources/android/native_app_glue)
add_library(droiddeckxr SHARED quest_xr.cpp)
target_compile_features(droiddeckxr PRIVATE cxx_std_17)
target_compile_definitions(droiddeckxr PRIVATE XR_USE_PLATFORM_ANDROID XR_USE_GRAPHICS_API_OPENGL_ES XR_USE_TIMESPEC)
target_compile_options(droiddeckxr PRIVATE -Wall -Wextra -Wno-missing-field-initializers -Werror=return-type)
target_link_options(droiddeckxr PRIVATE -Wl,-z,max-page-size=16384 -uANativeActivity_onCreate)
target_link_libraries(droiddeckxr PRIVATE droiddeckxr_glue OpenXR::openxr_loader EGL GLESv3 android log)
target_include_directories(droiddeckxr PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/vr/src)
+201
View File
@@ -0,0 +1,201 @@
#pragma once
#include <EGL/eglext.h>
#include <GLES3/gl31.h>
#include <GLES2/gl2ext.h>
#include <android/hardware_buffer.h>
#include "xr_gpu_channel.h"
class QuestGpuFrames {
public:
bool Start(const std::string &filename, EGLDisplay display) {
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
const char *eglExtensions = eglQueryString(display, EGL_EXTENSIONS);
if (!extensions || !eglExtensions || !strstr(extensions, "GL_EXT_external_buffer")
|| !strstr(eglExtensions, "EGL_ANDROID_get_native_client_buffer")) return false;
storage_ = reinterpret_cast<PFNGLBUFFERSTORAGEEXTERNALEXTPROC>(eglGetProcAddress("glBufferStorageExternalEXT"));
client_ = reinterpret_cast<PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC>(eglGetProcAddress("eglGetNativeClientBufferANDROID"));
if (strstr(extensions, "GL_OES_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewOES"));
else if (strstr(extensions, "GL_EXT_texture_view")) view_ = reinterpret_cast<PFNGLTEXTUREVIEWOESPROC>(eglGetProcAddress("glTextureViewEXT"));
if (!storage_ || !client_ || !view_ || filename.size() >= sizeof(sockaddr_un::sun_path)) return false;
const char *source = "#version 310 es\nlayout(local_size_x=8,local_size_y=8)in;"
"layout(std430,binding=0)readonly buffer Source{uint pixels[];};"
"layout(rgba8,binding=0)writeonly uniform highp image2D target;uniform bool bgra;"
"void main(){ivec2 p=ivec2(gl_GlobalInvocationID.xy),size=imageSize(target);"
"if(any(greaterThanEqual(p,size)))return;vec4 value=unpackUnorm4x8(pixels[p.y*size.x+p.x]);"
"imageStore(target,p,bgra?value.bgra:value);}";
GLuint shader = glCreateShader(GL_COMPUTE_SHADER); glShaderSource(shader, 1, &source, nullptr); glCompileShader(shader);
program_ = glCreateProgram(); glAttachShader(program_, shader); glLinkProgram(program_); glDeleteShader(shader);
GLint linked{}; glGetProgramiv(program_, GL_LINK_STATUS, &linked);
if (!linked) { Stop(); return false; }
lock_ = open((filename + ".lock").c_str(), O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0600);
struct stat status{};
if (lock_ < 0 || fstat(lock_, &status) || !S_ISREG(status.st_mode) || status.st_uid != getuid()
|| flock(lock_, LOCK_EX | LOCK_NB)) { Stop(); return false; }
if (lstat(filename.c_str(), &status) == 0) {
if (!S_ISSOCK(status.st_mode) || status.st_uid != getuid() || unlink(filename.c_str())) { Stop(); return false; }
} else if (errno != ENOENT) { Stop(); return false; }
listener_ = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
sockaddr_un address{}; address.sun_family = AF_UNIX; strcpy(address.sun_path, filename.c_str());
if (listener_ < 0 || bind(listener_, reinterpret_cast<sockaddr *>(&address), sizeof(address))) { Stop(); return false; }
filename_ = filename;
if (chmod(filename.c_str(), 0600) || listen(listener_, 2)) { Stop(); return false; }
return true;
}
bool Take(DroidDeckXrFrameHeader &header, GLuint &texture) {
if (listener_ < 0) return false;
if (peer_ < 0) {
peer_ = accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC | SOCK_NONBLOCK);
if (peer_ < 0) return false;
if (!xr_frame_peer(peer_)) { Disconnect(); return false; }
configured_ = false; deadline_ = xr_frame_now() + 2000000000LL;
}
pollfd ready{peer_, POLLIN, 0};
if (poll(&ready, 1, 0) <= 0) {
if (!configured_ && xr_frame_now() > deadline_) Disconnect();
return false;
}
if (!(ready.revents & POLLIN)) { Disconnect(); return false; }
if (!configured_) {
XrGpuRequest request;
if (!xr_gpu_receive(peer_, &request, sizeof(request)) || request.magic != XR_GPU_MAGIC
|| request.version != XR_GPU_VERSION || !xr_gpu_dimensions(request.width, request.height)
|| !Allocate(request.width, request.height)) { Disconnect(); return false; }
XrGpuPoolInfo info; info.width = width_; info.height = height_;
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) info.sizes[index] = sizes_[index];
configured_ = xr_gpu_send(peer_, &info, sizeof(info), fds_.data(), fds_.size());
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
if (!configured_) Disconnect();
return false;
}
XrGpuFrame frame;
if (!xr_gpu_receive(peer_, &frame, sizeof(frame))) { Disconnect(); return false; }
auto state = xr_gpu_frame_state(frame, width_, height_, lastFrame_, xr_frame_now());
if (state == XrGpuFrameState::Invalid) {
rejected_++; Disconnect(); return false;
}
lastFrame_ = frame.header.frame_id;
if (state == XrGpuFrameState::Stale) {
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
dropped_++;
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
return false;
}
if (!Output()) { Disconnect(); return false; }
glMemoryBarrier(GL_ALL_BARRIER_BITS);
glUseProgram(program_); glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffers_[frame.slot]);
glUniform1i(glGetUniformLocation(program_, "bgra"), !!(frame.flags & XR_GPU_BGRA));
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
// The producer may overwrite this slot only after the native GPU has finished reading it.
glFinish();
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
received_++;
header = frame.header;
texture = header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? srgb_ : raw_;
return true;
}
uint64_t Received() const { return received_; }
uint64_t Rejected() const { return rejected_; }
uint64_t Dropped() const { return dropped_; }
void Stop() {
Disconnect(); DropPool();
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
if (program_) glDeleteProgram(program_); program_ = 0;
if (listener_ >= 0) close(listener_); listener_ = -1;
if (!filename_.empty()) unlink(filename_.c_str()); filename_.clear();
if (lock_ >= 0) close(lock_); lock_ = -1;
}
private:
int listener_ = -1, peer_ = -1, lock_ = -1;
std::string filename_;
bool configured_ = false;
int64_t deadline_{};
uint32_t width_{}, height_{}, outputWidth_{}, outputHeight_{};
uint64_t lastFrame_{}, received_{}, rejected_{}, dropped_{};
std::array<AHardwareBuffer *, XR_GPU_SLOTS> hardware_{};
std::array<GLuint, XR_GPU_SLOTS> buffers_{};
std::array<int, XR_GPU_SLOTS> fds_{{-1, -1, -1}};
std::array<uint64_t, XR_GPU_SLOTS> sizes_{};
GLuint raw_{}, srgb_{}, program_{};
PFNGLBUFFERSTORAGEEXTERNALEXTPROC storage_{};
PFNEGLGETNATIVECLIENTBUFFERANDROIDPROC client_{};
PFNGLTEXTUREVIEWOESPROC view_{};
void Disconnect() { if (peer_ >= 0) close(peer_); peer_ = -1; configured_ = false; lastFrame_ = 0; }
void DropPool() {
if (buffers_[0]) glFinish();
glDeleteBuffers(buffers_.size(), buffers_.data()); buffers_.fill(0);
for (auto *&buffer : hardware_) { if (buffer) AHardwareBuffer_release(buffer); buffer = nullptr; }
for (int &fd : fds_) { if (fd >= 0) close(fd); fd = -1; }
}
static int AllocationFd(AHardwareBuffer *buffer, uint64_t bytes, uint64_t &allocation) {
int pair[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, pair)) return -1;
if (AHardwareBuffer_sendHandleToUnixSocket(buffer, pair[0])) { close(pair[0]); close(pair[1]); return -1; }
close(pair[0]);
int selected = -1; unsigned candidates = 0;
for (;;) {
unsigned char data[1024]; iovec bytesReceived{data, sizeof(data)};
alignas(cmsghdr) unsigned char control[CMSG_SPACE(32 * sizeof(int))]{};
msghdr message{}; message.msg_iov = &bytesReceived; message.msg_iovlen = 1;
message.msg_control = control; message.msg_controllen = sizeof(control);
ssize_t result = recvmsg(pair[1], &message, MSG_CMSG_CLOEXEC);
if (result < 0 && errno == EINTR) continue;
if (result <= 0) break;
for (auto *item = CMSG_FIRSTHDR(&message); item; item = CMSG_NXTHDR(&message, item)) {
if (item->cmsg_level != SOL_SOCKET || item->cmsg_type != SCM_RIGHTS) continue;
auto *fds = reinterpret_cast<int *>(CMSG_DATA(item));
for (unsigned index = 0; index < (item->cmsg_len - CMSG_LEN(0)) / sizeof(int); index++) {
struct stat status{};
if (!fstat(fds[index], &status) && status.st_size >= static_cast<int64_t>(bytes)
&& status.st_size <= static_cast<int64_t>(bytes + 65536)) {
candidates++; if (selected >= 0) close(selected);
selected = fds[index]; allocation = status.st_size;
} else close(fds[index]);
}
}
if (message.msg_flags & (MSG_TRUNC | MSG_CTRUNC)) { candidates = 0; break; }
}
close(pair[1]);
if (candidates != 1) { if (selected >= 0) close(selected); return -1; }
return selected;
}
bool Allocate(uint32_t width, uint32_t height) {
DropPool(); width_ = width; height_ = height;
const uint64_t bytes = uint64_t(width) * height * 4;
GLint64 limit{}; glGetInteger64v(GL_MAX_SHADER_STORAGE_BLOCK_SIZE, &limit);
if (bytes > static_cast<uint64_t>(limit)) return false;
AHardwareBuffer_Desc description{}; description.width = bytes; description.height = description.layers = 1;
description.format = AHARDWAREBUFFER_FORMAT_BLOB;
description.usage = AHARDWAREBUFFER_USAGE_GPU_DATA_BUFFER;
glGenBuffers(buffers_.size(), buffers_.data());
for (unsigned index = 0; index < XR_GPU_SLOTS; index++) {
if (AHardwareBuffer_allocate(&description, &hardware_[index])) { DropPool(); return false; }
fds_[index] = AllocationFd(hardware_[index], bytes, sizes_[index]);
if (fds_[index] < 0) { DropPool(); return false; }
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffers_[index]);
storage_(GL_SHADER_STORAGE_BUFFER, 0, bytes, client_(hardware_[index]), 0);
if (glGetError() != GL_NO_ERROR) { DropPool(); return false; }
}
glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
return true;
}
bool Output() {
if (raw_ && outputWidth_ == width_ && outputHeight_ == height_) return true;
glDeleteTextures(1, &srgb_); glDeleteTextures(1, &raw_); srgb_ = raw_ = 0;
glGenTextures(1, &raw_); glBindTexture(GL_TEXTURE_2D, raw_);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, width_, height_);
glGenTextures(1, &srgb_); view_(srgb_, GL_TEXTURE_2D, raw_, GL_SRGB8_ALPHA8, 0, 1, 0, 1);
for (GLuint texture : {raw_, srgb_}) {
glBindTexture(GL_TEXTURE_2D, texture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
outputWidth_ = width_; outputHeight_ = height_;
return glGetError() == GL_NO_ERROR;
}
};
+777
View File
@@ -0,0 +1,777 @@
#include <jni.h>
#include <EGL/egl.h>
#include <GLES3/gl3.h>
#include <android/log.h>
#include <android_native_app_glue.h>
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <array>
#include <vector>
#include <utility>
#include <string>
#include <stdexcept>
#include <cstdio>
#include <cstring>
#include <cmath>
#include <chrono>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <cerrno>
#include <cstddef>
#include <ctime>
#include "xr_tracking.h"
#include "xr_frame_channel.h"
#include "xr_frame_math.h"
#include "gpu_frames.h"
static void check(XrResult result, const char *operation) {
if (XR_FAILED(result)) throw std::runtime_error(std::string(operation) + ": " + std::to_string(result));
}
struct Eye {
XrSwapchain swapchain = XR_NULL_HANDLE;
int width = 0, height = 0;
std::vector<XrSwapchainImageOpenGLESKHR> images;
};
class QuestXR {
public:
explicit QuestXR(android_app *app) : app_(app) {}
~QuestXR() { cleanup(); }
void run() {
initialize();
while (!app_->destroyRequested && !exit_) {
android_poll_source *source = nullptr;
int events = 0;
int pollTimeout = running_ ? 0 : 100;
for (int eventCount = 0; eventCount < 32; eventCount++) {
if (ALooper_pollOnce(pollTimeout, nullptr, &events, reinterpret_cast<void **>(&source)) < 0) break;
if (source) source->process(app_, source);
if (app_->destroyRequested) break;
pollTimeout = 0;
}
receiveFrame();
pollEvents();
if (running_ && !app_->destroyRequested) frame();
}
if (exit_ && !app_->destroyRequested) ANativeActivity_finish(app_->activity);
}
void health(const char *error = "") {
int steamvrState = -1;
if (statusCallback_) {
steamvrState = env_->CallIntMethod(app_->activity->clazz, statusCallback_);
if (env_->ExceptionCheck()) { env_->ExceptionClear(); steamvrState = -1; }
}
std::string path = std::string(app_->activity->externalDataPath) + "/xr-health.json";
std::string temp = path + ".tmp";
FILE *file = std::fopen(temp.c_str(), "w");
if (!file) return;
std::fprintf(file,
"{\"frames\":%llu,\"rendered\":%llu,\"sessionState\":%d,\"headFlags\":%llu,"
"\"headPosition\":[%.6f,%.6f,%.6f],\"controllersActive\":[%s,%s],"
"\"periodNs\":%lld,\"surfaceSwapchain\":%s,\"source\":\"%s\",\"guestFrames\":%lld,\"steamvrState\":%d,"
"\"receivedFrames\":%llu,\"rejectedFrames\":%llu,\"uploadedFrames\":%llu,\"sourceFrameId\":%llu,"
"\"gpuReceivedFrames\":%llu,\"gpuDroppedFrames\":%llu,\"gpuVerifiedFrameId\":%llu,\"gpuChecksum\":%u,\"error\":\"%s\"}\n",
(unsigned long long)frames_, (unsigned long long)rendered_, state_,
(unsigned long long)head_.locationFlags,
head_.pose.position.x, head_.pose.position.y, head_.pose.position.z,
handActive_[0] ? "true" : "false", handActive_[1] ? "true" : "false",
(long long)period_, "false", "frames",
0LL, steamvrState, (unsigned long long)(receiver_.Received() + gpuFrames_.Received()),
(unsigned long long)(receiver_.Rejected() + gpuFrames_.Rejected()),
(unsigned long long)uploadedFrames_, (unsigned long long)sourceHeader_.frame_id,
(unsigned long long)gpuFrames_.Received(), (unsigned long long)gpuFrames_.Dropped(),
(unsigned long long)verifiedFrame_, gpuChecksum_, error);
std::fclose(file);
std::rename(temp.c_str(), path.c_str());
}
private:
android_app *app_;
JNIEnv *env_ = nullptr;
XrInstance instance_ = XR_NULL_HANDLE;
XrSession session_ = XR_NULL_HANDLE;
XrSystemId system_ = XR_NULL_SYSTEM_ID;
XrSpace world_ = XR_NULL_HANDLE, view_ = XR_NULL_HANDLE;
XrActionSet actions_ = XR_NULL_HANDLE;
XrAction grip_ = XR_NULL_HANDLE, trigger_ = XR_NULL_HANDLE, vibration_ = XR_NULL_HANDLE;
XrAction squeeze_ = XR_NULL_HANDLE, stick_ = XR_NULL_HANDLE, stickClick_ = XR_NULL_HANDLE;
XrAction primary_ = XR_NULL_HANDLE, secondary_ = XR_NULL_HANDLE, menu_ = XR_NULL_HANDLE;
XrAction triggerTouch_ = XR_NULL_HANDLE, stickTouch_ = XR_NULL_HANDLE, primaryTouch_ = XR_NULL_HANDLE;
XrAction secondaryTouch_ = XR_NULL_HANDLE, thumbrestTouch_ = XR_NULL_HANDLE;
DroidDeckXrHand inputs_[2]{};
PFN_xrConvertTimeToTimespecTimeKHR convertTime_ = nullptr;
bool timespecSupported_ = false;
PFN_xrPerfSettingsSetPerformanceLevelEXT setPerformance_ = nullptr;
bool performanceSupported_ = false;
XrPath hands_[2]{};
XrSpace handSpaces_[2]{};
XrSpaceLocation head_{XR_TYPE_SPACE_LOCATION};
XrSpaceLocation handLocations_[2]{{XR_TYPE_SPACE_LOCATION}, {XR_TYPE_SPACE_LOCATION}};
bool handActive_[2]{};
bool running_ = false, exit_ = false, quitRequested_ = false;
bool guestAttached_ = false, verifyPixels_ = false;
XrFrameReceiver receiver_;
QuestGpuFrames gpuFrames_;
DroidDeckXrFrameHeader sourceHeader_{};
uint64_t uploadedFrames_ = 0, verifiedFrame_ = 0;
uint32_t gpuChecksum_ = 0;
GLuint sourceTexture_ = 0, sourceProgram_ = 0;
GLuint cpuTexture_ = 0;
jmethodID frameCallback_ = nullptr;
jmethodID activeCallback_ = nullptr;
jmethodID statusCallback_ = nullptr;
DroidDeckXrTracking *tracking_ = nullptr;
DroidDeckXrHaptics *haptics_ = nullptr;
uint64_t lastPulse_[2]{};
XrSessionState state_ = XR_SESSION_STATE_UNKNOWN;
uint64_t frames_ = 0, rendered_ = 0;
XrDuration period_ = 0;
EGLDisplay display_ = EGL_NO_DISPLAY;
EGLContext context_ = EGL_NO_CONTEXT;
EGLSurface surface_ = EGL_NO_SURFACE;
std::array<Eye, 2> eyes_;
GLuint framebuffer_ = 0, vertexArray_ = 0;
GLint sourceEncoding_ = 0, eyeEncoding_ = 0;
std::string captureRequest() {
if (frames_ % 9) return {};
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.request";
FILE *request = std::fopen(file.c_str(), "r");
if (!request) return {};
char token[34]{};
size_t size = std::fread(token, 1, sizeof(token) - 1, request);
std::fclose(request);
unlink(file.c_str());
if (size != 32 || std::strspn(token, "0123456789abcdef") != 32) return {};
unlink((std::string(app_->activity->externalDataPath) + "/xr-capture.json").c_str());
return token;
}
bool captureTexture(GLuint texture, int width, int height, const char *name) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
GLint encoding = 0;
glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING, &encoding);
if (!std::strcmp(name, "source")) sourceEncoding_ = encoding;
else eyeEncoding_ = encoding;
std::vector<unsigned char> pixels((size_t)width * height * 4);
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
bool ok = glGetError() == GL_NO_ERROR;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (!ok) return false;
std::string file = std::string(app_->activity->externalDataPath) +
"/xr-capture-" + name + ".ppm";
std::string temporary = file + ".tmp";
FILE *output = std::fopen(temporary.c_str(), "wb");
if (!output) return false;
ok = std::fprintf(output, "P6\n%d %d\n255\n", width, height) > 0;
std::vector<unsigned char> row((size_t)width * 3);
for (int y = height - 1; y >= 0 && ok; y--) {
for (int x = 0; x < width; x++)
std::memcpy(row.data() + x * 3, pixels.data() + ((size_t)y * width + x) * 4, 3);
ok = std::fwrite(row.data(), 1, row.size(), output) == row.size();
}
if (std::fclose(output) != 0) ok = false;
if (!ok) { unlink(temporary.c_str()); return false; }
return std::rename(temporary.c_str(), file.c_str()) == 0;
}
void captureResult(const std::string &token, bool ok, bool valid,
const XrCompositionLayerProjectionView (&views)[2], XrTime displayTime) {
std::string file = std::string(app_->activity->externalDataPath) + "/xr-capture.json";
std::string temporary = file + ".tmp";
FILE *output = std::fopen(temporary.c_str(), "w");
if (!output) return;
std::fprintf(output, "{\"id\":\"%s\",\"ok\":%s,\"validLayer\":%s,\"frame\":%llu,"
"\"sourceFrameId\":%llu,\"displayTime\":%lld,\"sessionState\":%d,\"eyes\":[",
token.c_str(), ok ? "true" : "false", valid ? "true" : "false",
(unsigned long long)frames_, (unsigned long long)sourceHeader_.frame_id, (long long)displayTime, state_);
for (int eye = 0; eye < 2; eye++) {
const auto &pose = views[eye].pose;
const auto &fov = views[eye].fov;
std::fprintf(output, "%s{\"width\":%d,\"height\":%d,\"position\":[%g,%g,%g],"
"\"orientation\":[%g,%g,%g,%g],\"fov\":[%g,%g,%g,%g]}",
eye ? "," : "", eyes_[eye].width, eyes_[eye].height,
pose.position.x, pose.position.y, pose.position.z,
pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w,
fov.angleLeft, fov.angleRight, fov.angleUp, fov.angleDown);
}
const char *extensions = reinterpret_cast<const char *>(glGetString(GL_EXTENSIONS));
const bool srgbControl = extensions && std::strstr(extensions, "GL_EXT_sRGB_write_control");
std::fprintf(output, "],\"sourceImage\":true,\"sourceColorSpace\":\"%s\",\"sourceEncoding\":%d,\"eyeEncoding\":%d,"
"\"srgbWriteControl\":%s,\"srgbWriteEnabled\":%s}\n",
sourceHeader_.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? "srgb" : "linear", sourceEncoding_, eyeEncoding_,
srgbControl ? "true" : "false", srgbControl && glIsEnabled(0x8DB9) ? "true" : "false");
if (std::fclose(output) == 0) std::rename(temporary.c_str(), file.c_str());
}
XrPath path(const char *text) {
XrPath result{};
check(xrStringToPath(instance_, text, &result), "xrStringToPath");
return result;
}
void initialize() {
app_->userData = this;
app_->onInputEvent = [](android_app *app, AInputEvent *event) -> int32_t {
if (AInputEvent_getType(event) != AINPUT_EVENT_TYPE_KEY || AKeyEvent_getKeyCode(event) != AKEYCODE_BACK
|| AKeyEvent_getAction(event) != AKEY_EVENT_ACTION_UP) return 0;
auto *runtime = static_cast<QuestXR *>(app->userData);
if (runtime->running_) {
XrResult result = xrRequestExitSession(runtime->session_);
if (XR_SUCCEEDED(result)) runtime->quitRequested_ = true;
else __android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "exit request failed: %d", result);
} else ANativeActivity_finish(app->activity);
return 1;
};
app_->activity->vm->AttachCurrentThread(&env_, nullptr);
jclass activityClass = env_->GetObjectClass(app_->activity->clazz);
jmethodID getIntent = env_->GetMethodID(activityClass, "getIntent", "()Landroid/content/Intent;");
jobject intent = env_->CallObjectMethod(app_->activity->clazz, getIntent);
jclass intentClass = env_->GetObjectClass(intent);
jmethodID getBoolean = env_->GetMethodID(intentClass, "getBooleanExtra", "(Ljava/lang/String;Z)Z");
jstring extra = env_->NewStringUTF("frame_verify");
verifyPixels_ = env_->CallBooleanMethod(intent, getBoolean, extra, JNI_FALSE);
env_->DeleteLocalRef(extra);
activeCallback_ = env_->GetMethodID(activityClass, "onXrActive", "(Z)V");
frameCallback_ = env_->GetMethodID(activityClass, "onXrFrame", "(J)V");
statusCallback_ = env_->GetMethodID(activityClass, "onXrStatus", "()I");
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("XR activity bridge methods unavailable"); }
env_->DeleteLocalRef(intentClass); env_->DeleteLocalRef(intent); env_->DeleteLocalRef(activityClass);
PFN_xrInitializeLoaderKHR initializeLoader = nullptr;
check(xrGetInstanceProcAddr(XR_NULL_HANDLE, "xrInitializeLoaderKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&initializeLoader)), "loader init entrypoint");
XrLoaderInitInfoAndroidKHR loader{XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR};
loader.applicationVM = app_->activity->vm;
loader.applicationContext = app_->activity->clazz;
check(initializeLoader(reinterpret_cast<XrLoaderInitInfoBaseHeaderKHR *>(&loader)), "xrInitializeLoaderKHR");
uint32_t count = 0;
check(xrEnumerateInstanceExtensionProperties(nullptr, 0, &count, nullptr), "extensions count");
std::vector<XrExtensionProperties> extensions(count, {XR_TYPE_EXTENSION_PROPERTIES});
check(xrEnumerateInstanceExtensionProperties(nullptr, count, &count, extensions.data()), "extensions");
for (const auto &extension : extensions) {
if (!std::strcmp(extension.extensionName, XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME))
timespecSupported_ = true;
if (!std::strcmp(extension.extensionName, XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME))
performanceSupported_ = true;
}
std::vector<const char *> enabled{XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME, XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME};
if (timespecSupported_) enabled.push_back(XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME);
if (performanceSupported_) enabled.push_back(XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME);
XrInstanceCreateInfoAndroidKHR androidInfo{XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR};
androidInfo.applicationVM = app_->activity->vm;
androidInfo.applicationActivity = app_->activity->clazz;
XrInstanceCreateInfo create{XR_TYPE_INSTANCE_CREATE_INFO};
create.next = &androidInfo;
std::strcpy(create.applicationInfo.applicationName, "DroidDeck XR");
create.applicationInfo.applicationVersion = 1;
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = enabled.size();
create.enabledExtensionNames = enabled.data();
check(xrCreateInstance(&create, &instance_), "xrCreateInstance");
if (performanceSupported_) {
XrResult result = xrGetInstanceProcAddr(instance_, "xrPerfSettingsSetPerformanceLevelEXT",
reinterpret_cast<PFN_xrVoidFunction *>(&setPerformance_));
if (XR_FAILED(result)) setPerformance_ = nullptr;
}
if (timespecSupported_) check(xrGetInstanceProcAddr(instance_, "xrConvertTimeToTimespecTimeKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&convertTime_)), "time conversion entrypoint");
XrSystemGetInfo systemInfo{XR_TYPE_SYSTEM_GET_INFO};
systemInfo.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY;
check(xrGetSystem(instance_, &systemInfo, &system_), "xrGetSystem");
PFN_xrGetOpenGLESGraphicsRequirementsKHR requirementsFunction = nullptr;
check(xrGetInstanceProcAddr(instance_, "xrGetOpenGLESGraphicsRequirementsKHR",
reinterpret_cast<PFN_xrVoidFunction *>(&requirementsFunction)), "GLES requirements entrypoint");
XrGraphicsRequirementsOpenGLESKHR requirements{XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR};
check(requirementsFunction(instance_, system_, &requirements), "GLES requirements");
if (requirements.minApiVersionSupported > XR_MAKE_VERSION(3, 0, 0)
|| requirements.maxApiVersionSupported < XR_MAKE_VERSION(3, 0, 0))
throw std::runtime_error("Runtime does not support GLES 3.0");
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (!eglInitialize(display_, nullptr, nullptr)) throw std::runtime_error("eglInitialize failed");
const EGLint attributes[] = {EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_SURFACE_TYPE, EGL_PBUFFER_BIT | EGL_WINDOW_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 24, EGL_NONE};
EGLConfig config{};
EGLint found = 0;
if (!eglChooseConfig(display_, attributes, &config, 1, &found) || found != 1)
throw std::runtime_error("EGL config unavailable");
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE};
context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttributes);
const EGLint surfaceAttributes[] = {EGL_WIDTH, 16, EGL_HEIGHT, 16, EGL_NONE};
surface_ = eglCreatePbufferSurface(display_, config, surfaceAttributes);
if (!eglMakeCurrent(display_, surface_, surface_, context_)) throw std::runtime_error("eglMakeCurrent failed");
XrGraphicsBindingOpenGLESAndroidKHR binding{XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR};
binding.display = display_; binding.config = config; binding.context = context_;
XrSessionCreateInfo sessionInfo{XR_TYPE_SESSION_CREATE_INFO};
sessionInfo.next = &binding; sessionInfo.systemId = system_;
check(xrCreateSession(instance_, &sessionInfo, &session_), "xrCreateSession");
XrReferenceSpaceCreateInfo reference{XR_TYPE_REFERENCE_SPACE_CREATE_INFO};
reference.poseInReferenceSpace.orientation.w = 1;
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_STAGE;
XrResult referenceResult = xrCreateReferenceSpace(session_, &reference, &world_);
if (referenceResult == XR_ERROR_REFERENCE_SPACE_UNSUPPORTED) {
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL;
reference.poseInReferenceSpace.position.y = -1.6f;
check(xrCreateReferenceSpace(session_, &reference, &world_), "local standing space");
} else check(referenceResult, "stage space");
reference.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW;
reference.poseInReferenceSpace.position.y = 0;
check(xrCreateReferenceSpace(session_, &reference, &view_), "view space");
initializeActions();
initializeImages();
initializeGraphics();
initializeTracking();
{
std::string filename = std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_FRAME_SOCKET;
if (!receiver_.Start(filename)) throw std::runtime_error("XR frame receiver unavailable");
bool gpu = !verifyPixels_ && gpuFrames_.Start(std::string(app_->activity->internalDataPath) + "/.wayland-rt/" DROIDDECK_XR_GPU_SOCKET, display_);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "GPU frame channel available=%d", gpu);
}
health();
}
void initializeActions() {
hands_[0] = path("/user/hand/left"); hands_[1] = path("/user/hand/right");
XrActionSetCreateInfo setInfo{XR_TYPE_ACTION_SET_CREATE_INFO};
std::strcpy(setInfo.actionSetName, "quest"); std::strcpy(setInfo.localizedActionSetName, "Quest controllers");
check(xrCreateActionSet(instance_, &setInfo, &actions_), "action set");
auto action = [&](const char *name, XrActionType type, XrAction *handle) {
XrActionCreateInfo info{XR_TYPE_ACTION_CREATE_INFO};
std::strcpy(info.actionName, name); std::strcpy(info.localizedActionName, name);
info.actionType = type; info.countSubactionPaths = 2; info.subactionPaths = hands_;
check(xrCreateAction(actions_, &info, handle), "create action");
};
action("grip_pose", XR_ACTION_TYPE_POSE_INPUT, &grip_);
action("trigger", XR_ACTION_TYPE_FLOAT_INPUT, &trigger_);
action("squeeze", XR_ACTION_TYPE_FLOAT_INPUT, &squeeze_);
action("stick", XR_ACTION_TYPE_VECTOR2F_INPUT, &stick_);
action("stick_click", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickClick_);
action("primary", XR_ACTION_TYPE_BOOLEAN_INPUT, &primary_);
action("secondary", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondary_);
action("menu", XR_ACTION_TYPE_BOOLEAN_INPUT, &menu_);
action("trigger_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &triggerTouch_);
action("stick_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &stickTouch_);
action("primary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &primaryTouch_);
action("secondary_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &secondaryTouch_);
action("thumbrest_touch", XR_ACTION_TYPE_BOOLEAN_INPUT, &thumbrestTouch_);
action("vibration", XR_ACTION_TYPE_VIBRATION_OUTPUT, &vibration_);
std::vector<XrActionSuggestedBinding> bindings;
for (const char *hand : {"left", "right"}) {
std::string root = std::string("/user/hand/") + hand;
bindings.push_back({grip_, path((root + "/input/grip/pose").c_str())});
bindings.push_back({trigger_, path((root + "/input/trigger/value").c_str())});
bindings.push_back({triggerTouch_, path((root + "/input/trigger/touch").c_str())});
bindings.push_back({squeeze_, path((root + "/input/squeeze/value").c_str())});
bindings.push_back({stick_, path((root + "/input/thumbstick").c_str())});
bindings.push_back({stickClick_, path((root + "/input/thumbstick/click").c_str())});
bindings.push_back({stickTouch_, path((root + "/input/thumbstick/touch").c_str())});
bindings.push_back({thumbrestTouch_, path((root + "/input/thumbrest/touch").c_str())});
bindings.push_back({primary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/click" : "/input/a/click")).c_str())});
bindings.push_back({secondary_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/click" : "/input/b/click")).c_str())});
bindings.push_back({primaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/x/touch" : "/input/a/touch")).c_str())});
bindings.push_back({secondaryTouch_, path((root + (std::strcmp(hand, "left") == 0 ? "/input/y/touch" : "/input/b/touch")).c_str())});
bindings.push_back({vibration_, path((root + "/output/haptic").c_str())});
}
bindings.push_back({menu_, path("/user/hand/left/input/menu/click")});
XrInteractionProfileSuggestedBinding suggestion{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING};
suggestion.interactionProfile = path("/interaction_profiles/oculus/touch_controller");
suggestion.countSuggestedBindings = bindings.size(); suggestion.suggestedBindings = bindings.data();
check(xrSuggestInteractionProfileBindings(instance_, &suggestion), "Touch bindings");
XrSessionActionSetsAttachInfo attach{XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO};
attach.countActionSets = 1; attach.actionSets = &actions_;
check(xrAttachSessionActionSets(session_, &attach), "attach actions");
for (int hand = 0; hand < 2; hand++) {
XrActionSpaceCreateInfo space{XR_TYPE_ACTION_SPACE_CREATE_INFO};
space.action = grip_; space.subactionPath = hands_[hand]; space.poseInActionSpace.orientation.w = 1;
check(xrCreateActionSpace(session_, &space, &handSpaces_[hand]), "controller space");
}
}
void initializeImages() {
uint32_t count = 0;
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
0, &count, nullptr), "view count");
if (count != 2) throw std::runtime_error("Expected two stereo views");
std::vector<XrViewConfigurationView> views(count, {XR_TYPE_VIEW_CONFIGURATION_VIEW});
check(xrEnumerateViewConfigurationViews(instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO,
count, &count, views.data()), "view configuration");
check(xrEnumerateSwapchainFormats(session_, 0, &count, nullptr), "format count");
std::vector<int64_t> formats(count);
check(xrEnumerateSwapchainFormats(session_, count, &count, formats.data()), "formats");
int64_t format = 0;
for (int64_t candidate : formats) if (candidate == GL_SRGB8_ALPHA8) format = candidate;
if (!format) throw std::runtime_error("sRGB swapchain unavailable");
for (int eye = 0; eye < 2; eye++) {
Eye &target = eyes_[eye];
target.width = views[eye].recommendedImageRectWidth;
target.height = views[eye].recommendedImageRectHeight;
XrSwapchainCreateInfo create{XR_TYPE_SWAPCHAIN_CREATE_INFO};
create.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT;
create.format = format; create.sampleCount = 1;
create.width = target.width; create.height = target.height;
create.faceCount = 1; create.arraySize = 1; create.mipCount = 1;
check(xrCreateSwapchain(session_, &create, &target.swapchain), "swapchain");
check(xrEnumerateSwapchainImages(target.swapchain, 0, &count, nullptr), "image count");
target.images.resize(count, {XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR});
check(xrEnumerateSwapchainImages(target.swapchain, count, &count,
reinterpret_cast<XrSwapchainImageBaseHeader *>(target.images.data())), "images");
}
}
void initializeTracking() {
std::string directory = std::string(app_->activity->internalDataPath) + "/.wayland-rt";
if (mkdir(directory.c_str(), 0700) != 0 && errno != EEXIST)
throw std::runtime_error("XR tracking directory unavailable");
auto mapFile = [&](const char *name, size_t size) {
std::string filename = directory + "/" + name;
int descriptor = open(filename.c_str(), O_CREAT | O_RDWR | O_CLOEXEC, 0600);
if (descriptor < 0) throw std::runtime_error("XR IPC file unavailable");
if (ftruncate(descriptor, size) != 0) {
close(descriptor); throw std::runtime_error("XR IPC file size failed");
}
void *mapping = mmap(nullptr, size, PROT_READ | PROT_WRITE, MAP_SHARED, descriptor, 0);
close(descriptor);
if (mapping == MAP_FAILED) throw std::runtime_error("XR IPC map failed");
return mapping;
};
tracking_ = static_cast<DroidDeckXrTracking *>(mapFile(DROIDDECK_XR_FILE, sizeof(DroidDeckXrTracking)));
haptics_ = static_cast<DroidDeckXrHaptics *>(mapFile(DROIDDECK_XR_HAPTIC_FILE, sizeof(DroidDeckXrHaptics)));
haptics_->magic = DROIDDECK_XR_HAPTIC_MAGIC; haptics_->version = DROIDDECK_XR_VERSION;
for (int hand = 0; hand < 2; hand++) lastPulse_[hand] = haptics_->hands[hand].id;
}
static DroidDeckXrPose posePacket(const XrPosef &pose, XrSpaceLocationFlags flags) {
return {{pose.orientation.x, pose.orientation.y, pose.orientation.z, pose.orientation.w},
{pose.position.x, pose.position.y, pose.position.z}, (uint32_t)flags};
}
void publishTracking(const XrView views[2], XrTime predicted) {
if (!tracking_) return;
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
DroidDeckXrTracking packet{};
packet.magic = DROIDDECK_XR_MAGIC; packet.version = DROIDDECK_XR_VERSION;
packet.active = state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED;
packet.sampled_ns = (int64_t)now.tv_sec * 1000000000LL + now.tv_nsec;
if (convertTime_) {
timespec displayTime{};
check(convertTime_(instance_, predicted, &displayTime), "display time conversion");
packet.predicted_ns = (int64_t)displayTime.tv_sec * 1000000000LL + displayTime.tv_nsec;
}
packet.period_ns = period_;
packet.head = posePacket(head_.pose, head_.locationFlags);
for (int eye = 0; eye < 2; eye++) {
packet.eyes[eye].pose = posePacket(views[eye].pose, head_.locationFlags);
packet.eyes[eye].fov[0] = views[eye].fov.angleLeft;
packet.eyes[eye].fov[1] = views[eye].fov.angleRight;
packet.eyes[eye].fov[2] = views[eye].fov.angleUp;
packet.eyes[eye].fov[3] = views[eye].fov.angleDown;
packet.eyes[eye].width = eyes_[eye].width; packet.eyes[eye].height = eyes_[eye].height;
packet.hands[eye] = inputs_[eye];
packet.hands[eye].pose = posePacket(handLocations_[eye].pose, handLocations_[eye].locationFlags);
packet.hands[eye].active = handActive_[eye];
}
uint32_t sequence = __atomic_load_n(&tracking_->seq, __ATOMIC_RELAXED);
__atomic_store_n(&tracking_->seq, sequence | 1u, __ATOMIC_SEQ_CST);
__atomic_thread_fence(__ATOMIC_SEQ_CST);
std::memcpy(tracking_, &packet, offsetof(DroidDeckXrTracking, seq));
std::memcpy(reinterpret_cast<char *>(tracking_) + offsetof(DroidDeckXrTracking, active),
reinterpret_cast<const char *>(&packet) + offsetof(DroidDeckXrTracking, active),
sizeof(packet) - offsetof(DroidDeckXrTracking, active));
__atomic_store_n(&tracking_->seq, (sequence | 1u) + 1u, __ATOMIC_RELEASE);
}
void attachGuest(bool attach) {
if (attach == guestAttached_) return;
env_->CallVoidMethod(app_->activity->clazz, activeCallback_, static_cast<jboolean>(attach));
if (env_->ExceptionCheck()) {
env_->ExceptionDescribe(); env_->ExceptionClear(); throw std::runtime_error("Guest XR handshake failed");
}
guestAttached_ = attach;
}
void pollHaptics() {
if (!haptics_) return;
DroidDeckXrHaptics packet{};
uint32_t sequence = __atomic_load_n(&haptics_->seq, __ATOMIC_ACQUIRE);
if (sequence & 1u) return;
std::memcpy(&packet, haptics_, sizeof(packet));
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n(&haptics_->seq, __ATOMIC_RELAXED) != sequence
|| packet.magic != DROIDDECK_XR_HAPTIC_MAGIC || packet.version != DROIDDECK_XR_VERSION) return;
for (int hand = 0; hand < 2; hand++) {
const auto &pulse = packet.hands[hand];
if (pulse.id == lastPulse_[hand]) continue;
lastPulse_[hand] = pulse.id;
if (state_ != XR_SESSION_STATE_FOCUSED || !handActive_[hand]
|| pulse.duration_ns < 1000000 || pulse.duration_ns > 1000000000
|| !std::isfinite(pulse.amplitude) || pulse.amplitude < 0 || pulse.amplitude > 1
|| !std::isfinite(pulse.frequency) || pulse.frequency < 0) continue;
XrHapticActionInfo info{XR_TYPE_HAPTIC_ACTION_INFO}; info.action = vibration_; info.subactionPath = hands_[hand];
XrHapticVibration vibration{XR_TYPE_HAPTIC_VIBRATION};
vibration.duration = pulse.duration_ns; vibration.amplitude = pulse.amplitude; vibration.frequency = pulse.frequency;
check(xrApplyHapticFeedback(session_, &info, reinterpret_cast<XrHapticBaseHeader *>(&vibration)), "guest haptics");
}
}
GLuint shader(GLenum type, const char *text) {
GLuint handle = glCreateShader(type);
glShaderSource(handle, 1, &text, nullptr); glCompileShader(handle);
GLint success = 0; glGetShaderiv(handle, GL_COMPILE_STATUS, &success);
if (!success) { glDeleteShader(handle); throw std::runtime_error("Shader compile failed"); }
return handle;
}
void initializeGraphics() {
glGenFramebuffers(1, &framebuffer_);
glGenVertexArrays(1, &vertexArray_);
GLuint vertex = shader(GL_VERTEX_SHADER,
"#version 300 es\nout vec2 uv; uniform float eye; void main(){vec2 coordinate=vec2(float((gl_VertexID<<1)&2),float(gl_VertexID&2));uv=vec2(coordinate.x*0.5+eye*0.5,1.0-coordinate.y);gl_Position=vec4(coordinate*2.0-1.0,0.0,1.0);}");
GLuint fragment = shader(GL_FRAGMENT_SHADER,
"#version 300 es\nprecision highp float; in vec2 uv; uniform sampler2D source; uniform float eye; out vec4 outputColor; void main(){float edge=0.5/float(textureSize(source,0).x);vec2 coordinate=vec2(clamp(uv.x,eye*0.5+edge,(eye+1.0)*0.5-edge),uv.y);outputColor=texture(source,coordinate);}");
sourceProgram_ = glCreateProgram(); glAttachShader(sourceProgram_, vertex); glAttachShader(sourceProgram_, fragment);
glLinkProgram(sourceProgram_); glDeleteShader(vertex); glDeleteShader(fragment);
GLint linked = 0; glGetProgramiv(sourceProgram_, GL_LINK_STATUS, &linked);
if (!linked) throw std::runtime_error("Frame shader link failed");
glGenTextures(1, &sourceTexture_); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
cpuTexture_ = sourceTexture_;
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
}
void receiveFrame() {
if (gpuFrames_.Take(sourceHeader_, sourceTexture_)) {
uploadedFrames_++;
if (uploadedFrames_ % 72 == 1) health();
return;
}
DroidDeckXrPixels frame;
if (!receiver_.Take(&frame)) return;
GLint maximum = 0; glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maximum);
if (frame.header.width > static_cast<uint32_t>(maximum) || frame.header.height > static_cast<uint32_t>(maximum))
throw std::runtime_error("XR source exceeds GPU texture limit");
sourceTexture_ = cpuTexture_;
glBindTexture(GL_TEXTURE_2D, sourceTexture_);
GLenum encoding = frame.header.color_space == DROIDDECK_XR_COLOR_SRGB_RGBA8 ? GL_SRGB8_ALPHA8 : GL_RGBA8;
glTexImage2D(GL_TEXTURE_2D, 0, encoding, frame.header.width, frame.header.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, frame.pixels.data());
if (verifyPixels_) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sourceTexture_, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Frame readback framebuffer incomplete");
std::vector<unsigned char> readback(frame.header.bytes);
glReadPixels(0, 0, frame.header.width, frame.header.height, GL_RGBA, GL_UNSIGNED_BYTE, readback.data());
if (readback != frame.pixels) throw std::runtime_error("Frame GPU readback does not match owned pixels");
gpuChecksum_ = 2166136261u;
for (unsigned char value : readback) gpuChecksum_ = (gpuChecksum_ ^ value) * 16777619u;
verifiedFrame_ = frame.header.frame_id;
glBindFramebuffer(GL_FRAMEBUFFER, 0);
}
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("Frame texture upload failed");
sourceHeader_ = frame.header;
receiver_.Recycle(std::move(frame.pixels));
uploadedFrames_++;
if (verifyPixels_ || uploadedFrames_ % 72 == 1) health();
}
void render(Eye &eye, uint32_t image) {
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, eye.images[image].image, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
throw std::runtime_error("Incomplete eye framebuffer");
{
glViewport(0, 0, eye.width, eye.height); glDisable(GL_DEPTH_TEST);
glUseProgram(sourceProgram_); glBindVertexArray(vertexArray_);
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, sourceTexture_);
glUniform1i(glGetUniformLocation(sourceProgram_, "source"), 0);
glUniform1f(glGetUniformLocation(sourceProgram_, "eye"), &eye == &eyes_[1] ? 1.f : 0.f);
glDrawArrays(GL_TRIANGLES, 0, 3);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (glGetError() != GL_NO_ERROR) throw std::runtime_error("OpenGL render error");
glFlush();
}
void pollEvents() {
XrEventDataBuffer event{XR_TYPE_EVENT_DATA_BUFFER};
XrResult result;
while ((result = xrPollEvent(instance_, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
state_ = reinterpret_cast<XrEventDataSessionStateChanged *>(&event)->state;
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "session state=%d", state_);
attachGuest(state_ == XR_SESSION_STATE_VISIBLE || state_ == XR_SESSION_STATE_FOCUSED);
if (state_ == XR_SESSION_STATE_READY) {
XrSessionBeginInfo begin{XR_TYPE_SESSION_BEGIN_INFO};
begin.primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
check(xrBeginSession(session_, &begin), "xrBeginSession"); running_ = true;
if (setPerformance_) {
for (auto domain : {XR_PERF_SETTINGS_DOMAIN_CPU_EXT, XR_PERF_SETTINGS_DOMAIN_GPU_EXT}) {
XrResult request = setPerformance_(session_, domain, XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "sustained performance domain=%d result=%d", domain, request);
}
}
} else if (state_ == XR_SESSION_STATE_STOPPING) {
check(xrEndSession(session_), "xrEndSession"); running_ = false;
if (quitRequested_) exit_ = true;
} else if (state_ == XR_SESSION_STATE_EXITING || state_ == XR_SESSION_STATE_LOSS_PENDING) exit_ = true;
health();
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) exit_ = true;
else if (event.type == XR_TYPE_EVENT_DATA_PERF_SETTINGS_EXT) {
const auto &performance = *reinterpret_cast<XrEventDataPerfSettingsEXT *>(&event);
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "performance domain=%d subdomain=%d level=%d->%d",
performance.domain, performance.subDomain, performance.fromLevel, performance.toLevel);
}
event = {XR_TYPE_EVENT_DATA_BUFFER};
}
check(result, "xrPollEvent");
}
void frame() {
XrFrameWaitInfo wait{XR_TYPE_FRAME_WAIT_INFO};
XrFrameState frameState{XR_TYPE_FRAME_STATE};
check(xrWaitFrame(session_, &wait, &frameState), "xrWaitFrame");
period_ = frameState.predictedDisplayPeriod;
XrFrameBeginInfo begin{XR_TYPE_FRAME_BEGIN_INFO}; check(xrBeginFrame(session_, &begin), "xrBeginFrame");
check(xrLocateSpace(view_, world_, frameState.predictedDisplayTime, &head_), "head pose");
if (state_ == XR_SESSION_STATE_FOCUSED) {
XrActiveActionSet active{actions_, XR_NULL_PATH};
XrActionsSyncInfo sync{XR_TYPE_ACTIONS_SYNC_INFO}; sync.countActiveActionSets = 1; sync.activeActionSets = &active;
check(xrSyncActions(session_, &sync), "xrSyncActions");
for (int hand = 0; hand < 2; hand++) {
XrActionStateGetInfo get{XR_TYPE_ACTION_STATE_GET_INFO}; get.action = grip_; get.subactionPath = hands_[hand];
XrActionStatePose pose{XR_TYPE_ACTION_STATE_POSE}; check(xrGetActionStatePose(session_, &get, &pose), "grip state");
handActive_[hand] = pose.isActive;
check(xrLocateSpace(handSpaces_[hand], world_, frameState.predictedDisplayTime, &handLocations_[hand]), "hand pose");
get.action = trigger_;
XrActionStateFloat trigger{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &trigger), "trigger state");
inputs_[hand].trigger = trigger.isActive ? trigger.currentState : 0;
get.action = squeeze_;
XrActionStateFloat squeeze{XR_TYPE_ACTION_STATE_FLOAT}; check(xrGetActionStateFloat(session_, &get, &squeeze), "squeeze state");
inputs_[hand].squeeze = squeeze.isActive ? squeeze.currentState : 0;
get.action = stick_;
XrActionStateVector2f stick{XR_TYPE_ACTION_STATE_VECTOR2F}; check(xrGetActionStateVector2f(session_, &get, &stick), "stick state");
inputs_[hand].stick[0] = stick.isActive ? stick.currentState.x : 0;
inputs_[hand].stick[1] = stick.isActive ? stick.currentState.y : 0;
inputs_[hand].buttons = 0;
const std::pair<XrAction, uint32_t> buttons[] = {
{stickClick_, DROIDDECK_XR_STICK_CLICK}, {primary_, DROIDDECK_XR_PRIMARY_CLICK},
{secondary_, DROIDDECK_XR_SECONDARY_CLICK}, {menu_, DROIDDECK_XR_MENU_CLICK},
{triggerTouch_, DROIDDECK_XR_TRIGGER_TOUCH}, {stickTouch_, DROIDDECK_XR_STICK_TOUCH},
{primaryTouch_, DROIDDECK_XR_PRIMARY_TOUCH}, {secondaryTouch_, DROIDDECK_XR_SECONDARY_TOUCH},
{thumbrestTouch_, DROIDDECK_XR_THUMBREST_TOUCH}};
for (const auto &button : buttons) {
get.action = button.first;
XrActionStateBoolean value{XR_TYPE_ACTION_STATE_BOOLEAN}; check(xrGetActionStateBoolean(session_, &get, &value), "button state");
if (value.isActive && value.currentState) inputs_[hand].buttons |= button.second;
}
}
} else handActive_[0] = handActive_[1] = false;
pollHaptics();
XrViewLocateInfo locate{XR_TYPE_VIEW_LOCATE_INFO}; locate.viewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO;
locate.displayTime = frameState.predictedDisplayTime; locate.space = world_;
XrViewState viewState{XR_TYPE_VIEW_STATE}; uint32_t count = 0;
XrView views[2]{{XR_TYPE_VIEW}, {XR_TYPE_VIEW}};
check(xrLocateViews(session_, &locate, &viewState, 2, &count, views), "xrLocateViews");
publishTracking(views, frameState.predictedDisplayTime);
if (guestAttached_) {
timespec now{}; clock_gettime(CLOCK_MONOTONIC, &now);
env_->CallVoidMethod(app_->activity->clazz, frameCallback_, (jlong)now.tv_sec * 1000000000LL + now.tv_nsec);
if (env_->ExceptionCheck()) { env_->ExceptionClear(); throw std::runtime_error("Guest frame callback failed"); }
}
XrCompositionLayerProjectionView projection[2]{{XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}, {XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW}};
bool valid = frameState.shouldRender && count == 2 &&
(viewState.viewStateFlags & (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT))
== (XR_VIEW_STATE_ORIENTATION_VALID_BIT | XR_VIEW_STATE_POSITION_VALID_BIT);
{
valid = valid && sourceHeader_.frame_id && xr_frame_now() - sourceHeader_.captured_ns < 500000000LL;
if (valid) for (int eye = 0; eye < 2; eye++) {
auto source = xr_frame_view(sourceHeader_, eye);
views[eye].pose.orientation = {source.orientation[0], source.orientation[1], source.orientation[2], source.orientation[3]};
views[eye].pose.position = {source.position[0], source.position[1], source.position[2]};
views[eye].fov = {source.fov[0], source.fov[1], source.fov[2], source.fov[3]};
}
}
const std::string capture = captureRequest();
bool captured = valid;
if (!capture.empty() && valid)
captured = captureTexture(sourceTexture_, sourceHeader_.width, sourceHeader_.height, "source");
if (valid) for (int eye = 0; eye < 2; eye++) {
uint32_t image = 0;
XrSwapchainImageAcquireInfo acquire{XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO};
check(xrAcquireSwapchainImage(eyes_[eye].swapchain, &acquire, &image), "acquire image");
XrSwapchainImageWaitInfo imageWait{XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO}; imageWait.timeout = XR_INFINITE_DURATION;
check(xrWaitSwapchainImage(eyes_[eye].swapchain, &imageWait), "wait image");
render(eyes_[eye], image);
if (!capture.empty()) captured = captureTexture(eyes_[eye].images[image].image, eyes_[eye].width, eyes_[eye].height, eye ? "right" : "left") && captured;
XrSwapchainImageReleaseInfo release{XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO};
check(xrReleaseSwapchainImage(eyes_[eye].swapchain, &release), "release image");
projection[eye].pose = views[eye].pose; projection[eye].fov = views[eye].fov;
projection[eye].subImage.swapchain = eyes_[eye].swapchain;
projection[eye].subImage.imageRect.extent = {eyes_[eye].width, eyes_[eye].height};
}
XrCompositionLayerProjection layer{XR_TYPE_COMPOSITION_LAYER_PROJECTION};
layer.space = world_; layer.viewCount = 2; layer.views = projection;
const XrCompositionLayerBaseHeader *layers[] = {reinterpret_cast<XrCompositionLayerBaseHeader *>(&layer)};
XrFrameEndInfo end{XR_TYPE_FRAME_END_INFO}; end.displayTime = frameState.predictedDisplayTime;
end.environmentBlendMode = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; end.layerCount = valid ? 1 : 0; end.layers = layers;
check(xrEndFrame(session_, &end), "xrEndFrame");
if (!capture.empty()) captureResult(capture, captured, valid, projection, frameState.predictedDisplayTime);
frames_++; if (valid) rendered_++;
if (frames_ % 72 == 0) {
health();
__android_log_print(ANDROID_LOG_INFO, "DroidDeckXR", "frames=%llu rendered=%llu head=%llu hands=%d,%d period=%lld",
(unsigned long long)frames_, (unsigned long long)rendered_, (unsigned long long)head_.locationFlags,
handActive_[0], handActive_[1], (long long)period_);
}
}
void cleanup() {
receiver_.Stop();
gpuFrames_.Stop();
try { attachGuest(false); } catch (const std::exception &error) {
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "cleanup: %s", error.what());
}
if (tracking_) {
__atomic_store_n(&tracking_->active, 0u, __ATOMIC_RELEASE);
munmap(tracking_, sizeof(DroidDeckXrTracking));
}
if (haptics_) munmap(haptics_, sizeof(DroidDeckXrHaptics));
if (display_ != EGL_NO_DISPLAY && context_ != EGL_NO_CONTEXT) {
glDeleteFramebuffers(1, &framebuffer_); glDeleteVertexArrays(1, &vertexArray_);
glDeleteTextures(1, &cpuTexture_); glDeleteProgram(sourceProgram_);
}
for (auto &eye : eyes_) if (eye.swapchain) xrDestroySwapchain(eye.swapchain);
for (auto space : handSpaces_) if (space) xrDestroySpace(space);
if (view_) xrDestroySpace(view_); if (world_) xrDestroySpace(world_);
if (session_) xrDestroySession(session_);
if (actions_) xrDestroyActionSet(actions_);
if (instance_) xrDestroyInstance(instance_);
if (display_ != EGL_NO_DISPLAY) {
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
if (surface_ != EGL_NO_SURFACE) eglDestroySurface(display_, surface_);
if (context_ != EGL_NO_CONTEXT) eglDestroyContext(display_, context_);
eglTerminate(display_);
}
if (env_) app_->activity->vm->DetachCurrentThread();
}
};
void android_main(android_app *app) {
QuestXR runtime(app);
try { runtime.run(); }
catch (const std::exception &error) {
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckXR", "%s", error.what());
runtime.health(error.what());
ANativeActivity_finish(app->activity);
}
}
+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;
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
// Our controller uses the standard display-mode vocabulary, even for Beast.
// Native mode reuses some numbers with different meanings; never pass them through.
namespace droiddeck::viture {
constexpr int to_native_mode(int standard) {
switch (standard) {
case 0x31: return 0x31; // 1080p 60
case 0x33: return 0x32; // 1080p 90
case 0x34: return 0x33; // 1080p 120
case 0x32: return 0x37; // 1080p SBS 60
case 0x35: return 0x38; // 1080p SBS 90
case 0x41: return 0x34; // 1200p 60
case 0x43: return 0x35; // 1200p 90
case 0x44: return 0x36; // 1200p 120
case 0x42: return 0x3a; // 1200p SBS 60
case 0x45: return 0x3b; // 1200p SBS 90
default: return -4;
}
}
constexpr int from_native_mode(int native) {
switch (native) {
case 0x31: return 0x31;
case 0x32: return 0x33;
case 0x33: return 0x34;
case 0x37: return 0x32;
case 0x38: return 0x35;
case 0x34: return 0x41;
case 0x35: return 0x43;
case 0x36: return 0x44;
case 0x3a: return 0x42;
case 0x3b: return 0x45;
default: return -4; // Ultrawide is not stereo.
}
}
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstddef>
#include <cstdint>
inline std::size_t mjpeg_payload_size(const uint8_t* bytes, std::size_t size) {
if (!bytes || size < 4 || size > 4*1024*1024 || bytes[0] != 0xff || bytes[1] != 0xd8) return 0;
std::size_t end = size;
// Luma Ultra UVC frames can pad the JPEG to an eight-byte boundary with zeros.
while (end > 4 && size-end < 7 && bytes[end-1] == 0) --end;
return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0;
}
+556
View File
@@ -0,0 +1,556 @@
#include <jni.h>
#include <android/log.h>
#include <algorithm>
#include <array>
#include <vector>
#include <deque>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <memory>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cerrno>
#include <string>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/usbdevice_fs.h>
#include <time.h>
#include "viture_glasses_provider.h"
#include "viture_device.h"
#include "viture_device_carina.h"
#include "viture_camera_provider.h"
#include "viture_protocol_public.h"
#include "viture_result.h"
#include "viture_version.h"
#include "display_modes.h"
#include "stereo_frame_gate.h"
namespace {
// SDK callbacks have no user-data parameter. Only one provider may exist at a time.
std::mutex api_mutex;
std::mutex pose_mutex;
std::array<float, 4> latest_pose{};
int64_t latest_time = 0;
bool accepting_pose = false;
bool accepting_state = false;
std::array<int, 6> state_values{};
unsigned state_pending = 0;
void on_state(int id, int value) {
std::lock_guard<std::mutex> lock(pose_mutex);
if (!accepting_state || id < 0 || id >= int(state_values.size())) return;
state_values[id] = value;
state_pending |= 1u << id;
}
void clear_state(bool accept) {
std::lock_guard<std::mutex> lock(pose_mutex);
accepting_state = accept;
state_pending = 0;
}
int64_t now_ns() {
timespec ts{};
clock_gettime(CLOCK_BOOTTIME, &ts);
return int64_t(ts.tv_sec) * 1000000000LL + ts.tv_nsec;
}
void on_pose(float* data, uint64_t) {
if (!data) return;
std::lock_guard<std::mutex> lock(pose_mutex);
if (!accepting_pose) return;
std::copy_n(data + 3, 4, latest_pose.begin());
// SDK device ticks are not Android's elapsed-realtime clock.
latest_time = now_ns();
}
void clear_pose(bool accept) {
std::lock_guard<std::mutex> lock(pose_mutex);
accepting_pose = accept;
latest_time = 0;
}
int64_t monotonic_ns() {
timespec ts{}; clock_gettime(CLOCK_MONOTONIC, &ts);
return int64_t(ts.tv_sec) * 1000000000LL + ts.tv_nsec;
}
struct CameraPose { double time; std::array<float, 7> pose; };
std::mutex camera_mutex;
std::deque<CameraPose> camera_poses;
std::vector<uint8_t> camera_left, camera_right;
bool camera_accepting = false, camera_wanted = false;
int camera_width = 0, camera_height = 0;
uint64_t camera_sequence = 0, camera_consumed = 0;
StereoFrameGate camera_gate;
uint64_t camera_input_sequence = 0;
int64_t camera_receipt = 0;
int64_t camera_pose_receipt = 0;
double camera_source = 0;
const CameraPose *matched_camera_pose(double source) {
const CameraPose *best = nullptr;
double distance = 0.005;
for (const auto &p : camera_poses) if (std::abs(p.time-source) < distance) {
best = &p; distance = std::abs(p.time-source);
}
return best;
}
void on_carina_pose(float *pose, double stamp) {
if (!pose || !std::isfinite(stamp)) return;
std::lock_guard<std::mutex> lock(camera_mutex);
if (!camera_accepting) return;
camera_pose_receipt = monotonic_ns();
CameraPose p{stamp, {}}; std::copy_n(pose, 7, p.pose.begin());
camera_poses.push_back(p);
while (camera_poses.size() > 64) camera_poses.pop_front();
}
void on_camera(char *left, char *right, char *, char *, double stamp, int width, int height) {
if (!left || !right || width <= 0 || height <= 0 || width > 2048 || height > 2048 || !std::isfinite(stamp)) return;
const auto received = monotonic_ns();
std::lock_guard<std::mutex> lock(camera_mutex);
if (!camera_accepting || !camera_wanted) return;
if (!camera_gate.Accept(stamp)) return;
const size_t size = size_t(width) * height;
camera_left.resize(size); camera_right.resize(size);
memcpy(camera_left.data(), left, size); memcpy(camera_right.data(), right, size);
camera_width = width; camera_height = height; camera_source = stamp; camera_receipt = received;
camera_input_sequence = camera_gate.received;
++camera_sequence;
}
void clear_camera(bool enabled) {
std::lock_guard<std::mutex> lock(camera_mutex);
camera_accepting = enabled; camera_wanted = false;
camera_poses.clear(); camera_left.clear(); camera_right.clear();
camera_sequence = camera_consumed = 0;
camera_gate = {}; camera_input_sequence = 0;
camera_pose_receipt = 0;
}
struct Session {
XRDeviceProviderHandle device = nullptr;
int pid = 0;
int type = -1;
bool initialized = false;
bool started = false;
bool tracking = false;
bool native_dof = false;
bool switched_native = false;
int original_bypass_display = -1;
int saved_dof = -1;
int64_t consumed_time = 0;
jlong token = 0;
int spatial_status = 1;
};
std::unique_ptr<Session> session;
jlong next_token = 0;
void report(const char* step, int rc) {
if (rc < 0) __android_log_print(ANDROID_LOG_WARN, "VitureSdk", "%s: %d", step, rc);
}
int suspend_native_dof(Session& s) {
if (!s.native_dof) return 0;
int mode = xr_device_provider_native_get_mode(s.device);
if (mode < 0) return mode;
if (mode != 1) return 0;
if (s.saved_dof < 0) {
int dof = xr_device_provider_native_get_dof(s.device);
if (dof < 0) return dof;
s.saved_dof = dof;
}
return xr_device_provider_native_set_dof(s.device, VITURE_NATIVE_DOF_0);
}
void restore_native_dof(Session& s) {
if (s.saved_dof >= 0 && xr_device_provider_native_get_mode(s.device) == 1) {
report("restore native DOF", xr_device_provider_native_set_dof(s.device, s.saved_dof));
}
s.saved_dof = -1;
}
int tracking(Session& s, bool enabled) {
if (s.tracking == enabled) return 0;
if (!enabled) {
clear_pose(false);
int rc = s.type == XR_DEVICE_TYPE_VITURE_CARINA ? 0 :
xr_device_provider_close_imu(s.device, VITURE_IMU_MODE_POSE);
s.tracking = false;
restore_native_dof(s);
return rc;
}
int rc = suspend_native_dof(s);
if (rc < 0) return rc;
clear_pose(true);
s.consumed_time = 0;
if (s.type != XR_DEVICE_TYPE_VITURE_CARINA) {
int frequency = VITURE_IMU_FREQUENCY_MEDIUM_HIGH;
while (frequency >= VITURE_IMU_FREQUENCY_LOW &&
!xr_device_provider_is_product_support_imu_frequency(s.pid, VITURE_IMU_MODE_POSE, frequency)) --frequency;
rc = frequency < 0 ? VITURE_GLASSES_ERROR_NOT_SUPPORTED :
xr_device_provider_open_imu(s.device, VITURE_IMU_MODE_POSE, frequency);
}
if (rc == 0) s.tracking = true;
else { clear_pose(false); restore_native_dof(s); }
return rc;
}
void destroy(Session& s) {
clear_camera(false);
clear_pose(false);
clear_state(false);
if (s.device) {
if (s.tracking) report("close tracking", tracking(s, false));
if (s.switched_native && s.original_bypass_display >= 0) {
int rc = xr_device_provider_native_set_mode(s.device, 0);
if (rc == 0) rc = xr_device_provider_set_display_mode(s.device, s.original_bypass_display);
report("restore bypass mode", rc);
}
if (s.started) report("stop", xr_device_provider_stop(s.device));
if (s.initialized) report("shutdown", xr_device_provider_shutdown(s.device));
xr_device_provider_destroy(s.device);
s.device = nullptr;
}
}
int get_display(Session& s) {
if (s.native_dof) {
const int mode = xr_device_provider_native_get_mode(s.device);
if (mode < 0) return mode;
if (mode == 1) {
const int value = xr_device_provider_native_get_display_mode(s.device);
return value < 0 ? value : droiddeck::viture::from_native_mode(value);
}
}
return xr_device_provider_get_display_mode(s.device);
}
int set_display(Session& s, int standard) {
if (!s.native_dof) return xr_device_provider_set_display_mode(s.device, standard);
int mode = xr_device_provider_native_get_mode(s.device);
if (mode < 0) return mode;
if (mode == 0) {
int rc = xr_device_provider_set_display_mode(s.device, standard);
if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc;
// Older Beast firmware exposes fewer bypass modes. Fall back to its
// separate native table only when the SDK explicitly rejects the mode.
}
const int native = droiddeck::viture::to_native_mode(standard);
if (native < 0) return native;
if (mode == 0) {
if (!s.switched_native) {
s.original_bypass_display = xr_device_provider_get_display_mode(s.device);
if (s.original_bypass_display < 0) return s.original_bypass_display;
}
int rc = xr_device_provider_native_set_mode(s.device, 1);
if (rc < 0) return rc;
s.switched_native = true;
}
int rc = xr_device_provider_native_set_display_mode(s.device, native);
if (rc == 0 && s.tracking) rc = suspend_native_dof(s);
if (rc < 0 && mode == 0) {
restore_native_dof(s);
int rollback = xr_device_provider_native_set_mode(s.device, 0);
if (rollback == 0) rollback = xr_device_provider_set_display_mode(s.device, s.original_bypass_display);
report("rollback display mode", rollback);
if (rollback == 0) s.switched_native = false;
}
return rc;
}
}
#define JNI_METHOD(name) Java_com_droiddeck_launcher_glasses_VitureSdk_##name
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nativeAvailable)(JNIEnv*, jobject) {
__android_log_print(ANDROID_LOG_INFO, "VitureSdk", "SDK %s", GetVersionString());
return JNI_TRUE;
}
extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nativeMarketName)(JNIEnv* env, jobject, jint pid) {
std::lock_guard<std::mutex> lock(api_mutex);
if (!xr_device_provider_is_product_id_valid(pid)) return nullptr;
char name[64]{};
int length = sizeof(name) - 1;
if (xr_device_provider_get_market_name(pid, name, &length) != 0) return nullptr;
name[sizeof(name) - 1] = 0;
return env->NewStringUTF(name);
}
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativeOpen)(JNIEnv* env, jobject, jint pid, jint fd, jstring cache, jboolean spatial) {
std::lock_guard<std::mutex> lock(api_mutex);
if (session || fd < 0 || !cache || !xr_device_provider_is_product_id_valid(pid)) return 0;
auto s = std::make_unique<Session>();
s->pid = pid;
s->device = xr_device_provider_create(pid, fd);
if (!s->device) return 0;
s->type = xr_device_provider_get_device_type(s->device);
s->native_dof = xr_device_provider_is_product_support_native_dof(pid) == 1;
int rc = s->type < 0 ? s->type : 0;
if (rc == 0) {
clear_state(true);
rc = xr_device_provider_register_state_callback(s->device, on_state);
}
if (rc == 0) {
rc = s->type == XR_DEVICE_TYPE_VITURE_CARINA ?
xr_device_provider_set_dof_type_carina(s->device, spatial ? 1 : 0) :
xr_device_provider_register_imu_pose_callback(s->device, on_pose);
}
if (rc == 0) {
const char* directory = env->GetStringUTFChars(cache, nullptr);
if (!directory) { destroy(*s); return 0; }
rc = xr_device_provider_initialize(s->device, nullptr, directory);
env->ReleaseStringUTFChars(cache, directory);
s->initialized = rc == 0;
}
if (rc == 0 && s->type == XR_DEVICE_TYPE_VITURE_CARINA && spatial) {
clear_camera(true);
rc = xr_device_provider_register_callbacks_carina(s->device, on_carina_pose, nullptr, nullptr, on_camera);
}
if (rc == 0) {
rc = xr_device_provider_start(s->device);
s->started = rc == 0;
}
if (rc != 0) { report("initialize/start", rc); destroy(*s); return 0; }
s->token = ++next_token;
session = std::move(s);
return session->token;
}
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeType)(JNIEnv*, jobject, jlong token) {
std::lock_guard<std::mutex> lock(api_mutex);
return session && session->token == token ? session->type : -1;
}
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeTracking)(JNIEnv*, jobject, jlong token, jboolean enabled) {
std::lock_guard<std::mutex> lock(api_mutex);
return session && session->token == token ? tracking(*session, enabled) : -1;
}
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeCommand)(JNIEnv*, jobject, jlong token, jint command, jint value) {
std::lock_guard<std::mutex> lock(api_mutex);
if (!session || session->token != token) return -1;
auto& s = *session;
switch (command) {
case 1: return get_display(s);
case 2: return set_display(s, value);
case 3: return xr_device_provider_get_brightness_level(s.device);
case 4: return xr_device_provider_set_brightness_level(s.device, value);
case 5: return xr_device_provider_get_volume_level(s.device);
case 6: return xr_device_provider_set_volume_level(s.device, value);
case 7: return xr_device_provider_set_film_mode(s.device, value ? 1.0f : 0.0f);
default: return VITURE_GLASSES_ERROR_NOT_SUPPORTED;
}
}
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativePose)(JNIEnv* env, jobject, jlong token, jfloatArray output) {
std::lock_guard<std::mutex> lock(api_mutex);
if (!session || session->token != token || !output || env->GetArrayLength(output) != 4) return -1;
auto& s = *session;
if (!s.tracking) return 0;
if (s.type == XR_DEVICE_TYPE_VITURE_CARINA) {
float pose[7]{};
int status = 1;
int rc = xr_device_provider_get_gl_pose_carina(s.device, pose, 0.0, &status);
if (rc < 0) return rc;
if (status != 0) return 0;
env->SetFloatArrayRegion(output, 0, 4, pose + 3);
return now_ns();
}
std::lock_guard<std::mutex> pose_lock(pose_mutex);
if (!latest_time || latest_time == s.consumed_time) return 0;
env->SetFloatArrayRegion(output, 0, 4, latest_pose.data());
s.consumed_time = latest_time;
return latest_time;
}
extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeNextState)(JNIEnv* env, jobject, jlong token, jintArray output) {
std::lock_guard<std::mutex> lock(api_mutex);
if (!session || session->token != token || !output || env->GetArrayLength(output) != 2) return -1;
std::lock_guard<std::mutex> state_lock(pose_mutex);
for (int id = 0; id < int(state_values.size()); ++id) {
if (!(state_pending & (1u << id))) continue;
state_pending &= ~(1u << id);
const jint value[] = {id, state_values[id]};
env->SetIntArrayRegion(output, 0, 2, value);
return 1;
}
return 0;
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeClose)(JNIEnv*, jobject, jlong token) {
std::lock_guard<std::mutex> lock(api_mutex);
if (session && session->token == token) { destroy(*session); session.reset(); }
}
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativeSpatialPose)(JNIEnv* env, jobject, jlong token,
jfloatArray output, jintArray state) {
std::lock_guard<std::mutex> lock(api_mutex);
if (!session || session->token != token || !session->tracking || session->type != XR_DEVICE_TYPE_VITURE_CARINA ||
!output || !state || env->GetArrayLength(output) != 7 || env->GetArrayLength(state) != 1) return 0;
float pose[7]{}; int status = 1;
int rc = xr_device_provider_get_gl_pose_carina(session->device, pose, 0.0, &status);
session->spatial_status = rc == 0 ? status : rc;
jint valid = rc == 0 ? status : rc;
if (valid == 0) {
std::lock_guard<std::mutex> lock(camera_mutex);
if (monotonic_ns()-camera_pose_receipt > 100000000LL) valid = 1;
}
env->SetIntArrayRegion(state, 0, 1, &valid);
env->SetFloatArrayRegion(output, 0, 7, pose);
return monotonic_ns();
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeCapture)(JNIEnv*, jobject, jlong token, jboolean wanted, jint ratePermille) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token) return;
std::lock_guard<std::mutex> lock(camera_mutex);
camera_wanted = wanted;
camera_gate.SetRatePermille(wanted ? ratePermille : 1000);
if (!wanted) camera_consumed = camera_sequence;
}
extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nativeFirmware)(JNIEnv* env, jobject, jlong token) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token) return nullptr;
char value[256]{}; int length = 255;
if (xr_device_provider_get_glasses_version(session->device, value, &length) != 0) return nullptr;
value[255] = 0;
return env->NewStringUTF(value);
}
extern "C" JNIEXPORT jbyteArray JNICALL JNI_METHOD(nativeSerialHash)(JNIEnv* env, jobject, jlong token) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token) return nullptr;
uint8_t hash[32]{};
const int rc = xr_device_provider_get_sn_hash(session->device, hash);
if (rc != 0) { report("read device identity", rc); return nullptr; }
auto result = env->NewByteArray(32);
if (result) env->SetByteArrayRegion(result, 0, 32, reinterpret_cast<const jbyte*>(hash));
return result;
}
extern "C" JNIEXPORT jdoubleArray JNICALL JNI_METHOD(nativeSpatialDiagnostics)(JNIEnv* env, jobject, jlong token) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token) return nullptr;
std::lock_guard<std::mutex> lock(camera_mutex);
double values[3] = {double(session->spatial_status), camera_pose_receipt ? (monotonic_ns()-camera_pose_receipt)/1e6 : -1.0,
camera_poses.empty() ? -1.0 : (camera_poses.back().time-camera_source)*1000.0};
auto result=env->NewDoubleArray(3);
if(result)env->SetDoubleArrayRegion(result,0,3,values);
return result;
}
extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativeStereo)(JNIEnv* env, jobject, jlong token,
jobject left, jobject right, jdoubleArray metadata, jfloatArray head, jboolean waitForPose) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token || !left || !right || !metadata || !head ||
env->GetArrayLength(metadata) != 9 || env->GetArrayLength(head) != 7) return 0;
std::lock_guard<std::mutex> lock(camera_mutex);
if (!camera_wanted || camera_sequence == camera_consumed || camera_left.empty()) return 0;
const auto size = camera_left.size();
if (env->GetDirectBufferCapacity(left) < jlong(size) || env->GetDirectBufferCapacity(right) < jlong(size)) return -1;
auto l = env->GetDirectBufferAddress(left), r = env->GetDirectBufferAddress(right);
if (!l || !r) return -1;
const CameraPose *best = matched_camera_pose(camera_source);
if (waitForPose && !best && monotonic_ns()-camera_receipt < 35000000LL) return 0;
memcpy(l, camera_left.data(), size); memcpy(r, camera_right.data(), size);
double meta[9] = {double(camera_width), double(camera_height), camera_source, double(camera_receipt),
double(token), double(camera_sequence), best ? 1.0 : 0.0,
double(camera_input_sequence), double(camera_gate.skipped)};
env->SetDoubleArrayRegion(metadata, 0, 9, meta);
if (best) env->SetFloatArrayRegion(head, 0, 7, best->pose.data());
camera_consumed = camera_sequence;
return jlong(camera_sequence);
}
extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nativeCameraSynchronized)(JNIEnv*, jobject, jlong token, jdouble source) {
std::lock_guard<std::mutex> api(api_mutex);
if (!session || session->token != token || !std::isfinite(source)) return JNI_FALSE;
std::lock_guard<std::mutex> lock(camera_mutex);
return camera_accepting && matched_camera_pose(source) != nullptr;
}
namespace {
struct RgbUsbLog {
int savedStderr;
bool hadLevel;
std::string oldLevel;
explicit RgbUsbLog(int fd) : savedStderr(dup(STDERR_FILENO)),
hadLevel(getenv("LIBUSB_DEBUG") != nullptr), oldLevel(hadLevel ? getenv("LIBUSB_DEBUG") : "") {
fflush(stderr);
if (savedStderr >= 0) dup2(fd, STDERR_FILENO);
setenv("LIBUSB_DEBUG", "4", 1);
}
~RgbUsbLog() {
fflush(stderr);
if (savedStderr >= 0) { dup2(savedStderr, STDERR_FILENO); close(savedStderr); }
if (hadLevel) setenv("LIBUSB_DEBUG", oldLevel.c_str(), 1); else unsetenv("LIBUSB_DEBUG");
}
};
void rgb_usb_state(int fd, const char* stage) {
uint8_t alternate = 0;
usbdevfs_ctrltransfer request{};
request.bRequestType=0x81; request.bRequest=0x0a; request.wIndex=1;
request.wLength=1; request.timeout=200; request.data=&alternate;
int result=ioctl(fd, USBDEVFS_CONTROL, &request);
fprintf(stderr,"RGB %s GET_INTERFACE result=%d errno=%d alternate=%u\n",stage,result,result<0 ? errno : 0,alternate);
for (int selector : {1, 2}) {
uint8_t size[2]{};
request.bRequestType=0xa1; request.bRequest=0x85; request.wValue=selector << 8;
request.wLength=2; request.data=size;
result=ioctl(fd,USBDEVFS_CONTROL,&request);
const unsigned length=result==2 ? unsigned(size[0]) | unsigned(size[1]) << 8 : 26;
uint8_t bytes[64]{};
request.bRequest=0x81; request.wLength=std::clamp(length,26u,64u); request.data=bytes;
result=ioctl(fd,USBDEVFS_CONTROL,&request);
fprintf(stderr,"RGB %s %s result=%d errno=%d bytes=",stage,selector==1 ? "PROBE" : "COMMIT",result,result<0 ? errno : 0);
for (int i=0;i<std::max(0,result);++i) fprintf(stderr,"%02x",bytes[i]);
fprintf(stderr,"\n");
}
fflush(stderr);
}
}
extern "C" JNIEXPORT jbyteArray JNICALL JNI_METHOD(nativeRgbSnapshot)(JNIEnv* env, jobject,
jint vid, jint pid, jint fd, jlongArray metadata, jint logFd) {
if (fd < 0 || !metadata || env->GetArrayLength(metadata) != 7 ||
!xr_camera_provider_is_valid_camera(vid, pid)) return nullptr;
RgbUsbLog usbLog(logFd);
rgb_usb_state(fd,"before-create");
struct Capture {
std::mutex mutex;
std::condition_variable ready;
std::vector<uint8_t> jpeg;
unsigned frames = 0;
jlong values[7]{};
} capture;
auto camera = xr_camera_provider_create(vid, pid, fd);
if (!camera) { fprintf(stderr,"RGB provider create failed\n"); return nullptr; }
const int result = xr_camera_provider_start(camera, [](const XRCameraFrame* frame, void* data) {
auto& c = *static_cast<Capture*>(data);
std::lock_guard<std::mutex> lock(c.mutex);
++c.frames;
if (!frame || !frame->data || frame->format != XR_CAMERA_FORMAT_MJPEG ||
frame->size < 4 || frame->size > 4 * 1024 * 1024) return;
c.jpeg.assign(frame->data, frame->data + frame->size);
c.values[0] = frame->width; c.values[1] = frame->height;
c.values[2] = frame->timestamp; c.values[3] = frame->sequence;
c.values[6] = monotonic_ns();
c.ready.notify_one();
}, &capture);
fprintf(stderr,"RGB start result=%d streaming=%d\n",result,xr_camera_provider_is_streaming(camera));
rgb_usb_state(fd,"after-start");
if (result == 0) {
{
std::unique_lock<std::mutex> lock(capture.mutex);
capture.ready.wait_for(lock, std::chrono::seconds(5), [&] { return capture.frames >= 10 && !capture.jpeg.empty(); });
}
fprintf(stderr,"RGB stop result=%d\n",xr_camera_provider_stop(camera));
}
xr_camera_provider_destroy(camera);
rgb_usb_state(fd,"after-destroy");
capture.values[4] = result;
capture.values[5] = capture.frames;
env->SetLongArrayRegion(metadata, 0, 7, capture.values);
if (capture.jpeg.empty()) return nullptr;
auto bytes = env->NewByteArray(static_cast<jsize>(capture.jpeg.size()));
if (bytes) env->SetByteArrayRegion(bytes, 0, static_cast<jsize>(capture.jpeg.size()),
reinterpret_cast<const jbyte*>(capture.jpeg.data()));
return bytes;
}
@@ -0,0 +1,29 @@
#pragma once
#include <cmath>
#include <cstdint>
struct StereoFrameGate {
uint64_t received = 0, skipped = 0;
void SetRatePermille(int rate) {
if (rate != 500 && rate != 600 && rate != 800 && rate != 1000) rate = 500;
if (ratePermille_ != rate) phase_ = 1000-rate;
ratePermille_ = rate;
}
void SetHalfRate(bool enabled) {
SetRatePermille(enabled ? 500 : 1000);
}
bool Accept(double source) {
if (!std::isfinite(source) || source <= 0) return false;
++received;
if (source <= lastSource_) phase_ = 1000-ratePermille_;
lastSource_ = source;
phase_ += ratePermille_;
const bool copy = phase_ >= 1000;
if (copy) phase_ -= 1000;
if (!copy) ++skipped;
return copy;
}
private:
int ratePermille_ = 1000, phase_ = 0;
double lastSource_ = 0;
};
+134
View File
@@ -0,0 +1,134 @@
#include <jni.h>
#include <libusb.h>
#include <libuvc/libuvc.h>
#include "mjpeg_payload.h"
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <string>
#include <vector>
#include <time.h>
#include <unistd.h>
namespace {
struct UsbLog {
int saved;
bool hadLevel;
std::string level;
explicit UsbLog(int fd) : saved(dup(STDERR_FILENO)), hadLevel(getenv("LIBUSB_DEBUG") != nullptr),
level(hadLevel ? getenv("LIBUSB_DEBUG") : "") {
fflush(stderr);
if (saved >= 0) dup2(fd, STDERR_FILENO);
setenv("LIBUSB_DEBUG", "3", 1);
libusb_set_log_cb(nullptr, [](libusb_context*, libusb_log_level, const char* text) {
fputs(text, stderr);
}, LIBUSB_LOG_CB_GLOBAL);
}
~UsbLog() {
libusb_set_log_cb(nullptr, nullptr, LIBUSB_LOG_CB_GLOBAL);
fflush(stderr);
if (saved >= 0) { dup2(saved, STDERR_FILENO); close(saved); }
if (hadLevel) setenv("LIBUSB_DEBUG", level.c_str(), 1); else unsetenv("LIBUSB_DEBUG");
}
};
struct Capture {
std::mutex mutex;
std::condition_variable ready;
std::vector<uint8_t> jpeg;
jlong values[14]{};
bool accepting = true;
};
jlong monotonic_ns() {
timespec now{};
clock_gettime(CLOCK_MONOTONIC, &now);
return now.tv_sec*1000000000LL+now.tv_nsec;
}
void finish_observation(Capture& capture) {
std::lock_guard<std::mutex> lock(capture.mutex);
if (!capture.accepting) return;
capture.accepting = false;
capture.values[12] = monotonic_ns();
if (capture.values[6]) capture.values[13] = capture.values[12]-capture.values[6];
capture.values[8] = std::max(capture.values[8],capture.values[13]);
}
void frame_received(uvc_frame_t* frame, void* user) {
auto& capture = *static_cast<Capture*>(user);
std::lock_guard<std::mutex> lock(capture.mutex);
if (!capture.accepting) return;
++capture.values[5];
const auto* bytes = frame ? static_cast<const uint8_t*>(frame->data) : nullptr;
const size_t size = frame ? frame->data_bytes : 0;
const size_t jpegSize = frame && frame->frame_format == UVC_FRAME_FORMAT_MJPEG ? mjpeg_payload_size(bytes,size) : 0;
if (!jpegSize) {
++capture.values[10];
return;
}
const jlong time = monotonic_ns();
capture.values[0]=frame->width; capture.values[1]=frame->height; capture.values[3]=frame->sequence;
if (!capture.values[7]) capture.values[7]=time;
if (capture.values[6]) capture.values[8]=std::max(capture.values[8],time-capture.values[6]);
capture.values[6]=time;
++capture.values[9];
capture.values[11]+=jpegSize;
capture.jpeg.assign(bytes,bytes+jpegSize);
capture.ready.notify_one();
}
}
extern "C" JNIEXPORT jbyteArray JNICALL
Java_com_droiddeck_launcher_glasses_UvcRgbDiagnostic_nativeSnapshot(JNIEnv* env, jobject,
jint fd, jint width, jint height, jint fps, jint durationMs, jlongArray metadata, jint logFd) {
if (fd<0 || logFd<0 || !metadata || env->GetArrayLength(metadata)!=14 || width<160 || width>1920 ||
height<120 || height>1080 || fps<5 || fps>30 || durationMs<1000 || durationMs>30000) return nullptr;
UsbLog log(logFd);
Capture capture;
uvc_context_t* context=nullptr;
uvc_device_handle_t* camera=nullptr;
libusb_set_option(nullptr,LIBUSB_OPTION_NO_DEVICE_DISCOVERY);
int result=uvc_init(&context,nullptr);
fprintf(stderr,"UVC init=%d\n",result);
if (result==0) {
result=uvc_wrap(fd,context,&camera);
fprintf(stderr,"UVC wrap=%d\n",result);
}
if (result==0) {
uvc_print_diag(camera,stderr);
uvc_stream_ctrl_t control{};
result=uvc_get_stream_ctrl_format_size(camera,&control,UVC_FRAME_FORMAT_MJPEG,width,height,fps);
fprintf(stderr,"UVC negotiate=%d requested=%dx%d@%d\n",result,width,height,fps);
if (result==0) {
uvc_print_stream_ctrl(&control,stderr);
result=uvc_start_streaming(camera,&control,frame_received,&capture,0);
fprintf(stderr,"UVC start=%d\n",result);
if (result==0) {
{
std::unique_lock<std::mutex> lock(capture.mutex);
if (capture.ready.wait_for(lock,std::chrono::seconds(5),[&] { return !capture.jpeg.empty(); }))
capture.ready.wait_for(lock,std::chrono::milliseconds(durationMs),[] { return false; });
}
finish_observation(capture);
uvc_stop_streaming(camera);
}
}
}
finish_observation(capture);
if (camera) uvc_close(camera);
if (context) uvc_exit(context);
capture.values[4]=result;
fprintf(stderr,"UVC result=%d callbacks=%lld complete=%lld invalid=%lld maxGapMs=%.3f tailMs=%.3f\n",result,
static_cast<long long>(capture.values[5]),static_cast<long long>(capture.values[9]),
static_cast<long long>(capture.values[10]),capture.values[8]/1e6,capture.values[13]/1e6);
env->SetLongArrayRegion(metadata,0,14,capture.values);
if (result!=0 || capture.jpeg.empty()) return nullptr;
auto bytes=env->NewByteArray(static_cast<jsize>(capture.jpeg.size()));
if (bytes) env->SetByteArrayRegion(bytes,0,static_cast<jsize>(capture.jpeg.size()),
reinterpret_cast<const jbyte*>(capture.jpeg.data()));
return bytes;
}
+9 -8
View File
@@ -3,6 +3,7 @@ add_library(droiddeckwayland SHARED
src/vk_present.c
src/effects_chain.c
src/cardboard.c
src/spatialpose.c
src/vk_loader.c
src/sc_layer.c
src/ahb_swapchain.c
@@ -42,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
@@ -65,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}/..
@@ -76,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()
+46 -14
View File
@@ -28,22 +28,25 @@ static float clampf(float v, float lo, float hi) { return v < lo ? lo : v > hi ?
void cardboard_clamp(struct cardboard_params *p) {
p->enabled = p->enabled ? 1 : 0;
p->source = p->source == CARDBOARD_SOURCE_SBS ? CARDBOARD_SOURCE_SBS : CARDBOARD_SOURCE_SCREEN;
if (p->source < CARDBOARD_SOURCE_SCREEN || p->source > CARDBOARD_SOURCE_FLAT) p->source = CARDBOARD_SOURCE_SCREEN;
p->ipd_m = clampf(p->ipd_m, 0.0f, 0.1f);
p->screen_dist_m = clampf(p->screen_dist_m, 0.5f, 50.0f);
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
p->k1 = clampf(p->k1, -1.0f, 2.0f);
p->k2 = clampf(p->k2, -1.0f, 2.0f);
p->fov_deg = clampf(p->fov_deg, 30.0f, 140.0f);
// Optical vertical FOV on supported glasses can be as low as ~22 degrees.
p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f);
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
p->mono = p->mono ? 1 : 0;
p->display_hz = clampf(p->display_hz, 0.0f, 240.0f);
}
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
int src_w, int src_h, int out_w, int out_h, float out[24]) {
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c];
out[3] = p->ipd_m * 0.5f;
out[3] = p->mono ? 0.0f : p->ipd_m * 0.5f;
out[7] = p->screen_dist_m;
out[11] = p->screen_width_m * 0.5f;
out[12] = p->k1;
@@ -54,13 +57,17 @@ void cardboard_push_constants(const struct cardboard_params *p, const float pose
out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f;
if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f;
out[18] = (float)p->source;
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f;
out[20] = out[21] = out[22] = out[23] = 0.0f;
out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * (p->mono ? 1.0f : 0.5f)) / (float)out_h : 1.0f;
out[20] = p->mono ? 1.0f : 0.0f;
out[21] = out[22] = out[23] = 0.0f;
}
void cardboard_push_translation(const struct cardboard_params *p, const float position[3], float out[24]) {
for (int i = 0; i < 3; ++i) out[21+i] = p->source == CARDBOARD_SOURCE_SCREEN && isfinite(position[i]) ? position[i] : 0.0f;
}
#ifndef CARDBOARD_HOST_TEST
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static struct cardboard_params g_set = {
.enabled = 0, .source = CARDBOARD_SOURCE_SCREEN, .ipd_m = 0.064f, .screen_dist_m = 3.0f,
@@ -68,6 +75,7 @@ static struct cardboard_params g_set = {
static float g_pose[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
static volatile int g_changed = 1;
static float g_position[3];
static int g_live;
static void shm_publish(int live);
@@ -81,13 +89,22 @@ void vkp_cardboard_set(const struct cardboard_params *p) {
shm_publish(g_live);
pthread_mutex_unlock(&g_lock);
if (was != c.enabled || c.enabled)
droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen",
c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
droiddeck_log("cardboard", "%s: %s%s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f",
c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" :
c.source == CARDBOARD_SOURCE_FLAT ? "flat image in both eyes" : "virtual screen",
c.mono ? " (one eye)" : "", c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma);
}
static struct droiddeck_headpose *g_shm;
static int g_eye_w, g_eye_h;
static int g_render_height;
void vkp_cardboard_set_render_height(int height) {
pthread_mutex_lock(&g_lock);
g_render_height = height > 0 ? height : 0;
shm_publish(g_live);
pthread_mutex_unlock(&g_lock);
}
void vkp_cardboard_set_pose_file(const char *path) {
pthread_mutex_lock(&g_lock);
@@ -101,6 +118,7 @@ void vkp_cardboard_set_pose_file(const char *path) {
g_shm->magic = DROIDDECK_HEADPOSE_MAGIC;
g_shm->version = DROIDDECK_HEADPOSE_VERSION;
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
shm_publish(g_live);
}
}
if (fd >= 0) close(fd);
@@ -119,7 +137,10 @@ static void shm_publish(int live) {
atomic_thread_fence(memory_order_release);
memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot));
g_shm->ipd_m = g_set.ipd_m;
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u);
g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u) |
(g_set.mono ? DROIDDECK_HEADPOSE_MONO : 0u);
g_shm->display_hz = g_set.display_hz;
g_shm->render_height = (uint32_t)g_render_height;
g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec;
g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f);
g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0);
@@ -130,22 +151,31 @@ static void shm_publish(int live) {
}
void vkp_cardboard_set_pose(const float m[9], int live) {
void vkp_cardboard_set_spatial_pose(const float m[9], const float position[3], int live) {
pthread_mutex_lock(&g_lock);
memcpy(g_pose, m, sizeof(g_pose));
memcpy(g_position, position, sizeof(g_position));
g_live = live;
if (g_set.enabled && g_set.source == CARDBOARD_SOURCE_SCREEN) g_changed = 1;
shm_publish(live);
pthread_mutex_unlock(&g_lock);
}
void vkp_cardboard_set_pose(const float m[9], int live) {
const float zero[3] = {0};
vkp_cardboard_set_spatial_pose(m, zero, live);
}
static struct cardboard_params g_cur;
static float g_cur_pose[9];
static float g_cur_position[3];
static void snapshot(void) {
pthread_mutex_lock(&g_lock);
g_cur = g_set;
memcpy(g_cur_pose, g_pose, sizeof(g_cur_pose));
memcpy(g_cur_position, g_position, sizeof(g_cur_position));
pthread_mutex_unlock(&g_lock);
}
@@ -401,16 +431,18 @@ VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h
if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); }
return VK_NULL_HANDLE;
}
if (g_eye_w != out_w / 2 || g_eye_h != out_h) {
snapshot();
int eye_w = g_cur.mono ? out_w : out_w / 2;
if (g_eye_w != eye_w || g_eye_h != out_h) {
pthread_mutex_lock(&g_lock);
g_eye_w = out_w / 2;
g_eye_w = eye_w;
g_eye_h = out_h;
shm_publish(g_live);
pthread_mutex_unlock(&g_lock);
}
snapshot();
struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}};
cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest);
cardboard_push_translation(&g_cur, g_cur_position, pc.rest);
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_rp,
.framebuffer = g_t.fb, .renderArea = {{0, 0}, {(uint32_t)out_w, (uint32_t)out_h}}};
@@ -10,7 +10,7 @@ layout(push_constant) uniform PC {
vec4 rot2;
vec4 lens;
vec4 view;
vec4 extra;
} pc;
const vec3 BACKDROP = vec3(0.015, 0.015, 0.02);
@@ -24,14 +24,21 @@ vec3 eyeSample(vec2 tan, float eye) {
if (pc.view.z > 0.5) {
float tanH = pc.lens.z;
vec2 s = vec2(tan.x / (tanH * pc.view.w), tan.y / tanH) * 0.5 + 0.5;
if (pc.view.z > 1.5) {
vec2 fit = max(vec2(pc.view.w / pc.view.y, pc.view.y / pc.view.w), vec2(1.0));
s = (s - 0.5) * fit + 0.5;
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return vec3(0.0);
return texture(Source, s).rgb;
}
if (any(lessThan(s, vec2(0.0))) || any(greaterThan(s, vec2(1.0)))) return vec3(0.0);
return texture(Source, vec2((s.x + eye) * 0.5, s.y)).rgb;
}
mat3 R = transpose(mat3(pc.rot0.xyz, pc.rot1.xyz, pc.rot2.xyz));
vec3 d = R * normalize(vec3(tan.x, -tan.y, -1.0));
vec3 o = R * vec3((eye < 0.5 ? -1.0 : 1.0) * pc.rot0.w, 0.0, 0.0);
vec3 o = pc.extra.yzw + R * vec3((eye < 0.5 ? -1.0 : 1.0) * pc.rot0.w, 0.0, 0.0);
if (d.z > -1e-4) return BACKDROP;
float t = (-pc.rot1.w - o.z) / d.z;
if (t <= 0.0) return BACKDROP;
vec3 hit = o + d * t;
float halfW = pc.rot2.w, halfH = halfW / pc.view.y;
vec2 s = vec2(hit.x / halfW, -hit.y / halfH) * 0.5 + 0.5;
@@ -40,9 +47,10 @@ vec3 eyeSample(vec2 tan, float eye) {
}
void main() {
float eye = uv.x < 0.5 ? 0.0 : 1.0;
vec2 local = vec2(uv.x * 2.0 - eye, uv.y);
float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
bool mono = pc.extra.x > 0.5;
float eye = mono || uv.x < 0.5 ? 0.0 : 1.0;
vec2 local = mono ? uv : vec2(uv.x * 2.0 - eye, uv.y);
float cx = mono ? 0.5 : 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x);
vec2 p = local - vec2(cx, 0.5);
float tanH = pc.lens.z;
vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH;
+6 -1
View File
@@ -3,7 +3,7 @@
#define VK_USE_PLATFORM_ANDROID_KHR
#include <vulkan/vulkan.h>
enum { CARDBOARD_SOURCE_SCREEN = 0, CARDBOARD_SOURCE_SBS = 1 };
enum { CARDBOARD_SOURCE_SCREEN = 0, CARDBOARD_SOURCE_SBS = 1, CARDBOARD_SOURCE_FLAT = 2 };
struct cardboard_params {
int enabled;
@@ -15,11 +15,15 @@ struct cardboard_params {
float fov_deg;
float lens_inset;
float chroma;
int mono;
float display_hz;
};
void vkp_cardboard_set(const struct cardboard_params *p);
void vkp_cardboard_set_pose(const float m[9], int live);
void vkp_cardboard_set_spatial_pose(const float m[9], const float position[3], int live);
void vkp_cardboard_set_pose_file(const char *path);
void vkp_cardboard_set_render_height(int height);
void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops);
int vkp_cardboard_active(void);
@@ -30,5 +34,6 @@ VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h
void cardboard_clamp(struct cardboard_params *p);
void cardboard_push_constants(const struct cardboard_params *p, const float pose[9],
int src_w, int src_h, int out_w, int out_h, float out[24]);
void cardboard_push_translation(const struct cardboard_params *p, const float position[3], float out[24]);
#endif
+305 -253
View File
@@ -1,8 +1,9 @@
// 1115.1.0
#pragma once
const uint32_t cardboard_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b,
0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x0000012d,0x000001b2,0x00030010,
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b,
0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005,
@@ -11,255 +12,306 @@ const uint32_t cardboard_frag_code[] = {
0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e,
0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000,
0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004,
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005,
0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,
0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,
0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,
0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,
0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005,
0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c,
0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005,
0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125,
0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170,
0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170,
0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170,
0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170,
0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170,
0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,
0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170,
0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170,
0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000,
0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002,
0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001,
0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048,
0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023,
0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068,
0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee,
0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002,
0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,
0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,
0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,
0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,
0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,
0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,
0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,
0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,
0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,
0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,
0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,
0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,
0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,
0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,
0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,
0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,
0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,
0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,
0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,
0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,
0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,
0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,
0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,
0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001,
0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000,
0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d,
0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,
0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b,
0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b,
0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b,
0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b,
0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b,
0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b,
0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b,
0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b,
0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b,
0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b,
0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b,
0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b,
0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b,
0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024,
0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1,
0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e,
0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d,
0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7,
0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8,
0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006,
0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e,
0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b,
0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100,
0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020,
0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101,
0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107,
0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f,
0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103,
0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006,
0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007,
0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007,
0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f,
0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022,
0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113,
0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116,
0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d,
0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118,
0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b,
0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007,
0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e,
0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041,
0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121,
0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124,
0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041,
0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127,
0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007,
0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128,
0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d,
0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f,
0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006,
0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024,
0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e,
0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136,
0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006,
0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a,
0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001,
0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b,
0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,
0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d,
0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142,
0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e,
0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145,
0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051,
0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125,
0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a,
0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e,
0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,
0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e,
0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,
0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d,
0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050,
0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156,
0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157,
0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024,
0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020,
0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006,
0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b,
0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001,
0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031,
0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f,
0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f,
0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000,
0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b,
0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b,
0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007,
0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009,
0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d,
0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094,
0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d,
0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e,
0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020,
0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006,
0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081,
0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020,
0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006,
0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d,
0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030,
0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033,
0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013,
0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012,
0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008,
0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093,
0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009,
0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009,
0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008,
0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037,
0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039,
0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e,
0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037,
0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041,
0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043,
0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020,
0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006,
0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049,
0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c,
0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e,
0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e,
0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a,
0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042,
0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057,
0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b,
0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a,
0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba,
0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e,
0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058,
0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060,
0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062,
0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042,
0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c,
0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006,
0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b,
0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e,
0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f,
0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073,
0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d,
0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b,
0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d,
0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f,
0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020,
0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084,
0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085,
0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051,
0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080,
0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006,
0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000,
0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d,
0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087,
0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f,
0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e,
0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077,
0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096,
0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009,
0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f,
0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a,
0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091,
0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d,
0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8,
0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2,
0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046,
0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3,
0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091,
0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041,
0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9,
0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000,
0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1,
0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046,
0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5,
0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8,
0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009,
0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088,
0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d,
0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d,
0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0,
0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2,
0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006,
0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7,
0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d,
0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9,
0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024,
0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3,
0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0,
0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006,
0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006,
0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4,
0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7,
0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e,
0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056,
0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8,
0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc,
0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb,
0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1,
0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2,
0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa,
0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8,
0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7,
0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f,
0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9,
0x00010038
0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00050006,
0x0000001e,0x00000006,0x72747865,0x00000061,0x00030005,0x00000020,0x00006370,0x00040005,
0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,0x00000073,0x00030005,0x00000059,
0x00746966,0x00040005,0x00000082,0x72756f53,0x00006563,0x00030005,0x000000a3,0x00000052,
0x00030005,0x000000c0,0x00000064,0x00030005,0x000000cb,0x0000006f,0x00030005,0x000000e4,
0x00000074,0x00030005,0x000000f3,0x00746968,0x00040005,0x000000f9,0x666c6168,0x00000057,
0x00040005,0x000000fc,0x666c6168,0x00000048,0x00030005,0x00000101,0x00000073,0x00040005,
0x00000123,0x6f6e6f6d,0x00000000,0x00030005,0x00000127,0x00657965,0x00030005,0x0000012d,
0x00007675,0x00040005,0x00000134,0x61636f6c,0x0000006c,0x00030005,0x00000145,0x00007863,
0x00030005,0x00000159,0x00000070,0x00040005,0x0000015e,0x486e6174,0x00000000,0x00030005,
0x00000161,0x00000074,0x00030005,0x00000172,0x00000063,0x00040005,0x00000176,0x61726170,
0x0000006d,0x00040005,0x00000178,0x61726170,0x0000006d,0x00040005,0x0000017a,0x61726170,
0x0000006d,0x00040005,0x0000017b,0x61726170,0x0000006d,0x00040005,0x00000180,0x61726170,
0x0000006d,0x00040005,0x00000182,0x61726170,0x0000006d,0x00040005,0x00000184,0x61726170,
0x0000006d,0x00040005,0x00000185,0x61726170,0x0000006d,0x00040005,0x0000018d,0x61726170,
0x0000006d,0x00040005,0x0000018f,0x61726170,0x0000006d,0x00040005,0x00000191,0x61726170,
0x0000006d,0x00040005,0x00000192,0x61726170,0x0000006d,0x00040005,0x00000198,0x61726170,
0x0000006d,0x00040005,0x0000019a,0x61726170,0x0000006d,0x00040005,0x0000019c,0x61726170,
0x0000006d,0x00040005,0x0000019d,0x61726170,0x0000006d,0x00030005,0x000001a0,0x00000065,
0x00040005,0x000001a6,0x65646166,0x00000000,0x00050005,0x000001b2,0x4374756f,0x726f6c6f,
0x00000000,0x00030047,0x0000001e,0x00000002,0x00050048,0x0000001e,0x00000000,0x00000023,
0x00000000,0x00050048,0x0000001e,0x00000001,0x00000023,0x00000010,0x00050048,0x0000001e,
0x00000002,0x00000023,0x00000020,0x00050048,0x0000001e,0x00000003,0x00000023,0x00000030,
0x00050048,0x0000001e,0x00000004,0x00000023,0x00000040,0x00050048,0x0000001e,0x00000005,
0x00000023,0x00000050,0x00050048,0x0000001e,0x00000006,0x00000023,0x00000060,0x00040047,
0x00000082,0x00000021,0x00000000,0x00040047,0x00000082,0x00000022,0x00000000,0x00040047,
0x0000012d,0x0000001e,0x00000000,0x00040047,0x000001b2,0x0000001e,0x00000000,0x00020013,
0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,
0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,
0x00000009,0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,
0x00040017,0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,
0x00000009,0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,
0x00000004,0x0009001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,
0x0000001d,0x0000001d,0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,
0x00000020,0x00000009,0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,
0x00000022,0x00000004,0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,
0x00000024,0x00000000,0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,
0x0000002b,0x00000001,0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,
0x00000037,0x00000002,0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,
0x0004002b,0x00000023,0x00000046,0x00000003,0x0004002b,0x00000006,0x00000055,0x3fc00000,
0x0005002c,0x00000007,0x00000065,0x0000001c,0x0000001c,0x0004002b,0x00000006,0x0000006f,
0x00000000,0x0005002c,0x00000007,0x00000070,0x0000006f,0x0000006f,0x00040017,0x00000071,
0x0000003b,0x00000002,0x0006002c,0x0000000f,0x0000007d,0x0000006f,0x0000006f,0x0000006f,
0x00090019,0x0000007f,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,0x00000001,
0x00000000,0x0003001b,0x00000080,0x0000007f,0x00040020,0x00000081,0x00000000,0x00000080,
0x0004003b,0x00000081,0x00000082,0x00000000,0x00040018,0x000000a1,0x0000000f,0x00000003,
0x00040020,0x000000a2,0x00000007,0x000000a1,0x0004002b,0x00000021,0x000000a4,0x00000001,
0x00040020,0x000000a5,0x00000009,0x0000001d,0x0004002b,0x00000021,0x000000a9,0x00000002,
0x0004002b,0x00000021,0x000000ad,0x00000003,0x00040020,0x000000bf,0x00000007,0x0000000f,
0x0004002b,0x00000006,0x000000c7,0xbf800000,0x0004002b,0x00000021,0x000000cc,0x00000006,
0x0004002b,0x00000006,0x000000dc,0xb8d1b717,0x0004002b,0x00000006,0x000000e0,0x3c75c28f,
0x0004002b,0x00000006,0x000000e1,0x3ca3d70a,0x0006002c,0x0000000f,0x000000e2,0x000000e0,
0x000000e0,0x000000e1,0x00040020,0x00000122,0x00000007,0x0000003b,0x00040020,0x0000012c,
0x00000001,0x00000007,0x0004003b,0x0000012c,0x0000012d,0x00000001,0x00040020,0x0000012e,
0x00000001,0x00000006,0x0004002b,0x00000006,0x0000013d,0x40000000,0x0004002b,0x00000006,
0x000001a7,0x3cf5c28f,0x00040020,0x000001b1,0x00000003,0x0000001d,0x0004003b,0x000001b1,
0x000001b2,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,
0x00000005,0x0004003b,0x00000122,0x00000123,0x00000007,0x0004003b,0x00000009,0x00000127,
0x00000007,0x0004003b,0x00000008,0x00000134,0x00000007,0x0004003b,0x00000008,0x00000136,
0x00000007,0x0004003b,0x00000009,0x00000145,0x00000007,0x0004003b,0x00000009,0x00000147,
0x00000007,0x0004003b,0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x00000159,
0x00000007,0x0004003b,0x00000009,0x0000015e,0x00000007,0x0004003b,0x00000008,0x00000161,
0x00000007,0x0004003b,0x000000bf,0x00000172,0x00000007,0x0004003b,0x00000008,0x00000176,
0x00000007,0x0004003b,0x00000009,0x00000178,0x00000007,0x0004003b,0x00000008,0x0000017a,
0x00000007,0x0004003b,0x00000009,0x0000017b,0x00000007,0x0004003b,0x00000008,0x00000180,
0x00000007,0x0004003b,0x00000009,0x00000182,0x00000007,0x0004003b,0x00000008,0x00000184,
0x00000007,0x0004003b,0x00000009,0x00000185,0x00000007,0x0004003b,0x00000008,0x0000018d,
0x00000007,0x0004003b,0x00000009,0x0000018f,0x00000007,0x0004003b,0x00000008,0x00000191,
0x00000007,0x0004003b,0x00000009,0x00000192,0x00000007,0x0004003b,0x00000008,0x00000198,
0x00000007,0x0004003b,0x00000009,0x0000019a,0x00000007,0x0004003b,0x00000008,0x0000019c,
0x00000007,0x0004003b,0x00000009,0x0000019d,0x00000007,0x0004003b,0x00000008,0x000001a0,
0x00000007,0x0004003b,0x00000009,0x000001a6,0x00000007,0x00060041,0x00000025,0x00000124,
0x00000020,0x000000cc,0x00000024,0x0004003d,0x00000006,0x00000125,0x00000124,0x000500ba,
0x0000003b,0x00000126,0x00000125,0x0000003a,0x0003003e,0x00000123,0x00000126,0x0004003d,
0x0000003b,0x00000128,0x00000123,0x000400a8,0x0000003b,0x00000129,0x00000128,0x000300f7,
0x0000012b,0x00000000,0x000400fa,0x00000129,0x0000012a,0x0000012b,0x000200f8,0x0000012a,
0x00050041,0x0000012e,0x0000012f,0x0000012d,0x00000024,0x0004003d,0x00000006,0x00000130,
0x0000012f,0x000500b8,0x0000003b,0x00000131,0x00000130,0x0000003a,0x000200f9,0x0000012b,
0x000200f8,0x0000012b,0x000700f5,0x0000003b,0x00000132,0x00000128,0x00000005,0x00000131,
0x0000012a,0x000600a9,0x00000006,0x00000133,0x00000132,0x0000006f,0x0000001c,0x0003003e,
0x00000127,0x00000133,0x0004003d,0x0000003b,0x00000135,0x00000123,0x000300f7,0x00000138,
0x00000000,0x000400fa,0x00000135,0x00000137,0x0000013a,0x000200f8,0x00000137,0x0004003d,
0x00000007,0x00000139,0x0000012d,0x0003003e,0x00000136,0x00000139,0x000200f9,0x00000138,
0x000200f8,0x0000013a,0x00050041,0x0000012e,0x0000013b,0x0000012d,0x00000024,0x0004003d,
0x00000006,0x0000013c,0x0000013b,0x00050085,0x00000006,0x0000013e,0x0000013c,0x0000013d,
0x0004003d,0x00000006,0x0000013f,0x00000127,0x00050083,0x00000006,0x00000140,0x0000013e,
0x0000013f,0x00050041,0x0000012e,0x00000141,0x0000012d,0x0000002b,0x0004003d,0x00000006,
0x00000142,0x00000141,0x00050050,0x00000007,0x00000143,0x00000140,0x00000142,0x0003003e,
0x00000136,0x00000143,0x000200f9,0x00000138,0x000200f8,0x00000138,0x0004003d,0x00000007,
0x00000144,0x00000136,0x0003003e,0x00000134,0x00000144,0x0004003d,0x0000003b,0x00000146,
0x00000123,0x000300f7,0x00000149,0x00000000,0x000400fa,0x00000146,0x00000148,0x0000014a,
0x000200f8,0x00000148,0x0003003e,0x00000147,0x0000003a,0x000200f9,0x00000149,0x000200f8,
0x0000014a,0x0004003d,0x00000006,0x0000014b,0x00000127,0x000500b8,0x0000003b,0x0000014c,
0x0000014b,0x0000003a,0x000300f7,0x0000014f,0x00000000,0x000400fa,0x0000014c,0x0000014e,
0x00000152,0x000200f8,0x0000014e,0x00060041,0x00000025,0x00000150,0x00000020,0x00000036,
0x00000024,0x0004003d,0x00000006,0x00000151,0x00000150,0x0003003e,0x0000014d,0x00000151,
0x000200f9,0x0000014f,0x000200f8,0x00000152,0x00060041,0x00000025,0x00000153,0x00000020,
0x00000036,0x00000024,0x0004003d,0x00000006,0x00000154,0x00000153,0x0004007f,0x00000006,
0x00000155,0x00000154,0x0003003e,0x0000014d,0x00000155,0x000200f9,0x0000014f,0x000200f8,
0x0000014f,0x0004003d,0x00000006,0x00000156,0x0000014d,0x00050081,0x00000006,0x00000157,
0x0000003a,0x00000156,0x0003003e,0x00000147,0x00000157,0x000200f9,0x00000149,0x000200f8,
0x00000149,0x0004003d,0x00000006,0x00000158,0x00000147,0x0003003e,0x00000145,0x00000158,
0x0004003d,0x00000007,0x0000015a,0x00000134,0x0004003d,0x00000006,0x0000015b,0x00000145,
0x00050050,0x00000007,0x0000015c,0x0000015b,0x0000003a,0x00050083,0x00000007,0x0000015d,
0x0000015a,0x0000015c,0x0003003e,0x00000159,0x0000015d,0x00060041,0x00000025,0x0000015f,
0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x00000160,0x0000015f,0x0003003e,
0x0000015e,0x00000160,0x00050041,0x00000009,0x00000162,0x00000159,0x00000024,0x0004003d,
0x00000006,0x00000163,0x00000162,0x00060041,0x00000025,0x00000164,0x00000020,0x00000036,
0x00000046,0x0004003d,0x00000006,0x00000165,0x00000164,0x00050085,0x00000006,0x00000166,
0x00000163,0x00000165,0x00050041,0x00000009,0x00000167,0x00000159,0x0000002b,0x0004003d,
0x00000006,0x00000168,0x00000167,0x00050050,0x00000007,0x00000169,0x00000166,0x00000168,
0x0005008e,0x00000007,0x0000016a,0x00000169,0x0000013d,0x0004003d,0x00000006,0x0000016b,
0x0000015e,0x0005008e,0x00000007,0x0000016c,0x0000016a,0x0000016b,0x0003003e,0x00000161,
0x0000016c,0x00060041,0x00000025,0x0000016d,0x00000020,0x00000022,0x00000046,0x0004003d,
0x00000006,0x0000016e,0x0000016d,0x000500ba,0x0000003b,0x0000016f,0x0000016e,0x0000006f,
0x000300f7,0x00000171,0x00000000,0x000400fa,0x0000016f,0x00000170,0x00000197,0x000200f8,
0x00000170,0x00060041,0x00000025,0x00000173,0x00000020,0x00000022,0x00000046,0x0004003d,
0x00000006,0x00000174,0x00000173,0x00050083,0x00000006,0x00000175,0x0000001c,0x00000174,
0x0004003d,0x00000007,0x00000177,0x00000161,0x0003003e,0x00000176,0x00000177,0x0003003e,
0x00000178,0x00000175,0x00060039,0x00000007,0x00000179,0x0000000d,0x00000176,0x00000178,
0x0003003e,0x0000017a,0x00000179,0x0004003d,0x00000006,0x0000017c,0x00000127,0x0003003e,
0x0000017b,0x0000017c,0x00060039,0x0000000f,0x0000017d,0x00000013,0x0000017a,0x0000017b,
0x00050051,0x00000006,0x0000017e,0x0000017d,0x00000000,0x00050041,0x00000009,0x0000017f,
0x00000172,0x00000024,0x0003003e,0x0000017f,0x0000017e,0x0004003d,0x00000007,0x00000181,
0x00000161,0x0003003e,0x00000180,0x00000181,0x0003003e,0x00000182,0x0000001c,0x00060039,
0x00000007,0x00000183,0x0000000d,0x00000180,0x00000182,0x0003003e,0x00000184,0x00000183,
0x0004003d,0x00000006,0x00000186,0x00000127,0x0003003e,0x00000185,0x00000186,0x00060039,
0x0000000f,0x00000187,0x00000013,0x00000184,0x00000185,0x00050051,0x00000006,0x00000188,
0x00000187,0x00000001,0x00050041,0x00000009,0x00000189,0x00000172,0x0000002b,0x0003003e,
0x00000189,0x00000188,0x00060041,0x00000025,0x0000018a,0x00000020,0x00000022,0x00000046,
0x0004003d,0x00000006,0x0000018b,0x0000018a,0x00050081,0x00000006,0x0000018c,0x0000001c,
0x0000018b,0x0004003d,0x00000007,0x0000018e,0x00000161,0x0003003e,0x0000018d,0x0000018e,
0x0003003e,0x0000018f,0x0000018c,0x00060039,0x00000007,0x00000190,0x0000000d,0x0000018d,
0x0000018f,0x0003003e,0x00000191,0x00000190,0x0004003d,0x00000006,0x00000193,0x00000127,
0x0003003e,0x00000192,0x00000193,0x00060039,0x0000000f,0x00000194,0x00000013,0x00000191,
0x00000192,0x00050051,0x00000006,0x00000195,0x00000194,0x00000002,0x00050041,0x00000009,
0x00000196,0x00000172,0x00000037,0x0003003e,0x00000196,0x00000195,0x000200f9,0x00000171,
0x000200f8,0x00000197,0x0004003d,0x00000007,0x00000199,0x00000161,0x0003003e,0x00000198,
0x00000199,0x0003003e,0x0000019a,0x0000001c,0x00060039,0x00000007,0x0000019b,0x0000000d,
0x00000198,0x0000019a,0x0003003e,0x0000019c,0x0000019b,0x0004003d,0x00000006,0x0000019e,
0x00000127,0x0003003e,0x0000019d,0x0000019e,0x00060039,0x0000000f,0x0000019f,0x00000013,
0x0000019c,0x0000019d,0x0003003e,0x00000172,0x0000019f,0x000200f9,0x00000171,0x000200f8,
0x00000171,0x0004003d,0x00000007,0x000001a1,0x00000134,0x0004003d,0x00000007,0x000001a2,
0x00000134,0x00050050,0x00000007,0x000001a3,0x0000001c,0x0000001c,0x00050083,0x00000007,
0x000001a4,0x000001a3,0x000001a2,0x0007000c,0x00000007,0x000001a5,0x00000001,0x00000025,
0x000001a1,0x000001a4,0x0003003e,0x000001a0,0x000001a5,0x00050041,0x00000009,0x000001a8,
0x000001a0,0x00000024,0x0004003d,0x00000006,0x000001a9,0x000001a8,0x00060041,0x00000025,
0x000001aa,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x000001ab,0x000001aa,
0x00050085,0x00000006,0x000001ac,0x000001a9,0x000001ab,0x00050041,0x00000009,0x000001ad,
0x000001a0,0x0000002b,0x0004003d,0x00000006,0x000001ae,0x000001ad,0x0007000c,0x00000006,
0x000001af,0x00000001,0x00000025,0x000001ac,0x000001ae,0x0008000c,0x00000006,0x000001b0,
0x00000001,0x00000031,0x0000006f,0x000001a7,0x000001af,0x0003003e,0x000001a6,0x000001b0,
0x0004003d,0x0000000f,0x000001b3,0x00000172,0x0004003d,0x00000006,0x000001b4,0x000001a6,
0x0005008e,0x0000000f,0x000001b5,0x000001b3,0x000001b4,0x00050051,0x00000006,0x000001b6,
0x000001b5,0x00000000,0x00050051,0x00000006,0x000001b7,0x000001b5,0x00000001,0x00050051,
0x00000006,0x000001b8,0x000001b5,0x00000002,0x00070050,0x0000001d,0x000001b9,0x000001b6,
0x000001b7,0x000001b8,0x0000001c,0x0003003e,0x000001b2,0x000001b9,0x000100fd,0x00010038,
0x00050036,0x00000007,0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,
0x00030037,0x00000009,0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,
0x00000007,0x0004003d,0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,
0x0000000b,0x00050094,0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,
0x00000018,0x0004003d,0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,
0x0000000c,0x0005008e,0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,
0x00000026,0x00000020,0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,
0x0004003d,0x00000006,0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,
0x00000028,0x00050081,0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,
0x0000002c,0x00000020,0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,
0x0004003d,0x00000006,0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,
0x0000002e,0x0004003d,0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,
0x0000002f,0x00000030,0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,
0x00000007,0x00000033,0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,
0x0000000f,0x00000013,0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,
0x00000009,0x00000012,0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,
0x0004003b,0x00000008,0x00000042,0x00000007,0x0004003b,0x00000008,0x00000059,0x00000007,
0x0004003b,0x000000a2,0x000000a3,0x00000007,0x0004003b,0x000000bf,0x000000c0,0x00000007,
0x0004003b,0x000000bf,0x000000cb,0x00000007,0x0004003b,0x00000009,0x000000e4,0x00000007,
0x0004003b,0x000000bf,0x000000f3,0x00000007,0x0004003b,0x00000009,0x000000f9,0x00000007,
0x0004003b,0x00000009,0x000000fc,0x00000007,0x0004003b,0x00000008,0x00000101,0x00000007,
0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037,0x0004003d,0x00000006,
0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039,0x0000003a,0x000300f7,
0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e,0x000200f8,0x0000003d,
0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,
0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041,0x00000009,0x00000043,
0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043,0x0004003d,0x00000006,
0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020,0x00000036,0x00000046,
0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006,0x00000049,0x00000045,
0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049,0x00050041,0x00000009,
0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c,0x0000004b,0x0004003d,
0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e,0x0000004c,0x0000004d,
0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e,0x00000007,0x00000050,
0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a,0x0000003a,0x00050081,
0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042,0x00000052,0x00060041,
0x00000025,0x00000053,0x00000020,0x00000036,0x00000037,0x0004003d,0x00000006,0x00000054,
0x00000053,0x000500ba,0x0000003b,0x00000056,0x00000054,0x00000055,0x000300f7,0x00000058,
0x00000000,0x000400fa,0x00000056,0x00000057,0x00000058,0x000200f8,0x00000057,0x00060041,
0x00000025,0x0000005a,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x0000005b,
0x0000005a,0x00060041,0x00000025,0x0000005c,0x00000020,0x00000036,0x0000002b,0x0004003d,
0x00000006,0x0000005d,0x0000005c,0x00050088,0x00000006,0x0000005e,0x0000005b,0x0000005d,
0x00060041,0x00000025,0x0000005f,0x00000020,0x00000036,0x0000002b,0x0004003d,0x00000006,
0x00000060,0x0000005f,0x00060041,0x00000025,0x00000061,0x00000020,0x00000036,0x00000046,
0x0004003d,0x00000006,0x00000062,0x00000061,0x00050088,0x00000006,0x00000063,0x00000060,
0x00000062,0x00050050,0x00000007,0x00000064,0x0000005e,0x00000063,0x0007000c,0x00000007,
0x00000066,0x00000001,0x00000028,0x00000064,0x00000065,0x0003003e,0x00000059,0x00000066,
0x0004003d,0x00000007,0x00000067,0x00000042,0x00050050,0x00000007,0x00000068,0x0000003a,
0x0000003a,0x00050083,0x00000007,0x00000069,0x00000067,0x00000068,0x0004003d,0x00000007,
0x0000006a,0x00000059,0x00050085,0x00000007,0x0000006b,0x00000069,0x0000006a,0x00050050,
0x00000007,0x0000006c,0x0000003a,0x0000003a,0x00050081,0x00000007,0x0000006d,0x0000006b,
0x0000006c,0x0003003e,0x00000042,0x0000006d,0x0004003d,0x00000007,0x0000006e,0x00000042,
0x000500b8,0x00000071,0x00000072,0x0000006e,0x00000070,0x0004009a,0x0000003b,0x00000073,
0x00000072,0x000400a8,0x0000003b,0x00000074,0x00000073,0x000300f7,0x00000076,0x00000000,
0x000400fa,0x00000074,0x00000075,0x00000076,0x000200f8,0x00000075,0x0004003d,0x00000007,
0x00000077,0x00000042,0x000500ba,0x00000071,0x00000078,0x00000077,0x00000065,0x0004009a,
0x0000003b,0x00000079,0x00000078,0x000200f9,0x00000076,0x000200f8,0x00000076,0x000700f5,
0x0000003b,0x0000007a,0x00000073,0x00000057,0x00000079,0x00000075,0x000300f7,0x0000007c,
0x00000000,0x000400fa,0x0000007a,0x0000007b,0x0000007c,0x000200f8,0x0000007b,0x000200fe,
0x0000007d,0x000200f8,0x0000007c,0x0004003d,0x00000080,0x00000083,0x00000082,0x0004003d,
0x00000007,0x00000084,0x00000042,0x00050057,0x0000001d,0x00000085,0x00000083,0x00000084,
0x0008004f,0x0000000f,0x00000086,0x00000085,0x00000085,0x00000000,0x00000001,0x00000002,
0x000200fe,0x00000086,0x000200f8,0x00000058,0x0004003d,0x00000007,0x00000088,0x00000042,
0x000500b8,0x00000071,0x00000089,0x00000088,0x00000070,0x0004009a,0x0000003b,0x0000008a,
0x00000089,0x000400a8,0x0000003b,0x0000008b,0x0000008a,0x000300f7,0x0000008d,0x00000000,
0x000400fa,0x0000008b,0x0000008c,0x0000008d,0x000200f8,0x0000008c,0x0004003d,0x00000007,
0x0000008e,0x00000042,0x000500ba,0x00000071,0x0000008f,0x0000008e,0x00000065,0x0004009a,
0x0000003b,0x00000090,0x0000008f,0x000200f9,0x0000008d,0x000200f8,0x0000008d,0x000700f5,
0x0000003b,0x00000091,0x0000008a,0x00000058,0x00000090,0x0000008c,0x000300f7,0x00000093,
0x00000000,0x000400fa,0x00000091,0x00000092,0x00000093,0x000200f8,0x00000092,0x000200fe,
0x0000007d,0x000200f8,0x00000093,0x0004003d,0x00000080,0x00000095,0x00000082,0x00050041,
0x00000009,0x00000096,0x00000042,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,
0x0004003d,0x00000006,0x00000098,0x00000012,0x00050081,0x00000006,0x00000099,0x00000097,
0x00000098,0x00050085,0x00000006,0x0000009a,0x00000099,0x0000003a,0x00050041,0x00000009,
0x0000009b,0x00000042,0x0000002b,0x0004003d,0x00000006,0x0000009c,0x0000009b,0x00050050,
0x00000007,0x0000009d,0x0000009a,0x0000009c,0x00050057,0x0000001d,0x0000009e,0x00000095,
0x0000009d,0x0008004f,0x0000000f,0x0000009f,0x0000009e,0x0000009e,0x00000000,0x00000001,
0x00000002,0x000200fe,0x0000009f,0x000200f8,0x0000003e,0x00050041,0x000000a5,0x000000a6,
0x00000020,0x000000a4,0x0004003d,0x0000001d,0x000000a7,0x000000a6,0x0008004f,0x0000000f,
0x000000a8,0x000000a7,0x000000a7,0x00000000,0x00000001,0x00000002,0x00050041,0x000000a5,
0x000000aa,0x00000020,0x000000a9,0x0004003d,0x0000001d,0x000000ab,0x000000aa,0x0008004f,
0x0000000f,0x000000ac,0x000000ab,0x000000ab,0x00000000,0x00000001,0x00000002,0x00050041,
0x000000a5,0x000000ae,0x00000020,0x000000ad,0x0004003d,0x0000001d,0x000000af,0x000000ae,
0x0008004f,0x0000000f,0x000000b0,0x000000af,0x000000af,0x00000000,0x00000001,0x00000002,
0x00050051,0x00000006,0x000000b1,0x000000a8,0x00000000,0x00050051,0x00000006,0x000000b2,
0x000000a8,0x00000001,0x00050051,0x00000006,0x000000b3,0x000000a8,0x00000002,0x00050051,
0x00000006,0x000000b4,0x000000ac,0x00000000,0x00050051,0x00000006,0x000000b5,0x000000ac,
0x00000001,0x00050051,0x00000006,0x000000b6,0x000000ac,0x00000002,0x00050051,0x00000006,
0x000000b7,0x000000b0,0x00000000,0x00050051,0x00000006,0x000000b8,0x000000b0,0x00000001,
0x00050051,0x00000006,0x000000b9,0x000000b0,0x00000002,0x00060050,0x0000000f,0x000000ba,
0x000000b1,0x000000b2,0x000000b3,0x00060050,0x0000000f,0x000000bb,0x000000b4,0x000000b5,
0x000000b6,0x00060050,0x0000000f,0x000000bc,0x000000b7,0x000000b8,0x000000b9,0x00060050,
0x000000a1,0x000000bd,0x000000ba,0x000000bb,0x000000bc,0x00040054,0x000000a1,0x000000be,
0x000000bd,0x0003003e,0x000000a3,0x000000be,0x0004003d,0x000000a1,0x000000c1,0x000000a3,
0x00050041,0x00000009,0x000000c2,0x00000011,0x00000024,0x0004003d,0x00000006,0x000000c3,
0x000000c2,0x00050041,0x00000009,0x000000c4,0x00000011,0x0000002b,0x0004003d,0x00000006,
0x000000c5,0x000000c4,0x0004007f,0x00000006,0x000000c6,0x000000c5,0x00060050,0x0000000f,
0x000000c8,0x000000c3,0x000000c6,0x000000c7,0x0006000c,0x0000000f,0x000000c9,0x00000001,
0x00000045,0x000000c8,0x00050091,0x0000000f,0x000000ca,0x000000c1,0x000000c9,0x0003003e,
0x000000c0,0x000000ca,0x00050041,0x000000a5,0x000000cd,0x00000020,0x000000cc,0x0004003d,
0x0000001d,0x000000ce,0x000000cd,0x0008004f,0x0000000f,0x000000cf,0x000000ce,0x000000ce,
0x00000001,0x00000002,0x00000003,0x0004003d,0x000000a1,0x000000d0,0x000000a3,0x0004003d,
0x00000006,0x000000d1,0x00000012,0x000500b8,0x0000003b,0x000000d2,0x000000d1,0x0000003a,
0x000600a9,0x00000006,0x000000d3,0x000000d2,0x000000c7,0x0000001c,0x00060041,0x00000025,
0x000000d4,0x00000020,0x000000a4,0x00000046,0x0004003d,0x00000006,0x000000d5,0x000000d4,
0x00050085,0x00000006,0x000000d6,0x000000d3,0x000000d5,0x00060050,0x0000000f,0x000000d7,
0x000000d6,0x0000006f,0x0000006f,0x00050091,0x0000000f,0x000000d8,0x000000d0,0x000000d7,
0x00050081,0x0000000f,0x000000d9,0x000000cf,0x000000d8,0x0003003e,0x000000cb,0x000000d9,
0x00050041,0x00000009,0x000000da,0x000000c0,0x00000037,0x0004003d,0x00000006,0x000000db,
0x000000da,0x000500ba,0x0000003b,0x000000dd,0x000000db,0x000000dc,0x000300f7,0x000000df,
0x00000000,0x000400fa,0x000000dd,0x000000de,0x000000df,0x000200f8,0x000000de,0x000200fe,
0x000000e2,0x000200f8,0x000000df,0x00060041,0x00000025,0x000000e5,0x00000020,0x000000a9,
0x00000046,0x0004003d,0x00000006,0x000000e6,0x000000e5,0x0004007f,0x00000006,0x000000e7,
0x000000e6,0x00050041,0x00000009,0x000000e8,0x000000cb,0x00000037,0x0004003d,0x00000006,
0x000000e9,0x000000e8,0x00050083,0x00000006,0x000000ea,0x000000e7,0x000000e9,0x00050041,
0x00000009,0x000000eb,0x000000c0,0x00000037,0x0004003d,0x00000006,0x000000ec,0x000000eb,
0x00050088,0x00000006,0x000000ed,0x000000ea,0x000000ec,0x0003003e,0x000000e4,0x000000ed,
0x0004003d,0x00000006,0x000000ee,0x000000e4,0x000500bc,0x0000003b,0x000000ef,0x000000ee,
0x0000006f,0x000300f7,0x000000f1,0x00000000,0x000400fa,0x000000ef,0x000000f0,0x000000f1,
0x000200f8,0x000000f0,0x000200fe,0x000000e2,0x000200f8,0x000000f1,0x0004003d,0x0000000f,
0x000000f4,0x000000cb,0x0004003d,0x0000000f,0x000000f5,0x000000c0,0x0004003d,0x00000006,
0x000000f6,0x000000e4,0x0005008e,0x0000000f,0x000000f7,0x000000f5,0x000000f6,0x00050081,
0x0000000f,0x000000f8,0x000000f4,0x000000f7,0x0003003e,0x000000f3,0x000000f8,0x00060041,
0x00000025,0x000000fa,0x00000020,0x000000ad,0x00000046,0x0004003d,0x00000006,0x000000fb,
0x000000fa,0x0003003e,0x000000f9,0x000000fb,0x0004003d,0x00000006,0x000000fd,0x000000f9,
0x00060041,0x00000025,0x000000fe,0x00000020,0x00000036,0x0000002b,0x0004003d,0x00000006,
0x000000ff,0x000000fe,0x00050088,0x00000006,0x00000100,0x000000fd,0x000000ff,0x0003003e,
0x000000fc,0x00000100,0x00050041,0x00000009,0x00000102,0x000000f3,0x00000024,0x0004003d,
0x00000006,0x00000103,0x00000102,0x0004003d,0x00000006,0x00000104,0x000000f9,0x00050088,
0x00000006,0x00000105,0x00000103,0x00000104,0x00050041,0x00000009,0x00000106,0x000000f3,
0x0000002b,0x0004003d,0x00000006,0x00000107,0x00000106,0x0004007f,0x00000006,0x00000108,
0x00000107,0x0004003d,0x00000006,0x00000109,0x000000fc,0x00050088,0x00000006,0x0000010a,
0x00000108,0x00000109,0x00050050,0x00000007,0x0000010b,0x00000105,0x0000010a,0x0005008e,
0x00000007,0x0000010c,0x0000010b,0x0000003a,0x00050050,0x00000007,0x0000010d,0x0000003a,
0x0000003a,0x00050081,0x00000007,0x0000010e,0x0000010c,0x0000010d,0x0003003e,0x00000101,
0x0000010e,0x0004003d,0x00000007,0x0000010f,0x00000101,0x000500b8,0x00000071,0x00000110,
0x0000010f,0x00000070,0x0004009a,0x0000003b,0x00000111,0x00000110,0x000400a8,0x0000003b,
0x00000112,0x00000111,0x000300f7,0x00000114,0x00000000,0x000400fa,0x00000112,0x00000113,
0x00000114,0x000200f8,0x00000113,0x0004003d,0x00000007,0x00000115,0x00000101,0x000500ba,
0x00000071,0x00000116,0x00000115,0x00000065,0x0004009a,0x0000003b,0x00000117,0x00000116,
0x000200f9,0x00000114,0x000200f8,0x00000114,0x000700f5,0x0000003b,0x00000118,0x00000111,
0x000000f1,0x00000117,0x00000113,0x000300f7,0x0000011a,0x00000000,0x000400fa,0x00000118,
0x00000119,0x0000011a,0x000200f8,0x00000119,0x000200fe,0x000000e2,0x000200f8,0x0000011a,
0x0004003d,0x00000080,0x0000011c,0x00000082,0x0004003d,0x00000007,0x0000011d,0x00000101,
0x00050057,0x0000001d,0x0000011e,0x0000011c,0x0000011d,0x0008004f,0x0000000f,0x0000011f,
0x0000011e,0x0000011e,0x00000000,0x00000001,0x00000002,0x000200fe,0x0000011f,0x00010038
};
+94 -18
View File
@@ -6,6 +6,7 @@
#include <sys/stat.h>
#include <unistd.h>
#include <stdint.h>
#include <stdatomic.h>
#include <fcntl.h>
#include <time.h>
#include <stdarg.h>
@@ -217,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;
@@ -271,7 +274,9 @@ volatile int g_ubwc = 1;
volatile int g_no_render_node;
volatile int g_output_refresh_mhz;
static atomic_int g_output_refresh_mhz = 60000;
static atomic_int g_output_refresh_pending;
static int g_output_announced_refresh_mhz = 60000;
volatile int g_output_w, g_output_h;
struct pending_release {
@@ -657,6 +662,7 @@ static void take_pixel(struct surface *s, const uint8_t bgra[4], struct wl_resou
static struct surface *g_hud_surface;
static int64_t g_hud_last_ns, g_hud_alive_ns;
static int g_hud_fresh;
static uint64_t g_game_frames;
extern void droiddeck_on_game_surface(const char *window, const char *gpu);
extern void droiddeck_on_game_frame(void);
extern void droiddeck_on_game_program(int pid, const char *program);
@@ -740,10 +746,12 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
}
if (s == g_hud_surface) {
g_hud_alive_ns = now_ns();
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; droiddeck_on_game_frame(); }
if (!repeat) { g_hud_last_ns = g_hud_alive_ns; g_hud_fresh = 1; g_game_frames++; droiddeck_on_game_frame(); }
}
}
uint64_t droiddeck_game_frames(void) { return g_game_frames; }
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
@@ -794,7 +802,7 @@ static void feedback_discard_all(struct wl_list *list) {
}
static uint32_t nominal_refresh_ns(void) {
int mhz = g_output_refresh_mhz;
int mhz = atomic_load(&g_output_refresh_mhz);
if (mhz <= 0) mhz = 60000;
return (uint32_t)(1000000000000LL / (int64_t)mhz);
}
@@ -893,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;
@@ -921,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);
}
@@ -1049,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;
@@ -1507,10 +1539,10 @@ static const struct wl_output_interface output_impl = { .release = output_releas
static struct wl_list g_outputs;
static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); }
static void output_send_mode(struct wl_resource *r) {
static void output_send_mode(struct wl_resource *r, int mhz) {
wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED,
g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080,
g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000);
mhz);
}
static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) {
@@ -1520,7 +1552,7 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
wl_list_insert(&g_outputs, wl_resource_get_link(r));
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
output_send_mode(r);
output_send_mode(r, g_output_announced_refresh_mhz);
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
wl_output_send_name(r, "DroidDeck-1");
wl_output_send_description(r, "DroidDeck display");
@@ -2036,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); }
@@ -2587,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) {
@@ -2835,10 +2874,19 @@ static void deliver_key(const struct input_msg *m) {
key_event((uint32_t)m->p1, m->p2);
}
static void scroll_event(int steps) {
static void send_axis(struct seat_pointer *sp, uint32_t t, uint32_t axis, int value120) {
if (!value120) return;
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION)
wl_pointer_send_axis_value120(sp->ptr, axis, value120);
else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION && value120 % 120 == 0)
wl_pointer_send_axis_discrete(sp->ptr, axis, value120 / 120);
wl_pointer_send_axis(sp->ptr, t, axis, wl_fixed_from_double(value120 / 12.0));
}
static void axis_event(int vertical120, int horizontal120) {
struct surface *target = g_active_constraint ? g_active_constraint->surface
: g_desktop ? g_desktop : (g_grab ? g_grab : toplevel_at(g_ptr_x, g_ptr_y));
if (!target || !steps) return;
if (!target || (!vertical120 && !horizontal120)) return;
struct wl_client *client = wl_resource_get_client(target->resource);
struct seat_pointer *sp;
if (!pointer_for(client)) return;
@@ -2847,17 +2895,18 @@ static void scroll_event(int steps) {
uint32_t t = now_ms();
pointer_focus(target->resource, wl_fixed_from_double(g_ptr_x - tx), wl_fixed_from_double(g_ptr_y - ty));
for_each_pointer_of(client, sp) {
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_VALUE120_SINCE_VERSION)
wl_pointer_send_axis_value120(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps * 120);
else if (wl_resource_get_version(sp->ptr) >= WL_POINTER_AXIS_DISCRETE_SINCE_VERSION)
wl_pointer_send_axis_discrete(sp->ptr, WL_POINTER_AXIS_VERTICAL_SCROLL, steps);
wl_pointer_send_axis(sp->ptr, t, WL_POINTER_AXIS_VERTICAL_SCROLL, wl_fixed_from_int(steps * 10));
send_axis(sp, t, WL_POINTER_AXIS_VERTICAL_SCROLL, vertical120);
send_axis(sp, t, WL_POINTER_AXIS_HORIZONTAL_SCROLL, horizontal120);
if (wl_resource_get_version(sp->ptr) >= WL_POINTER_FRAME_SINCE_VERSION)
wl_pointer_send_frame(sp->ptr);
}
wl_display_flush_clients(g_display);
}
static void scroll_event(int steps) {
axis_event(steps * 120, 0);
}
static int update_modifiers(uint32_t evdev, int pressed) {
static const struct { uint32_t evdev, mask; } mod_keys[] = {
{ 42, 0x1 }, { 54, 0x1 },
@@ -2913,13 +2962,19 @@ static void key_event(uint32_t evdev, int pressed) {
wl_display_flush_clients(g_display);
}
static void output_resized(void) {
struct surface *s;
static void output_notify_mode(void) {
int mhz = atomic_load(&g_output_refresh_mhz);
struct wl_resource *r;
wl_resource_for_each(r, &g_outputs) {
output_send_mode(r);
output_send_mode(r, mhz);
if (wl_resource_get_version(r) >= 2) wl_output_send_done(r);
}
g_output_announced_refresh_mhz = mhz;
}
static void output_resized(void) {
struct surface *s;
output_notify_mode();
wl_list_for_each(s, &g_surfaces, link)
if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s);
droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h);
@@ -2928,13 +2983,15 @@ static void output_resized(void) {
static int on_input_readable(int fd, uint32_t mask, void *data) {
struct input_msg m;
int vsync = 0;
int64_t vsync_ns = 0;
while (read(fd, &m, sizeof(m)) == (ssize_t)sizeof(m)) {
switch (m.type) {
case 1: deliver_key(&m); break;
case 2: pointer_event(m.p1, m.p2, 0, 0); break;
case 3: pointer_button(m.p1, m.p2); break;
case 4: scroll_event(m.p1); break;
case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break;
case 5: vsync = 1; vsync_ns = ((int64_t)m.p1 << 32) | (uint32_t)m.p2; break;
case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break;
case 7: deliver_touch(&m); break;
case 8: output_resized(); break;
@@ -2942,9 +2999,17 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
vk_present_set_scale_mode(m.p1, m.p2);
schedule_render();
break;
case 10: break;
case 11: axis_event(m.p1, m.p2); break;
default: deliver_pointer(&m); break;
}
}
if (atomic_exchange(&g_output_refresh_pending, 0) &&
atomic_load(&g_output_refresh_mhz) != g_output_announced_refresh_mhz) {
output_notify_mode();
wl_display_flush_clients(g_display);
}
if (vsync) on_vsync(vsync_ns);
return 0;
}
@@ -2986,6 +3051,16 @@ void droiddeck_wayland_output_resized(void) {
(void)n;
}
void droiddeck_wayland_set_output_refresh_rate(int mhz) {
if (mhz <= 0) mhz = 60000;
if (atomic_exchange(&g_output_refresh_mhz, mhz) == mhz || g_input_pipe[1] < 0) return;
atomic_store(&g_output_refresh_pending, 1);
struct input_msg m = { 10, 0, 0, 0, 0 };
ssize_t n;
do { n = write(g_input_pipe[1], &m, sizeof(m)); } while (n < 0 && errno == EINTR);
(void)n;
}
void droiddeck_wayland_vsync(int64_t frame_time_ns) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 };
@@ -3223,6 +3298,7 @@ int droiddeck_wayland_run(void) {
if ((g_stats_timer = wl_event_loop_add_timer(wl_display_get_event_loop(display), on_stats_timer, NULL)))
wl_event_source_timer_update(g_stats_timer, 10000);
g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz);
wl_display_run(display);
wl_display_destroy(display);
@@ -11,6 +11,7 @@
#include <android/log.h>
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
uint64_t droiddeck_game_frames(void);
#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__)
@@ -85,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);
@@ -124,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);
@@ -179,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; }
@@ -372,7 +373,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
return 0;
}
uint32_t n_gen = fge_plan(g_ring_n, ++g_source_frames, g_presented_rate);
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
* generations would otherwise read as a slower game and justify even more generations. */
++g_source_frames;
const uint64_t game_frames = droiddeck_game_frames();
uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate);
if (n_gen > g_ring_n) n_gen = g_ring_n;
g_planned = n_gen;
@@ -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
@@ -9,6 +9,7 @@
enum {
DROIDDECK_HEADPOSE_LIVE = 1u << 0,
DROIDDECK_HEADPOSE_ENABLED = 1u << 1,
DROIDDECK_HEADPOSE_MONO = 1u << 2,
};
struct droiddeck_headpose {
@@ -22,7 +23,10 @@ struct droiddeck_headpose {
float tan_half_fov;
float eye_aspect;
uint32_t eye_w, eye_h;
uint32_t reserved[12];
float display_hz;
// Uses a formerly reserved word; older publishers leave this zero.
uint32_t render_height;
uint32_t reserved[10];
};
#ifdef __cplusplus
@@ -0,0 +1,59 @@
#define _POSIX_C_SOURCE 200809L
#include "spatialpose_shm.h"
#include <jni.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
static pthread_mutex_t spatial_lock = PTHREAD_MUTEX_INITIALIZER;
static struct droiddeck_spatialpose *shared;
JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetSpatialFile(
JNIEnv *env, jclass clazz, jstring path) {
(void)clazz;
pthread_mutex_lock(&spatial_lock);
if (!shared && path) {
const char *name = (*env)->GetStringUTFChars(env, path, NULL);
if (name) {
int fd = open(name, O_RDWR | O_CREAT | O_CLOEXEC, 0600);
(*env)->ReleaseStringUTFChars(env, path, name);
if (fd >= 0) {
if (ftruncate(fd, sizeof(*shared)) == 0) {
void *p = mmap(NULL, sizeof(*shared), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (p != MAP_FAILED) {
shared = p;
memset(shared, 0, sizeof(*shared));
shared->magic = DROIDDECK_SPATIALPOSE_MAGIC;
shared->version = DROIDDECK_SPATIALPOSE_VERSION;
shared->size = sizeof(*shared);
}
}
close(fd);
}
}
}
jboolean ready = shared != NULL;
pthread_mutex_unlock(&spatial_lock);
return ready;
}
JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativePublishSpatial(
JNIEnv *env, jclass clazz, jobject buffer) {
(void)clazz;
if (!buffer || (*env)->GetDirectBufferCapacity(env, buffer) != sizeof(*shared)) return;
const struct droiddeck_spatialpose *src = (*env)->GetDirectBufferAddress(env, buffer);
if (!src || src->magic != DROIDDECK_SPATIALPOSE_MAGIC || src->version != DROIDDECK_SPATIALPOSE_VERSION ||
src->size != sizeof(*shared)) return;
pthread_mutex_lock(&spatial_lock);
if (shared) {
unsigned seq = __atomic_load_n(&shared->seq, __ATOMIC_RELAXED);
__atomic_store_n(&shared->seq, seq | 1u, __ATOMIC_RELAXED);
__atomic_thread_fence(__ATOMIC_RELEASE);
memcpy((char *)shared + 16, (const char *)src + 16, sizeof(*shared) - 16);
__atomic_store_n(&shared->seq, (seq | 1u) + 1u, __ATOMIC_RELEASE);
}
pthread_mutex_unlock(&spatial_lock);
}
@@ -0,0 +1,33 @@
#ifndef DROIDDECK_SPATIALPOSE_SHM_H
#define DROIDDECK_SPATIALPOSE_SHM_H
#include <stdint.h>
#define DROIDDECK_SPATIALPOSE_MAGIC 0x53504444u
#define DROIDDECK_SPATIALPOSE_VERSION 4u
#define DROIDDECK_SPATIALPOSE_FILE "droiddeck-spatialpose"
enum { DD_SPATIAL_ACTIVE = 1u, DD_SPATIAL_HANDS = 2u, DD_SPATIAL_JOYCONS = 4u, DD_SPATIAL_STEADY_AIM = 8u, DD_SPATIAL_VALID = 1u };
enum { DD_SPATIAL_TRIGGER_VALID = 2u, DD_SPATIAL_GRIP_VALID = 4u };
struct dd_spatial_pose {
int64_t time_ns;
float position[3];
float rotation[4];
uint32_t flags;
};
struct dd_spatial_hand {
struct dd_spatial_pose pose;
float trigger;
uint32_t grip;
};
struct droiddeck_spatialpose {
uint32_t magic, version, size, seq;
uint64_t connection, origin;
uint32_t flags, input_epochs;
struct dd_spatial_pose head;
struct dd_spatial_hand hands[2];
};
#ifdef __cplusplus
static_assert(sizeof(dd_spatial_pose) == 40, "spatial pose ABI");
static_assert(sizeof(droiddeck_spatialpose) == 176, "spatial snapshot ABI");
#else
_Static_assert(sizeof(struct droiddeck_spatialpose) == 176, "spatial snapshot ABI");
#endif
#endif
+32 -1
View File
@@ -138,6 +138,9 @@ static pthread_mutex_t g_req_lock = PTHREAD_MUTEX_INITIALIZER;
static ANativeWindow *g_req_window;
static int g_req_pending;
static int g_req_detached;
static uint64_t g_req_generation, g_req_applied;
static pthread_cond_t g_req_condition = PTHREAD_COND_INITIALIZER;
static volatile int g_mode = VKP_MODE_STRETCH, g_align = VKP_ALIGN_CENTER;
@@ -218,26 +221,53 @@ void vk_present_set_window(ANativeWindow *window) {
if (g_req_pending && g_req_window && g_req_window != window) superseded = g_req_window;
g_req_window = window;
g_req_pending = 1;
g_req_generation++;
if (!window) g_req_detached = 1;
pthread_mutex_unlock(&g_req_lock);
if (superseded) ANativeWindow_release(superseded);
}
static void window_request_applied(uint64_t generation) {
pthread_mutex_lock(&g_req_lock);
g_req_applied = generation;
pthread_cond_broadcast(&g_req_condition);
pthread_mutex_unlock(&g_req_lock);
}
int vkp_wait_window_request(int timeout_ms) {
if (timeout_ms <= 0 || timeout_ms > 10000) return 0;
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) { deadline.tv_sec++; deadline.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&g_req_lock);
const uint64_t generation = g_req_generation;
int error = 0;
while (g_req_applied < generation && error == 0)
error = pthread_cond_timedwait(&g_req_condition, &g_req_lock, &deadline);
int applied = g_req_applied >= generation;
pthread_mutex_unlock(&g_req_lock);
return applied;
}
int vkp_apply_window_request(void) {
ANativeWindow *w;
pthread_mutex_lock(&g_req_lock);
if (!g_req_pending) { pthread_mutex_unlock(&g_req_lock); return 0; }
w = g_req_window;
const int detached = g_req_detached;
const uint64_t generation = g_req_generation;
g_req_window = NULL;
g_req_pending = 0;
g_req_detached = 0;
pthread_mutex_unlock(&g_req_lock);
if (w == g_window) {
if (w) ANativeWindow_release(w);
if (!w || !detached) return 0;
if (!w || !detached) { window_request_applied(generation); return 0; }
sc_layer_window_gone();
destroy_swapchain();
window_request_applied(generation);
droiddeck_log("gpu", "screen surface rebound: rebuilding the swapchain at its new size");
return 1;
}
@@ -248,6 +278,7 @@ int vkp_apply_window_request(void) {
g_window = w;
g_swap_retry_at_ns = 0;
g_swap_fail_logged = 0;
window_request_applied(generation);
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
return 1;
}
@@ -51,6 +51,7 @@ const char *vkp_gpu_name(void);
int vkp_ready(void);
int vkp_has_window(void);
int vkp_wait_window_request(int timeout_ms);
int vkp_device_lost(void);
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n);
@@ -27,13 +27,13 @@ extern void droiddeck_wayland_send_key(int evdev, int state);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
extern void droiddeck_wayland_output_resized(void);
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
extern volatile int g_fps_limit;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
extern volatile unsigned g_zero_copy_last;
extern volatile int g_ubwc;
extern volatile int g_no_render_node;
extern volatile int g_output_refresh_mhz;
extern volatile int g_output_w, g_output_h;
#define TAG "DroidDeckWayland"
@@ -328,7 +328,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JN
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) {
g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0;
droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0);
}
extern void vkp_reset_first_frame(void);
@@ -342,6 +342,7 @@ JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv *env, jclass clazz, jint w, jint h) {
g_output_w = w > 0 ? w : 0;
g_output_h = h > 0 ? h : 0;
vkp_cardboard_set_render_height(h);
}
JNIEXPORT void JNICALL
@@ -350,6 +351,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv
if (w <= 0 || h <= 0) return;
g_output_w = w;
g_output_h = h;
vkp_cardboard_set_render_height(h);
droiddeck_wayland_output_resized();
}
@@ -402,10 +404,11 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled,
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) {
jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma,
jboolean mono, jfloat hz) {
struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist,
.screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset,
.chroma = chroma};
.chroma = chroma, .mono = mono ? 1 : 0, .display_hz = hz};
vkp_cardboard_set(&p);
}
@@ -592,6 +595,12 @@ static char *dup_bytes(JNIEnv *env, jbyteArray arr, int *len) {
return buf;
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeWaitSurface(
JNIEnv *env, jclass clazz, jint timeout_ms) {
return vkp_wait_window_request(timeout_ms) ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
@@ -640,3 +649,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv
int after = getpriority(PRIO_PROCESS, (id_t)tid);
return after == -1 && errno != 0 ? 100 : after;
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialPose(JNIEnv *env, jclass clazz,
jfloatArray rot, jfloatArray pos, jboolean live) {
(void)clazz;
if (!rot || !pos || (*env)->GetArrayLength(env, rot) != 9 || (*env)->GetArrayLength(env, pos) != 3) return;
float m[9], p[3];
(*env)->GetFloatArrayRegion(env, rot, 0, 9, m);
(*env)->GetFloatArrayRegion(env, pos, 0, 3, p);
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
}
@@ -16,16 +16,48 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
// Helper processes must not initialize the main USB provider or guest graphics context.
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)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
com.droiddeck.launcher.stores.StoresState.init(this)
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()
}
}
@@ -11,6 +11,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import com.droiddeck.launcher.gpu.AutoDriverTrial
import com.droiddeck.launcher.gpu.AutoPicks
import com.droiddeck.launcher.gpu.DriverBundle
import com.droiddeck.launcher.gpu.DriverPairs
import com.droiddeck.launcher.gpu.GpuInfo
@@ -21,7 +23,12 @@ import com.droiddeck.launcher.gpu.TurnipReleases
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.QuestExperimental
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.ui.DriverRow
import com.droiddeck.launcher.ui.DriverSlot
import com.droiddeck.launcher.ui.GPU_MODE_AUTO
import com.droiddeck.launcher.ui.GPU_MODE_MIX
import com.droiddeck.launcher.ui.GPU_MODE_PAIR
import com.droiddeck.launcher.ui.GpuDriversState
import com.droiddeck.launcher.ui.PairRow
import com.droiddeck.launcher.wayland.CompositorHost
@@ -42,7 +49,18 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var pairPercent by mutableIntStateOf(-1)
var autoStatus by mutableStateOf("")
var activeBundle by mutableStateOf<String?>(null)
private var autoCheckedThisProcess = false
private var lastAutoCheck = 0L
private var pairModeOpen by mutableStateOf(false)
var restartPending by mutableStateOf(false)
private set
private var runtimePending by mutableStateOf(false)
private var sessionRunning by mutableStateOf(false)
val uiMode: String get() = when (mode) {
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
SessionPrefs.GPU_DRIVERS_MIX -> GPU_MODE_MIX
else -> if (pairModeOpen || pairRows.any { it.active }) GPU_MODE_PAIR else GPU_MODE_MIX
}
fun state() = GpuDriversState(
gpuName = gpu.displayName(activity), gpuFamily = gpu.family.label(activity), soc = gpu.soc, supportText = gpu.supportText(activity),
@@ -52,7 +70,56 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
activeBundle = activeBundle,
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
canRestart = !sessionRunning,
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
)
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
activeBundle != null -> R.string.gpu_src_bundle
chosenDefault -> defaultLabel
else -> R.string.gpu_src_chosen
})
private fun displaySlot(): DriverSlot {
val row = androidRows.firstOrNull { it.id == androidSelected }
val live = androidRows.firstOrNull { it.id == CompositorHost.displayChoice }?.name
return DriverSlot(
name = row?.name ?: activity.getString(R.string.gpu_auto_by_gpu),
source = slotSource(androidSelected == TurnipDriver.AUTO, R.string.gpu_src_by_gpu),
pending = restartPending,
status = when {
restartPending -> activity.getString(R.string.gpu_display_pending, live ?: activity.getString(R.string.gpu_previous_driver))
CompositorHost.isStarted -> activity.getString(R.string.gpu_display_live)
else -> activity.getString(R.string.gpu_display_next)
},
)
}
private fun runtimeSlot(): DriverSlot {
val row = linuxRows.firstOrNull { it.id == linuxSelected }
val live = SessionState.runtimeDriver?.let { id -> linuxRows.firstOrNull { it.id == id }?.name }
return DriverSlot(
name = row?.name ?: activity.getString(R.string.gpu_runtime_default),
source = slotSource(linuxSelected.isEmpty(), R.string.gpu_src_default),
pending = runtimePending,
status = when {
runtimePending -> activity.getString(R.string.gpu_runtime_pending, live ?: activity.getString(R.string.gpu_previous_driver))
sessionRunning -> activity.getString(R.string.gpu_runtime_live)
else -> activity.getString(R.string.gpu_runtime_hint)
},
)
}
fun restartApp() {
if (SessionState.running) return
val launch = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return
activity.startActivity(android.content.Intent.makeRestartActivityTask(launch.component)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "components"))
Runtime.getRuntime().exit(0)
}
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
var androidSelected by mutableStateOf("")
@@ -90,21 +157,30 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
canRestoreBundled = td.hiddenBundled().isNotEmpty()
androidSelected = SessionPrefs.androidDriver(activity)
activeBundle = DriverBundle.active(activity)?.label
restartPending = CompositorHost.isStarted && CompositorHost.displayChoice.let { it != null && it != androidSelected }
sessionRunning = SessionState.running
runtimePending = sessionRunning && SessionState.runtimeDriver.let { it != null && it != linuxSelected }
refreshReleaseRows()
refreshPairs()
}
fun summary(): String {
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
val label = activity.getString(if (mode == SessionPrefs.GPU_DRIVERS_AUTO) R.string.common_auto else R.string.drivers_manual)
return active?.let { activity.getString(R.string.drivers_summary, label, it) } ?: label
val display = displaySlot().name
val base = when {
uiMode == GPU_MODE_AUTO -> activity.getString(R.string.drivers_summary, activity.getString(R.string.common_auto), active ?: display)
uiMode == GPU_MODE_PAIR && active != null -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_pair_short), active)
else -> activity.getString(R.string.drivers_summary, activity.getString(R.string.gpu_mode_mix_short),
runtimeSlot().name.let { if (it == display) it else activity.getString(R.string.gpu_summary_mix, display, it) })
}
return if (restartPending) activity.getString(R.string.gpu_summary_restart, base) else base
}
fun refreshPairs() {
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs)
val recommended = if (QuestExperimental.isSupported()) null else recommendedKey(pairs)
pairRows = pairs.map { p ->
val bundle = p.bundle?.let { installedBundle(it) }
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
@@ -121,18 +197,53 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}.sortedByDescending { it.recommended }
}
fun setMode(auto: Boolean) {
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
private fun recommendedKey(pairs: List<DriverPairs.DriverPair>) = DriverPairs.recommendedKey(
gpu, pairs, TurnipReleases.autoPicks(activity), AutoDriverTrial.bad(activity),
blob = AutoPicks.blobVersion(com.droiddeck.launcher.gpu.VulkanInfo.cachedOrNull()?.get("driver_version")),
)
fun chooseMode(next: String) {
pairModeOpen = next == GPU_MODE_PAIR
setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX)
}
fun setMode(auto: Boolean, mix: Boolean = false) {
mode = when {
auto -> SessionPrefs.GPU_DRIVERS_AUTO
mix -> SessionPrefs.GPU_DRIVERS_MIX
else -> SessionPrefs.GPU_DRIVERS_MANUAL
}
SessionPrefs.setGpuDriverMode(activity, mode)
autoStatus = ""
if (auto) ensureAuto(force = false)
}
fun checkOnOpen() {
if (SessionPrefs.gpuDriverMode(activity) == SessionPrefs.GPU_DRIVERS_AUTO) ensureAuto(force = false)
else if (!releaseChecking && releasesStale() && com.droiddeck.launcher.frontend.AutoComponents.isOnline(activity)) checkLatestTurnip()
}
private fun releasesStale(): Boolean {
val cached = TurnipReleases.cached(activity)
return cached == null || System.currentTimeMillis() - cached.checkedAt > RELEASE_CHECK_INTERVAL_MS ||
cached.assets.none { it.pair.isNotEmpty() }
}
fun ensureAuto(force: Boolean) {
mode = SessionPrefs.gpuDriverMode(activity)
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
if (!force && autoCheckedThisProcess) return
autoCheckedThisProcess = true
if (SessionState.running) {
if (force) autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
return
}
AutoDriverTrial.rollbackDue(activity)?.let { trial ->
AutoDriverTrial.rollBack(activity, trial)
autoStatus = activity.getString(R.string.drivers_rolled_back, trial.name)
refreshDrivers()
return
}
if (!force && System.currentTimeMillis() - lastAutoCheck < RELEASE_CHECK_INTERVAL_MS) return
lastAutoCheck = System.currentTimeMillis()
val displayChanged = SessionPrefs.androidDriver(activity) != QuestExperimental.defaultAndroidDriver()
if (QuestExperimental.applyAutoDrivers(activity)) {
autoStatus = activity.getString(
@@ -148,16 +259,15 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
releaseChecking = true
autoStatus = activity.getString(R.string.drivers_checking_latest)
Thread({
val cached = TurnipReleases.cached(activity)
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
cached.assets.none { it.pair.isNotEmpty() }
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity)
runCatching { com.droiddeck.launcher.gpu.VulkanInfo.query() }
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val key = DriverPairs.recommendedKey(gpu, pairs)
val key = recommendedKey(pairs)
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
@@ -165,6 +275,10 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
?: activity.getString(R.string.upd_current_status)
pair == null || !pair.complete ->
autoStatus = problem?.let { activity.getString(R.string.upd_couldnt_check, it) } ?: activity.getString(R.string.drivers_recommended_unpublished)
AutoDriverTrial.release(pair) in AutoDriverTrial.bad(activity) ->
autoStatus = activity.getString(R.string.drivers_skipping_bad, pair.name, pair.version)
SessionState.running -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
holdBack && row?.installed != true -> autoStatus = activity.getString(R.string.drivers_auto_waits_for_network, pair.name, pair.version)
else -> installPair(pair, auto = true)
}
}
@@ -174,6 +288,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
fun selectPair(key: String) {
if (pairBusy != null) return
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
pairModeOpen = true
if (mode != SessionPrefs.GPU_DRIVERS_MANUAL) setMode(auto = false)
installPair(pair, auto = false)
}
@@ -183,8 +299,6 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
pairPercent = 0
if (auto) autoStatus = activity.getString(R.string.drivers_downloading_pair, pair.name, pair.version)
Thread({
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
var downloaded = false
val result = runCatching {
val zips = pair.assets
@@ -198,22 +312,27 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
pairBusy = null
pairPercent = -1
result.onSuccess { (displayId, linuxId) ->
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
if (auto && SessionState.running) {
lastAutoCheck = 0L
autoStatus = activity.getString(R.string.drivers_auto_waits_for_session)
return@onSuccess
}
val previousDisplay = SessionPrefs.androidDriver(activity)
val previousLinux = SessionPrefs.linuxDriver(activity)
val displayChanged = previousDisplay != displayId
SessionPrefs.setAndroidDriver(activity, displayId)
SessionPrefs.setLinuxDriver(activity, linuxId)
val restart = displayChanged && CompositorHost.isStarted
if (auto) {
val previous = SessionPrefs.gpuAutoInstalled(activity)
for (id in previous - setOf(displayId, linuxId)) {
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
when {
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
lm.isInstalled(id) -> lm.removeDriver(id)
else -> td.remove(id)
}
TurnipReleases.forget(activity, id)
}
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
val trial = AutoDriverTrial.current(activity)
val lastGood = trial?.let { setOf(it.previousDisplay, it.previousLinux) } ?: setOf(previousDisplay, previousLinux)
val changed = previousDisplay != displayId || previousLinux != linuxId
SessionPrefs.setGpuAutoInstalled(activity, SessionPrefs.gpuAutoInstalled(activity) + setOf(displayId, linuxId))
if (changed) AutoDriverTrial.begin(
activity, AutoDriverTrial.release(pair), "${pair.name} ${pair.version}", displayId, linuxId,
trial?.previousDisplay ?: previousDisplay, trial?.previousLinux ?: previousLinux,
)
if (!SessionState.running) AutoDriverTrial.prune(activity, lastGood + setOf(displayId, linuxId))
autoStatus = activity.getString(
if (downloaded) { if (restart) R.string.drivers_updated_to_restart else R.string.drivers_updated_to }
else { if (restart) R.string.drivers_switched_to_restart else R.string.drivers_switched_to },
@@ -304,14 +423,15 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
linux -> SessionPrefs.setLinuxDriver(activity, id)
else -> SessionPrefs.setAndroidDriver(activity, id)
}
setMode(false)
pairModeOpen = false
setMode(auto = false, mix = true)
refreshDrivers()
}
private fun useBundle(bundle: DriverBundle.Bundle): String {
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
DriverBundle.select(activity, bundle)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(auto = false, mix = true)
return activity.getString(
if (displayChanged && CompositorHost.isStarted) R.string.drivers_using_bundle_restart else R.string.drivers_using_bundle,
bundle.label,
@@ -420,6 +540,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
private companion object {
const val TAG = "MainActivity"
const val RELEASE_CHECK_INTERVAL_MS = 3_600_000L
}
}
@@ -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)
}
}
}
@@ -47,6 +47,7 @@ import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.DownloadSource
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbFix
@@ -108,7 +109,13 @@ 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
private var switchDeadline = 0L
private var showResetSettings by mutableStateOf(false)
private var lowSpace by mutableStateOf<Long?>(null)
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
@@ -117,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
@@ -154,7 +162,11 @@ class MainActivity : ComponentActivity() {
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
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)
@@ -187,6 +199,10 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
}
private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
com.droiddeck.launcher.handtracking.HandTracking.refreshPermission(this)
}
private var placementCheck by mutableStateOf<Boolean?>(null)
private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
@@ -204,6 +220,9 @@ class MainActivity : ComponentActivity() {
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
}
private val pickBootMovie = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importBootMovie(it) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
@@ -248,6 +267,38 @@ class MainActivity : ComponentActivity() {
}, "lossless-sync").start()
}
private fun importBootMovie(file: File) {
bootMovieImporting = true
bootMovieProgress = -1
Thread({
val result = com.droiddeck.launcher.session.BootMovie.import(
this, file,
onStart = { ui.post { android.widget.Toast.makeText(this, getString(R.string.boot_movie_converting_started, file.name), android.widget.Toast.LENGTH_LONG).show() } },
onProgress = { percent -> ui.post { bootMovieProgress = percent } },
)
val message = when (result) {
is com.droiddeck.launcher.session.BootMovie.Import.Done -> getString(
if (result.audioDropped) R.string.boot_movie_imported_silent else R.string.boot_movie_imported, result.name)
com.droiddeck.launcher.session.BootMovie.Import.TooLong ->
getString(R.string.boot_movie_too_long, com.droiddeck.launcher.session.BootMovie.MAX_CONVERT_SECONDS)
is com.droiddeck.launcher.session.BootMovie.Import.Unsupported -> getString(R.string.boot_movie_unsupported)
com.droiddeck.launcher.session.BootMovie.Import.Failed -> getString(R.string.boot_movie_import_failed)
}
ui.post {
bootMovieImporting = false
bootMovieProgress = -1
loadBootMovie()
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "boot-movie-import").start()
}
private fun loadBootMovie() {
bootMovie = com.droiddeck.launcher.session.BootMovie.choice(this)
bootMovieSound = SessionPrefs.bootMovieSound(this)
bootMovieCustomName = com.droiddeck.launcher.session.BootMovie.customName(this)
}
private fun importLossless(dll: File) {
Thread({
val message = Lossless.message(this, Lossless.import(this, dll))
@@ -323,7 +374,13 @@ class MainActivity : ComponentActivity() {
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var forceSsbs by mutableStateOf(false)
private var steamClientArch by mutableStateOf("arm64")
private var runSteamAtStartup by mutableStateOf(false)
private var bootMovie by mutableStateOf(com.droiddeck.launcher.session.BootMovie.DROIDDECK_30)
private var bootMovieSound by mutableStateOf(true)
private var bootMovieCustomName by mutableStateOf<String?>(null)
private var bootMovieImporting by mutableStateOf(false)
private var bootMovieProgress by mutableStateOf(-1)
private var theme by mutableStateOf("graphite")
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
@@ -346,11 +403,13 @@ class MainActivity : ComponentActivity() {
}
private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
private var steamGamesX64 by mutableStateOf(false)
@Volatile private var componentSweepDone = false
@Volatile private var componentSweepSession: String? = null
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
private var runningLabel by mutableStateOf<String?>(null)
private var logsEnabled by mutableStateOf(true)
private var vacDiagnostics by mutableStateOf(false)
private var agentCommands by mutableStateOf(false)
private var showRoms by mutableStateOf(false)
private var homeAppSelected by mutableStateOf(false)
@@ -428,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
@@ -457,24 +530,186 @@ class MainActivity : ComponentActivity() {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, game)) return launchWithoutSteam(game, returnHome)
if (SessionState.running) {
if (SessionState.mode != SessionService.MODE_STEAM || SessionState.stopRequested) {
android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
return true
}
if (!SessionService.launchGame(this, game.gameIdString)) {
val steamVr = game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID
if (!steamVr && !SessionService.launchGame(this, game.gameIdString)) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_STEAMVR, steamVr)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
return startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true)
.apply {
if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) {
putExtra(SessionActivity.EXTRA_STEAMVR, true)
} else {
putExtra(SessionService.EXTRA_STEAM_URL,
com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString))
}
}, steamSession = true)
}
private fun launchWithoutSteam(game: Library.SteamGame, returnHome: Boolean): Boolean {
val app = applicationContext
Thread({
val added = if (game.library == Library.ADDED) runCatching { com.droiddeck.launcher.session.NoSteamLaunch.addedGame(app, game) }.getOrNull() else null
val command = com.droiddeck.launcher.session.NoSteamLaunch.command(game, added)
ui.post {
if (isDestroyed) return@post
if (command == null) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return@post
}
com.droiddeck.launcher.session.NoSteamLaunch.remember(command)
val next = Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
.putExtra(SessionService.EXTRA_PROGRAM, command.program)
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, command.args.toTypedArray())
when {
!SessionState.running -> startSessionOrWait(next)
SessionState.stopRequested -> android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
SessionState.mode == SessionService.MODE_RUN && SessionState.program == command.program && SessionState.programArgs == command.args ->
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
else -> pendingSwitch = next
}
}
}, "no-steam-launch").start()
return true
}
private fun opensEpicLauncher(next: Intent): Boolean =
next.getStringExtra(SessionService.EXTRA_PROGRAM) == com.droiddeck.launcher.session.NoSteamLaunch.EPIC ||
next.getStringExtra(SessionService.EXTRA_STEAM_URL) == com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId))
private fun uninstallEpicLauncher() {
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) return
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = true
pendingSwitch?.takeIf(::opensEpicLauncher)?.let { pendingSwitch = null }
switchingTo?.takeIf(::opensEpicLauncher)?.let {
switchPoll?.let { poll -> ui.removeCallbacks(poll) }
switchPoll = null
switchingTo = null
}
if (SessionState.running) SessionService.stop(this)
val app = applicationContext
val deadline = android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS
Thread({
while ((SessionState.running || SessionState.phase !in SESSION_ENDED) && android.os.SystemClock.uptimeMillis() < deadline) {
Thread.sleep(SESSION_SWITCH_POLL_MS)
}
val ended = !SessionState.running && SessionState.phase in SESSION_ENDED
val done = try {
ended && com.droiddeck.launcher.stores.epic.EpicClient.uninstall(app)
} finally {
com.droiddeck.launcher.stores.epic.EpicClient.uninstalling = false
}
ui.post {
if (!isDestroyed) refresh()
android.widget.Toast.makeText(app, if (done) R.string.epic_client_uninstalled else R.string.epic_client_uninstall_failed,
android.widget.Toast.LENGTH_LONG).show()
}
}, "epic-uninstall").start()
}
private fun openEpicLauncher(): Boolean {
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling) {
android.widget.Toast.makeText(this, R.string.epic_client_uninstalling, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return false
}
if (!com.droiddeck.launcher.stores.epic.EpicClient.launcherRunning(this) && !com.droiddeck.launcher.stores.epic.EpicClient.fexReady(this)) {
com.droiddeck.launcher.stores.epic.EpicClient.requestFex(this)
android.widget.Toast.makeText(this, R.string.epic_client_needs_fex, android.widget.Toast.LENGTH_LONG).show()
if (!SessionState.running) startSteamSession()
else if (SessionState.mode == SessionService.MODE_STEAM) startActivity(Intent(this, SessionActivity::class.java)
.setAction(SessionService.ACTION_RESUME).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
return true
}
if (com.droiddeck.launcher.session.NoSteamLaunch.enabled(this, com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)) {
com.droiddeck.launcher.stores.epic.EpicClient.launcherEntry(this, Library.EPIC_LAUNCHER_NAME)?.let { return launchWithoutSteam(it, false) }
}
val launcher = Intent(this, SessionActivity::class.java)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
.putExtra(SessionService.EXTRA_STEAM_URL, com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(
com.droiddeck.launcher.stores.epic.EpicClient.gameId(com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId)))
if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM && !SessionState.stopRequested) {
val app = applicationContext
val shortcut = com.droiddeck.launcher.stores.epic.EpicClient.shortcutAppId
val listed = com.droiddeck.launcher.frontend.AddedGames.inSteam(app, shortcut)
if (listed && !SessionService.launchGame(this, com.droiddeck.launcher.stores.epic.EpicClient.gameId(shortcut))) {
android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show()
return false
}
startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME)
.putExtra(SessionActivity.EXTRA_RETURN_HOME, false)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP))
if (!listed) Thread({
com.droiddeck.launcher.frontend.AddedGamesPublisher.republish(app)
val id = com.droiddeck.launcher.stores.epic.EpicClient.steamLauncherId(app) ?: shortcut
ui.post { SessionService.launchGame(app, com.droiddeck.launcher.stores.epic.EpicClient.gameId(id)) }
}, "epic-launcher-open").start()
return true
}
if (SessionState.running) {
if (SessionState.stopRequested) android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
else pendingSwitch = launcher
return true
}
return startSessionOrWait(launcher)
}
private fun startSessionOrWait(next: Intent): Boolean {
val started = startSession(next, steamSession = next.hasExtra(SessionService.EXTRA_STEAM_URL))
if (!started && (busy || LinuxRuntimeInstaller.isBusy())) android.widget.Toast.makeText(this, R.string.main_wait_install_game, android.widget.Toast.LENGTH_SHORT).show()
return started
}
private fun startAfterSession(next: Intent) {
SessionService.stop(this)
awaitSessionEnd(next, android.os.SystemClock.uptimeMillis() + SESSION_SWITCH_WAIT_MS)
}
private fun awaitSessionEnd(next: Intent, deadline: Long) {
switchingTo = next
switchDeadline = deadline
switchPoll?.let { ui.removeCallbacks(it) }
val poll = object : Runnable {
override fun run() {
if (isDestroyed) return
if (SessionState.running || SessionState.phase !in SESSION_ENDED) {
if (android.os.SystemClock.uptimeMillis() < deadline) ui.postDelayed(this, SESSION_SWITCH_POLL_MS)
else {
switchPoll = null
switchingTo = null
android.widget.Toast.makeText(this@MainActivity, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show()
}
return
}
switchPoll = null
switchingTo = null
if (com.droiddeck.launcher.stores.epic.EpicClient.uninstalling && opensEpicLauncher(next)) return
startSessionOrWait(next)
}
}
switchPoll = poll
ui.postDelayed(poll, SESSION_SWITCH_POLL_MS)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
@@ -509,12 +744,19 @@ class MainActivity : ComponentActivity() {
theme = SessionPrefs.theme(this)
frameGenLabel = getString(R.string.frame_gen_off)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
val (cached, cachedX64) = com.droiddeck.launcher.frontend.LibraryCache.snapshot(this)
steamGames = if (shortcutPicker) emptyList() else cached
steamGamesX64 = cachedX64
backActionsInverted = SessionPrefs.backActionsInverted(this)
orientation = SessionPrefs.orientation(this)
requestedOrientation = orientation
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
downloadSource = DownloadSource.mode(this)
downloadMirror = DownloadSource.mirror(this)
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)
@@ -542,8 +784,9 @@ class MainActivity : ComponentActivity() {
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled, desktopPresent = desktopPresent,
offlineAccount = offlineAccount, offline = offline,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled, agentCommands = agentCommands,
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
vacDiagnostics = vacDiagnostics, agentCommands = agentCommands,
steamGames = steamGames, steamGamesX64 = steamGamesX64, emulators = emulatorList, running = runningLabel,
frameGen = FrameGen.mode(this),
lossless = lossless,
pageKey = if (showPerformance) "performance" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
@@ -570,7 +813,11 @@ class MainActivity : ComponentActivity() {
showPhantomGate = showPhantomGate,
orientation = orientation,
launcherFullscreen = launcherFullscreen,
downloadSource = downloadSource,
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
@@ -583,7 +830,13 @@ class MainActivity : ComponentActivity() {
storeDownloadsActive = com.droiddeck.launcher.stores.StoresState.activeDownloads,
),
FrontEndActions(
onPlay = { startSteamSession() },
onPlay = { playSteam() },
onPlaySteamVr = { playSteamVr() },
onSteamUrl = { url ->
if (!SessionState.running) startSession(
Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true,
)
},
onPlayDesktopUi = {
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM)
@@ -596,6 +849,8 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
}
},
onEpicLauncher = { openEpicLauncher() },
onUninstallEpicLauncher = { uninstallEpicLauncher() },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
@@ -604,7 +859,9 @@ class MainActivity : ComponentActivity() {
val known = steamGames.mapNotNull { it.gameFiles?.path }.toSet()
updateAddedGames(folders, then = { if (folders.any { it !in known }) fetchAddedGameArt() })
},
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({
@@ -666,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) } },
@@ -680,6 +938,10 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setLogsEnabled(this, !SessionPrefs.logsEnabled(this))
logsEnabled = SessionPrefs.logsEnabled(this)
},
onVacDiagnostics = {
SessionPrefs.setVacDiagnostics(this, !SessionPrefs.vacDiagnostics(this))
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
},
onAgentCommands = {
com.droiddeck.launcher.agent.AgentAccess.setCommandsEnabled(this, !com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this))
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
@@ -711,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)
@@ -743,6 +1006,18 @@ class MainActivity : ComponentActivity() {
animationsEnabled = on
com.droiddeck.launcher.ui.Motion.refresh(this)
},
onDownloadSource = { mode ->
DownloadSource.setMode(this, mode)
downloadSource = mode
},
onDownloadMirror = { host ->
DownloadSource.setMirror(this, host)
downloadMirror = host
},
onDownloadPrefix = { prefix ->
DownloadSource.setCustom(this, prefix)
downloadPrefix = DownloadSource.custom(this)
},
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -816,6 +1091,7 @@ class MainActivity : ComponentActivity() {
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() },
onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { showPerformance = false; showComponents = false; showMapping = true },
@@ -843,6 +1119,22 @@ class MainActivity : ComponentActivity() {
onDismiss = { showNonAdreno = null },
)
}
pendingSwitch?.let { next ->
ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.drawer_stop_title),
text = runningLabel ?: "",
confirm = androidx.compose.ui.res.stringResource(R.string.drawer_stop),
onConfirm = { pendingSwitch = null; startAfterSession(next) },
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),
@@ -850,6 +1142,20 @@ class MainActivity : ComponentActivity() {
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
lowSpace?.let { free ->
com.droiddeck.launcher.ui.LowSpaceDialog(
free,
onFreeUp = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.requestOpen() },
onPlay = { lowSpace = null; com.droiddeck.launcher.storage.StorageState.lowSpaceDismissed = true; startSteamSession() },
onDismiss = { lowSpace = null },
)
}
com.droiddeck.launcher.ui.HandCameraHost(
check = placementCheck,
onAskPermission = { cameraPermission.launch(Manifest.permission.CAMERA) },
onPlay = { placementCheck = null; startSteamSession(steamVr = true) },
onClose = { placementCheck = null },
)
if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog(
onConfirm = { gameProfiles -> resetSettings(gameProfiles) },
onDismiss = { showResetSettings = false },
@@ -867,6 +1173,10 @@ class MainActivity : ComponentActivity() {
}
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
else {
pendingSwitch = savedIntent(savedInstanceState, KEY_PENDING_SWITCH)
savedIntent(savedInstanceState, KEY_SWITCHING_TO)?.let { awaitSessionEnd(it, savedInstanceState.getLong(KEY_SWITCH_DEADLINE)) }
}
val wanted = ArrayList<String>()
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
@@ -902,8 +1212,23 @@ class MainActivity : ComponentActivity() {
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
private fun startSteamSession() {
startSession(Intent(this, SessionActivity::class.java), steamSession = true)
private fun playSteam() {
val storage = com.droiddeck.launcher.storage.StorageState
if (!SessionState.running && !storage.lowSpaceDismissed && storage.lowOnSpace(this)) {
lowSpace = storage.freeBytes(this)
storage.refresh(this)
} else startSteamSession()
}
private fun playSteamVr() {
val tracking = com.droiddeck.launcher.glasses.Glasses.settings.value
if (!com.droiddeck.launcher.glasses.Glasses.state.value.usesSpatialControllers(tracking) &&
com.droiddeck.launcher.handtracking.HandTracking.wanted(this)) placementCheck = true
else startSteamSession(steamVr = true)
}
private fun startSteamSession(steamVr: Boolean = false) {
startSession(Intent(this, SessionActivity::class.java).putExtra(SessionActivity.EXTRA_STEAMVR, steamVr), steamSession = true)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@@ -965,7 +1290,8 @@ class MainActivity : ComponentActivity() {
refreshPhantomStatus()
syncLossless()
updates.onResume()
drivers.ensureAuto(force = false)
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1000,6 +1326,19 @@ class MainActivity : ComponentActivity() {
refreshSecondScreenDisplays()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
pendingSwitch?.let { outState.putParcelable(KEY_PENDING_SWITCH, it) }
switchingTo?.let {
outState.putParcelable(KEY_SWITCHING_TO, it)
outState.putLong(KEY_SWITCH_DEADLINE, switchDeadline)
}
}
private fun savedIntent(state: Bundle, key: String): Intent? =
if (Build.VERSION.SDK_INT >= 33) state.getParcelable(key, Intent::class.java)
else @Suppress("DEPRECATION") state.getParcelable(key)
override fun onDestroy() {
updates.unregister()
super.onDestroy()
@@ -1027,7 +1366,9 @@ class MainActivity : ComponentActivity() {
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
secondScreenDisplays = SecondScreenDisplays.available(displayManager)
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
}
private fun launchAndroidApp(app: HomeApp.LaunchableApp, displayId: Int?) {
@@ -1095,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
@@ -1145,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(),
@@ -1161,6 +1504,8 @@ class MainActivity : ComponentActivity() {
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_android))) },
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, getString(R.string.drivers_pick_any))) },
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
@@ -1202,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
@@ -1264,10 +1630,16 @@ class MainActivity : ComponentActivity() {
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
bootMovie = if (mode == SessionService.MODE_STEAM) bootMovie else null,
bootMovieSound = bootMovieSound,
bootMovieCustomName = bootMovieCustomName,
bootMovieImporting = bootMovieImporting,
bootMovieProgress = bootMovieProgress,
wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null,
wifiDiscoveryPermission = wifiDiscoveryPermission,
wifiDiscoveryLocation = wifiDiscoveryLocation,
@@ -1342,12 +1714,19 @@ class MainActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
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)))
},
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
@@ -1418,6 +1797,44 @@ class MainActivity : ComponentActivity() {
}, "added-games-update").start()
}
private val steamArtRefreshes = HashMap<Int, Int>()
private fun refreshSteamGameArt(appId: Int) {
val version = (steamArtRefreshes[appId] ?: 0) + 1
steamArtRefreshes[appId] = version
Thread({
val fresh = com.droiddeck.launcher.frontend.Library.steamGames(this).firstOrNull { it.appId == appId } ?: return@Thread
ui.post {
if (steamArtRefreshes[appId] != version) return@post
steamGames = steamGames.map { if (it.appId == appId && it.library != Library.ADDED) fresh else it }
}
}, "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()
@@ -1461,10 +1878,12 @@ class MainActivity : ComponentActivity() {
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamClientArch = SessionPrefs.steamClientArch(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
@@ -1536,7 +1955,14 @@ class MainActivity : ComponentActivity() {
}
private fun refresh() {
// Until the scan finishes, the other client's games would launch in this one.
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
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()
addedGamesArt = SessionPrefs.addedGamesArt(this)
desktopInstalled = DesktopCatalog.desktopInstalled(this)
desktopPresent = DesktopCatalog.desktopPresent(this)
@@ -1548,6 +1974,7 @@ class MainActivity : ComponentActivity() {
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
vacDiagnostics = SessionPrefs.vacDiagnostics(this)
agentCommands = com.droiddeck.launcher.agent.AgentAccess.commandsEnabled(this)
runningLabel = if (SessionState.running) when (SessionState.mode) {
SessionService.MODE_DESKTOP -> getString(R.string.rail_desktop)
@@ -1556,26 +1983,33 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
val scanGeneration = ++libraryScanGeneration
val artRefreshesAtStart = HashMap(steamArtRefreshes)
if (shortcutPicker) shortcutLibraryScanning = true
if (SessionPrefs.steamClientArch(this) == "x86_64" && !OfflineMode.enabled(this)) {
Thread({ com.droiddeck.launcher.session.FexCatalog.refresh(this) }, "fex-catalog").start()
}
Thread({
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
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) }
}
}
}
}
val games = if (ready) Library.launchableGames(this) else emptyList()
val scannedArch = SessionPrefs.activeSteamClientArch(this)
val scannedX64 = scannedArch == "x86_64"
val games = if (ready) Library.launchableGames(this, arch = scannedArch) else emptyList()
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
if (ready && scanGeneration == libraryScanGeneration) com.droiddeck.launcher.frontend.LibraryCache.save(this, all, scannedX64)
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
if (ready && (!componentSweepDone || endedSession != null)) {
componentSweepDone = true
@@ -1584,7 +2018,12 @@ class MainActivity : ComponentActivity() {
}
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
val current = steamGames.filter { it.library != Library.ADDED }.associateBy { it.appId }
steamGamesX64 = scannedX64
steamGames = all.map { g ->
val newer = current[g.appId]
if (newer != null && g.library != Library.ADDED && steamArtRefreshes[g.appId] != artRefreshesAtStart[g.appId]) g.withArt(newer.art, newer.hero, newer.icon) else g
}
shortcutLibraryScanning = false
handleGameLink(all)
}
@@ -1599,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
@@ -1613,6 +2056,10 @@ class MainActivity : ComponentActivity() {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
if (com.droiddeck.launcher.storage.StorageState.cleaning) {
android.widget.Toast.makeText(this, R.string.storage_cleanup_busy, android.widget.Toast.LENGTH_SHORT).show()
return false
}
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
@@ -1679,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()
}
@@ -1726,6 +2173,12 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
private const val MEDIA_SETTLE_MS = 1500L
private const val SESSION_SWITCH_WAIT_MS = 30_000L
private const val SESSION_SWITCH_POLL_MS = 250L
private const val KEY_PENDING_SWITCH = "pending_switch"
private const val KEY_SWITCHING_TO = "switching_to"
private const val KEY_SWITCH_DEADLINE = "switch_deadline"
private val SESSION_ENDED = setOf(SessionPhase.IDLE, SessionPhase.FAILED)
private val ZIP_EXT = listOf("zip")
private val WCP_EXT = listOf("wcp")
}
@@ -0,0 +1,118 @@
package com.droiddeck.launcher
import android.app.NativeActivity
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import android.widget.Toast
import com.droiddeck.launcher.gpu.SteamVr
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
class QuestXrActivity : NativeActivity() {
private var steamVrStarted = false
@Volatile private var closing = false
@Volatile private var steamVrStatus = SteamVr.Status.UNAVAILABLE
private var sessionId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
sessionId = SessionState.sessionId
SessionService.setXrActivityVisible(this, true)
super.onCreate(savedInstanceState)
}
fun onXrActive(active: Boolean) {
if (active) {
check(CompositorHost.isStarted) { "DroidDeck compositor is not running" }
SessionService.resume(this)
WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f, false, 0f)
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
WaylandCompositor.nativeSetZeroCopy(false)
CompositorHost.attachXr()
WaylandCompositor.nativeVsync(System.nanoTime())
check(WaylandCompositor.nativeWaitSurface(3000)) { "XR frame GPU handshake timed out" }
}
SessionService.setXrActivityVisible(this, active)
if (active) startSteamVr()
}
private fun startSteamVr() {
if (!steamVrStarted) {
steamVrStarted = true
Thread({
try {
val started = synchronized(steamVrLock) {
if (closing || !sessionAlive()) false else {
steamVrOwner = this
SteamVr.run(this, "start")
}
}
if (!started) {
if (!closing) leaveXr("SteamVR start failed", sessionAlive())
return@Thread
}
val startupDeadline = SystemClock.elapsedRealtime() + 30_000
var lastRunning: Long? = null
while (!closing) {
if (!sessionAlive()) {
leaveXr("Steam session ended", false)
break
}
steamVrStatus = SteamVr.status(this)
val now = SystemClock.elapsedRealtime()
if (steamVrStatus == SteamVr.Status.RUNNING) lastRunning = now
val deadline = lastRunning?.plus(5_000) ?: startupDeadline
if (steamVrStatus != SteamVr.Status.RUNNING && now >= deadline) {
leaveXr("SteamVR stopped or did not finish starting", true)
break
}
Thread.sleep(1000)
}
} finally {
synchronized(steamVrLock) {
// A retiring activity must not stop a replacement's SteamVR session.
if (steamVrOwner === this) {
if (sessionAlive()) SteamVr.run(this, "stop")
steamVrOwner = null
}
}
}
}, "quest-steamvr-start").start()
}
}
private fun sessionAlive() = SessionState.running && !SessionState.stopRequested &&
sessionId != null && SessionState.sessionId == sessionId
private fun leaveXr(reason: String, failed: Boolean) {
Log.w("DroidDeckXR", reason)
closing = true
runOnUiThread {
if (failed) Toast.makeText(this, R.string.cardboard_steamvr_failed, Toast.LENGTH_LONG).show()
finish()
}
}
fun onXrFrame(frameTimeNanos: Long) {
WaylandCompositor.nativeVsync(frameTimeNanos)
}
fun onXrStatus(): Int = steamVrStatus.ordinal
override fun onDestroy() {
closing = true
SessionService.setXrActivityVisible(this, false)
super.onDestroy()
}
override fun onStop() {
SessionService.setXrActivityVisible(this, false)
super.onStop()
}
companion object {
private val steamVrLock = Any()
private var steamVrOwner: QuestXrActivity? = null
}
}
File diff suppressed because it is too large. Load diff
@@ -92,11 +92,111 @@ class AgentBridgeProvider : ContentProvider() {
AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override")
}
"prefs" -> prefs(context, request)
"glasses" -> glasses(context, request)
"access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context))
.put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath)
else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'")
}
private fun glasses(context: Context, request: JSONObject): JSONObject {
val g = com.droiddeck.launcher.glasses.Glasses
when (request.optString("op", "status")) {
"status" -> {}
"allow" -> g.requestControl()
"set" -> {
val key = request.optString("key")
val value = request.optString("value")
fun on() = value == "1" || value.equals("true", true) || value.equals("on", true)
val number = value.toIntOrNull()
val numeric = setOf("refreshHz", "sbsHz", "brightness", "volume", "picture3d", "screenWidthDm", "screenDistanceDm", "vrFovDeg", "sbsSupport", "eyeHeightCm")
if (key in numeric && number == null) return error("INVALID_VALUE", "'$key' takes a number")
when (key) {
"refreshHz" -> g.setRefreshHz(number!!)
"sbsHz" -> g.setSbsHz(number!!)
"resolution2d" -> g.setResolution(com.droiddeck.launcher.glasses.GlassesDisplayProfile.TWO_D, value)
"resolution3d" -> g.setResolution(com.droiddeck.launcher.glasses.GlassesDisplayProfile.THREE_D, value)
"resolutionVr" -> g.setResolution(com.droiddeck.launcher.glasses.GlassesDisplayProfile.VR, value)
"brightness" -> g.setBrightness(number!!)
"volume" -> g.setVolume(number!!)
"sunblock" -> g.setSunblock(on())
"3d" -> g.set3d(on())
"3dof" -> g.set3dof(on())
"gameOnGlasses" -> g.setGameOnGlasses(on())
"headTracking" -> g.setHeadTracking(on())
"auto3d" -> g.setAuto3d(on())
"picture3d" -> g.setPicture3d(number!!)
"screenWidthDm" -> g.setScreenWidthDm(number!!)
"screenDistanceDm" -> g.setScreenDistanceDm(number!!)
"vrFovDeg" -> g.setVrFovDeg(number!!)
"sbsSupport" -> g.setSbsSupport(number!!)
"positionalTracking" -> g.setPositionalTracking(on())
"handControllers" -> g.setHandControllers(on())
"joyConTracking" -> g.setJoyConTracking(on())
"eyeHeightCm" -> g.setEyeHeight(number!!)
else -> return error("INVALID_KEY", "Unknown glasses setting '$key'")
}
}
"simulate" -> {
fun angle(key: String) = if (request.has(key)) request.optDouble(key).takeIf { it.isFinite() }?.coerceIn(-90.0, 90.0) else null
if (!g.simulate(request.optInt("seconds", 20).coerceIn(1, 600), angle("yaw"), angle("pitch"))) return error("BUSY", "Already simulating")
}
"stop-simulate" -> g.stopSimulation()
"calibrate" -> g.startCalibration()
"cancel-calibration" -> g.cancelCalibration()
"reset-calibration" -> g.resetCalibration()
"spatial-diagnostics" -> com.droiddeck.launcher.glasses.SpatialTracking.diagnostics(request.optBoolean("enabled",true))
"spatial-recenter" -> com.droiddeck.launcher.glasses.SpatialTracking.recenter(request.optBoolean("screen",false))
"spatial-floor" -> g.setSpatialFloor()
"spatial-restart" -> g.restartSpatialTracking()
"spatial-hand-options" -> {
AgentAccess.requireCommands(context)
if(request.has("lite")) com.droiddeck.launcher.glasses.SpatialTracking.setHandModel(request.getBoolean("lite"))
if(request.has("parallel")) com.droiddeck.launcher.glasses.SpatialTracking.setHandCpuParallel(request.getBoolean("parallel"))
if(request.has("halfRate")) com.droiddeck.launcher.glasses.SpatialTracking.setHandHalfRate(request.getBoolean("halfRate"))
if(request.has("adaptive")) com.droiddeck.launcher.glasses.SpatialTracking.setHandAdaptiveInput(request.getBoolean("adaptive"))
if(request.has("scheduled")) com.droiddeck.launcher.glasses.SpatialTracking.setHandScheduledTracking(request.getBoolean("scheduled"))
}
"spatial-hand-benchmark" -> {
AgentAccess.requireCommands(context)
com.droiddeck.launcher.glasses.SpatialTracking.benchmarkSavedCapture(request.getString("capture"))
}
"spatial-hand-capture" -> {
AgentAccess.requireCommands(context)
com.droiddeck.launcher.glasses.SpatialTracking.captureDiagnostic()
}
"spatial-rgb-capture", "spatial-rgb-status" -> {
AgentAccess.requireCommands(context)
return (if (request.getString("op") == "spatial-rgb-capture")
com.droiddeck.launcher.glasses.LumaRgbDiagnostic.capture(context!!,request.optString("transport","sdk"),
request.optInt("width",640),request.optInt("height",480),request.optInt("fps",30),
request.optInt("durationMs",5000))
else com.droiddeck.launcher.glasses.LumaRgbDiagnostic.status(context!!)).put("ok", true)
}
"query" -> { g.query(); Thread.sleep(600) }
"mode" -> {
val mode = hex(request.optString("mode"), 0xFF) ?: return error("INVALID_VALUE", "mode must be one hex byte, e.g. 0x31")
g.sendMode(mode)
Thread.sleep(600)
}
"imu" -> return g.sampleImu(request.optLong("ms", 2000).coerceIn(200, 20_000)).put("ok", true).put("command", "glasses")
"raw" -> {
AgentAccess.requireCommands(context)
val msgId = hex(request.optString("msgId"), 0xFFFF) ?: return error("INVALID_VALUE", "msgId must be a hex number up to 0xFFFF")
val digits = request.optString("payload").removePrefix("0x")
if (digits.length % 2 != 0 || digits.any { Character.digit(it, 16) < 0 }) return error("INVALID_VALUE", "payload must be whole hex bytes")
val payload = digits.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
if (!g.sendRaw(msgId, payload)) return error("NOT_CONNECTED", "The glasses' control link is not open")
Thread.sleep(300)
}
else -> return error("INVALID_OP", "Unknown glasses op")
}
if (request.optString("op") in setOf("set", "allow")) Thread.sleep(400)
return g.status().put("ok", true).put("command", "glasses").put("frames", g.recentFrames())
}
private fun hex(text: String, max: Int): Int? =
text.removePrefix("0x").removePrefix("0X").takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.takeIf { it in 0..max }
private fun state(context: Context): JSONObject {
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
@@ -109,6 +209,7 @@ class AgentBridgeProvider : ContentProvider() {
.put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
.put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
.put("suspended", SessionState.suspended)
.put("steamVr", SessionState.steamVr)
.put("pip", SessionState.pipActive)
.put("firstFrame", SessionState.firstFrameSeen)
.put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
@@ -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"),
@@ -136,8 +151,12 @@ object AgentGuest {
return null
}
fun call(context: Context, request: JSONObject, timeoutMs: Long): JSONObject {
if (request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
fun steamEval(context: Context, expression: String, timeoutMs: Long): JSONObject =
call(context, JSONObject().put("kind", "cdp").put("expression", expression).put("timeout", timeoutMs / 1000.0),
timeoutMs + 2_000, trusted = true)
fun call(context: Context, request: JSONObject, timeoutMs: Long, trusted: Boolean = false): JSONObject {
if (!trusted && request.optString("kind") in setOf("exec", "cdp")) AgentAccess.requireCommands(context)
if (!SessionState.running) throw AgentException("NO_ACTIVE_SESSION", "There is no running session")
hello(context) ?: throw AgentException("AGENT_UNAVAILABLE", "The guest agent is not running in this session (yet)")
val dir = dir(context)
@@ -97,6 +97,7 @@ class AgentStartActivity : Activity() {
request.optString("steamUrl").takeIf { it.startsWith("steam://") }?.let {
putExtra(SessionService.EXTRA_STEAM_URL, it)
}
if (request.optBoolean("steamVr")) putExtra(SessionActivity.EXTRA_STEAMVR, true)
}
}
startActivity(sessionIntent)
@@ -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"))
}
}
@@ -2,10 +2,15 @@ package com.droiddeck.launcher.core
import android.app.ActivityManager
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.os.Environment
import android.os.PerformanceHintManager
import android.os.PowerManager
import android.os.StatFs
import android.provider.Settings
import android.util.Log
import android.view.Display
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
import com.droiddeck.launcher.gpu.TurnipDriver
@@ -32,6 +37,57 @@ object DeviceReport {
}
}
/** Name of an ApplicationExitInfo reason code, so a report says "LOW_MEMORY" instead of "3". */
internal fun exitReasonName(reason: Int): String = when (reason) {
0 -> "UNKNOWN"
1 -> "EXIT_SELF"
2 -> "SIGNALED"
3 -> "LOW_MEMORY"
4 -> "CRASH"
5 -> "CRASH_NATIVE"
6 -> "ANR"
7 -> "INITIALIZATION_FAILURE"
8 -> "PERMISSION_CHANGE"
9 -> "EXCESSIVE_RESOURCE_USAGE"
10 -> "USER_REQUESTED"
11 -> "USER_STOPPED"
12 -> "DEPENDENCY_DIED"
13 -> "OTHER"
14 -> "FREEZER"
15 -> "PACKAGE_STATE_CHANGE"
16 -> "PACKAGE_UPDATED"
else -> "REASON_$reason"
}
internal fun thermalStatusName(status: Int): String = when (status) {
0 -> "NONE"
1 -> "LIGHT"
2 -> "MODERATE"
3 -> "SEVERE"
4 -> "CRITICAL"
5 -> "EMERGENCY"
6 -> "SHUTDOWN"
else -> "STATUS_$status"
}
internal fun gameModeName(mode: Int): String = when (mode) {
0 -> "UNSUPPORTED"
1 -> "STANDARD"
2 -> "PERFORMANCE"
3 -> "BATTERY"
4 -> "CUSTOM"
else -> "MODE_$mode"
}
internal fun refreshModesSummary(rates: List<Float>): String =
rates.map { Math.round(it) }.distinct().sortedDescending().joinToString(", ") { "$it Hz" }
internal fun exitSummary(processName: String?, reason: Int, status: Int, description: String?): String =
buildString {
append(processName ?: "?").append(" ").append(exitReasonName(reason)).append(" status=").append(status)
if (!description.isNullOrBlank()) append(" ").append(description.trim())
}
fun build(context: Context, mode: String): String {
val b = StringBuilder()
fun h(title: String) {
@@ -68,6 +124,20 @@ object DeviceReport {
k("Fingerprint", Build.FINGERPRINT)
k("Kernel", System.getProperty("os.version"))
k("ABIs", Build.SUPPORTED_ABIS.joinToString(", "))
k("Memory page size", runCatching { android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE) }.getOrNull()?.let { "$it bytes" })
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
h("Recent process exits")
val exits = runCatching {
context.getSystemService(android.app.ActivityManager::class.java).getHistoricalProcessExitReasons(context.packageName, 0, 5)
}.getOrNull().orEmpty()
if (exits.isEmpty()) k("Exits", "none recorded") else {
val stamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
for (e in exits) {
k(stamp.format(Date(e.timestamp)), exitSummary(e.processName, e.reason, e.status, e.description))
}
}
}
h("CPU and memory")
k("Cores", CpuCores.all.size)
@@ -82,6 +152,19 @@ object DeviceReport {
k("RAM total", FileUtils.sizeToString(mi.totalMem))
k("RAM available", FileUtils.sizeToString(mi.availMem))
}
runCatching {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) k("Thermal status", thermalStatusName(pm.currentThermalStatus))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
k("Thermal headroom (10 s)", pm.getThermalHeadroom(10).let { if (it.isNaN()) "not reported" else String.format(Locale.US, "%.2f", it) })
}
k("Power saver", pm.isPowerSaveMode)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val rate = runCatching { context.getSystemService(PerformanceHintManager::class.java)?.preferredUpdateRateNanos }.getOrNull()
k("ADPF hint sessions", if (rate == null || rate <= 0) "not supported" else "supported (preferred update ${rate / 1000} us)")
k("Game Mode", runCatching { context.getSystemService(android.app.GameManager::class.java)?.gameMode }.getOrNull()?.let { gameModeName(it) })
}
runCatching {
val fs = StatFs(context.filesDir.path)
k("App storage free", FileUtils.sizeToString(fs.availableBlocksLong * fs.blockSizeLong))
@@ -99,6 +182,13 @@ object DeviceReport {
h("Display")
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
runCatching {
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
k("Panel refresh now", String.format(Locale.US, "%.2f Hz", display.refreshRate))
k("Panel refresh modes", refreshModesSummary(display.supportedModes.map { it.refreshRate }))
}
k("peak_refresh_rate", runCatching { Settings.System.getString(context.contentResolver, "peak_refresh_rate") }.getOrNull() ?: "not set")
k("refresh_rate_mode", runCatching { Settings.Secure.getString(context.contentResolver, "refresh_rate_mode") }.getOrNull() ?: "not set")
val panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(context)
for ((label, mode) in listOf("Resolution (Steam)" to SessionService.MODE_STEAM, "Resolution (Desktop)" to SessionService.MODE_DESKTOP)) {
val choice = SessionPrefs.resolutionChoice(context, mode, panelSize)
@@ -110,9 +200,11 @@ 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)))?.let { " (recommended pair: $it)" } ?: ""))
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
com.droiddeck.launcher.gpu.AutoDriverTrial.bad(context))?.let { " (recommended pair: $it)" } ?: ""))
val turnip = TurnipDriver(context)
val androidChoice = SessionPrefs.androidDriver(context)
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
@@ -133,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")
@@ -0,0 +1,208 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.content.SharedPreferences
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
object DownloadSource {
const val MODE_AUTO = "auto"
const val MODE_DIRECT = "direct"
const val MODE_MIRROR = "mirror"
const val MODE_CUSTOM = "custom"
private const val PREFS = "download-source"
private const val KEY_MODE = "mode"
private const val KEY_MIRROR = "mirror"
private const val KEY_CUSTOM = "custom"
private const val KEY_FASTEST = "fastest"
private const val PROBE = "https://raw.githubusercontent.com/Droid-Deck/DroidDeck/main/README.md"
private const val PROBE_TIMEOUT_MS = 4_000
private const val PROBE_COOLDOWN_MS = 30_000L
private const val UA = "DroidDeck-Android"
private val MODES = listOf(MODE_AUTO, MODE_DIRECT, MODE_MIRROR, MODE_CUSTOM)
private val GITHUB_HOSTS = listOf(
"github.com",
"raw.githubusercontent.com",
"api.github.com",
)
private val CATALOGS = listOf(
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/",
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/",
"https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/",
)
data class Mirror(val host: String, val api: Boolean)
val MIRRORS = listOf(
Mirror("gh-proxy.com", true),
Mirror("gh-proxy.org", true),
Mirror("ghproxy.net", false),
Mirror("ghfast.top", false),
)
@Volatile
private var app: Context? = null
@Volatile
private var lastGood: String? = null
private val probing = AtomicBoolean(false)
private val probedAt = AtomicLong(0L)
fun init(context: Context) {
app = context.applicationContext
lastGood = prefs()?.getString(KEY_FASTEST, null)
}
fun probe() {
if (app == null || prefs()?.getString(KEY_MODE, MODE_AUTO) == MODE_DIRECT) return
if (!probing.compareAndSet(false, true)) return
val now = System.currentTimeMillis()
if (now - probedAt.get() < PROBE_COOLDOWN_MS) {
probing.set(false)
return
}
probedAt.set(now)
Thread {
try {
val times = ConcurrentHashMap<String, Long>()
val sources = listOf<String?>(null) + MIRRORS.map { it.host }
val latch = CountDownLatch(sources.size)
for (mirror in sources) {
Thread {
try {
val ms = latency(mirror?.let { "https://$it/$PROBE" } ?: PROBE)
if (ms > 0) times[mirror ?: ""] = ms
} finally {
latch.countDown()
}
}.apply { isDaemon = true }.start()
}
latch.await(PROBE_TIMEOUT_MS * 3L, TimeUnit.MILLISECONDS)
val best = times.minByOrNull { it.value }?.key ?: return@Thread
lastGood = best
prefs()?.edit()?.putString(KEY_FASTEST, best)?.apply()
} finally {
probing.set(false)
}
}.apply { isDaemon = true }.start()
}
private fun latency(url: String): Long {
val started = System.nanoTime()
var connection: HttpURLConnection? = null
return try {
val c = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = PROBE_TIMEOUT_MS
readTimeout = PROBE_TIMEOUT_MS
instanceFollowRedirects = true
setRequestProperty("Range", "bytes=0-1023")
setRequestProperty("User-Agent", UA)
}
connection = c
if (c.responseCode / 100 != 2) 0L else {
c.inputStream.use { it.read() }
(System.nanoTime() - started) / 1_000_000
}
} catch (e: Exception) {
0L
} finally {
runCatching { connection?.disconnect() }
}
}
fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") }
/** Catalogs carry the digests everything else is checked against, so a mirror must never supply one. */
fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) }
fun candidates(target: String): List<String> {
// GitHub API metadata carries executable URLs and digests; never proxy it.
if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target)
val prefs = prefs() ?: return listOf(target)
if (!isGithub(target)) return listOf(target)
val api = target.startsWith("https://api.github.com/")
val proxies = MIRRORS.filter { !api || it.api }.map { it.proxy(target) }
val ordered = when (prefs.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO) {
MODE_DIRECT -> listOf(target)
MODE_MIRROR -> listOfNotNull(picked(prefs, api)?.proxy(target)) + listOf(target) + quickest(proxies)
MODE_CUSTOM -> listOfNotNull(customProxy(prefs, target)) + listOf(target) + quickest(proxies)
else -> if (api) listOf(target) else listOf(target) + quickest(proxies)
}
return ordered.distinct()
}
private fun quickest(sources: List<String>): List<String> =
sources.sortedBy { if (host(it) == lastGood) 0 else 1 }
fun succeeded(source: String) {
lastGood = host(source)
}
fun failed() {
lastGood = null
prefs()?.edit()?.remove(KEY_FASTEST)?.apply()
}
fun mode(context: Context): String = prefs(context)?.getString(KEY_MODE, MODE_AUTO) ?: MODE_AUTO
fun setMode(context: Context, mode: String) {
prefs(context)?.edit()?.putString(KEY_MODE, mode.takeIf { it in MODES } ?: MODE_AUTO)?.apply()
}
fun mirror(context: Context): String {
val host = prefs(context)?.getString(KEY_MIRROR, "") ?: ""
return MIRRORS.firstOrNull { it.host == host }?.host ?: MIRRORS.first().host
}
fun setMirror(context: Context, host: String) {
prefs(context)?.edit()?.putString(KEY_MIRROR, host.takeIf { h -> MIRRORS.any { it.host == h } } ?: "")?.apply()
}
fun custom(context: Context): String = prefs(context)?.getString(KEY_CUSTOM, "") ?: ""
fun setCustom(context: Context, prefix: String) {
prefs(context)?.edit()?.putString(KEY_CUSTOM, normalizePrefix(prefix))?.apply()
}
fun reset(context: Context) {
lastGood = null
prefs(context)?.edit()?.clear()?.apply()
}
private fun prefs(): SharedPreferences? = app?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun prefs(context: Context): SharedPreferences? =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun Mirror.proxy(target: String): String = "https://$host/$target"
private fun host(url: String): String =
MIRRORS.firstOrNull { url.startsWith("https://${it.host}/") }?.host ?: ""
private fun picked(prefs: SharedPreferences, api: Boolean): Mirror? {
val chosen = MIRRORS.firstOrNull { it.host == (prefs.getString(KEY_MIRROR, "") ?: "") } ?: MIRRORS.firstOrNull()
return if (api && chosen != null && !chosen.api) MIRRORS.firstOrNull { it.api } else chosen
}
private fun custom(prefs: SharedPreferences): String = prefs.getString(KEY_CUSTOM, "") ?: ""
private fun customProxy(prefs: SharedPreferences, target: String): String? =
normalizePrefix(custom(prefs)).takeIf { it.isNotEmpty() }?.let { "$it/$target" }
private fun normalizePrefix(value: String): String {
val trimmed = value.trim().trimEnd('/')
if (trimmed.isEmpty() || trimmed.startsWith("http://")) return ""
return if (trimmed.startsWith("https://")) trimmed else "https://$trimmed"
}
}
@@ -6,69 +6,201 @@ import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Properties;
public final class Downloader {
private static final String TAG = "Downloader";
private static final int TIMEOUT_MS = 30_000;
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int STALL_TIMEOUT_MS = 15_000;
private static final int HTTP_RANGE_NOT_SATISFIABLE = 416;
private Downloader() {}
public static String downloadString(String url) {
HttpURLConnection connection = null;
try {
connection = open(url);
if (connection.getResponseCode() / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + connection.getResponseCode());
return null;
List<String> sources = DownloadSource.INSTANCE.candidates(url);
for (int i = 0; i < sources.size(); i++) {
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
Fetch fetch = fetch(sources.get(i));
if (fetch.body != null) {
DownloadSource.INSTANCE.succeeded(sources.get(i));
return fetch.body;
}
try (InputStream in = connection.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
FileUtils.copy(in, out);
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
} catch (Exception e) {
Log.w(TAG, "GET " + url, e);
return null;
} finally {
if (connection != null) connection.disconnect();
if (fetch.fatal && sources.get(i).equals(url)) return null;
}
DownloadSource.INSTANCE.failed();
return null;
}
/** Fetches without the mirror list, for a URL whose body is a digest the payload is checked against. */
public static String downloadDirect(String url) {
return fetch(url).body;
}
public static boolean downloadFile(String url, File destination, boolean resume,
java.util.function.Consumer<Float> progress) {
return downloadFile(url, destination, resume, null, progress);
}
/** Verify each candidate before accepting it, so a bad mirror can fall back. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.Consumer<Float> progress) {
if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}"))
throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters");
return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress);
}
// Package-private for the local multi-source HTTP test.
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
String expectedSha256, java.util.function.Consumer<Float> progress) {
Identity identity = loadIdentity(destination, resume, expectedSha256);
for (int i = 0; i < sources.size(); i++) {
String source = sources.get(i);
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, progress);
if (outcome == Outcome.DONE) {
if (expectedSha256 != null && !expectedSha256.equalsIgnoreCase(Hashes.sha256(destination))) {
Log.w(TAG, "checksum mismatch from " + source + "; trying another source");
if (!discardPartial(destination, identity)) return false;
continue;
}
resumeFile(destination).delete();
DownloadSource.INSTANCE.succeeded(source);
if (progress != null) progress.accept(1f);
return true;
}
if (outcome == Outcome.FATAL && source.equals(original)) return false;
}
DownloadSource.INSTANCE.failed();
return false;
}
private enum Outcome { DONE, RETRY, FATAL }
private static final class Identity {
private String source;
private String etag;
}
private static File resumeFile(File dest) {
return new File(dest.getPath() + ".resume.properties");
}
private static boolean strongETag(String value) {
return value != null && !value.isEmpty() && !value.startsWith("W/");
}
/** Only reuse saved bytes when we persisted a strong validator and their source. */
private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) {
Identity identity = new Identity();
File marker = resumeFile(dest);
if (!resume || !dest.isFile() || dest.length() == 0) {
marker.delete();
return identity;
}
try (FileInputStream in = new FileInputStream(marker)) {
Properties p = new Properties();
p.load(in);
String source = p.getProperty("source");
String etag = p.getProperty("etag");
String expected = p.getProperty("sha256", "");
if (source != null && source.startsWith("http") && strongETag(etag)
&& expected.equals(expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT))) {
identity.source = source;
identity.etag = etag;
return identity;
}
} catch (IOException e) {
// Legacy partials or interrupted metadata writes have no trustworthy identity.
}
if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest);
return identity;
}
private static boolean discardPartial(File dest, Identity identity) {
identity.source = null;
identity.etag = null;
File marker = resumeFile(dest);
return (!dest.exists() || dest.delete()) && (!marker.exists() || marker.delete());
}
private static void saveIdentity(File dest, Identity identity, String expectedSha256) {
File marker = resumeFile(dest);
if (!strongETag(identity.etag)) {
marker.delete();
return;
}
Properties p = new Properties();
p.setProperty("source", identity.source);
p.setProperty("etag", identity.etag);
p.setProperty("sha256", expectedSha256 == null ? "" : expectedSha256.toLowerCase(java.util.Locale.ROOT));
try (FileOutputStream out = new FileOutputStream(marker)) {
p.store(out, "DroidDeck partial download validator");
} catch (IOException e) {
marker.delete(); // No valid metadata means no future resume.
Log.w(TAG, "cannot persist download validator", e);
}
}
private static Outcome attempt(String url, File destination, boolean resume, Identity identity,
String expectedSha256, java.util.function.Consumer<Float> progress) {
HttpURLConnection connection = null;
long have = resume && destination.isFile() ? destination.length() : 0;
try {
connection = open(url);
if (have > 0) connection.setRequestProperty("Range", "bytes=" + have + "-");
if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) {
// A new source or unvalidated bytes must restart from byte zero.
if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0;
}
connection = open(url, STALL_TIMEOUT_MS);
if (have > 0) {
connection.setRequestProperty("Range", "bytes=" + have + "-");
connection.setRequestProperty("If-Range", identity.etag);
}
int code = connection.getResponseCode();
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
// A 206 that does not begin where the kept bytes end would corrupt the file if appended.
if (have > 0 && code == HTTP_RANGE_NOT_SATISFIABLE) {
connection.disconnect();
destination.delete();
return downloadFile(url, destination, false, progress);
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
}
if (code / 100 != 2) {
if (have > 0) {
connection.disconnect();
destination.delete();
return downloadFile(url, destination, false, progress);
}
Log.w(TAG, url + " -> HTTP " + code);
return false;
return code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE
? Outcome.FATAL : Outcome.RETRY;
}
boolean appending = code == HttpURLConnection.HTTP_PARTIAL;
if (appending && !rangeStartsAt(connection.getHeaderField("Content-Range"), have)) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
}
String etag = connection.getHeaderField("ETag");
if (have > 0 && appending && (!strongETag(etag) || !etag.equals(identity.etag))) {
connection.disconnect();
if (!discardPartial(destination, identity)) return Outcome.RETRY;
return attempt(url, destination, false, identity, expectedSha256, progress);
}
if (!appending && have > 0) {
// If-Range may legitimately return full 200 when representation changed.
if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0;
}
if (!appending) have = 0;
long length = connection.getContentLengthLong();
long total = length < 0 ? -1 : length + have;
long total = appending ? rangeTotal(connection.getHeaderField("Content-Range")) : -1;
if (total < 0) total = length < 0 ? -1 : length + have;
File parent = destination.getParentFile();
if (parent != null && !parent.isDirectory())
parent.mkdirs();
if (parent != null && !parent.isDirectory()) parent.mkdirs();
identity.source = url;
identity.etag = strongETag(etag) ? etag : null;
saveIdentity(destination, identity, expectedSha256);
long written = have;
try (InputStream in = new BufferedInputStream(connection.getInputStream(), 1 << 16);
OutputStream out = new FileOutputStream(destination, appending)) {
@@ -83,11 +215,11 @@ public final class Downloader {
}
}
}
if (progress != null) progress.accept(1f);
return total <= 0 || written >= total;
if (total > 0 && written < total) return Outcome.RETRY;
return Outcome.DONE;
} catch (Exception e) {
Log.w(TAG, "download " + url, e);
return false;
return Outcome.RETRY;
} finally {
if (connection != null) connection.disconnect();
}
@@ -100,10 +232,49 @@ public final class Downloader {
return m.matches() && Long.parseLong(m.group(1)) == start;
}
private static HttpURLConnection open(String url) throws java.io.IOException {
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
static long rangeTotal(String contentRange) {
if (contentRange == null) return -1;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
return m.matches() ? Long.parseLong(m.group(1)) : -1;
}
private static final class Fetch {
final String body;
final boolean fatal;
Fetch(String body, boolean fatal) {
this.body = body;
this.fatal = fatal;
}
}
private static Fetch fetch(String url) {
HttpURLConnection connection = null;
try {
connection = open(url, STALL_TIMEOUT_MS);
int code = connection.getResponseCode();
if (code / 100 != 2) {
Log.w(TAG, url + " -> HTTP " + code);
return new Fetch(null, code == HttpURLConnection.HTTP_NOT_FOUND || code == HttpURLConnection.HTTP_GONE);
}
try (InputStream in = connection.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
FileUtils.copy(in, out);
return new Fetch(new String(out.toByteArray(), StandardCharsets.UTF_8), false);
}
} catch (Exception e) {
Log.w(TAG, "GET " + url, e);
return new Fetch(null, false);
} finally {
if (connection != null) connection.disconnect();
}
}
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(TIMEOUT_MS);
connection.setReadTimeout(TIMEOUT_MS);
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(readTimeoutMs);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("User-Agent", "DroidDeck-Android");
return connection;
@@ -1,5 +1,7 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.session.SessionPrefs
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
@@ -27,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",
@@ -83,4 +86,34 @@ object GameEnvironment {
}
const val CUSTOM_FEX_PRESET = "__custom__"
val syncBackends = listOf(SessionPrefs.SYNC_NTSYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_WINESERVER)
val syncKeys = listOf("BL_SYNC", "BL_FSYNC_FIRST", "BL_SYNC_FALLBACK", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC")
fun syncEnvironment(backend: String): Map<String, String> {
require(backend in syncBackends) { "unknown sync $backend" }
return buildMap {
put("BL_SYNC", if (backend == SessionPrefs.SYNC_NTSYNC) "1" else "0")
put("BL_FSYNC_FIRST", if (backend == SessionPrefs.SYNC_FSYNC) "1" else "0")
put("BL_SYNC_FALLBACK", if (backend == SessionPrefs.SYNC_WINESERVER) "0" else "1")
if (backend == SessionPrefs.SYNC_FSYNC) put("PROTON_NO_ESYNC", "1")
}
}
fun gameSync(config: Config, scope: String): String? {
require(scope.isNotEmpty() && validScope(scope))
val entries = config.games[scope].orEmpty().filterKeys { it in syncKeys }
if (entries.isEmpty()) return null
return syncBackends.firstOrNull { entries == syncEnvironment(it) } ?: CUSTOM_SYNC
}
fun withGameSync(config: Config, scope: String, backend: String?): Config {
require(scope.isNotEmpty() && validScope(scope))
val entries = config.entries(scope).filterKeys { it !in syncKeys }.toMutableMap()
if (backend != null) entries += syncEnvironment(backend)
return config.withEntries(scope, entries)
}
const val CUSTOM_SYNC = "__custom__"
}
@@ -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")),
@@ -51,25 +51,29 @@ object LogRedactor {
@Volatile
private var accounts: List<Learned> = emptyList()
fun learnAccounts(loginUsers: java.io.File) {
val found = ArrayList<Learned>()
try {
if (loginUsers.isFile) {
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
kv.findAll(loginUsers.readText()).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.distinct().forEach { name ->
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
found += Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
}
} catch (e: Exception) {
return
fun learnAccounts(vararg loginUsers: java.io.File) {
val names = LinkedHashSet<String>()
val kv = Regex("\"(AccountName|PersonaName)\"\\s+\"([^\"]*)\"")
var unread = false
for (file in loginUsers) {
if (!file.isFile) continue
val text = try { file.readText() } catch (e: Exception) { unread = true; continue }
kv.findAll(text).map { it.groupValues[2].trim() }.filter { it.length >= 3 }.forEach { names += it }
}
accounts = found
val learned = names.map { name ->
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
}
// A file that could not be read keeps the names learned from it before.
accounts = if (unread) (accounts + learned).distinctBy { it.regex.pattern } else learned
}
fun learnFromRuntime(root: java.io.File) {
learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
learnAccounts(
java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"),
java.io.File(root, "root/.droiddeck-x64/.local/share/Steam/config/loginusers.vdf"),
)
}
@Volatile
@@ -142,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(
@@ -172,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 {
@@ -26,10 +26,12 @@ object PhantomProcessLimit {
usesDeviceConfig(sdk) && enabled -> listOf(
"device_config delete activity_manager max_phantom_processes",
"device_config set_sync_disabled_for_tests none",
"settings put global $SETTING true",
)
usesDeviceConfig(sdk) -> listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
"settings put global $SETTING false",
)
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
}
@@ -57,7 +59,12 @@ object PhantomProcessLimit {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
if (usesDeviceConfig(sdk)) {
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
null
}
return android12Status(off, global, systemProperty(OVERRIDE_PROPERTY))
}
val global = try {
Settings.Global.getString(context.contentResolver, SETTING)
@@ -67,6 +74,10 @@ object PhantomProcessLimit {
return status(global, systemProperty(OVERRIDE_PROPERTY))
}
fun android12Status(rememberedOff: Boolean, global: String?, override: String?): PhantomProcessStatus =
if (rememberedOff || status(global, override) == PhantomProcessStatus.DISABLED) PhantomProcessStatus.DISABLED
else PhantomProcessStatus.UNREADABLE
fun status(global: String?, override: String?): PhantomProcessStatus {
val value = global?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
?: override?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
@@ -4,6 +4,7 @@ import android.os.Process
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileOutputStream
object SessionLogCapture {
private const val TAG = "SessionLogCapture"
@@ -11,6 +12,7 @@ object SessionLogCapture {
private var pid = -1
private var writer: BufferedWriter? = null
private var target: File? = null
private var syncer: Thread? = null
@Synchronized
fun start(target: File) {
@@ -37,6 +39,7 @@ object SessionLogCapture {
out.write("logcat could not be started; this ROM may not hand an app its own entries.\n")
out.flush()
}
target.parentFile?.let { syncer = startSyncing(it) }
Log.i(TAG, "app log -> $target (logcat pid $pid)")
} catch (e: Exception) {
Log.w(TAG, "could not start the app log", e)
@@ -49,8 +52,19 @@ object SessionLogCapture {
if (t.parentFile?.absolutePath == dir.absolutePath) stop()
}
/** flush() only reaches the page cache, and a hard reset on f2fs lost the last minutes of every
* log. Every 2 s the session folder's files (ours and the native parts') are synced to storage. */
private fun startSyncing(dir: File): Thread = Thread({
while (true) {
try { Thread.sleep(2000) } catch (e: InterruptedException) { break }
dir.listFiles()?.forEach { runCatching { FileOutputStream(it, true).use { s -> s.fd.sync() } } }
}
}, "session-log-sync").apply { isDaemon = true; start() }
@Synchronized
fun stop() {
syncer?.interrupt()
syncer = null
target = null
if (pid != -1) {
try {
@@ -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)
}
@@ -220,7 +228,7 @@ fun FileManagerScreen(
fun uniqueDestination(dir: File, name: String): File {
var candidate = File(dir, name)
if (!candidate.exists()) return candidate
if (!java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate
val dot = name.lastIndexOf('.')
val base = if (dot > 0) name.substring(0, dot) else name
val ext = if (dot > 0) name.substring(dot) else ""
@@ -228,7 +236,7 @@ fun FileManagerScreen(
do {
candidate = File(dir, "$base ($i)$ext")
i++
} while (candidate.exists())
} while (java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS))
return candidate
}
@@ -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
@@ -278,7 +287,7 @@ fun FileManagerScreen(
}
var dst = File(dstDir, src.name)
if (dst.exists()) {
if (java.nio.file.Files.exists(dst.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
val choice = applyToAll ?: askConflict(src)?.also {
if (conflictApplyToAll) applyToAll = it
} ?: run { skipped++; null } ?: continue
@@ -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,23 +45,26 @@ 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 {
if (isSymlink(src)) return true
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 copyLink(src, dst)
if (sameFile(src, dst)) return true
if (src.isDirectory && isWithin(dst, src)) {
Log.e(TAG, "Refusing to copy directory into itself: $src -> $dst")
@@ -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,54 @@ 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()
}
}
}
private fun copyLink(src: File, dst: File): Boolean {
if (src.absoluteFile == dst.absoluteFile) return true
return try {
val target = Files.readSymbolicLink(src.toPath())
val parent = dst.parentFile
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
try {
Files.createSymbolicLink(dst.toPath(), target)
} catch (e: java.nio.file.FileAlreadyExistsException) {
val staging = Files.createTempDirectory(checkNotNull(dst.absoluteFile.parentFile).toPath(), ".droiddeck-link-")
val tmp = staging.resolve("link")
try {
Files.createSymbolicLink(tmp, target)
Files.move(tmp, dst.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
} finally {
Files.deleteIfExists(tmp)
Files.deleteIfExists(staging)
}
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to copy link: $src to $dst", e)
false
}
}
@@ -22,7 +22,7 @@ object AddedGameArt {
}
enum class Slot(val key: String, val cacheName: String, val assetType: Int?) {
COVER("p", "p.jpg", 0), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
COVER("p", "p.jpg", 0), WIDE("header", "header.jpg", 3), BACKGROUND("hero", "hero.jpg", 1), LOGO("logo", "logo.png", 2), ICON("icon", "icon.png", null),
}
const val FOLDER = "folder"
@@ -40,10 +40,10 @@ object AddedGameArt {
class Found<T>(val items: List<T>, val status: SteamGridDb.Status)
private val CHOSEN = Regex("""chosen-(\w+)\.(\w+)""")
private val CHOSEN = Regex("""chosen-(p|header|hero|logo|icon)(?:-\d+)?\.(\w+)""")
private fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.firstOrNull()
internal fun chosen(cache: File, slot: Slot): File? =
cache.listFiles { f -> CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.maxWithOrNull(compareBy({ it.lastModified() }, { it.name }))
private val IMAGE = listOf("png", "jpg", "jpeg")
private val PORTRAIT = listOf("cover", "poster", "grid", "boxart", "capsule", "folder", "library_600x900", "portrait")
@@ -79,7 +79,7 @@ object AddedGameArt {
fun cached(name: String) = File(cache, name).takeIf { it.isFile }
return Art(
portrait = chosen(cache, Slot.COVER) ?: own.portrait ?: cached("p.jpg") ?: exeArt(context, game, cover = true),
header = own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
header = chosen(cache, Slot.WIDE) ?: own.header ?: cached("header.jpg") ?: exeArt(context, game, cover = false),
hero = chosen(cache, Slot.BACKGROUND) ?: own.hero ?: cached("hero.jpg"),
logo = chosen(cache, Slot.LOGO) ?: own.logo ?: cached("logo.png"),
icon = chosen(cache, Slot.ICON) ?: own.icon ?: cached("icon-sgdb.png") ?: exeIcon(context, game),
@@ -96,6 +96,7 @@ object AddedGameArt {
chosen(cache, slot)?.let { return@map SlotState(slot, it, chosenSource(context, game, slot).first.ifEmpty { FILE }, true) }
val auto: Pair<File, String>? = when (slot) {
Slot.COVER -> own.portrait?.let { it to FOLDER } ?: cached("p.jpg") ?: exeArt(context, game, cover = true)?.let { it to EXE }
Slot.WIDE -> own.header?.let { it to FOLDER } ?: cached("header.jpg") ?: exeArt(context, game, cover = false)?.let { it to EXE }
Slot.BACKGROUND -> own.hero?.let { it to FOLDER } ?: cached("hero.jpg")
Slot.LOGO -> own.logo?.let { it to FOLDER } ?: cached("logo.png")
Slot.ICON -> own.icon?.let { it to FOLDER } ?: File(cache, "icon-sgdb.png").takeIf { it.isFile }?.let { it to SGDB }
@@ -117,36 +118,37 @@ object AddedGameArt {
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
val cache = cacheDir(context, game)
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
if (steamId != null && remote != null) {
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
if (url != null) out.add(Option(STEAM, url, url))
}
var status = SteamGridDb.Status.OK
val key = SteamGridDb.key(context)
if (key.isEmpty()) {
status = SteamGridDb.Status.NO_KEY
} else {
cache.mkdirs()
try {
val gridId = remembered(File(cache, "sgdb-id"), SteamGridDb.keyChangedAt(context), retryMiss = true) {
SteamGridDb.gameId(key, steamId?.toIntOrNull(), game.name)?.toString()
}?.toIntOrNull()
if (gridId == null) status = SteamGridDb.Status.NO_GAME
else SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5)
.forEach { (thumb, full) -> out.add(Option(SGDB, thumb, full)) }
} catch (e: Exception) {
Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName})")
status = (e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER
}
}
if (steamId != null) steamOption(steamId, slot)?.let { out.add(it) }
val (found, status) = sgdbChoices(context, File(cache, "sgdb-id"), steamId?.toIntOrNull(), game.name, slot)
out.addAll(found)
return Choices(out, status)
}
internal fun steamOption(steamId: String, slot: Slot): Option? {
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.WIDE -> "header.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null } ?: return null
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote) ?: return null
return Option(STEAM, url, url)
}
internal fun sgdbChoices(context: Context, marker: File, steamId: Int?, name: String, slot: Slot): Pair<List<Option>, SteamGridDb.Status> {
val key = SteamGridDb.key(context)
if (key.isEmpty()) return emptyList<Option>() to SteamGridDb.Status.NO_KEY
marker.parentFile?.mkdirs()
return try {
val gridId = remembered(marker, SteamGridDb.keyChangedAt(context), retryMiss = true) {
SteamGridDb.gameId(key, steamId, name)?.toString()
}?.toIntOrNull() ?: return emptyList<Option>() to SteamGridDb.Status.NO_GAME
SteamGridDb.images(key, gridId, slot, if (slot == Slot.ICON) 3 else 5).map { (thumb, full) -> Option(SGDB, thumb, full) } to SteamGridDb.Status.OK
} catch (e: Exception) {
Log.w(TAG, "$name: SteamGridDB failed (${e.javaClass.simpleName})")
emptyList<Option>() to ((e as? SteamGridDb.Failed)?.status ?: SteamGridDb.Status.SERVER)
}
}
fun searchGames(context: Context, term: String): Found<SteamGridDb.Game> = sgdb(context) { SteamGridDb.search(it, term) }
fun gameImages(context: Context, id: Int, slot: Slot, page: Int): Found<Option> = sgdb(context) { key ->
SteamGridDb.page(key, id, slot, page).map { (thumb, full) -> Option(SGDB, thumb, full) }
fun gameImages(context: Context, id: Int, slot: Slot): Found<Option> = sgdb(context) { key ->
SteamGridDb.all(key, id, slot).map { (thumb, full) -> Option(SGDB, thumb, full) }
}
private fun <T> sgdb(context: Context, call: (String) -> List<T>): Found<T> {
@@ -161,19 +163,38 @@ object AddedGameArt {
}
fun choose(context: Context, game: AddedGames.Game, slot: Slot, option: Option): Boolean {
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
bytes ?: return false
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
val cache = cacheDir(context, game).apply { mkdirs() }
chosen(cache, slot)?.delete()
val dst = File(cache, "chosen-${slot.key}.${if (ext == "jpeg") "jpg" else ext}")
val tmp = File(cache, dst.name + ".tmp")
tmp.writeBytes(bytes)
if (!tmp.renameTo(dst)) return false
if (!store(cacheDir(context, game), slot, option)) return false
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, option.group + "|" + option.full)
return true
}
internal fun store(cache: File, slot: Slot, option: Option): Boolean {
val fresh = storeNew(cache, slot, option) ?: return false
dropOlder(cache, slot, fresh)
return true
}
internal fun dropOlder(cache: File, slot: Slot, keep: File) {
cache.listFiles { f -> f != keep && CHOSEN.matchEntire(f.name)?.groupValues?.get(1) == slot.key }?.forEach { it.delete() }
}
private val lastStamp = java.util.concurrent.atomic.AtomicLong(0)
private fun nextStamp(): Long = lastStamp.updateAndGet { maxOf(it + 1, System.currentTimeMillis()) }
internal fun storeNew(cache: File, slot: Slot, option: Option): File? {
val bytes = if (option.full.startsWith("https://")) get(option.full) else runCatching { File(option.full).readBytes() }.getOrNull()
bytes ?: return null
val ext = option.full.substringAfterLast('.', "png").substringBefore('?').lowercase().takeIf { it in CHOOSABLE + "ico" } ?: "png"
cache.mkdirs()
val suffix = if (ext == "jpeg") "jpg" else ext
var dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
while (dst.exists()) dst = File(cache, "chosen-${slot.key}-" + "%013d".format(nextStamp()) + "." + suffix)
val tmp = File(cache, dst.name + ".tmp")
tmp.writeBytes(bytes)
return if (tmp.renameTo(dst)) dst else null
}
fun chosenSource(context: Context, game: AddedGames.Game, slot: Slot): Pair<String, String> {
val value = SessionPrefs.addedGameArtSource(context, game.folder.path, slot.key)
return value.substringBefore('|') to value.substringAfter('|', "")
@@ -36,19 +36,34 @@ object AddedGameEdits {
fun setArt(context: Context, folder: String, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
val game = game(context, folder) ?: return false
if (!AddedGameArt.choose(context, game, slot, option)) return false
applyArt(context, game, slot)
return true
return synchronized(SteamGrid.lock) {
if (!AddedGameArt.choose(context, game, slot, option)) return@synchronized false
applyArt(context, game, slot)
true
}
}
fun resetArt(context: Context, folder: String, slot: AddedGameArt.Slot) {
val game = game(context, folder) ?: return
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
synchronized(SteamGrid.lock) {
AddedGameArt.reset(context, game, slot)
applyArt(context, game, slot)
}
}
fun logoPosition(context: Context, folder: String): LogoPosition.Value? =
game(context, folder)?.let { SteamGrid.position(SteamGrid.target(it, context)) }
fun setLogoPosition(context: Context, folder: String, value: LogoPosition.Value?): Boolean {
val game = game(context, folder) ?: return false
if (!SteamGrid.writePosition(context, SteamGrid.target(game, context), value)) return false
publish(context) { SteamLiveShortcuts.setLogoPosition(context, game.appId, value) }
return true
}
private fun applyArt(context: Context, game: AddedGames.Game, slot: AddedGameArt.Slot) {
val file = AddedGameArt.describe(context, game).first { it.slot == slot }.file
if (slot != AddedGameArt.Slot.ICON) SteamGrid.write(context, SteamGrid.target(game, context), slot, AddedGameArt.chosen(AddedGameArt.cacheDir(context, game), slot))
publish(context) {
if (slot == AddedGameArt.Slot.ICON) SteamLiveShortcuts.setArt(context, game.appId, null, null, iconPath = file?.absolutePath.orEmpty())
else SteamLiveShortcuts.setArt(context, game.appId, slot.assetType, file)
@@ -6,6 +6,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot
import java.io.File
@@ -40,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"
@@ -74,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(
@@ -127,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()
@@ -138,6 +149,10 @@ object AddedGames {
"appxmanifest.xml" to Regex("<Application\\b[^>]*?\\bExecutable\\s*=\\s*\"([^\"]+)\"", RegexOption.IGNORE_CASE),
)
/** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
GamesFolderMigration.run(context)
@@ -150,7 +165,7 @@ object AddedGames {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
val steamInstalls = steamInstallDirs(dir)
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
if (folder.name.lowercase() in steamInstalls || folder.name.equals("steamapps", ignoreCase = true)) continue
if (folder.name.lowercase() in steamInstalls || isSteamLibraryFolder(folder.name)) continue
if (folder.path in removed) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
scanGame(context, folder, out)
@@ -199,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") },
@@ -210,11 +227,29 @@ object AddedGames {
val base = canonical(dir)
if (!inside(path, base)) continue
val first = path.removePrefix("$base/").substringBefore('/')
File(dir, first).takeIf { it.isDirectory && first != "steamapps" }?.let { return it }
File(dir, first).takeIf { it.isDirectory && !isSteamLibraryFolder(first) }?.let { return it }
}
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
@@ -223,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)
@@ -238,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
@@ -261,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)
@@ -270,7 +324,7 @@ object AddedGames {
)
for (dir in dirs.distinctBy { it.absolutePath }) {
for (folder in dir.listFiles { f -> f.isDirectory }.orEmpty()) {
if (folder.path in removed || folder.name.equals("steamapps", ignoreCase = true)) continue
if (folder.path in removed || isSteamLibraryFolder(folder.name)) continue
if (folder.name == "Games" && StoreInstallRoot.roots(context).any { it.absolutePath == folder.absolutePath }) continue
out.add(canonical(folder))
}
@@ -296,19 +350,22 @@ object AddedGames {
private fun scanGame(context: Context, folder: File, out: MutableList<Game>, single: AddedExes.Entry? = null) {
run {
val sidecar = StoreGameSidecar.read(folder)
sidecar?.takeIf { it.id.isNotEmpty() }?.let { SessionPrefs.followStoreGame(context, "${it.store.id}:${it.id}", folder.path) { old -> !File(old).exists() } }
val ranking = rank(folder)
val candidates = ranking.exes
val picked = SessionPrefs.addedGameExe(context, folder.path)
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
?: single?.takeIf { it.picked }?.let { File(it.exe) }?.takeIf { it.isFile }
val found = chosen ?: sidecar?.exeFile(folder)?.takeIf { it.isFile } ?: candidates.firstOrNull() ?: return
val launch = if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
val epicLauncher = sidecar?.takeIf { it.store == Store.EPIC && (chosen == null || canonical(chosen) == canonical(it.exeFile(folder))) }
?.launcherFile(folder)?.takeIf { it.isFile }
val launch = epicLauncher ?: if (chosen == null) sidecar?.launcherFile(folder)?.takeIf { it.isFile } ?: found else found
val foundGuest = guestPath(context, launch)
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${launch.path}"); return }
val autoName = sidecar?.title ?: identifiedName(folder, found) ?: folder.name
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + autoName).toByteArray()) }.value
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
val config = accountConfig(SessionPrefs.activeSteamRoot(context))
val record = config?.let { shortcutRecord(it, appId) }
val rev = record?.optInt("rev", 0) ?: 0
if (sidecar == null && record != null) adoptSteamEdits(context, folder, record)
@@ -330,7 +387,7 @@ object AddedGames {
}
}
private fun accountConfig(root: File): File? {
internal fun accountConfig(root: File): File? {
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
@@ -343,6 +400,9 @@ object AddedGames {
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
fun inSteam(context: Context, appId: Long): Boolean =
accountConfig(SessionPrefs.activeSteamRoot(context))?.let { shortcutRecord(it, appId) } != null
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
}.getOrNull()
@@ -382,7 +442,8 @@ object AddedGames {
}
}
fun writeListing(context: Context, games: List<Game>): File {
fun writeListing(context: Context, games: List<Game>,
extra: List<JSONObject> = listOfNotNull(com.droiddeck.launcher.stores.epic.EpicClient.steamListing(context))): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
val json = StringBuilder("[")
games.forEachIndexed { i, g ->
@@ -399,6 +460,10 @@ object AddedGames {
if (pieces.isNotEmpty()) json.append(",\"art\":{").append(pieces.joinToString(",") { (k, v) -> quote(k) + ":" + quote(v) }).append('}')
json.append('}')
}
extra.forEachIndexed { i, entry ->
if (games.isNotEmpty() || i > 0) json.append(',')
json.append(entry.toString())
}
file.writeText(json.append(']').toString())
return file
}
@@ -26,6 +26,7 @@ object AddedGamesPublisher {
fun republish(context: Context) {
val games = AddedGames.scan(context)
AddedGames.writeListing(context, games)
LibraryCache.save(context, Library.launchableGames(context, added = games))
val arch = com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(context)
LibraryCache.save(context, Library.launchableGames(context, added = games, arch = arch), arch == "x86_64")
}
}
@@ -332,7 +332,7 @@ object AutoComponents {
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
private fun constrained(context: Context): Boolean {
fun constrained(context: Context): Boolean {
val power = context.getSystemService(PowerManager::class.java)
if (power?.isPowerSaveMode == true) return true
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.graphics.BitmapFactory
import android.util.Log
import com.droiddeck.launcher.core.DownloadSource
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -199,24 +200,62 @@ object CoverArt {
setRequestProperty("User-Agent", "DroidDeck")
}
private fun exists(url: String): Boolean = try {
val c = open(url, "HEAD"); val ok = c.responseCode == 200; c.disconnect(); ok
} catch (e: Exception) { false }
private fun <T> through(url: String, attempt: (String, Boolean) -> T?): T? {
for (source in DownloadSource.candidates(url)) {
val result = attempt(source, source == url)
if (result != null) {
DownloadSource.succeeded(source)
return result
}
}
DownloadSource.failed()
return null
}
private fun get(url: String): String? = try {
private fun exists(url: String): Boolean = through(url) { source, original -> head(source, original) } ?: false
private fun head(url: String, original: Boolean): Boolean? = try {
val c = open(url, "HEAD")
val code = c.responseCode
c.disconnect()
when {
code == 200 -> true
code == 404 || code == 410 -> if (original) false else null
else -> null
}
} catch (e: Exception) { null }
private fun get(url: String): String? = through(url) { source, original -> text(source, original) }
private fun text(url: String, original: Boolean): String? = try {
val c = open(url)
if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() }
val code = c.responseCode
when {
code == 200 -> c.inputStream.bufferedReader().use { it.readText() }.takeIf { it.isNotEmpty() }
code == 404 || code == 410 -> { c.disconnect(); if (original) "" else null }
else -> { c.disconnect(); null }
}
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
private fun download(url: String, target: File): Boolean = try {
private fun download(url: String, target: File): Boolean = through(url) { source, original -> fetch(source, target, original) } ?: false
private fun fetch(url: String, target: File, original: Boolean): Boolean? = try {
val c = open(url)
if (c.responseCode != 200) { c.disconnect(); false } else {
target.parentFile?.mkdirs()
val tmp = File(target.path + ".part")
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(tmp.path, bounds)
if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false }
when (val code = c.responseCode) {
200 -> {
target.parentFile?.mkdirs()
val tmp = File(target.path + ".part")
c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } }
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(tmp.path, bounds)
when {
bounds.outWidth > 0 && tmp.renameTo(target) -> true
original -> { tmp.delete(); false }
else -> { tmp.delete(); null }
}
}
404, 410 -> { c.disconnect(); if (original) false else null }
else -> { c.disconnect(); null }
}
} catch (e: Exception) { Log.w(TAG, "$url: $e"); false }
} catch (e: Exception) { Log.w(TAG, "$url: $e"); null }
}
@@ -38,7 +38,7 @@ object GameFileSync {
@Synchronized private fun sync(context: Context, folder: File) {
check(LinuxRuntime.isInstalled(context)) { context.getString(R.string.game_sync_err_runtime) }
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
check(File(SessionPrefs.activeSteamRoot(context), "steamapps").isDirectory) { context.getString(R.string.game_sync_err_steam_library) }
val storage = GameStorage.effective(context)
val storageSetting = SessionPrefs.gameStorage(context)
val previousStorage = prefs(context).getString("storagePath", null)
@@ -14,6 +14,7 @@ import java.io.InputStream
object GameFiles {
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val MAX_NAME_BYTES = 200
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -50,8 +51,15 @@ object GameFiles {
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
.trim().trim('.').ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
val points = sanitized.codePoints().toArray()
var end = 0
var bytes = 0
while (end < points.size) {
bytes += String(points, end, 1).toByteArray().size
if (bytes > MAX_NAME_BYTES) break
end++
}
val title = String(points, 0, end).trimEnd { it.isWhitespace() || it == '.' }.ifBlank { "Game" }
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
@@ -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()
}
@@ -25,7 +25,7 @@ object GamesFolderMigration {
var n = 0
val steamInstalls = AddedGames.steamInstallDirs(root.host)
for (folder in root.host.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() }.orEmpty()) {
if (folder.name.equals("steamapps", ignoreCase = true) || folder.name.lowercase() in steamInstalls) continue
if (AddedGames.isSteamLibraryFolder(folder.name) || folder.name.lowercase() in steamInstalls) continue
if (folder.absolutePath in storeRoots || StoreGameSidecar.read(folder) != null) continue
if (folder.path in entries) continue
val picked = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
@@ -17,10 +17,15 @@ object Library {
val source: String = SOURCE_STEAM,
val storeId: String? = null,
) {
fun withArt(art: File?, hero: File?, icon: File?) =
SteamGame(appId, name, art, library, gameId, hero, lastPlayed, gameFiles, protonPrefix, icon, source, storeId)
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
val profileKey: String get() = if (library == ADDED) java.lang.Integer.toUnsignedString(appId) else appId.toString()
}
const val ADDED = "added"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
@@ -67,13 +72,17 @@ 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)
private fun steamLibraries(context: Context): List<Pair<File, String>> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
val root = SessionPrefs.steamRoot(context, arch)
// The session gives the x86-64 client only its card folder; the games folders stay the arm64 client's.
val card = GameStorage.effective(context)
if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label })
return listOfNotNull(
root to "internal",
GameStorage.effective(context)?.let { File(it.path) to it.label },
card?.let { File(it.path) to it.label },
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
}
@@ -93,10 +102,10 @@ object Library {
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
fun steamGames(context: Context, strictRead: Boolean = false, arch: String = SessionPrefs.activeSteamClientArch(context)): List<SteamGame> {
val root = SessionPrefs.steamRoot(context, arch)
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val libraries = steamLibraries(context, arch)
val roots = libraries.map { it.first }
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
@@ -116,31 +125,76 @@ object Library {
val installDir = INSTALL_DIR.find(text)?.groupValues?.get(1)?.trim()
?.takeIf { it.isNotEmpty() && it != "." && it != ".." && '/' !in it && '\\' !in it }
val gameFiles = installDir?.let { File(steamapps, "common/$it").takeIf(File::isDirectory) }
out[appId] = SteamGame(
out[appId] = SteamGameArt.withOverrides(context, SteamGame(
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library, roots),
icon = steamCacheImage(cache, appId, listOf("icon.jpg", "icon.png"))
?: File(cache, appId.toString()).listFiles()?.firstOrNull { it.isFile && STEAM_ICON.matches(it.name) },
)
))
}
}
return out.values.toList()
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/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 }
}
}
fun launchableGames(context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null): List<SteamGame> {
val roots = steamLibraries(context).map { it.first }
return (steamGames(context, strictRead) + (added ?: AddedGames.scan(context)).map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
/** 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(
context: Context, strictRead: Boolean = false, added: List<AddedGames.Game>? = null,
arch: String = SessionPrefs.activeSteamClientArch(context),
): List<SteamGame> {
val roots = steamLibraries(context, arch).map { it.first }
val list = added ?: AddedGames.scan(context)
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)
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(
@@ -152,7 +206,7 @@ object Library {
)
}
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
internal fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
val dirs = listOf(appDir) + appDir.listFiles()
.orEmpty().filter { it.isDirectory }.sortedBy { it.name }
@@ -185,7 +239,8 @@ object Library {
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER ||
program == com.droiddeck.launcher.runtime.UserApps.SCRIPT_LAUNCHER) {
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
} else specs.firstOrNull { it.program == program }?.name
} else com.droiddeck.launcher.session.NoSteamLaunch.nameFor(program, com.droiddeck.launcher.session.SessionState.programArgs)
?: specs.firstOrNull { it.program == program }?.name
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
@@ -10,10 +10,14 @@ object LibraryCache {
private const val TAG = "LibraryCache"
private fun file(context: Context) = File(context.filesDir, "library-cache.json")
fun load(context: Context): List<Library.SteamGame> = runCatching {
/** The cached games and whether they are the x86-64 client's; a cache written as a bare list is arm64's. */
@Synchronized
fun snapshot(context: Context): Pair<List<Library.SteamGame>, Boolean> = runCatching {
val f = file(context)
if (!f.isFile) return emptyList()
val array = JSONArray(f.readText())
if (!f.isFile) return Pair(emptyList(), false)
val text = f.readText().trim()
val (array, x64) = if (text.startsWith("{")) JSONObject(text).let { it.getJSONArray("games") to (it.optString("client") == "x86_64") }
else JSONArray(text) to false
List(array.length()) { i ->
val o = array.getJSONObject(i)
Library.SteamGame(
@@ -24,11 +28,18 @@ object LibraryCache {
source = o.optString("source").ifEmpty { if (o.getString("library") == Library.ADDED) Library.ADDED else Library.SOURCE_STEAM },
storeId = o.optString("storeId").ifEmpty { null },
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
} to x64
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); Pair(emptyList(), false) }
fun save(context: Context, games: List<Library.SteamGame>) {
fun load(context: Context): List<Library.SteamGame> = snapshot(context).first
fun listedX64(context: Context): Boolean = snapshot(context).second
/** Without [x64] the list keeps the client already recorded. */
@Synchronized
fun save(context: Context, games: List<Library.SteamGame>, x64: Boolean? = null) {
runCatching {
val client = x64 ?: listedX64(context)
val array = JSONArray()
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
@@ -38,8 +49,12 @@ object LibraryCache {
)
val f = file(context)
val tmp = File(f.path + ".tmp")
tmp.writeText(array.toString())
tmp.renameTo(f)
tmp.writeText(JSONObject().put("client", if (client) "x86_64" else "arm64").put("games", array).toString())
if (!tmp.renameTo(f)) {
tmp.delete()
f.delete()
Log.w(TAG, "not saved: rename failed; cache dropped so the next start scans")
}
}.onFailure { e -> Log.w(TAG, "not saved: ${e.message}") }
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.frontend
import org.json.JSONObject
/** Where Steam pins a game's logo on its hero image and how big it draws it; the same JSON Steam keeps in `<appid>.json`. */
object LogoPosition {
enum class Pin(val steam: String) {
UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter");
companion object {
fun of(steam: String): Pin? = values().firstOrNull { it.steam == steam }
}
}
data class Value(val pin: Pin, val widthPct: Int, val heightPct: Int)
const val MIN_PCT = 10
const val MAX_PCT = 100
/** What Steam draws when a game has no custom position. */
val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50)
private const val VERSION = 1
private const val KEY = "logoPosition"
fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) }
/** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */
fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into
.put("nVersion", VERSION)
.put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct))
fun parse(text: String): Value? = runCatching { parse(JSONObject(text)) }.getOrNull()
fun parse(json: JSONObject): Value? {
val inner = json.optJSONObject(KEY) ?: return null
val pin = Pin.of(inner.optString("pinnedPosition")) ?: return null
if (!inner.has("nWidthPct") || !inner.has("nHeightPct")) return null
return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT))
}
/** [existing] without the logo position; null when nothing but the version number would be left. */
fun without(existing: JSONObject): JSONObject? {
existing.remove(KEY)
val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList()
return if (rest.isEmpty()) null else existing
}
}
@@ -25,9 +25,16 @@ object SessionInstalls {
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
}.getOrNull()
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
internal fun steamappsDirs(context: Context): List<File> = (
if (com.droiddeck.launcher.session.SessionPrefs.activeSteamClientArch(context) == "x86_64")
listOfNotNull(
File(com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context), "steamapps"),
GameStorage.x64CardLibrary(context)?.let { File(it, "steamapps") },
)
else listOfNotNull(
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
)
).filter { it.isDirectory }
internal class Watch(private val dirs: () -> List<File>) {
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.AtomicFile
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONArray
import org.json.JSONObject
import java.io.DataInputStream
@@ -20,7 +19,7 @@ object SteamAppInfo {
class Info(val name: String, val sharedDepots: List<String>)
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
fun appinfoFile(context: Context) = File(com.droiddeck.launcher.session.SessionPrefs.activeSteamRoot(context), "appcache/appinfo.vdf")
@Synchronized
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
@@ -154,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
@@ -0,0 +1,99 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.stores.SteamLiveShortcuts
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
object SteamGameArt {
internal fun dir(context: Context, appId: Int) = File(context.filesDir, "steam-art/$appId")
private fun sourceFile(context: Context, appId: Int, slot: AddedGameArt.Slot) = File(dir(context, appId), "source-${slot.key}")
internal fun setSource(context: Context, appId: Int, slot: AddedGameArt.Slot, value: String) {
val file = sourceFile(context, appId, slot)
if (value.isEmpty()) file.delete() else file.apply { parentFile?.mkdirs() }.writeText(value)
}
fun chosenFile(context: Context, appId: Int, slot: AddedGameArt.Slot): File? = AddedGameArt.chosen(dir(context, appId), slot)
fun withOverrides(context: Context, game: Library.SteamGame): Library.SteamGame {
if (game.library == Library.ADDED || !dir(context, game.appId).isDirectory) return game
val cover = chosenFile(context, game.appId, AddedGameArt.Slot.COVER)
val hero = chosenFile(context, game.appId, AddedGameArt.Slot.BACKGROUND)
val icon = chosenFile(context, game.appId, AddedGameArt.Slot.ICON)
if (cover == null && hero == null && icon == null) return game
return Library.SteamGame(
game.appId, game.name, cover ?: game.art, game.library, game.gameId,
hero ?: game.hero, game.lastPlayed, game.gameFiles, game.protonPrefix, icon ?: game.icon, game.source, game.storeId,
)
}
fun chosenSource(context: Context, appId: Int, slot: AddedGameArt.Slot): Pair<String, String> {
val value = sourceFile(context, appId, slot).takeIf { it.isFile }?.readText()?.trim().orEmpty()
return value.substringBefore('|') to value.substringAfter('|', "")
}
fun describe(context: Context, game: Library.SteamGame): List<AddedGameArt.SlotState> {
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
val own = withOverrides(context, game)
return AddedGameArt.Slot.values().map { slot ->
chosenFile(context, game.appId, slot)?.let {
return@map AddedGameArt.SlotState(slot, it, chosenSource(context, game.appId, slot).first.ifEmpty { AddedGameArt.FILE }, true)
}
val auto = when (slot) {
AddedGameArt.Slot.COVER -> own.art
AddedGameArt.Slot.WIDE -> Library.steamCacheImage(cache, game.appId, listOf("library_header.jpg", "header.jpg"))
AddedGameArt.Slot.BACKGROUND -> own.hero
AddedGameArt.Slot.ICON -> own.icon
AddedGameArt.Slot.LOGO -> Library.steamCacheImage(cache, game.appId, listOf("logo.png"))
}
AddedGameArt.SlotState(slot, auto, if (auto != null) AddedGameArt.STEAM else AddedGameArt.AUTO, false)
}
}
fun choices(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot): AddedGameArt.Choices {
val out = ArrayList<AddedGameArt.Option>()
AddedGameArt.steamOption(game.appId.toString(), slot)?.let { out.add(it) }
val (found, status) = AddedGameArt.sgdbChoices(context, File(dir(context, game.appId), "sgdb-id"), game.appId, game.name, slot)
out.addAll(found)
return AddedGameArt.Choices(out, status)
}
fun choose(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, option: AddedGameArt.Option): Boolean {
return synchronized(SteamGrid.lock) {
val folder = dir(context, game.appId)
val fresh = AddedGameArt.storeNew(folder, slot, option) ?: return@synchronized false
if (!push(context, game, slot, fresh)) {
fresh.delete()
return@synchronized false
}
AddedGameArt.dropOlder(folder, slot, fresh)
setSource(context, game.appId, slot, option.group + "|" + option.full)
true
}
}
fun reset(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot) {
synchronized(SteamGrid.lock) {
if (!push(context, game, slot, null)) return@synchronized
AddedGameArt.chosen(dir(context, game.appId), slot)?.delete()
setSource(context, game.appId, slot, "")
}
}
fun position(context: Context, game: Library.SteamGame): LogoPosition.Value? = SteamGrid.position(SteamGrid.target(game, context))
fun setPosition(context: Context, game: Library.SteamGame, value: LogoPosition.Value?): Boolean {
if (!SteamGrid.writePosition(context, SteamGrid.target(game, context), value)) return false
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setLogoPosition(context, game.appId.toLong(), value) }
return true
}
private fun push(context: Context, game: Library.SteamGame, slot: AddedGameArt.Slot, file: File?): Boolean {
val type = slot.assetType ?: return true
if (!SteamGrid.write(context, SteamGrid.target(game, context), slot, file)) return false
AddedGamesPublisher.later(context) { SteamLiveShortcuts.setArt(context, game.appId.toLong(), type, file) }
return true
}
}
@@ -0,0 +1,209 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.session.SessionPrefs
import org.json.JSONObject
import java.io.File
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
object SteamGrid {
private const val TAG = "SteamGrid"
/** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
internal val lock = Any()
private const val MANIFEST = ".droiddeck-art.json"
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
private val EXTS = listOf("png", "jpg", "jpeg", "webp")
class Target(val artDir: File, val id: Long, val setSource: (AddedGameArt.Slot, String) -> Unit)
fun target(game: Library.SteamGame, context: Context) = Target(SteamGameArt.dir(context, game.appId), game.appId.toLong()) { slot, value ->
SteamGameArt.setSource(context, game.appId, slot, value)
}
fun target(game: AddedGames.Game, context: Context) = Target(AddedGameArt.cacheDir(context, game), game.appId and 0xFFFFFFFFL) { slot, value ->
SessionPrefs.setAddedGameArtSource(context, game.folder.path, slot.key, value)
}
internal fun dir(context: Context): File? {
return AddedGames.accountConfig(SessionPrefs.activeSteamRoot(context))?.let { File(it, "grid") }
}
private fun files(grid: File, id: Long, slot: AddedGameArt.Slot): List<File> =
EXTS.map { File(grid, "$id${SUFFIX[slot]}.$it") }.filter { it.isFile }
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
/** Each Steam client has its own grid folder, so what was last seen there is kept per client. */
private fun sigName(context: Context, key: String) =
if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key"
private fun sigFile(context: Context, target: Target, slot: AddedGameArt.Slot) = File(target.artDir, sigName(context, slot.key))
private fun recorded(context: Context, target: Target, slot: AddedGameArt.Slot): String? =
sigFile(context, target, slot).takeIf { it.isFile }?.readText()
private fun record(context: Context, target: Target, slot: AddedGameArt.Slot, value: String) {
target.artDir.mkdirs()
sigFile(context, target, slot).writeText(value)
}
private fun writtenByDroidDeck(grid: File): Set<String> = runCatching {
val manifest = JSONObject(File(grid, MANIFEST).readText())
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
}.getOrDefault(emptySet())
/** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
val suffix = SUFFIX[slot] ?: return true
val grid = dir(context) ?: return true
if (file == null) {
if (files(grid, target.id, slot).map { it.delete() }.any { !it }) return false
record(context, target, slot, "")
return true
}
val ext = file.extension.lowercase().let { if (it == "jpeg") "jpg" else it }
if (ext !in EXTS) return false
grid.mkdirs()
val dst = File(grid, "${target.id}$suffix.$ext")
val tmp = File(grid, dst.name + ".tmp")
val placed = runCatching { file.copyTo(tmp, overwrite = true); if (tmp.renameTo(dst)) dst else null }.getOrNull()
if (placed == null) {
tmp.delete()
return false
}
if (files(grid, target.id, slot).filter { it != dst }.map { it.delete() }.any { !it }) return false
record(context, target, slot, signature(placed))
return true
}
private const val POSITION = "logo-position"
private const val POSITION_SIG = "json"
private fun positionFile(grid: File, id: Long) = File(grid, "$id.json")
private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse)
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
private fun positionRecorded(context: Context, target: Target): String? =
File(target.artDir, sigName(context, POSITION_SIG)).takeIf { it.isFile }?.readText()
private fun recordPosition(context: Context, target: Target, value: String) {
target.artDir.mkdirs()
File(target.artDir, sigName(context, POSITION_SIG)).writeText(value)
}
private fun storePosition(target: Target, value: LogoPosition.Value?): Boolean {
val file = File(target.artDir, POSITION)
if (value == null) return !file.exists() || file.delete()
target.artDir.mkdirs()
return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess
}
/** Sets (or with null, clears) a game's logo position here and in the client's grid folder, whether or not Steam is running. */
fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) {
placePosition(context, target, value) && storePosition(target, value)
}
/** Steam keeps the position in `<id>.json`; anything else that file holds is left as it was. */
private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean {
val grid = dir(context) ?: return true
val file = positionFile(grid, target.id)
val existing = if (file.isFile) runCatching { JSONObject(file.readText()) }.getOrNull() else null
if (file.isFile && existing == null) return value == null
val next = if (value == null) existing?.let(LogoPosition::without) else LogoPosition.toJson(value, existing ?: JSONObject())
if (next == null) {
if (file.isFile && !file.delete()) return false
recordPosition(context, target, "")
return true
}
grid.mkdirs()
val tmp = File(grid, file.name + ".tmp")
val placed = runCatching { tmp.writeText(next.toString()); tmp.renameTo(file) }.getOrDefault(false)
if (!placed) {
tmp.delete()
return false
}
recordPosition(context, target, signature(file))
return true
}
/** A `logoPosition` this version cannot read (a pin it does not know, say) is left alone rather than read as "removed". */
private fun hasUnknownPosition(file: File): Boolean {
val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false
return json.has("logoPosition") && LogoPosition.parse(json) == null
}
private fun reconcilePosition(context: Context, grid: File, target: Target) {
val file = positionFile(grid, target.id)
if (hasUnknownPosition(file)) return
val theirs = positionIn(file)
val now = if (theirs != null) signature(file) else ""
val before = positionRecorded(context, target)
val own = position(target)
when {
before == null && own != null && theirs == null -> placePosition(context, target, own)
before == null || now != before -> {
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
if (theirs != null) {
if (own == theirs || storePosition(target, theirs)) recordPosition(context, target, now)
} else {
if (own == null || storePosition(target, null)) recordPosition(context, target, "")
}
}
own != null && own != theirs -> placePosition(context, target, own)
}
}
/** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) }
private fun reconcile(context: Context, targets: List<Target>): Boolean {
val grid = dir(context) ?: return false
val ours = writtenByDroidDeck(grid)
var changed = false
for (target in targets) for (slot in SUFFIX.keys) {
try {
val own = AddedGameArt.chosen(target.artDir, slot)
val present = files(grid, target.id, slot)
val theirs = present.filter { it.name !in ours }.maxByOrNull { it.lastModified() }
val now = signature(theirs)
val before = recorded(context, target, slot)
when {
before == null && own != null && theirs == null -> write(context, target, slot, own)
before == null || now != before -> {
if (theirs != null) {
if (own != null && own.length() == theirs.length() && own.readBytes().contentEquals(theirs.readBytes())) record(context, target, slot, now)
else if (AddedGameArt.store(target.artDir, slot, AddedGameArt.Option(AddedGameArt.STEAM, theirs.path, theirs.path))) {
target.setSource(slot, AddedGameArt.STEAM + "|steam-client")
record(context, target, slot, now)
changed = true
}
} else {
if (own != null && present.isEmpty()) { own.delete(); target.setSource(slot, ""); changed = true }
record(context, target, slot, "")
}
}
own != null && theirs != null && now == before && own.length() != theirs.length() -> write(context, target, slot, own)
own != null && present.isEmpty() -> write(context, target, slot, own)
}
} catch (e: Exception) {
Log.w(TAG, "${target.id} ${slot.key}: ${e.javaClass.simpleName}")
}
}
for (target in targets) {
try {
reconcilePosition(context, grid, target)
} catch (e: Exception) {
Log.w(TAG, "${target.id} logo position: ${e.javaClass.simpleName}")
}
}
if (changed) Log.i(TAG, "took over art changed in Steam")
return changed
}
}
@@ -118,23 +118,42 @@ object SteamGridDb {
.filter { it.optInt("id") > 0 && it.optString("name").isNotEmpty() }
.map { Game(it.optInt("id"), it.optString("name"), it.optBoolean("verified")) }
internal fun path(id: Int, slot: AddedGameArt.Slot, page: Int): String {
val base = when (slot) {
internal fun path(id: Int, slot: AddedGameArt.Slot): String =
when (slot) {
AddedGameArt.Slot.COVER -> "grids/game/$id?dimensions=600x900&$QUERY"
AddedGameArt.Slot.WIDE -> "grids/game/$id?dimensions=460x215,920x430&$QUERY"
AddedGameArt.Slot.BACKGROUND -> "heroes/game/$id?$QUERY"
AddedGameArt.Slot.LOGO -> "logos/game/$id?$QUERY"
AddedGameArt.Slot.ICON -> "icons/game/$id?$QUERY"
}
return if (page > 0) "$base&page=$page" else base
}
fun page(key: String, id: Int, slot: AddedGameArt.Slot, page: Int): List<Pair<String, String>> {
val items = json(key, "$API/${path(id, slot, page)}")?.optJSONArray("data") ?: return emptyList()
fun page(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: return emptyList()
return ranked(items, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
}
private const val PAGE_SIZE = 50
private const val MAX_PAGES = 4
fun all(key: String, id: Int, slot: AddedGameArt.Slot): List<Pair<String, String>> {
val raw = JSONArray()
for (n in 0 until MAX_PAGES) {
val url = "$API/${path(id, slot)}" + if (n > 0) "&page=$n" else ""
val data: JSONArray? = try {
json(key, url)?.optJSONArray("data")
} catch (e: Failed) {
if (n == 0) throw e
null
}
val items = data ?: break
for (i in 0 until items.length()) raw.put(items.get(i))
if (items.length() < PAGE_SIZE) break
}
return ranked(raw, pngOnly = slot == AddedGameArt.Slot.LOGO || slot == AddedGameArt.Slot.ICON)
}
fun images(key: String, id: Int, slot: AddedGameArt.Slot, limit: Int): List<Pair<String, String>> =
page(key, id, slot, 0).take(limit)
page(key, id, slot).take(limit)
internal fun ranked(items: JSONArray, pngOnly: Boolean): List<Pair<String, String>> =
(0 until items.length()).map { items.getJSONObject(it) }
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.glasses
import android.content.Context
import com.droiddeck.launcher.R
import kotlin.math.abs
internal data class CalibrationDistanceResult(val accepted: Boolean, val message: String)
internal object CalibrationDistanceCheck {
fun evaluate(context: Context, expected: Float, corners: Int, points: List<SpatialVector>?): CalibrationDistanceResult {
require(expected.isFinite() && expected in 0.15f..1.5f)
if (corners != 24) return CalibrationDistanceResult(false,
context.getString(R.string.spatial_distance_corners, corners))
if (points?.size != 24 || points.any { !it.finite() }) return CalibrationDistanceResult(false,
context.getString(R.string.spatial_distance_depth))
val measured=(points.reduce { a,b -> a+b }*(1f/points.size)).length()
if (!measured.isFinite()) return CalibrationDistanceResult(false,context.getString(R.string.spatial_distance_invalid))
val accepted=abs(measured-expected)<0.05f
return CalibrationDistanceResult(accepted,
context.getString(R.string.spatial_distance_result,
SpatialMeasurements.validation(measured), SpatialMeasurements.validation(expected),
context.getString(if (accepted) R.string.spatial_distance_passed else R.string.spatial_distance_retry)))
}
}
@@ -0,0 +1,27 @@
package com.droiddeck.launcher.glasses
import org.json.JSONObject
internal data class CalibrationObservationData(
val objects: List<DoubleArray>, val left: List<DoubleArray>, val right: List<DoubleArray>, val head: SpatialPose,
) {
companion object {
fun parse(view: JSONObject): CalibrationObservationData {
fun points(name: String, dimensions: Int): List<DoubleArray> {
val rows=view.getJSONArray(name)
require(rows.length() in 12..24) { "Invalid saved board corner count" }
return (0 until rows.length()).map { row ->
val values=rows.getJSONArray(row)
require(values.length()==dimensions)
DoubleArray(dimensions) { values.getDouble(it).also { value -> require(value.isFinite()) } }
}
}
val objects=points("objects",3); val left=points("left",2); val right=points("right",2)
require(objects.size==left.size && objects.size==right.size)
val pose=view.getJSONArray("head")
require(pose.length()==7)
val head=SpatialPose.fromSdk(FloatArray(7) { pose.getDouble(it).toFloat() }) ?: error("Invalid saved head pose")
return CalibrationObservationData(objects,left,right,head)
}
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 881 files, more files were not shown because too many files have changed in this diff. Show more