Compare commits

...
Author SHA1 Message Date
The412Banner 40461caf4a perf(storage): opt-in skip of Steam's space reservation on the second library
Steam reserves every file's full size before downloading. On a portable SD card
the app reaches through Android's FUSE layer, fallocate is rejected and the
reservation can take tens of minutes for a large game, with nothing downloaded.

A new experimental setting (Steam > storage, off by default) makes the session
preload answer plain fallocate/posix_fallocate requests on the selected library
with success and leave the file untouched, so Steam goes straight to the
download. Hole punching and other fallocate modes still pass through, as does
every other device. A full card shows up as a disk write error during the
download instead of up front. Storage diagnostics behave as before and log the
skipped calls.
2026-10-06 15:35:35 -04:00
Kurt Himebauch 1e877fa602 fix: give each new session the current screen's output size (#357) 2026-10-06 19:17:52 +00:00
Kurt Himebauch 906009a33a fix: share session logs in about a second instead of re-scrubbing them (#352) 2026-10-06 14:51:43 -04:00
The412BannerandThe412Banner 08559a02f8 Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them (#347)
* Windows components: install .msi packages (XNA, PhysX, GFWL, MSXML...) without running them

droiddeck-msi-install reads a Windows Installer package's tables with msiinfo and unpacks its
cabinets with cabextract, then lays out what Wine's msiexec would have installed: files at their
64-bit Windows paths (Program Files (x86), syswow64...), .NET assemblies in the GAC, side-by-side
assemblies in winsxs, and the registry values in the view the component writes through. Conditions,
features and the folder-setting custom actions are evaluated as on Windows 10 64-bit; what needs
the package's own code (DXSETUP, self-registration) is listed in the component's notes.

droiddeck-wincomponents puts a component's drive_c tree into the prefix (keeping the prefix's own
spelling of each folder) and writes its registry values into system.reg/user.reg before launch,
following Wine's link keys (Wow6432Node\Classes, CurrentControlSet...). Turning it off puts every
value back and takes an added PATH entry out again.

The app installs these from the Windows components menu like the DLL ones: it downloads the .msi
into the runtime and runs the installer there (GuestCommand). msitools 0.106 and cabextract 1.11
come from Arch Linux ARM, pinned in tools/msitools/packages.txt and hosted on the msitools-0.106-r1
release. wine-gecko stays hidden (Proton ships it) and wine-mono keeps waiting (uninstall step).

* Windows components: open with the top switch focused, by touch or by pad

The dialog's window starts in touch mode, where a switch cannot take focus, and the first switch
was asked once, two frames after the list loaded, only when a pad opened it - racing the dialog's
own Done focus. Now it always puts the window in pad mode, holds Done while the list loads and
then asks the top switch every frame until it has focus.

* Windows components: give msitools its own libraries; show a failed install on its row

msiinfo and cabextract sit with their libraries in /usr/local/lib/droiddeck-msitools, which the
loader does not search: every .msi install stopped at "libmsi-1.0.so.0: cannot open shared
object file". droiddeck-msi-install now runs them with that folder on LD_LIBRARY_PATH.

A failed install also says so under the component itself ("Could not install: ..."), not
only at the foot of the dialog, past the list.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 14:14:28 -04:00
Kurt Himebauch 1d93626453 feat: remove Force game windows fullscreen now that games run on their own Xwayland (#349) 2026-10-06 18:04:22 +00:00
Kurt Himebauch 9d0989374b feat: run Steam sessions on two Xwayland servers as SteamOS does (#344) 2026-10-06 17:56:13 +00:00
The412BannerandThe412Banner 3278afaa1a Fast path: hold every bind, so added games start in their own folder (#343)
* Fast path: hold every bind, so added games start in their own folder

libblfastpath kept the first 64 binds and dropped the rest without a word. A session has 87 (two
Games folders are the last two), so /root/Games/<folder>/... was mapped into the rootfs - the
empty mount point - instead of the bound storage. Wine resolves a new process's working directory
by those paths, found no drive for it and started every added game in C:\windows ("could not
find DOS drive for current working directory"): DiRT 3 did not mind, FEZ looks for
Content\Essentials.pak there and quits.

The library now holds 256, and turns itself off for the process when a bind does not fit or the
list runs past that, leaving the call to proot, which never maps it wrong. The app keeps the fast
path off for a session with more binds than that.

Verified on an AYANEO Pocket FIT: FEZ (added game) reaches its title screen; the warning is gone.

* Fast path: say in the session log when it stays off

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 13:48:01 -04:00
Kurt Himebauch bf221ffbbd fix: resolution presets keep the panel shape at 720p, 900p and 1080p (#337) 2026-10-06 13:19:46 -04:00
Kurt Himebauch c636eea355 fix: hold flood edges at the page edge once reached (#330) 2026-10-06 12:07:24 -04:00
Kurt Himebauch ffaa906cd1 fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl (#305)
* fix: avoid reusing retired KGSL GEM handles

* fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors

* Revert "fix: defer closing GEM_CLOSE'd KGSL dma-buf descriptors"

This reverts commit 191bf4b6f2.

* test: interpose drmIoctl without releasing GEM_CLOSE handles

* fix: catch KGSL GEM_CLOSE at ioctl instead of exporting drmIoctl
2026-10-06 11:52:16 -04:00
The412BannerandThe412Banner d61af961c1 Windows components for Steam and added games; no more redist installer prompts (#287)
* Put a game's Windows components into its Proton prefix at launch

droiddeck-wincomponents copies the DLLs of the components picked for a game
into compatdata/<appid>/pfx before Proton starts and hands their overrides to
WINEDLLOVERRIDES, so the registry is never touched. It re-checks every launch
(an older Proton deletes the files it once placed), takes a component's files
out again when it is turned off, and forgets Proton's config_info then so
Proton puts its own builtins back.

* Windows components for added games: picker, packages and their catalog

Settings > Games lists a Windows components row under each added game. Its
dialog switches components on per game; the first switch-on for any game
downloads the package (a catalog of its own beside desktop.json, installed
with the desktop package installer to /opt/droiddeck/wincomponents/<id>).
The picks are kept in the app and published to the runtime at every session
start for droiddeck-wincomponents to apply.

tools/wincomponents/package.py builds the packages: Bannerlator's eight
wincomponents archives re-rooted, and OpenAL from OpenAL Soft's release
(soft_oal.dll placed as OpenAL32.dll, 32- and 64-bit).

* Windows components: list the installed ones when the catalog cannot be reached

Offline the dialog only looked up ids it already knew - the catalog's and
the game's picks - so a component downloaded earlier never showed.

* Windows components: Bannerlator's list with Recommended, on the game's launch settings

The picker moves from Settings to a Windows components card in an added
game's launch settings. It offers Bannerlator's whole catalog
(components.json on winlator-contents): what the game's folder points at
first (Bannerlator's DependencyDetector, mapped to the DLL-copy twin where
there is one, oalinst -> oalinst_dll), then every component that installs
here, then the ones that need a Windows installer, greyed until DroidDeck can
run one.

A component installs by running its catalog steps into the runtime once:
archives downloaded (one download per archive for a batch), DLLs found by
name as Bannerlator does and copied to /opt/droiddeck/wincomponents/<name>,
overrides and bundled components written to component.json. The launch
script now takes catalog names (xaudio2.7), expands bundles (directmusic),
places files one level down (drivers/gm.dls) and keeps an override's own
mode (dsound=n). Drops the hand-made packages and their script.

* Windows components: Steam games too, and a pad that can drive the list

The card shows on a Steam title's launch settings as well, keyed by its
appid - the compatdata folder droiddeck-game-env reads - and Recommended
scans the game's install folder.

With a pad: Done takes focus while the catalog loads and the first
component once it is there (the list was not drawn yet when the dialog
asked for focus, so nothing had it); switches stay enabled during a
download and a press then is ignored (a disabled switch dropped the
pad's focus); LB/RB jump between Recommended, All, the installers and
Done.

* Redist markers from games' own install scripts; VC++ runtime as installed

The client still started a game's own _CommonRedist installer - its
script sits in the game's folder, often with another version in the
value name, and only Steamworks Shared's were read. On ARM64 Proton the
x64 VC++ 2015-2022 bundle then stops with "Microsoft Visual C++
2015-2022 Redistributable (arm64) is required" (reported on an AYN Odin 2
Portal Max).

- Read installscript*.vdf from every installed game in each library as
  well; the list is cached until an appmanifest changes, since this runs
  every quarter minute.
- Record the VC++ 2015-2022 runtime as installed where its installers
  look (VisualStudio\14.0\VC\Runtimes\{x86,x64,arm64}, both registry
  views), so any bundle - the client's or a game's own - finds it and
  exits; Proton's msvcp140/vcruntime140 serve the game.
- Join the Wow6432Node path instead of sub(): mawk and gawk read the
  replacement's backslashes differently (gawk, the runtime's, was right).

* Mark a game's install-script processes as run just before the client evaluates them

An Odin 3 bundle (0.3.1, Proton Experimental ARM64) shows the client
running its install-script evaluator on the first launch of three new
titles (409710, 2161700, 3404260) while their Steamworks Shared
redistributables (VC++ 2010/2012/2019/2022, DirectX) were still
downloading - 2161700 at 22:03:22, the shared app finished at 22:03:29 -
so the periodic seeder had nothing to mark yet and the installers ran.

The client writes the exact steps to legacycompat/evaluatorscript_<id>.vdf
and starts iscriptevaluator.exe through our launcher. droiddeck-game-env
now hands that script to droiddeck-seed-redists first, which marks every
process in it in the template and the prefixes before the evaluator
reads them.

The seeder's reader no longer assumes a depth: a block with a HasRunKey
is a process named by the key it opened under, wherever it sits.

* Windows components: keep a file that was not Proton's, put it back when turned off

A component's DLL replaced whatever had that name in the prefix, and
turning it off deleted ours, so a file a real installer (or the user) had
put there was gone for good. Before replacing a file that is neither
Proton's own (a symlink into Proton, or a Wine builtin/placeholder DLL)
nor an earlier copy of ours, keep it under
compatdata/<id>/.droiddeck-wincomponents-backup and move it back when the
component is turned off. The launch note says how many went back.

* Windows components: recommend what Steam installs, say what the game already has

- Steam titles: the appmanifest's SharedDepots (Steamworks Shared, app
  228980) list the VC++ 2005-2022 and DirectX redists Steam installs with
  the game; they live outside the game folder, so the folder scan never
  saw them. They join Recommended as "Steam installs VC++ 2010 with this
  game" (SteamRedists).
- Bannerlator's PrefixInstalledDetector, on compatdata/<id>/pfx:
  OpenAL32, Mono, PhysX and Games for Windows Live that are already there
  from elsewhere show "Already in this game". Files droiddeck-wincomponents
  placed itself do not count.
- The game page passes the prefix and, for Steam titles, the appid.

* Windows components: friendly names

The list showed catalog keys (oalinst_dll, vcredist2010_dll, dotnet452,
d3dcompiler_47). Bannerlator's catalog has no display name and some of
its descriptions do not fit the entry, so WinComponentNames gives one:
families by pattern (Visual C++ <year>, .NET Framework x.y[.z], XAudio,
D3DCompiler, MSXML, Wine Mono, DirectMusic parts), the rest from a
table, the key itself for anything new. The key stays in the row's
detail for logs and bug reports; both lists sort by the name, and the
game page's card names the picks the same way.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 10:46:27 -04:00
The412BannerandThe412Banner bc01b77752 Force SSBS for Proton games (libssbs.so), switch under FEX preset (#328)
* Force SSBS for a game's Wine: libssbs.so, opt in per game

Wine's arm64 signal code resumes threads from a Windows CONTEXT whose
Cpsr never carries PSTATE.SSBS, so every signal return leaves the
thread running with speculative store bypass disabled - slower on cores
such as Oryon. Bannerlator fixes it in its own Wine builds
(force_ssbs.diff, Sonicadvance1) and with an LD_PRELOAD adapter for
layers it cannot rebuild. DroidDeck's Protons (Valve, GE, CachyOS ARM64)
come prebuilt from Steam but run the same aarch64 Wine signal path, so
the adapter applies here: tools/linuxfs/ssbs/ssbs_adapter.c, the same
source built for glibc (+ sysv_signal), staged to
/usr/local/lib/libssbs.so.

droiddeck-game-env preloads it when the game's (or the shared)
environment sets DROIDDECK_FORCE_SSBS=1, and only into an ARM64 Proton.
Off unless asked for: the gain is unproven on Snapdragon 8 Gen 3.

For testing: SSBS_ADAPTER_OBSERVE=1 counts without changing anything
(the baseline) and SSBS_ADAPTER_DEBUG=1 logs the fixes; both also count
signals that ARRIVE while a thread runs without SSBS, which shows
whether anything besides signal returns clears it. Checked on an ARM64
Linux kernel: a handler that clears SSBS left 4 of 5 later signals
arriving with it clear; with the adapter, 0 of 5.

* Force SSBS: a switch under the FEX preset in Steam settings

Steam settings > Games gets "Force SSBS" right under the FEX preset, off
unless turned on. Like the preset it goes into the game environment's
defaults (DROIDDECK_FORCE_SSBS=1), published at once and read by
droiddeck-game-env on the next game launch, so a game's own environment
can still set it to 0.

Measured on the device with the adapter's counters (AYANEO Pocket FIT,
DiRT 3 on GE-Proton11-7-aarch64): observing only, 8192 of 8277 signals
found the thread running without SSBS; with the adapter, none did - it
keeps the bit set everywhere Wine clears it, as a rebuilt ntdll would.
The speed-up itself is reported on Oryon cores and was not measurable on
the FIT's 8 Gen 3, hence off by default.

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-06 09:27:15 -04:00
UnbelievableFlavour a1b01a04a8 Added support for Adreno 6xx devices (#320) 2026-10-06 09:08:04 -04:00
Kurt Himebauch b44235c495 Add Native Steam background sleep and automatic wake (#291) 2026-10-06 00:15:39 -04:00
Kurt Himebauch b80d5db176 ci: require isolated version bumps (#303) 2026-10-06 03:26:14 +00:00
Kurt Himebauch d3c0d258b7 Return frontend-launched sessions to Android Home (#300) 2026-10-05 21:39:40 -04:00
xXJsonDeruloXx 9dcdbf245a Merge remote-tracking branch 'origin/main' into frontend-quit-return 2026-10-05 21:34:49 -04:00
xXJsonDeruloXx 369e218afb Return frontend-launched sessions to Android Home 2026-10-05 21:34:36 -04:00
Magarto 119d24bf6b Keep the 16:9 stretch (#63) working in Steam Deck mode (#133) 2026-10-05 21:28:31 -04:00
The412BannerandThe412Banner 2a905510d5 Device details on the GPU card; Adreno model and SoC name when the kernel leaves them out (#295)
* Device details on the GPU card, and the Adreno model when the kernel leaves it out

The Components page called an AYANEO Pocket FIT (Adreno 750) "Adreno"
and warned it was "Below tested hardware (Adreno 725 and newer)": its
kernel names the GPU "Adreno33v2", GpuInfo wanted three digits, and the
SoC model is blank. When KGSL's name has no model, take it from the
phone's Vulkan driver (once this process has asked it) or from the
platform code name (pineapple -> 750, kalama -> 740, sun -> 830, ...).

The GPU card now opens with a tap or A onto the device's details,
gathered once off the main thread (DeviceInfo): SoC (maker, model,
platform, soc0 id), GPU (model and where it came from, the kernel's
name, clock, load, temperature, support), CPU clusters, memory, system,
the phone's own Vulkan driver - device, API, driver version and build,
conformance, limits, the extensions DXVK and VKD3D lean on, Android's
certified version and level - and the drivers DroidDeck uses. A button
copies it all for a bug report; each section takes pad focus so the
d-pad walks the list.

Vulkan is asked through a new small native library (deviceinfo,
vkinfo.c) that opens the system loader with dlopen and keeps nothing
open.

* Device details: the chip's model and name, CPU clusters, GPU clock range

Ideas from ClusterTune (GPL-2.0, its approach, not its code), which
tunes clocks on AYN, Retroid and other handhelds:
- The SoC model is read where vendors put it - ro.soc.model,
  ro.vendor.qti.soc_model, Android's SOC_MODEL, ro.fota.platform (unless
  it is a branch name like "MSM_13.0") - since handhelds often leave
  Android's blank (Pocket FIT: SG8350P).
- SocNames names it: model codes we are sure of (QCS8550 -> Snapdragon
  8 Gen 2, SM8750 -> Snapdragon 8 Elite, ...), else the platform as a
  family (pineapple -> "Snapdragon 8 Gen 3 family"). The GPU card's
  subtitle shows it: "Snapdragon 8 Gen 3 family (SG8350P)".
- CPU clusters come from the cpufreq policies (the cores each covers,
  min-max speed) rather than grouping cores by top speed.
- GPU clock range from KGSL's step list, or a devfreq GPU node on
  non-Adreno chips (Mali), not only the top clock.

* Device details: drop Qualcomm's own "Driver Build:" label from the driver build line

---------

Co-authored-by: The412Banner <[email protected]>
2026-10-05 20:32:51 -04:00
Kurt Himebauch da29919b5a Fix legacy Steam sleep and missed wake handshakes (#289)
Fix legacy Steam sleep and recover missed wake handshakes
2026-10-05 16:49:55 -04:00
輕鈴andKurt Himebauch 05608ac4d4 Refine Simplified Chinese localization (#232)
Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:58:59 -04:00
starwishHiandKurt Himebauch 135b84a501 Add French and Russian translate, add fr,ru,jp,ko in locales_config.xml And overlap #234 and #259 (#286)
* Create values-zh-rCN

values language of Simplified Chinese

* Delete app/src/main/res/values-zh-rCN

* Add Korean,Japanese and Tranitional Chinese (zh-TW,zh-HK,zh-MO) translations

* Delete app/src/main/res/values/values-ko/strings.xml

* Create strings.xml

Add korean translate

* Add French translate

* Add Russian translate

* Fix French translate error

* Add the fr,ko,ja,ru in locales_config.xml
Fix French bulid error

* Overlap #234 and #259

---------

Co-authored-by: Kurt Himebauch <[email protected]>
2026-10-05 15:52:39 -04:00
MaxsTechReview e7cbba37d1 sign-apk: rename the agent provider's authority for the other packages (#285) 2026-10-05 14:41:38 -04:00
Kurt Himebauch d9bb5e15b0 Use plain-ID .steam files for frontend exports (#277) 2026-10-05 13:48:18 -04:00
The412BannerandThe412Banner 46cdc32cf1 Firefox: skip shared-memory seals (tabs crash on Android 16) (#280)
On Android 16 phones (Galaxy S25 SM-S931B, SM-S948B, RedMagic NP05J)
every Firefox and LibreWolf tab dies at once: Firefox seals the size of
the shared memory it hands a tab, adding the seal fails there, Firefox
only warns, and the tab refuses the unsealed memory ("IPDL protocol
error: Shared memory PlatformHandle is not safe to map") and exits on
signal 11. The window opens; no page ever loads.

MOZ_SHM_NO_SEALS=1 is Firefox's own switch to skip the seals
(ipc/glue/SharedMemoryPlatform_posix.cpp). Set it beside the sandbox
switches in all three places a browser starts from: the desktop session,
AppImages, and Flatpak sandboxes.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 13:37:08 -04:00
Lobanokivan11 387572e733 merge bridge to main (second time) (#279) 2026-10-05 17:09:24 +00:00
The412BannerandThe412Banner 9f46c7a50b Force game windows fullscreen: off by default (#276)
A game whose resolution differs from the screen's (DiRT 3 at 1280x720)
fights --force-windows-fullscreen: every forced resize makes it rebuild
its swapchain, and the picture flickers between its own size and the
screen's - often from the first menu, before the player can reach the
game's resolution setting. It also drove the Xwayland dma-buf leak fixed
separately.

The switch stays in Settings > Games and the session drawer, now off
unless turned on; its hint says when to turn it on (a game coming back
small or in a corner after switching apps, e.g. FlatOut) and when to
leave it off (flicker). Users who already set it keep their choice. The
session script's fallback when the app passes nothing is off as well.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:45 -04:00
The412BannerandThe412Banner 0607bb0b3a Fix Xwayland dma-buf leak: close Zink's emulated GEM handles on KGSL (#275)
Close Zink's emulated GEM handles on KGSL (Xwayland dma-buf leak)

The session preload hands out GEM handles on the KGSL render node by
duplicating the dma-buf descriptor, and released them only through
drmCloseBufferHandle. Zink gives handles back with
drmIoctl(DRM_IOCTL_GEM_CLOSE) instead, which KGSL refuses, so every
duplicated descriptor stayed open and kept its buffer alive.

Xwayland asks for a handle for each buffer it imports through gbm. With
"Force game windows fullscreen" fighting a game's own resolution
(DiRT 3 at 1280x720), the game recreates its swapchain many times a
second and Xwayland piled up ~2,000 old swapchain images (6+ GB) in
seconds until Android killed the session. A preload trace on the device
put 2,090 of 2,118 open dma-buf descriptors in Xwayland on
drmPrimeFDToHandle called from Zink.

Handle GEM_CLOSE on the KGSL node in drmIoctl, and check the three DRM
symbols are exported in CI.

Co-authored-by: The412Banner <[email protected]>
2026-10-05 12:44:16 -04:00
Kurt Himebauch b89d87cbde Add app UI scale presets to Setup (#272) 2026-10-05 11:25:51 -04:00
Kurt Himebauch 06e06b9673 Fix MangoHud double-counting battery power and remaining life (#270) 2026-10-05 15:07:40 +00:00
Kurt Himebauch b896e7a3b9 Compact Steam, Desktop, and Setup settings headers (#268) 2026-10-05 10:34:31 -04:00
Kurt Himebauch c91daa9147 Keep the game undimmed while the session menu is open (#267)
Remove background dimming from the session menu
2026-10-05 09:59:35 -04:00
Kurt Himebauch 31ea194f3d Prepare 0.3.1 release (#263)
* docs: draft 0.3.1 release notes

* chore: bump version to 0.3.1 (11)
2026-10-05 07:46:36 -04:00
MaxsTechReview 472bb28c88 Fix controller rings on linked data dirs, route rumble to the controller, capture the mouse for pointer locks (#242)
- #166: export the fake-input ring paths with the spelling the session tree is bound under
  (Context.getFilesDir(), /data/user/0/...) instead of the canonical path. Where /data/user/0
  resolves to /data/data (ColorOS 16) the guest has nothing bound at the canonical prefix, so
  libfakeinput could not open the rings and every pad, physical and on-screen, was dead. The ring
  path logic moves to FakeInputRingPaths so it can be unit-tested without the native library.
- #219: play a game's rumble on the physical controller that last drove the pad, when Android
  exposes its motors (strong and weak separately on Android 12+), and fall back to the device's
  vibrator for the on-screen pad or a controller without rumble. PadBridge records the active
  controller; the on-screen pad taking over moves rumble back to the phone.
- #153: when a program takes a pointer lock (a mouse-look game through gamescope), capture a
  physical mouse and send its raw deltas as relative motion, so the camera no longer stops where
  Android's pointer meets the screen edge. Capture is released for the drawer, the PC keyboard,
  picture in picture, focus loss and pause, and when the lock ends.
2026-10-05 07:33:33 -04:00
Kurt Himebauch eaa4a7a047 Clarify display settings and organize in-session controls (#247)
* Clarify display sizing, game fit and scaling controls

* Replace aspect and resolution controls with one dimension menu

* Organize Steam session settings into tabs

* Keep session resolution out of the live controls panel

* Group live session controls and add a conditional second-screen tab

* Refine session sliders, tab icons and contextual controls

* Simplify screen effects with presets and collapsed advanced controls

* Give in-session tab icons more spacing

* Center the loading cancel button glyph
2026-10-04 23:32:30 -04:00
MaxsTechReview 4fa467c800 Fetch the Steam client again when it never shows up (#225)
A client stopped part way through updating itself can be left unable to
draw anything. Its files and our install stamp are still on disk, so the
installer never looked at it again and every session sat on "Waiting for
Steam" with nothing retried.

The session now adds a line to package/droiddeck-unconfirmed each time it
starts the client, and the app removes the file on the client's first
frame. After two starts in a row with nothing shown, the installer:
- drops Steam's update manifests, the stamp, .crash and steam.pid
- deletes package zips whose sha1 no longer matches their name
- downloads the client again over the old one

steamapps, config and userdata are left alone. An automatic repair runs
once per failure, until a start is seen again.

The Steam page's Client settings get a "Repair Steam client" item that
queues the same repair for the next start. The request is only cleared
once the download completes, so an interrupted repair is retried.
2026-10-04 21:33:16 -04:00
Kurt Himebauch beb96e6f0c docs: record Thor Steam SD allocation findings (#240) 2026-10-04 20:15:24 -04:00
Kurt Himebauch 26cb9e2f03 Add Decky plugin ZIP import (#79)
* Add Decky plugin ZIP import

* Match Decky plugin identity and loader settings on ZIP import

* Use the shared focus ring for the Decky toggle
2026-10-04 18:09:46 +00:00
MaxsTechReview 6dc74523c3 Start every title with a Valve pick on the Compatible Proton (#224)
The client runs in Steam Deck mode, where a title's "Default" is not the
"0" mapping but Valve's own pick for it: the Steam Deck compatibility
recommended_runtime (proton-stable, a pinned Proton, or native for the
Steam Linux Runtime) or an app_mappings entry in the SteamPlay manifests.
A title the session had not mapped yet - not installed, or installed
while the client was running on a build without the live helper - showed
"Default (Proton Stable)" and launched with a Proton that cannot run
here.

Every playable Windows title in the appinfo cache with such a pick is now
mapped to the default tool up front, before the client starts and in the
live helper, the same way installed titles are. A runnable tool the user
picks for one of them is kept, and they follow a change of the default.
2026-10-04 13:44:10 -04:00
MaxsTechReview 3858a69867 Start fresh installs on droiddeck-esync (#223)
In the Valheim and PEAK runs esync used the least CPU per frame. fsync
stays available, and esync still hands a Proton build without a pack to
droiddeck-fsync until its pack is published. A sync someone already picked
is kept.
2026-10-04 12:55:32 -04:00
MaxsTechReview 49a5f4aa94 ESync, FSync, NTSync + OpenNow + FEX for Desktop Apps (#197)
* Make the Compatible Proton ARM64 the default and label compatible builds

The CompatToolMapping "0" default always points at DroidDeck's Proton
ARM64 tool, and per-game picks of adopted GE/CachyOS builds are kept,
including when the shortcuts writer remaps.

The tool is named after the Proton build it runs, Proton Experimental
ARM64 (Compatible), or Proton 11.0 ARM64 (Compatible) once that newer build
is installed. Adopted builds carry the same Compatible mark, read from a
string resource the app stages into the runtime, and the Proton page
explains which builds have the prefix fixes.

The registrar is renamed steam-compatibility and the tool
droiddeck-proton-arm64. Existing installs migrate on the next session: the
old tool directory is removed, every mapping that named it moves to the new
tool, and the retired bannerlator-steam-compat script is removed from the
runtime. CI and local packaging include the renamed script.

* Drop the duplicate R import left by merging main

* Add userspace ntsync for Proton on Android

Android kernels have no /dev/ntsync and its app policy refuses futex_waitv,
so every Proton 11 build here fell back to wineserver for every wait.
libblsession now serves /dev/ntsync itself: wineserver opens a memfd region,
each sync object is a timerfd that carries its slot, and the kernel ntsync
ioctls are answered in shared memory with futexes, following the kernel
driver's wake order. Valve's Proton Experimental ARM64, GE-Proton and
proton-cachyos use it unchanged.

Locks record their owner so a killed process cannot wedge the others,
signals are held off inside each critical section, dead processes and
exiting threads are swept, and object lifetimes follow close, dup2, dup3,
close_range and received descriptors.

Fast sync is on by default under Performance > Session fixes, and
PROTON_NO_NTSYNC=1 turns it off per game.

* Add droiddeck-esync as the default sync for Proton on Android

Without a kernel ntsync driver, Proton 11 builds here fell back to
wineserver for every wait. droiddeck-esync brings eventfd and shared-memory
sync to the exact Proton build a game runs on: a pack holds that build's
ntdll.so and wineserver with the patch series in tools/droiddeck-esync
applied, and the guest runs Proton from a shadow tree that links the stock
tool and swaps in those two files. A wineserver from a pack still prefers
/dev/ntsync when a kernel driver or droiddeck-ntsync is there.

Packs are built in Valve's SDK image from each release's own wine source
(Proton Experimental ARM64 tags, GE-Proton and proton-cachyos releases); the
control build must reproduce the release binaries byte for byte, and a
signed index lists the packs. The APK bundles the packs pinned in
release.env, the app fetches newer ones, and Steam's compatibility tool
picks the pack for each Proton build, so new Proton releases get packs
without an app update.

The ntdll side reads, writes and polls its eventfds with raw system calls,
so the guest's LD_PRELOAD wrappers stay out of the sync path. The server
abandons a mutex once a thread killed by TerminateThread has really exited
and when its process dies, and a mutex's owner and recursion count change
in one atomic step, so a killed owner cannot touch the next owner's count.

droiddeck-esync is on by default and PROTON_NO_ESYNC=1 turns it off per
game; droiddeck-ntsync, the userspace ntsync, is now an opt-in toggle.

* Watch Valve's Proton releases and build droiddeck-esync packs for them

A scheduled workflow reads the Proton ARM64 builds Steam ships (anonymous
steamcmd app info) and the upstream source tags, compares them with the
previous watch state and starts the pack build for any build that has no
pack yet. The pack build applies the patch series with --fuzz=0 and runs a
smoke test of each new pack against the esstress suite before it is
published.

* Add droiddeck-fsync, keep Steam and the app on one Proton, fix DirectAudio

droiddeck-fsync: libblsession answers futex_waitv(2), which the app
sandbox refuses, in userspace, so Proton's own fsync runs on the stock
Proton files with no pack. Waiters register the words they wait on in a
table beside the fsync shared memory and sleep on a futex of their own;
FUTEX_WAKE on the fsync memory wakes the registered waiters. Slots carry
one owner word that the sweeper and the owner claim with a CAS, and the
mapping table is rebuilt under a lock held across the real munmap, so a
reused address or a recycled slot cannot lose a waiter. It stands in for
a Proton build without a pack (droiddeck-ntsync where fsync is off), takes
over for games with esync off, and the "droiddeck-fsync first" toggle
(BL_FSYNC_FIRST, also per game) puts it ahead of the packs. The games
Proton keeps off fsync and esync never get it. A launch into a prefix
whose wineserver is still running keeps that server's sync choice.

One Proton for Steam and the app: the Components page's Proton is the
default Steam uses, applied live through the client while Steam runs
(droiddeck-steam-compat) and at the next start otherwise; a choice made in
Steam's menu comes back to the app. Requests are ordered by a sequence that
never goes backwards, and the page no longer jumps back to the default
after a swap or restore.

DirectAudio: the wrapper wrote the driver key with its backslashes
stripped, so no game ever used DirectAudio. Wine 11 also loads a builtin
only from the prefix, so the driver is now linked into system32 and
syswow64 before "directaudio,pulse" is selected; mmdevapi falls back to
winepulse whenever DirectAudio cannot start.

* Pick esync, ntsync or fsync from the Steam settings' Games group

A three-way tab row above the FEX preset sets the sync Proton games use. It
writes the existing switches together (droiddeck-ntsync, droiddeck-fsync
first, droiddeck-esync), so the Performance toggles and the tabs always agree,
starts on esync and keeps the choice until it is changed again.

* Make droiddeck-fsync the default sync for Proton games

A fresh install, or one that never picked a sync, starts on fsync; a choice made
in the Steam settings' Games group still sticks. Games Proton keeps off fsync
still fall back to droiddeck-esync.

* Add wineserver to the Wine sync tabs and order them ntsync, fsync, esync, wineserver

wineserver turns every in-process sync off so games wait through wineserver;
picking any other tab turns sync back on. The tabs shrink to fit a narrow pane.

* Shorten the Wine sync description

* Answer openat2 and execveat for FEX in the session preload

FEX opens every file of its x86 rootfs with openat2(RESOLVE_IN_ROOT) and
only falls back to the host path on EXDEV. Under proot on Android openat2
answers ENOSYS, so each lookup of an x86 library silently went to the host
and missed. The exported syscall() now turns openat2 into openat once the
real call reports ENOSYS: RESOLVE_IN_ROOT paths are taken relative to the
root fd (FEX has already resolved the links inside the rootfs), and an
absolute path under RESOLVE_BENEATH fails with EXDEV as the kernel would.

execveat with an absolute path, or relative to AT_FDCWD, and without
AT_EMPTY_PATH or AT_SYMLINK_NOFOLLOW, is plain execve; it is sent as such
because proot refuses execveat with a directory fd, which FEX uses when it
re-executes itself for a child x86 program.

* Run x86 Linux games and programs through FEX

Steam: Linux-only titles that have no ARM64 build (from the client's
appinfo cache: type game, demo, application or beta) are mapped to a new
compatibility tool, droiddeck-fex ("Linux x86 (FEX)", linux to linux,
no required runtime). Valve's own "fex" tool needs pressure-vessel, which
cannot run under proot, so a mapping to it or to a Steam Linux Runtime is
replaced; any other pick the user made stays. Titles that also ship for
Windows stay on Proton. When such a title is installed and FEX or the
sniper runtime is missing, the client is asked to install them.

droiddeck-fex runs the program under the FEX that Steam installs (app
3127680), with Steam Linux Runtime 3.0 (sniper, app 1628350) as the x86
rootfs:
- the sniper platform is linked in place from its usr-mtree (relative
  links only, files/usr -> .), once per runtime version;
- the x86_64 and i386 builds of libblsession and libfakeinput go into the
  runtime's lib/<triplet>/droiddeck and its etc/ld.so.preload, mirroring
  the session's own list, so x86 programs get the pads and the session
  fixes (FEX passes their system calls straight to the kernel, so the
  ARM64 preloads cannot reach them);
- Config.json sets RootFS and the GL and Vulkan thunks, so x86 games draw
  with the device's Turnip and Mesa;
- the FEX preset and the game's own environment from the Steam settings
  apply, and ARM64 entries are dropped from LD_PRELOAD/LD_LIBRARY_PATH.
It also links ~/.steam/sdk64 and sdk32 to the client's x86 steamclient
when the client has one and nothing is there yet.

Desktop: x86_64 and i386 AppImages can be imported now; they unpack
through FEX (or unsquashfs) and record their architecture. Scripts,
AppImages and programs started in a run session use FEX automatically
when they are x86, and each added app has a "Use FEX" Auto/On/Off choice
in its edit dialog. The launchers' "still running" checks also look at
FEX processes, whose exe is FEX rather than the program. A GitHub release
without an ARM64 AppImage falls back to its x86_64 one. Decky's x86
loader starts through droiddeck-fex instead of the fex-mesa rootfs path,
which no runtime ships.

The x86 preloads are built against glibc 2.31 (Debian 11) so they load
in sniper: tools/linuxfs/build-x86-preloads.sh, run in CI and by
build_local.sh. fakeinput's stat hooks fall back to __xstat on a glibc
without a stat export, and mmap64 takes off64_t for the i386 build.

* Keep a FEX unixlib from outliving its DLL

A FEX package or original bundle without the aarch64-unix helpers left
the previous version's libarm64ecfex.so / libwow64fex.so in the Proton,
so the new DLLs loaded a unixlib built for another FEX. A swap now removes
the helpers the package does not carry; FEX then runs DLL-only, which it
already falls back to when Wine cannot load the unixlib.

* Fix the Wine sync choices an audit found

- wineserver tab: it passed Proton's environment through, so a build with
  esync built in (GE, cachyos) still ran esync and a kernel ntsync would
  still be used. It now sets PROTON_NO_ESYNC, PROTON_NO_FSYNC and
  PROTON_NO_NTSYNC (unless the game's own environment sets them).
- ntsync tab: a Proton whose wineserver has no /dev/ntsync client got
  wineserver-only sync although fsync would work. The hash cache records
  whether the wineserver has one; without it the launch goes to
  droiddeck-fsync, and no stand-in or pack turns droiddeck-ntsync on.
- A build with esync built in returned before the per-game fallbacks, so
  a game that turns esync off ran with no in-process sync. It now gets
  droiddeck-fsync, or droiddeck-ntsync where fsync is off too.
- When a pack's proton cannot start and the stock one is used, or the
  selection fails, the prefix's record now says stock, so a second
  launch into the same running prefix no longer picks the pack's ntdll.
- droiddeck-fsync: its mutex is held across fork and re-made in the
  child, a closed or replaced shm fd is forgotten (close, dup2, dup3,
  close_range, closefrom), and a waiter woken while it was leaving on a
  timeout or signal reports the wake instead of losing it.
- BL_SYNC_MIN_NOFILE applies per game to every verb, debug logs carry
  prefixes.json, the device report names the chosen sync instead of the
  three raw switches, and PATCHES.md and the proot notes describe the
  selector, the fsync default and droiddeck-fsync's futex_waitv.

* Name the runtime's scripts and files droiddeck, migrating old installs

The guest scripts are droiddeck-* now (droiddeck-session and the other 19),
as are the files the app and scripts generate (etc/droiddeck, the network
state files, the components dir, the Steam install stamp, the redist marker,
the extra-Proton wrapper and backups) and the compositor's own names
(libdroiddeckwayland, droiddeck_* sources, DROIDDECK_* switches, the
wl_output and seat names).

Installed runtimes carry on: LegacyNames moves etc/bannerlator and
share/bannerlator over and leaves relative links at the old paths before
every proot command, turns any old script into a link to its new name,
moves the generated files and points menu entries at the new scripts.
The session scripts and steam-compatibility move their markers on first
use, the components dir's desired.tsv is rewritten with it, and the
runtime counts as installed under either session script.

Kept: the banner_ahb_v1/banner_desktop_v1 wire names and BANNER_AUDIO_DIRECT
(read by prebuilt Turnip and Wine builds), Banners-Turnip and other
third-party names, BL_* switches, libblsession, and credits to Bannerlator.

* Unpack x86 AppImages without FEX, then ask Steam for FEX

Importing an x86 AppImage failed when FEX was not installed yet ("needs FEX
to unpack and no unsquashfs is installed"). AppImages now unpack with
uruntime (VHSgunzo, MIT, v0.8.1 aarch64, pinned by sha256 and shipped
unmodified with its licence), which reads SquashFS and DwarFS images of any
architecture natively. FEX's own --appimage-extract and unsquashfs stay as
fallbacks, and an ARM64 image whose own runtime cannot unpack it goes the
same way.

An x86 import with FEX missing records that FEX is wanted, so the next
Steam session requests FEX and the Steam Linux Runtime, and the program's
page says FEX is not set up yet and how to get it.

* Run Vulkan Qt Quick AppImages on Wayland

OpenNOW (Qt 6.8, Qt Quick on Vulkan) closed within two seconds in a run
mode. The session sends toolkits to gamescope's Xwayland, and Qt's xcb
backend only presents with Vulkan when Mesa's X11 WSI does, which needs
DRI3; Xwayland on KGSL has none, so Qt found no queue that can present and
aborted ("Failed to create RHI").

An AppImage that bundles Qt Quick, the Vulkan loader and Qt's Wayland
plugin now runs on gamescope's Wayland socket (the desktop's, outside
gamescope), where Turnip presents as it does for games, with Qt's xcb
backend as the fallback. SDL_VIDEODRIVER and XDG_SESSION_TYPE follow, so
helpers that pick their window system from them (OpenNOW's streamer) agree
with Qt. Everything else stays on X11; a qt file in the app's directory
(wayland or xcb) overrides the choice.

* Keep gamescope's WSI layer away from Qt apps on its Wayland socket

OpenNOW still closed at once: its FFmpeg (the Raspberry Pi fork, libavutil
61) creates its Vulkan queues internally synchronized whenever the driver
offers VK_KHR_internally_synchronized_queues, which DD-Turnip does. The
gamescope WSI layer registers every queue of a new device through
vkGetDeviceQueue, which only returns queues created without flags, so it
got VK_NULL_HANDLE and vkroots aborted on assert(obj) before Qt opened a
window.

Programs that draw on gamescope's Wayland socket do not need the layer,
which only carries X11 windows past Xwayland, so it is turned off for
them. OpenNOW bundles no Vulkan loader (it reaches Vulkan through Qt and
dlopen), so a binary that uses QVulkanInstance counts as a Vulkan app too,
and a Qt Quick app left on X11 says why in the session log.

* Keep the end of the session log, and say when FEX is missing

The session sends its output through head -c into session.log. head
writes through stdio, so it held up to 4 KB that proot's --kill-on-exit
threw away when the guest ended: every log stopped mid-line at a 4096-byte
boundary, losing exactly the lines that say why a program quit (an x86
AppImage's "x86 Linux programs need FEX" among them). dd writes each read
as it comes and keeps the same cap.

A run mode always ends with status 0 (gamescope does not pass its child's
on), so a program that could not start just closed. When the log says FEX
is missing, the ended screen now says so and how to get it.

The app reads FEX's readiness from status.json, which only a launch or an
import wrote, so it still said "not set up" after Steam installed FEX.
droiddeck-fex status rewrites it, and a Steam session ends with it while
FEX is wanted. FEX is also looked for at the tool's root and under files/,
and a tool folder without it lists what it holds.

* Let FEX and its runtime install runnable on an SD card library

A Steam library on shared storage loses the executable bits, so native
tools are staged in private directories bound over it; only Proton and
SteamLinuxRuntime folders were. FEX (3127680) installed there could never
run, droiddeck-fex saw no FEX and kept asking Steam for it. FEX and the
sniper runtime (1628350) now get private directories from their first
download, whatever their install folder is called.

* Keep fsync off in the pack smoke test

A runner whose kernel allows futex_waitv started stock Proton with fsync,
so the server-side baseline never said what the test waits for and every
pack was waved through as untestable; the esync run would have measured
fsync too.

* Judge a pack against stock's own esstress failures

Stock Proton fails esstress's dup wait all under server-side sync as well
(a Wine bug, seen on the phone too), and any stock failure made the test
call the runner unusable. A pack now fails only when it crashes, hangs or
fails a check stock passes.

* Ask Steam for FEX and its runtime one at a time

Both steam://install requests were started together, and the running client
took only the first: FEX installed, the sniper runtime FEX needs never did.
Each request now waits for the client to take it before the next.

* Bundle the first published droiddeck-esync packs in the apk

* Log how an x86 program under FEX ends

FEX keeps its own errors quiet unless asked, so a program it could not run
left nothing in the session log. Its log now goes to stderr, and the AppImage
launcher records the program's exit status or the signal that ended it.

* Show the log a failed AppImage program wrote

Programs like Eden stop themselves on a failed check and say why only in
their own log under the home folder. When the program ends badly, the end of
any log it wrote during the run is copied into the session log.

* Report where an x86 program under FEX hit an illegal instruction

A tiny x86_64 preload, built without libc, catches SIGILL and writes the
address, the instruction bytes and the mapping they belong to before the
program dies as it would have. droiddeck-fex preloads it into x86_64 programs.

* Load the fault reporter from the x86 runtime

Programs under FEX look libraries up in the x86 runtime, so the reporter is
copied there with the other preloads and named by its path inside it.

* Hand FEX its x86 runtime and thunks through the environment

Valve's FEX runs portable and reads its config beside itself, so the
Config.json droiddeck-fex writes was not seen: programs ran without the
runtime. The runtime, thunk libraries and thunk list now also go in FEX's
own environment variables, and each launch says what it was given.

* Use the sync pack built for the installed Proton release

Some Proton releases ship byte-identical ntdll.so and wineserver (Valve
11.0-2b/2c, CachyOS 0702/0703), so a pack built for a newer release
matched an older install exactly and the older install never fetched its
own pack. A pack from a newer release no longer counts as an exact match;
among matches the one built for the installed version wins, then rev.

The app ranks the pack a tool already uses the same way and only fetches
a pack that ranks higher, reads the release epoch from tools.tsv and
refetches when tools.tsv changes. A prefix whose server is still running
re-checks its recorded pack on waitforexitandrun and starts fresh when a
better one now applies.

* Keep published sync packs and revocations consistent

The publish step never replaces a pack that has an entry beside it; a
pack left without its entry by an interrupted run is replaced so the
build stops repeating. Discovery counts revocations the signed index does
not show yet, including superseded packs only the flavor releases list,
so the index is signed again. The release watch always builds every
flavor.

* Fetch the droiddeck-esync packs from DroidDeck-Components

The packs, the signed index and the apk bundle now live in the releases
of Droid-Deck/DroidDeck-Components, together with the pipeline that
builds them, so this repository keeps only the app and the guest code
that consumes them. The app's index and asset URLs point there,
release.env names the repository its bundle comes from, and the apk
builds drop bundled packs that the published index revokes.

* Stop the apk build when the published sync index cannot be read

* Ask Steam for one FEX dependency at a time

The client shows a confirmation dialog for each steam://install, and a
second request replaces the first dialog, so FEX was never installed
when the runtime was requested right after it. The next dependency is
now requested once the previous one has an app manifest.

* Use generic names in the AppImage test fixtures

* Give x86-64 apps under FEX the Vulkan thunk

Apps that bring their own glibc and Vulkan loader skipped FEX's thunk
overlay and loaded their bundled desktop Mesa drivers, which crash on the
phone's GPU during Vulkan setup. droiddeck-fex now adds an x86-64 audit
module when a program's bundled glibc and libstdc++ meet the thunk's
version needs. The module sends libvulkan.so.1 lookups to a nodelete stub
that needs FEX's thunk path. That keeps the thunk mapped when an app
unloads Vulkan and loads it again, since FEX keeps its first trampoline
links and the old addresses get reused.

The fault reporter now restores the default action before re-raising,
because FEX ignores SA_RESETHAND and the handler looped.
2026-10-04 12:43:57 -04:00
starwishHi 4cc9f2a3f3 Add Korean,Japanese and Traditional Chinese(Taiwan,Macau and Hongkong)translate (#216) 2026-10-04 12:13:13 -04:00
Kurt Himebauch b0d7fc01d8 Add opt-in Steam storage diagnostics (#158) 2026-10-04 08:19:48 -04:00
Magarto 9cc40246a3 Start .NET and Godot games under FEX without per-game setup (#168) 2026-10-04 08:19:11 -04:00
The412Banner 423cf11919 Effects page in the session menu: scaling modes, screen effects and texture filtering (#198) 2026-10-04 08:17:00 -04:00
Dantalian 33f7213599 l10n: add Simplified Chinese (#202) 2026-10-04 11:21:24 +00:00
Kurt Himebauch 6f1fb462ed Add controller and desktop quality-of-life options (#194) 2026-10-04 00:56:09 -04:00
Kurt Himebauch 336b8851f7 Add Touch Off and compact session menu shortcuts (#196)
Add touch off mode and compact session menu shortcuts
2026-10-04 04:37:56 +00:00
Kurt Himebauch 5da3cf673c Fix automatic Picture in Picture after session handoffs (#195)
Preserve session readiness for automatic picture in picture
2026-10-04 04:24:25 +00:00
Kurt Himebauch 9e54e094fb Restore 500 ms Back double-press window (#193) 2026-10-04 03:40:10 +00:00
Kurt Himebauch eb9ca4ab6b Remove redundant in-session Desktop button (#188) 2026-10-03 23:00:02 -04:00
MaxsTechReview e7b6876877 Add the droiddeck-esync pack pipeline (#189)
Builds esync packs for new Valve, GE and CachyOS Proton 11 releases on arm64
runners, smoke-tests them against stock with esstress, and on main publishes
each pack and a signed index as non-latest releases. A watcher checks Steam
and the GE/CachyOS releases every 20 minutes and starts a build for new ones.
The app side lands separately with feat/userspace-esync-ntsync.
2026-10-04 02:58:55 +00:00
Kurt Himebauch c535f6ef6e Empty library intro (#182) 2026-10-03 22:27:24 -04:00
Kurt Himebauch db2e1d7da2 Fix runtime removal and first-run Steam setup (#185)
* Fix runtime removal races and show removal progress

* Bootstrap Steam from the selected client channel

* Show runtime progress below its Setup row

* Always stage current session helpers and verify APK assets

* Initialize Steam UI scale once using the handheld default
2026-10-03 22:03:26 -04:00
Kurt Himebauch f5e204eb1d Add game file exports and sync for Android frontends (#179) 2026-10-03 21:29:01 -04:00
Magarto 705a5e7b2a Give protonfixes a readable /proc/sys/fs/file-max (#169) 2026-10-03 15:26:20 -04:00
Kurt Himebauch e60eae3646 Add standard session picture in picture (#156)
* Add session picture in picture controls

* Simplify PiP to standard Android controls

* Remove PiP testing document
2026-10-03 15:06:16 -04:00
Kurt Himebauch b04900b1b2 Merge pull request #172 from jmarti326/fix/steam-sleep-resume-black-screen 2026-10-03 14:28:24 -04:00
Kurt Himebauch 6357543b44 Merge branch 'main' into fix/steam-sleep-resume-black-screen 2026-10-03 14:18:44 -04:00
Kurt Himebauch 67fb338e5d Merge pull request #159 from Droid-Deck/preview-change-history
Show Preview update history
2026-10-03 13:36:00 -04:00
xXJsonDeruloXx ba140c4ab5 Merge remote-tracking branch 'origin/main' into preview-history-status-card 2026-10-03 13:30:13 -04:00
xXJsonDeruloXx f5fdbc7941 Move Preview history below the update action 2026-10-03 13:27:09 -04:00
Juan Marti Mercado 90ae85da4d Fix Steam sleep resume handshake 2026-10-03 12:08:40 -04:00
Kurt Himebauch f3600fa618 Merge pull request #157 from Droid-Deck/game-launcher-shortcuts 2026-10-03 12:04:11 -04:00
xXJsonDeruloXx 2d8befe1cb Add Preview update history 2026-10-03 00:06:09 -04:00
xXJsonDeruloXx f982ffcaf7 Add game launcher shortcuts 2026-10-02 23:49:26 -04:00
Kurt Himebauch 255c64551e Merge pull request #128 from magarto/upscaler-setting
Expose the compositor's upscaler (FSR 1 / GSR / NIS) as a session setting
2026-10-02 23:29:01 -04:00
Kurt Himebauch 746f28c1b2 Merge branch 'main' into upscaler-setting 2026-10-02 23:25:35 -04:00
Kurt Himebauch 2b481ba250 Merge pull request #155 from Droid-Deck/fix/steam-idle-wake
Fix Steam idle sleep and Android session wake
2026-10-02 21:44:13 -04:00
xXJsonDeruloXx 49622623de Merge remote-tracking branch 'origin/main' into fix/steam-idle-wake 2026-10-02 21:43:52 -04:00
Kurt Himebauch 69f99ade42 Merge pull request #146 from Droid-Deck/fix/deck-controller-discovery 2026-10-02 21:34:08 -04:00
xXJsonDeruloXx c236e9711f Restore Steam after idle sleep through session pause and login1 wake 2026-10-02 21:32:12 -04:00
Kurt Himebauch 4c2efb6688 Merge branch 'main' into fix/deck-controller-discovery 2026-10-02 20:51:17 -04:00
xXJsonDeruloXx 7eb2d1cf1a Trim Deck discovery to mount-ID compatibility fix 2026-10-02 20:35:28 -04:00
xXJsonDeruloXx b4ff83cf9f Recover Deck discovery mount IDs on older kernels 2026-10-02 19:08:01 -04:00
Kurt Himebauch 7b20b70c6b Merge pull request #151 from Droid-Deck/feat/android-clipboard 2026-10-02 17:47:25 -04:00
xXJsonDeruloXx 86207f9d0e Record visible Android copy and Steam paste test on Thor 2026-10-02 17:38:16 -04:00
xXJsonDeruloXx eda5ece382 Add Android clipboard text paste into Steam sessions 2026-10-02 17:29:41 -04:00
Kurt Himebauch 3404bbc128 Merge pull request #150 from Droid-Deck/feat/steam-native-network
Show Android Wi-Fi and cellular networks in Steam BPM
2026-10-02 16:16:46 -04:00
xXJsonDeruloXx b5a431aa3c Merge remote-tracking branch 'origin/main' into feat/steam-native-network 2026-10-02 16:11:04 -04:00
xXJsonDeruloXx 49249f6541 Add optional Wi-Fi discovery permission flow in Steam settings 2026-10-02 16:05:47 -04:00
xXJsonDeruloXx 5614621d6e Expose Android Wi-Fi names and nearby networks to Steam 2026-10-02 15:39:49 -04:00
xXJsonDeruloXx 0816562065 Report Android Wi-Fi and cellular connections to Steam 2026-10-02 15:05:03 -04:00
Kurt Himebauch 39618e6862 Merge pull request #147 from Droid-Deck/controller-overlay-toggle 2026-10-02 14:56:02 -04:00
xXJsonDeruloXx c66e4ba4b3 Release controls when touch is cancelled 2026-10-02 13:48:19 -04:00
xXJsonDeruloXx e4b033861f Add quick toggle for on-screen controls 2026-10-02 13:28:13 -04:00
xXJsonDeruloXx 85a07a556c Preserve native HID discovery and trace Deck enumeration 2026-10-02 12:00:12 -04:00
xXJsonDeruloXx 8dfc48d58b Fix Deck controller discovery across sysfs layouts 2026-10-02 10:45:25 -04:00
Magarto 82ebdaa0b5 Merge branch 'main' into upscaler-setting 2026-10-02 15:38:57 +02:00
Kurt Himebauch ec016f85bc Merge pull request #142 from Droid-Deck/xXJSONDeruloXx-patch-1
Update README.md
2026-10-02 01:55:28 -04:00
Kurt Himebauch c2c812deaa Update README.md 2026-10-02 01:55:15 -04:00
Magarto cbd4ce6636 Merge branch 'main' into upscaler-setting 2026-10-01 22:02:07 +02:00
magartus d4130181e7 Add the upscaler to the session drawer, applied live
The drawer's Display page gets the same Upscaler and Upscaler sharpness
choices as the session settings. A pick is saved and handed straight to
the compositor, so it takes effect on the next frame without restarting
the session.
2026-10-01 21:21:52 +02:00
Magarto 4e90e03536 Merge branch 'main' into upscaler-setting 2026-10-01 19:10:55 +02:00
Magarto 454193da9b Merge branch 'main' into upscaler-setting 2026-10-01 19:06:00 +02:00
magartus 38d658abf5 Expose the compositor's upscaler as a session setting
The Wayland compositor already carries the X11 renderer's spatial
upscalers (effects_chain.c: FSR 1 EASU+RCAS, Snapdragon GSR and its
quality variant, NIS, RCAS sharpen) behind
WaylandCompositor.nativeSetUpscaler / nativeSetUpscaleSharpness, but
nothing calls them since the Winlator-era drawer was removed, so the
scene is always enlarged to the panel with a plain linear blit.

Add "Upscaler" and "Upscaler sharpness" to the session page's Display
group (off by default, 75%), stored in SessionPrefs and applied next to
nativeSetScaleMode when the session's surface is set up. The choice is
also listed in device.txt.
2026-10-01 18:38:02 +02:00
295 changed files with 36705 additions and 3122 deletions

No files matched your search

+108 -6
View File
@@ -28,7 +28,7 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
PRELOAD_CACHE_VERSION: v2
PRELOAD_CACHE_VERSION: v3
jobs:
build:
@@ -57,6 +57,22 @@ jobs:
exit 1
fi
- name: Version bumps are isolated
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
diff=$(git diff -U0 "$base..HEAD" -- app/build.gradle)
grep -Eq '^[+-][[:space:]]*version(Code|Name)[[:space:]]' <<<"$diff" || exit 0
other_files=$(git diff --name-only "$base..HEAD" | grep -v '^app/build.gradle$' || true)
other_gradle=$(grep -E '^[+-]' <<<"$diff" | grep -Ev '^(---|\+\+\+)' | grep -Ev '^[+-][[:space:]]*version(Code|Name)[[:space:]]' || true)
if [[ -n "$other_files" || -n "$other_gradle" ]]; then
echo "::error::versionCode/versionName changes must be isolated in their own PR"
echo "A version PR may only change the versionCode/versionName lines in app/build.gradle." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
run: |
@@ -157,7 +173,7 @@ 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', 'tools/linuxfs/preload/*.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/mangoapp/*') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', '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/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
@@ -179,7 +195,14 @@ jobs:
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do
# Force-SSBS adapter, preloaded into a game's Wine only when the game asks for it.
aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
-o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
mkdir -p "$D/usr/local/bin"
aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
done
for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
@@ -201,6 +224,12 @@ jobs:
test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so"
rm wlroots.tzst
fi
. tools/linuxfs/uruntime.env
curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
rm uruntime
M="$D/usr/local/lib/mangoapp"
mkdir -p "$M" mango-pkgs
grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
@@ -221,6 +250,26 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
# The Windows components installer's tools (tools/msitools/packages.txt).
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
rm msi-pkgs/pkg.tar.xz
done
install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
done
for f in NOTICE GPL-2 GPL-3 LGPL-2.1; do
install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
done
rm -rf msi-pkgs
ls -l "$T"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
@@ -230,6 +279,46 @@ jobs:
install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
ls -lR "$D"
- name: Build the x86 preloads FEX guests load (glibc 2.31)
if: steps.preloads.outputs.cache-hit != 'true'
run: |
set -euo pipefail
docker run --rm -v "$PWD:/src" -w /src 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
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
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
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
echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
fi
done
rm sync-index.json
test -f app/src/main/assets/droiddeck-esync/index.json
test -f app/src/main/assets/droiddeck-esync/index.json.sig
ls -lR app/src/main/assets/droiddeck-esync
fi
- name: Cache PulseAudio 13.0 source
id: pa-src
uses: actions/cache@v4
@@ -294,11 +383,23 @@ jobs:
echo "::error::libfakeinput.so exports the C++ runtime"; exit 1
fi
SESSION=$(syms "$D/libblsession.so")
for sym in socket bind getsockname setsockopt statfs statvfs; do
for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap drmPrimeFDToHandle drmCloseBufferHandle; do
grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/bannerlator-session"
test -f "$D/usr/local/bin/bannerlator-proton-extra"
# Zink's GEM_CLOSE is caught at ioctl(); an exported drmIoctl made NMS replay old frames.
if grep -qx drmIoctl <<<"$SESSION"; then
echo "::error::libblsession.so exports drmIoctl"; exit 1
fi
SSBS=$(syms "$D/libssbs.so")
for sym in sigaction signal bsd_signal sysv_signal; do
grep -qx "$sym" <<<"$SSBS" || { echo "::error::libssbs.so does not export $sym"; exit 1; }
done
test -f "$D/usr/local/bin/droiddeck-session"
test -f "$D/usr/local/bin/droiddeck-proton-extra"
test -f "$D/usr/local/bin/droiddeck-msi-install"
for f in msiinfo cabextract libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
test -f "$D/usr/local/lib/droiddeck-msitools/$f" || { echo "::error::droiddeck-msitools/$f missing"; exit 1; }
done
- name: Cache the CMake build tree
uses: actions/cache@v4
@@ -317,6 +418,7 @@ jobs:
run: |
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+1
View File
@@ -14,6 +14,7 @@ app/src/main/assets/linuxfs/
app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/assets/droiddeck-esync/
# Kotlin compiler session files (build-tool state, never source).
.kotlin/
+5 -3
View File
@@ -11,11 +11,13 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team.
## Requirements and install
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/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; see [docs/development/flatpak.md](docs/development/flatpak.md).
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.
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
Before Steam launches, you must turn off **Restrict child processes** 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.
## Community
@@ -31,4 +33,4 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. 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.
+14 -2
View File
@@ -79,8 +79,8 @@ android {
// file out of its own data directory, and this app's entire job is to exec a rootfs:
// proot, gamescope, the Steam client and everything it launches all live under files/.
targetSdkVersion 28
versionCode 10
versionName "0.3.0"
versionCode 11
versionName "0.3.1"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
buildConfigField "int", "PR_NUMBER", "${prNumber}"
@@ -176,10 +176,22 @@ android {
}
}
// Session scripts must follow this checkout even when the native asset bundle is cached.
// A stale local bundle can omit a newly required helper on a fresh runtime install.
def stageSessionScripts = tasks.register('stageSessionScripts', Copy) {
from(rootProject.file('tools/linuxfs/overlay')) {
include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**'
}
into(layout.projectDirectory.dir('src/main/assets/linuxfs'))
}
tasks.named('preBuild').configure { dependsOn(stageSessionScripts) }
dependencies {
// The runtime tarball is a zstd-compressed tar.
implementation 'com.github.luben:zstd-jni:1.5.2-3@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
// Bannerlator's Windows components come as .tar.xz (WinComponents).
implementation 'org.tukaani:xz:1.10'
// The UI: every menu, dialog and overlay is Compose. The session's SurfaceView and the
// on-screen pad are Views underneath it, which is what they are in every Compose app.
implementation platform('androidx.compose:compose-bom:2024.02.00')
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Debug builds only. Android grants DUMP to adb shell; release APKs have no bridge. -->
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
</application>
</manifest>
+34 -1
View File
@@ -13,6 +13,10 @@
download from being throttled to nothing by the radio's power-save. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Optional Wi-Fi discovery: requested only from Steam settings after an explanation. -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Ends background processes of another app that holds the Steam login. GameHub starts
itself with the phone via boot receivers and signs this client out seconds after every
@@ -57,6 +61,20 @@
<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" />
<data android:scheme="droiddeck" android:host="game" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CREATE_SHORTCUT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="com.droiddeck.launcher.LAUNCH_GAME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Android owns the Home key. This small translucent Home handler routes the resulting
@@ -109,10 +127,19 @@
android:screenOrientation="sensorLandscape"
android:theme="@style/AppTheme" />
<activity
android:name=".agent.AgentStartActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:permission="android.permission.DUMP"
android:theme="@style/SessionTheme" />
<activity
android:name=".SessionActivity"
android:exported="false"
android:launchMode="singleTop"
android:supportsPictureInPicture="true"
android:launchMode="singleTask"
android:theme="@style/SessionTheme"
android:preferMinimalPostProcessing="true"
android:screenOrientation="sensorLandscape"
@@ -158,5 +185,11 @@
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
<provider
android:name=".agent.AgentBridgeProvider"
android:authorities="${applicationId}.agent"
android:exported="true"
android:permission="android.permission.DUMP"
android:grantUriPermissions="false" />
</application>
</manifest>
+5
View File
@@ -44,6 +44,11 @@ add_subdirectory(waylandcomp)
add_library(sdnative SHARED sdnative/sdnative.c)
target_link_libraries(sdnative log)
# The device info window's Vulkan section: the phone's own driver, asked once through the system
# loader (opened with dlopen, so a phone without Vulkan still loads the app).
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl)
# Rebuild Termux terminal-emulator's PTY JNI helper with the app's NDK instead of carrying its
# prebuilt libtermux.so with a redundant libstdc++.so dependency.
add_library(termux SHARED termux/termux.c)
+112
View File
@@ -0,0 +1,112 @@
// What the phone's own Vulkan driver says about its GPU, for the app's device info window: one
// instance on the system loader, the first physical device's properties, a few limits and the
// extensions DXVK and VKD3D-Proton lean on. Returned as "key=value" lines; nothing is kept open.
#include <dlfcn.h>
#include <jni.h>
#include <stdio.h>
#include <string.h>
#define VK_NO_PROTOTYPES
#include <vulkan/vulkan.h>
#define LINE(...) do { if (used < sizeof(out)) used += snprintf(out + used, sizeof(out) - used, __VA_ARGS__); } while (0)
static const char *const EXTENSIONS[] = {
"VK_EXT_robustness2", "VK_EXT_transform_feedback", "VK_EXT_custom_border_color",
"VK_KHR_dynamic_rendering", "VK_EXT_extended_dynamic_state3", "VK_EXT_descriptor_indexing",
"VK_KHR_timeline_semaphore", "VK_EXT_mesh_shader", "VK_KHR_ray_query",
};
static const char *type_name(VkPhysicalDeviceType type) {
switch (type) {
case VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU: return "integrated GPU";
case VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU: return "discrete GPU";
case VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU: return "virtual GPU";
case VK_PHYSICAL_DEVICE_TYPE_CPU: return "CPU";
default: return "other";
}
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_gpu_VulkanInfo_nativeQuery(JNIEnv *env, jclass clazz) {
static char out[4096];
size_t used = 0;
out[0] = 0;
void *lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL);
if (!lib) { LINE("error=no Vulkan loader\n"); return (*env)->NewStringUTF(env, out); }
PFN_vkGetInstanceProcAddr gipa = (PFN_vkGetInstanceProcAddr) dlsym(lib, "vkGetInstanceProcAddr");
PFN_vkCreateInstance create = gipa ? (PFN_vkCreateInstance) gipa(NULL, "vkCreateInstance") : NULL;
PFN_vkEnumerateInstanceVersion instanceVersion = gipa ? (PFN_vkEnumerateInstanceVersion) gipa(NULL, "vkEnumerateInstanceVersion") : NULL;
if (!create) { LINE("error=no vkCreateInstance\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
uint32_t loader = VK_API_VERSION_1_0;
if (instanceVersion) instanceVersion(&loader);
LINE("loader=%u.%u.%u\n", VK_API_VERSION_MAJOR(loader), VK_API_VERSION_MINOR(loader), VK_API_VERSION_PATCH(loader));
VkApplicationInfo app = { .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, .pApplicationName = "DroidDeck device info",
.apiVersion = loader >= VK_API_VERSION_1_1 ? VK_API_VERSION_1_1 : VK_API_VERSION_1_0 };
VkInstanceCreateInfo info = { .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app };
VkInstance instance = VK_NULL_HANDLE;
if (create(&info, NULL, &instance) != VK_SUCCESS) { LINE("error=vkCreateInstance failed\n"); dlclose(lib); return (*env)->NewStringUTF(env, out); }
#define GET(name) PFN_##name name = (PFN_##name) gipa(instance, #name)
GET(vkDestroyInstance); GET(vkEnumeratePhysicalDevices); GET(vkGetPhysicalDeviceProperties);
GET(vkGetPhysicalDeviceProperties2); GET(vkGetPhysicalDeviceMemoryProperties);
GET(vkEnumerateDeviceExtensionProperties); GET(vkGetPhysicalDeviceFeatures);
uint32_t count = 1;
VkPhysicalDevice device = VK_NULL_HANDLE;
VkResult listed = vkEnumeratePhysicalDevices ? vkEnumeratePhysicalDevices(instance, &count, &device) : VK_ERROR_INITIALIZATION_FAILED;
if ((listed != VK_SUCCESS && listed != VK_INCOMPLETE) || count == 0 || !device || !vkGetPhysicalDeviceProperties) {
LINE("error=no Vulkan device\n");
} else {
VkPhysicalDeviceProperties p;
vkGetPhysicalDeviceProperties(device, &p);
LINE("device=%s\n", p.deviceName);
LINE("type=%s\n", type_name(p.deviceType));
LINE("api=%u.%u.%u\n", VK_API_VERSION_MAJOR(p.apiVersion), VK_API_VERSION_MINOR(p.apiVersion), VK_API_VERSION_PATCH(p.apiVersion));
// Qualcomm packs its driver version as 10.10.12 bits, the same layout as VK_MAKE_VERSION.
LINE("driver_version=%u.%u.%u\n", p.driverVersion >> 22, (p.driverVersion >> 12) & 0x3ff, p.driverVersion & 0xfff);
LINE("vendor_id=0x%04x\ndevice_id=0x%08x\n", p.vendorID, p.deviceID);
LINE("max_image_2d=%u\n", p.limits.maxImageDimension2D);
// Driver name and build string (Vulkan 1.2 or VK_KHR_driver_properties).
uint32_t extCount = 0;
if (vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, NULL);
static VkExtensionProperties exts[512];
if (extCount > 512) extCount = 512;
if (extCount && vkEnumerateDeviceExtensionProperties) vkEnumerateDeviceExtensionProperties(device, NULL, &extCount, exts);
int driverProps = p.apiVersion >= VK_API_VERSION_1_2;
for (uint32_t i = 0; i < extCount; i++)
if (!strcmp(exts[i].extensionName, "VK_KHR_driver_properties")) driverProps = 1;
if (driverProps && vkGetPhysicalDeviceProperties2) {
VkPhysicalDeviceDriverProperties driver = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES };
VkPhysicalDeviceProperties2 p2 = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, .pNext = &driver };
vkGetPhysicalDeviceProperties2(device, &p2);
LINE("driver_name=%s\n", driver.driverName);
LINE("driver_info=%s\n", driver.driverInfo);
LINE("conformance=%u.%u.%u.%u\n", driver.conformanceVersion.major, driver.conformanceVersion.minor,
driver.conformanceVersion.subminor, driver.conformanceVersion.patch);
}
LINE("extension_count=%u\n", extCount);
for (size_t e = 0; e < sizeof(EXTENSIONS) / sizeof(*EXTENSIONS); e++) {
int has = 0;
for (uint32_t i = 0; i < extCount && !has; i++) has = !strcmp(exts[i].extensionName, EXTENSIONS[e]);
LINE("ext.%s=%s\n", EXTENSIONS[e], has ? "yes" : "no");
}
if (vkGetPhysicalDeviceFeatures) {
VkPhysicalDeviceFeatures f;
vkGetPhysicalDeviceFeatures(device, &f);
LINE("geometry_shader=%s\ntessellation_shader=%s\ntexture_bc=%s\n",
f.geometryShader ? "yes" : "no", f.tessellationShader ? "yes" : "no", f.textureCompressionBC ? "yes" : "no");
}
if (vkGetPhysicalDeviceMemoryProperties) {
VkPhysicalDeviceMemoryProperties m;
vkGetPhysicalDeviceMemoryProperties(device, &m);
VkDeviceSize largest = 0;
for (uint32_t i = 0; i < m.memoryHeapCount; i++)
if ((m.memoryHeaps[i].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT) && m.memoryHeaps[i].size > largest) largest = m.memoryHeaps[i].size;
LINE("device_heap_mb=%llu\n", (unsigned long long) (largest >> 20));
}
}
if (vkDestroyInstance) vkDestroyInstance(instance, NULL);
dlclose(lib);
return (*env)->NewStringUTF(env, out);
}
+148 -19
View File
@@ -54,7 +54,7 @@
#include <sys/un.h>
#include <unistd.h>
#define EXPORT __attribute__((visibility("default"))) extern "C"
#define EXPORT extern "C" __attribute__((visibility("default")))
// glibc declares the request as unsigned long; bionic as int. The same source also serves the
// Linux runtime's preload (tools/linuxfs), where Steam and SDL run against glibc.
@@ -1717,6 +1717,81 @@ __attribute__((visibility("hidden"))) static std::string deck_listing_for(const
return std::string(listing) + "/" + names[which];
}
#ifdef __GLIBC__
// sd-device's path chase now requires a mount ID, which statx only supplies from Linux 5.8.
// Older handheld kernels still publish the real ID in fdinfo. Scope this compatibility path to
// libudev/libsystemd in the Deck-reading Steam client, and never invent a unique mount ID.
__attribute__((visibility("hidden"))) static bool deck_device_library(void *caller) {
Dl_info info;
if (!dladdr(caller, &info) || !info.dli_fname) return false;
const char *name = strrchr(info.dli_fname, '/');
name = name ? name + 1 : info.dli_fname;
return !strncmp(name, "libudev.so", 10) || !strncmp(name, "libsystemd.so", 13);
}
static int real_fstat(int fd, struct stat *buf);
__attribute__((visibility("hidden"))) static bool deck_mount_id_from_fdinfo(
int dirfd, const char *path, int flags, struct statx *buf) {
static auto real_openat = reinterpret_cast<decltype(&::openat)>(dlsym(RTLD_NEXT, "openat"));
int target;
if (!path || !*path) {
if (!(flags & AT_EMPTY_PATH)) return false;
target = dirfd == AT_FDCWD ? real_openat(AT_FDCWD, ".", O_PATH | O_CLOEXEC) :
syscall(SYS_fcntl, dirfd, F_DUPFD_CLOEXEC, 0);
} else {
target = real_openat(dirfd, path, O_PATH | O_CLOEXEC |
((flags & AT_SYMLINK_NOFOLLOW) ? O_NOFOLLOW : 0));
}
if (target < 0) return false;
struct stat st;
// Pin the inode and check it still matches the successful statx before adding information.
bool same = real_fstat(target, &st) == 0 && (buf->stx_mask & STATX_INO) &&
st.st_ino == buf->stx_ino && major(st.st_dev) == buf->stx_dev_major &&
minor(st.st_dev) == buf->stx_dev_minor;
char name[64];
snprintf(name, sizeof(name), "/proc/self/fdinfo/%d", target);
int info = same ? real_openat(AT_FDCWD, name, O_RDONLY | O_CLOEXEC) : -1;
char value[512];
ssize_t length = info >= 0 ? syscall(SYS_read, info, value, sizeof(value) - 1) : -1;
if (info >= 0) syscall(SYS_close, info);
syscall(SYS_close, target);
if (length < 0) return false;
value[length] = '\0';
for (const char *line = value; line && *line;) {
unsigned long long id;
if (sscanf(line, "mnt_id: %llu", &id) == 1 && id > 0) {
buf->stx_mnt_id = id;
buf->stx_mask |= STATX_MNT_ID;
return true;
}
const char *next = strchr(line, '\n');
line = next ? next + 1 : nullptr;
}
return false;
}
EXPORT int statx(int dirfd, const char *path, int flags, unsigned mask, struct statx *buf) {
static auto real = reinterpret_cast<decltype(&::statx)>(dlsym(RTLD_NEXT, "statx"));
int result = real(dirfd, path, flags, mask, buf);
int saved_errno = errno;
constexpr unsigned unique_mount_id = 0x4000; // STATX_MNT_ID_UNIQUE, newer than build headers.
if (result == 0 && (mask & STATX_MNT_ID) &&
!(buf->stx_mask & (STATX_MNT_ID | unique_mount_id)) && fake_deck_enabled() &&
process_is_steam_client() && deck_device_library(__builtin_return_address(0))) {
bool recovered = deck_mount_id_from_fdinfo(dirfd, path, flags, buf);
static std::atomic<bool> noted_success{false}, noted_failure{false};
if (!(recovered ? noted_success : noted_failure).exchange(true))
Logger::log("deck discovery: statx mount id %s for %s (returned mask=0x%x id=%llu)\n",
recovered ? "recovered from fdinfo" : "unavailable in fdinfo",
path && *path ? path : "(fd)", buf->stx_mask,
static_cast<unsigned long long>(buf->stx_mnt_id));
}
errno = saved_errno;
return result;
}
#endif
EXPORT DIR *opendir(const char *name) {
static auto real = reinterpret_cast<DIR *(*)(const char *)>(dlsym(RTLD_NEXT, "opendir"));
DIR *dir = real(name);
@@ -1876,9 +1951,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) {
return fd;
}
EXPORT int stat(const char *pathname, struct stat *statbuf) {
static auto my_stat = reinterpret_cast<decltype(&::stat)>(dlsym(RTLD_NEXT, "stat"));
template <typename S, typename Real>
static int fake_stat_path(const char *pathname, S *statbuf, Real real) {
if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) {
errno = ENOENT;
return -1;
@@ -1914,7 +1988,7 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) {
}
}
int ret = my_stat(pathname, statbuf);
int ret = real(pathname, statbuf);
if (ret == 0 && event && get_event_number(event) >= 0) {
statbuf->st_mode = (statbuf->st_mode & ~S_IFMT) | S_IFCHR;
@@ -1927,10 +2001,9 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) {
return ret;
}
EXPORT int fstat(int fd, struct stat *buf) {
static auto my_fstat = reinterpret_cast<decltype(&::fstat)>(dlsym(RTLD_NEXT, "fstat"));
int ret = my_fstat(fd, buf);
template <typename S, typename Real>
static int fake_stat_fd(int fd, S *buf, Real real) {
int ret = real(fd, buf);
std::lock_guard<std::recursive_mutex> guard(controller_mutex());
if (ret == 0 && deck_map().count(fd)) {
@@ -1947,6 +2020,36 @@ EXPORT int fstat(int fd, struct stat *buf) {
return ret;
}
static int real_stat(const char *pathname, struct stat *statbuf) {
static auto fn = reinterpret_cast<int (*)(const char *, struct stat *)>(dlsym(RTLD_NEXT, "stat"));
if (fn) return fn(pathname, statbuf);
#ifdef _STAT_VER
static auto xfn = reinterpret_cast<int (*)(int, const char *, struct stat *)>(dlsym(RTLD_NEXT, "__xstat"));
if (xfn) return xfn(_STAT_VER, pathname, statbuf);
#endif
errno = ENOSYS;
return -1;
}
static int real_fstat(int fd, struct stat *buf) {
static auto fn = reinterpret_cast<int (*)(int, struct stat *)>(dlsym(RTLD_NEXT, "fstat"));
if (fn) return fn(fd, buf);
#ifdef _STAT_VER
static auto xfn = reinterpret_cast<int (*)(int, int, struct stat *)>(dlsym(RTLD_NEXT, "__fxstat"));
if (xfn) return xfn(_STAT_VER, fd, buf);
#endif
errno = ENOSYS;
return -1;
}
EXPORT int stat(const char *pathname, struct stat *statbuf) {
return fake_stat_path(pathname, statbuf, real_stat);
}
EXPORT int fstat(int fd, struct stat *buf) {
return fake_stat_fd(fd, buf, real_fstat);
}
EXPORT int access(const char *pathname, int mode) {
static auto my_access = reinterpret_cast<decltype(&::access)>(dlsym(RTLD_NEXT, "access"));
if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0;
@@ -2792,31 +2895,57 @@ EXPORT int openat64(int dirfd, const char *pathname, int flags, ...) {
return openat(dirfd, pathname, flags, mode);
}
static int real_stat64(const char *pathname, struct stat64 *statbuf) {
static auto fn = reinterpret_cast<int (*)(const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "stat64"));
if (fn) return fn(pathname, statbuf);
#ifdef _STAT_VER
static auto xfn = reinterpret_cast<int (*)(int, const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "__xstat64"));
if (xfn) return xfn(_STAT_VER, pathname, statbuf);
#endif
errno = ENOSYS;
return -1;
}
static int real_fstat64(int fd, struct stat64 *buf) {
static auto fn = reinterpret_cast<int (*)(int, struct stat64 *)>(dlsym(RTLD_NEXT, "fstat64"));
if (fn) return fn(fd, buf);
#ifdef _STAT_VER
static auto xfn = reinterpret_cast<int (*)(int, int, struct stat64 *)>(dlsym(RTLD_NEXT, "__fxstat64"));
if (xfn) return xfn(_STAT_VER, fd, buf);
#endif
errno = ENOSYS;
return -1;
}
EXPORT int stat64(const char *pathname, struct stat64 *statbuf) {
return stat(pathname, reinterpret_cast<struct stat *>(statbuf));
return fake_stat_path(pathname, statbuf, real_stat64);
}
EXPORT int fstat64(int fd, struct stat64 *buf) {
return fstat(fd, reinterpret_cast<struct stat *>(buf));
return fake_stat_fd(fd, buf, real_fstat64);
}
EXPORT int __xstat(int version, const char *pathname, struct stat *statbuf) {
(void)version;
return stat(pathname, statbuf);
static auto real = reinterpret_cast<int (*)(int, const char *, struct stat *)>(dlsym(RTLD_NEXT, "__xstat"));
if (!real) return stat(pathname, statbuf);
return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat *buf) { return real(version, path, buf); });
}
EXPORT int __xstat64(int version, const char *pathname, struct stat64 *statbuf) {
(void)version;
return stat(pathname, reinterpret_cast<struct stat *>(statbuf));
static auto real = reinterpret_cast<int (*)(int, const char *, struct stat64 *)>(dlsym(RTLD_NEXT, "__xstat64"));
if (!real) return stat64(pathname, statbuf);
return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat64 *buf) { return real(version, path, buf); });
}
EXPORT int __fxstat(int version, int fd, struct stat *buf) {
(void)version;
return fstat(fd, buf);
static auto real = reinterpret_cast<int (*)(int, int, struct stat *)>(dlsym(RTLD_NEXT, "__fxstat"));
if (!real) return fstat(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat *b) { return real(version, f, b); });
}
EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
(void)version;
return fstat(fd, reinterpret_cast<struct stat *>(buf));
static auto real = reinterpret_cast<int (*)(int, int, struct stat64 *)>(dlsym(RTLD_NEXT, "__fxstat64"));
if (!real) return fstat64(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
}
#endif
+37 -37
View File
@@ -4,7 +4,7 @@
# generated/ (no host wayland-scanner needed at app build time). Built by the app
# (add_subdirectory from ../CMakeLists.txt) and on its own by standalone/ for
# build-waylandcomp.yml.
add_library(bannerwayland SHARED
add_library(droiddeckwayland SHARED
src/compositor.c
src/vk_present.c
src/effects_chain.c
@@ -12,7 +12,7 @@ add_library(bannerwayland SHARED
src/sc_layer.c
src/ahb_swapchain.c
src/waylandcomp_jni.c
src/banner_ext.c
src/droiddeck_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
src/wl_text_input.c
@@ -36,9 +36,9 @@ add_library(bannerwayland SHARED
src/framegen_jni.c
)
# Warnings on for the compositor's own C. They do not fail the build; they are there to be read.
target_compile_options(bannerwayland PRIVATE
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:C>:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>)
target_include_directories(bannerwayland PRIVATE
target_include_directories(droiddeckwayland PRIVATE
generated
prebuilt/include
src
@@ -54,56 +54,56 @@ target_include_directories(bannerwayland PRIVATE
# framegen tree and the vendored DXBC translator (thirdparty/dxbc, for the LSFG cache format),
# which only the app build has: the standalone build (standalone/) gets the stub, which reports
# every engine unavailable and generates nothing.
set(BANNER_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
set(BANNER_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
if (TARGET dxbc AND EXISTS ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
AND EXISTS ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
message(STATUS "bannerwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
target_sources(bannerwayland PRIVATE
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
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)")
target_sources(droiddeckwayland PRIVATE
src/framegen_engine.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
# The Lossless.dll import (validate / extract / cache) for the app's LsfgNative.
${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
${BANNER_FRAMEGEN_WINFG_DIR}/framegen.cpp
${BANNER_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.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
)
target_include_directories(bannerwayland PRIVATE
target_include_directories(droiddeckwayland PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/.. # <thirdparty/...> as the frame-generation engines see it
${CMAKE_CURRENT_SOURCE_DIR}/../framegen # VulkanRendererContext.h (VkTable), lsfg/, winfg/
)
target_compile_options(bannerwayland PRIVATE
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
target_compile_definitions(bannerwayland PRIVATE
target_compile_definitions(droiddeckwayland PRIVATE
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
)
target_link_libraries(bannerwayland dxbc atomic)
target_link_libraries(droiddeckwayland dxbc atomic)
else()
message(STATUS "bannerwayland: frame-generation engines NOT built in (standalone build): stub")
target_sources(bannerwayland PRIVATE src/framegen_engine_stub.c)
message(STATUS "droiddeckwayland: frame-generation engines NOT built in (standalone build): stub")
target_sources(droiddeckwayland PRIVATE src/framegen_engine_stub.c)
endif()
add_library(wayland-server SHARED IMPORTED)
set_target_properties(wayland-server PROPERTIES IMPORTED_LOCATION
${CMAKE_CURRENT_SOURCE_DIR}/prebuilt/lib/libwayland-server.so)
# Vulkan is loaded at runtime via adrenotools (Turnip), not linked directly - so the
# compositor uses Turnip's driver, which supports dmabuf import (drm_format_modifier).
target_link_libraries(bannerwayland
target_link_libraries(droiddeckwayland
wayland-server
adrenotools
dl
@@ -29,7 +29,7 @@ the dma-buf fd through `zwp_linux_dmabuf_v1` as today (so the blit path stays as
The host half - one `ASurfaceControl` layer per fullscreen window, geometry from the fullscreen
mode, release fences from `ASurfaceTransaction_setOnComplete`, black base surface, HUD/pointer as
Android views above - is implemented in this branch as a gated prototype (`sc_layer.c`,
`BANNER_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the
`DROIDDECK_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the
SurfaceFlinger side can be measured on the device before the driver work starts.
---
@@ -240,13 +240,13 @@ Prerequisite: a build of this branch, container on Wayland, the AIO Graphics Tes
D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`.
0. **UBWC modifier A/B (no layers).** Done on `feat/wayland-ubwc` (advertised when the compositor's
driver imports it, `BANNER_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md`
driver imports it, `DROIDDECK_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md`
"Compressed (UBWC) game buffers"). Run the AIO Vulkan/D3D12 with and without. Expect
`vulkan: … is presenting GPU frames through Wayland: WxH, format XB24, tiled (zero-copy)` and
no `dmabuf import failed`. Records how much of the 20 % is the linear swapchain alone.
1. **Layer mode smoke.** Put `BANNER_WAYLAND_ZERO_COPY=1` in the container's environment
1. **Layer mode smoke.** Put `DROIDDECK_WAYLAND_ZERO_COPY=1` in the container's environment
variables, launch the AIO fullscreen. Expected log lines (tag `layer`):
`SurfaceControl "banner_wayland_game" created as a child of the screen surface`,
`SurfaceControl "droiddeck_wayland_game" created as a child of the screen surface`,
`pool buffer 1920x1080 UBWC (QCOM_COMPRESSED), stride 1920 px (gralloc handle 2 fds / N ints)`
(or `linear` after `import of a UBWC … failed` - also a result: it tells whether the compositor
Turnip accepts gralloc's UBWC pitch), `geometry: buffer 0,0-1920,1080 -> screen …`,
@@ -254,7 +254,7 @@ D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`.
and the probe line `kernel exports sync_file fences from the game's dma-buf …` or
`DMA_BUF_IOCTL_EXPORT_SYNC_FILE … failed (…)`. Picture correct, HUD and pointer on top, fullscreen
mode changes move the layer (`geometry:` lines).
2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 banner_wayland_game`
2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 droiddeck_wayland_game`
and the `GPU_TARGET` line of the pipe table. DEVICE/DEVICE on the game layer with an empty GPU
target = the DPU does the scaling; CLIENT = no gain on this panel orientation → try the
handheld's native orientation / a resolution equal to the panel's.
@@ -271,7 +271,7 @@ harden: async fence instead of CPU wait, per-window layers, presentation feedbac
`getLatchTime`); option (a) WSI + protocol + wcp = 2–3 days + 1 day device proving; option (c) =
3–4 days + the same proving; (b) = not buildable.
## 6. What the prototype in this branch does (`BANNER_WAYLAND_ZERO_COPY=1`)
## 6. What the prototype in this branch does (`DROIDDECK_WAYLAND_ZERO_COPY=1`)
Files: `src/sc_layer.{c,h}` (new), `src/vk_present.{c,h}` (`vkp_image_import_dmabuf` with a
blit-destination role, `vkp_blit_image`, `vkp_update_map`, `vkp_map_draw`, `vkp_window`,
@@ -2,8 +2,8 @@
* See ahb_swapchain.h and ZERO_COPY_SPIKE.md. */
#define _GNU_SOURCE
#include "ahb_swapchain.h"
#include "banner_color.h"
#include "banner_ext.h"
#include "droiddeck_color.h"
#include "droiddeck_ext.h"
#include "sc_layer.h"
#include "vk_present.h"
#include "banner-ahb-v1-server-protocol.h"
@@ -22,10 +22,10 @@
#define MOD_QCOM_COMPRESSED 0x0500000000000001ULL /* DRM_FORMAT_MOD_QCOM_COMPRESSED (UBWC) */
/* linux/dma-buf.h (not in the NDK sysroot's older headers): sync_file export / import. */
struct banner_dma_buf_sync_file { uint32_t flags; int32_t fd; };
#define BANNER_DMA_BUF_SYNC_READ 1u
#define BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct banner_dma_buf_sync_file)
#define BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct banner_dma_buf_sync_file)
struct droiddeck_dma_buf_sync_file { uint32_t flags; int32_t fd; };
#define DROIDDECK_DMA_BUF_SYNC_READ 1u
#define DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct droiddeck_dma_buf_sync_file)
#define DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct droiddeck_dma_buf_sync_file)
extern volatile int g_zero_copy;
@@ -90,9 +90,9 @@ static void buf_free(struct ahb_buf *ab) {
if (ab->fence_src) { wl_event_source_remove(ab->fence_src); ab->fence_src = NULL; }
if (ab->fence_fd >= 0) { close(ab->fence_fd); ab->fence_fd = -1; }
wl_list_remove(&ab->link);
*banner_dmabuf_ahb_slot(ab->b) = NULL;
*droiddeck_dmabuf_ahb_slot(ab->b) = NULL;
AHardwareBuffer_release(ab->ahb);
banner_dmabuf_unref(ab->b);
droiddeck_dmabuf_unref(ab->b);
free(ab);
}
@@ -113,7 +113,7 @@ static void on_buffer_resource_destroyed(struct wl_listener *l, void *data) {
static void send_deferred_release(struct ahb_buf *ab) {
if (ab->release_pending && ab->resource)
banner_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns);
droiddeck_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns);
ab->release_pending = 0;
ab->surface = NULL;
}
@@ -138,14 +138,14 @@ static void handle_released(uint64_t id, int fd) {
if (!ab) { if (fd >= 0) close(fd); return; }
ab->on_layer = 0;
if (fd >= 0) {
struct banner_dma_buf_sync_file imp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = fd};
int dmabuf_fd = banner_dmabuf_fd(ab->b);
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) {
struct droiddeck_dma_buf_sync_file imp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = fd};
int dmabuf_fd = droiddeck_dmabuf_fd(ab->b);
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) {
close(fd);
} else {
if (!g_import_failed_logged) {
g_import_failed_logged = 1;
banner_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead",
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead",
strerror(errno));
}
/* Wait it out in the event loop (a sync_file is readable once signalled). */
@@ -196,23 +196,23 @@ void ahb_swapchain_layer_released(void *token, int release_fd) {
/* ---- present */
int ahb_swapchain_has_ahb(const struct dmabuf_buffer *b) {
return b && *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL;
return b && *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL;
}
int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_w, int scene_h) {
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL;
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
if (!ab) return -1;
int dmabuf_fd = banner_dmabuf_fd(b);
int dmabuf_fd = droiddeck_dmabuf_fd(b);
/* Acquire fence: the game's render fence, which its driver put into the dma-buf before the
* commit (Mesa's implicit sync). The display waits on it, not the CPU. */
int acquire = -1;
struct banner_dma_buf_sync_file exp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = -1};
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) {
struct droiddeck_dma_buf_sync_file exp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = -1};
if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) {
acquire = exp.fd;
} else if (dmabuf_fd >= 0) {
if (!g_export_failed_logged) {
g_export_failed_logged = 1;
banner_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead",
droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead",
strerror(errno));
}
struct pollfd p = {.fd = dmabuf_fd, .events = POLLIN}; /* readable = the writers are done */
@@ -220,7 +220,7 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
}
int r = sc_layer_present_ahb(ab->ahb, ab->w, ab->h, acquire, (void *)(uintptr_t)ab->id, scene_w, scene_h,
s ? banner_surface_color(s) : NULL, ab->format);
s ? droiddeck_surface_color(s) : NULL, ab->format);
if (r < 0) return -1;
if (r == 1) return 0; /* nothing of it on screen: not on the layer, nothing to release later */
if (!ab->on_layer) {
@@ -231,8 +231,8 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_
if (s && g_announced != s) {
char name[160];
g_announced = s;
banner_surface_describe(s, name, sizeof(name));
banner_log("layer", "zero-copy: presenting %s without a copy", name);
droiddeck_surface_describe(s, name, sizeof(name));
droiddeck_log("layer", "zero-copy: presenting %s without a copy", name);
}
return 0;
}
@@ -245,7 +245,7 @@ int ahb_swapchain_last_frame_age_ms(void) {
}
int ahb_swapchain_defer_release(struct dmabuf_buffer *b, struct wl_resource *buffer, struct surface *s, int paced) {
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL;
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL;
if (!ab || !ab->on_layer || !buffer || ab->resource != buffer) return 0;
ab->release_pending = 1;
ab->deferred_ns = now_ns();
@@ -268,7 +268,7 @@ unsigned ahb_swapchain_stats_take(void) {
int ahb_swapchain_advertised(void) { return g_advertised; }
uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b) {
struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL;
struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL;
return ab ? ab->format : 0;
}
@@ -279,14 +279,14 @@ static void ahb_destroy(struct wl_client *c, struct wl_resource *r) { wl_resourc
static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_resource *buffer, int32_t sock,
uint32_t width, uint32_t height, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo,
uint32_t image_count) {
struct dmabuf_buffer *b = banner_dmabuf_from_resource(buffer);
struct dmabuf_buffer *b = droiddeck_dmabuf_from_resource(buffer);
uint64_t modifier = ((uint64_t)mod_hi << 32) | mod_lo;
if (!b) {
banner_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", banner_client_name(c));
droiddeck_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", droiddeck_client_name(c));
close(sock);
return;
}
void **slot = banner_dmabuf_ahb_slot(b);
void **slot = droiddeck_dmabuf_ahb_slot(b);
if (*slot) { close(sock); return; } /* once per buffer */
/* The handle was written into the socket before the request was sent; a short wait is just
@@ -298,17 +298,17 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
int rc = pr > 0 ? AHardwareBuffer_recvHandleFromUnixSocket(sock, &ahb) : -1;
close(sock);
if (rc != 0 || !ahb) {
banner_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", banner_client_name(c),
droiddeck_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", droiddeck_client_name(c),
pr > 0 ? "recvHandleFromUnixSocket" : "nothing on the socket");
return;
}
AHardwareBuffer_Desc d;
AHardwareBuffer_describe(ahb, &d);
int bw, bh;
banner_dmabuf_size(b, &bw, &bh);
droiddeck_dmabuf_size(b, &bw, &bh);
if ((int)d.width != bw || (int)d.height != bh || (int)width != bw || (int)height != bh) {
banner_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored",
banner_client_name(c), d.width, d.height, bw, bh);
droiddeck_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored",
droiddeck_client_name(c), d.width, d.height, bw, bh);
AHardwareBuffer_release(ahb);
return;
}
@@ -317,7 +317,7 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
if (!ab) { AHardwareBuffer_release(ahb); wl_client_post_no_memory(c); return; }
ab->id = g_next_id++;
ab->b = b;
banner_dmabuf_ref(b);
droiddeck_dmabuf_ref(b);
ab->ahb = ahb;
ab->w = bw; ab->h = bh;
ab->stride = d.stride ? d.stride : stride;
@@ -335,8 +335,8 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res
if (g_last_chain.client != c || g_last_chain.w != bw || g_last_chain.h != bh ||
g_last_chain.image_count != image_count || g_last_chain.modifier != modifier || g_last_chain.format != d.format) {
g_last_chain = (struct chain_seen){c, bw, bh, image_count, modifier, d.format};
banner_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", banner_client_name(c),
image_count, bw, bh, banner_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)"
droiddeck_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", droiddeck_client_name(c),
image_count, bw, bh, droiddeck_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)"
: modifier == 0 ? "linear" : "unknown modifier", ab->stride);
}
}
@@ -363,7 +363,7 @@ static void bind_ahb(struct wl_client *c, void *data, uint32_t ver, uint32_t id)
/* One line per program, not per surface-format query (each binds its own). */
if (last_named != c) {
last_named = c;
banner_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", banner_client_name(c), ver,
droiddeck_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", droiddeck_client_name(c), ver,
ver >= 2 ? "follows the live switch" : "version 1: decides from its launch environment only");
}
}
@@ -372,7 +372,7 @@ void ahb_swapchain_set_mode(int on, int live) {
on = on ? 1 : 0;
g_zero_copy = on;
if (!g_advertised) {
if (live) banner_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change",
if (live) droiddeck_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change",
on ? "on" : "off");
return;
}
@@ -390,37 +390,37 @@ void ahb_swapchain_set_mode(int on, int live) {
memset(&g_last_chain, 0, sizeof(g_last_chain));
g_announced = NULL;
if (live)
banner_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s",
droiddeck_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s",
on ? "on" : "off", told, told == 1 ? "" : "s",
on ? "; frames go on the display layer once the new gralloc swapchain is up"
: "; gralloc frames still in flight stay on the layer (or take the copy path) until then");
else
banner_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off",
on ? "BANNER_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer"
droiddeck_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off",
on ? "DROIDDECK_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer"
: "the drawer's Zero-copy presentation switch turns it on live");
if (live) wl_display_flush_clients(banner_get_display());
banner_request_redraw();
if (live) wl_display_flush_clients(droiddeck_get_display());
droiddeck_request_redraw();
}
void ahb_swapchain_init(struct wl_display *display) {
wl_list_init(&g_bufs);
wl_list_init(&g_clients);
if (!sc_layer_available()) {
banner_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised");
droiddeck_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised");
return;
}
g_loop = wl_display_get_event_loop(display);
if (pipe2(g_rel_pipe, O_CLOEXEC | O_NONBLOCK) != 0) {
banner_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno));
droiddeck_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno));
g_rel_pipe[0] = g_rel_pipe[1] = -1;
return;
}
wl_event_loop_add_fd(g_loop, g_rel_pipe[0], WL_EVENT_READABLE, on_release_pipe, NULL);
if (!wl_global_create(display, &banner_ahb_v1_interface, 2, NULL, bind_ahb)) {
banner_log("error", "zero-copy: banner_ahb_v1 global creation failed");
droiddeck_log("error", "zero-copy: banner_ahb_v1 global creation failed");
return;
}
g_advertised = 1;
banner_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)");
droiddeck_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)");
ahb_swapchain_set_mode(g_zero_copy, 0);
}
@@ -1,7 +1,7 @@
#ifndef AHB_SWAPCHAIN_H
#define AHB_SWAPCHAIN_H
/*
* Zero-copy game frames (layer mode, BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy
* Zero-copy game frames (layer mode, DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy
* presentation" switch live): the guest half of the design in ZERO_COPY_SPIKE.md. Our Wayland Turnip
* (banners-turnip-wayland, patches/wayland/banner_ahb_wsi.py) binds the private global banner_ahb_v1
* (version 2), which this compositor advertises on every session where a display layer is possible,
@@ -63,21 +63,21 @@ uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b);
void ahb_swapchain_layer_released(void *token, int release_fd);
/* ---- ahb_swapchain.c -> compositor.c (hooks) */
struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer);
int banner_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */
void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h);
void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */
void banner_dmabuf_ref(struct dmabuf_buffer *b);
void banner_dmabuf_unref(struct dmabuf_buffer *b);
struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer);
int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */
void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h);
void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */
void droiddeck_dmabuf_ref(struct dmabuf_buffer *b);
void droiddeck_dmabuf_unref(struct dmabuf_buffer *b);
/* Give a wl_buffer back to its client now (paced = 0) or on the FPS limiter's cadence (s != NULL).
* since_ns = when the compositor let go of it (CLOCK_MONOTONIC; the perf line's release latency). */
void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns);
void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns);
/* Redraw the scene on the next tick (the present path may have changed). */
void banner_request_redraw(void);
void droiddeck_request_redraw(void);
/* "<title>" (program) of the window a surface belongs to, for the log. */
void banner_surface_describe(const struct surface *s, char *out, size_t size);
/* The surface's current image description (banner_color.h), NULL = none / HDR gate closed. */
struct banner_color;
const struct banner_color *banner_surface_color(const struct surface *s);
void droiddeck_surface_describe(const struct surface *s, char *out, size_t size);
/* The surface's current image description (droiddeck_color.h), NULL = none / HDR gate closed. */
struct droiddeck_color;
const struct droiddeck_color *droiddeck_surface_color(const struct surface *s);
#endif
@@ -1,50 +0,0 @@
/*
* Interface between compositor.c and the extension modules that live in their own files:
* wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only)
* wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard)
* wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered)
* banner_ext.c the host→compositor message queue shared by the modules
* Everything here runs on the compositor (dispatch) thread unless noted.
*/
#pragma once
#include <stdint.h>
#include <wayland-server.h>
void banner_log(const char *tag, const char *fmt, ...);
/* ---- compositor.c → modules */
void banner_ext_init(struct wl_display *display); /* creates the globals + host queue */
void banner_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */
void banner_text_input_refocus(void); /* the text-input target may have changed */
void banner_text_input_surface_gone(struct wl_resource *surface);
/* ---- modules → compositor.c */
const char *banner_client_name(struct wl_client *client);
struct wl_display *banner_get_display(void);
/* The surface text input follows (the last clicked window, else the topmost program window). */
struct wl_resource *banner_ime_target(void);
/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */
void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y);
/* Deliver a key press/release through the wl_keyboard path as if typed. */
void banner_inject_key(uint32_t evdev, int pressed);
/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */
void banner_on_clipboard_text(const char *utf8, int len);
void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h);
/* ---- app → modules (callable from any thread; queued to the compositor thread) */
void banner_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */
void banner_host_text_commit(const char *utf8, int len);
void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end);
void banner_host_text_delete(int before, int after);
void banner_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */
void banner_host_hdr_output(int on); /* the drawer's HDR output switch (banner_color_set_output) */
/* ---- module internals reached through the queue (banner_ext.c → modules) */
void clipboard_init(struct wl_display *display);
void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */
void text_input_init(struct wl_display *display);
void text_input_host_commit(const char *utf8, size_t len);
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end);
void text_input_host_delete(int before, int after);
void toplevel_icon_init(struct wl_display *display);
@@ -2,7 +2,7 @@
#define _POSIX_C_SOURCE 200809L
#include "blend_pass.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log, vkp_gpu_name */
#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */
#include <string.h>
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
@@ -168,12 +168,12 @@ static int objects_ensure(void) {
if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail;
if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail;
g_ready = 1;
banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
droiddeck_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them",
vkp_gpu_name());
return 0;
fail:
objects_destroy();
banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
droiddeck_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces "
"are drawn opaque this session");
return -1;
}
+130 -130
View File
@@ -1,5 +1,5 @@
/*
* bannerlator-wayland - the embedded compositor of the Wayland display path.
* droiddeck-wayland - the embedded compositor of the Wayland display path.
*
* Clients are the Wine processes of a container (winewayland.drv) and, through them,
* Mesa's Vulkan WSI. Each process is its own client, so a Windows virtual desktop is
@@ -50,12 +50,12 @@
#include "framegen_bridge.h"
#include <pthread.h>
#include "effects_chain.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "droiddeck_ext.h"
#include "droiddeck_color.h"
#include "compositor_internal.h"
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
/* ------------------------------------------------------------------ session log
* One readable, time-stamped file per Wayland session in Download/Wayland-logs, also
@@ -75,7 +75,7 @@ static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
static char *g_prelog[PRELOG_MAX];
static int g_prelog_n;
void banner_log(const char *tag, const char *fmt, ...) {
void droiddeck_log(const char *tag, const char *fmt, ...) {
char msg[512];
va_list ap;
va_start(ap, fmt);
@@ -121,11 +121,11 @@ static void open_session_log(void) {
setvbuf(f, NULL, _IOLBF, 0);
strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &tm);
fprintf(f,
"Bannerlator Wayland session\n"
"===========================\n"
"DroidDeck Wayland session\n"
"=========================\n"
"Started %s\n"
"Display Wayland: Windows programs draw through the Bannerlator compositor.\n"
" No X server is used for this session.\n"
"Display Wayland: programs draw through the DroidDeck compositor.\n"
" gamescope and the desktop start their own Xwayland for X11 programs.\n"
"Log %s\n\n"
"Time Area Event\n"
"------------ -------- -----------------------------------------------------\n",
@@ -178,8 +178,8 @@ static const char *client_name(struct wl_client *client) {
static void on_client_destroyed(struct wl_listener *l, void *data) {
struct client_info *ci = wl_container_of(l, ci, destroy), **pp;
banner_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid);
banner_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */
droiddeck_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid);
droiddeck_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */
for (pp = &g_clients; *pp; pp = &(*pp)->next)
if (*pp == ci) { *pp = ci->next; break; }
free(ci);
@@ -211,7 +211,7 @@ static void on_client_created(struct wl_listener *l, void *data) {
wl_client_add_destroy_listener(client, &ci->destroy);
ci->next = g_clients;
g_clients = ci;
banner_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid);
droiddeck_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid);
}
/* ------------------------------------------------------------------ surfaces */
@@ -304,7 +304,7 @@ volatile int g_fps_limit;
* the X11 renderer's unviewable "explorer.exe". They still exist for input routing. */
volatile int g_hide_shell;
/* Experimental layer mode (BANNER_WAYLAND_ZERO_COPY=1 in the container's environment): a single
/* Experimental layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 in the container's environment): a single
* fullscreen window is shown on its own Android layer (sc_layer.c) instead of being blitted into the
* screen swapchain. Set from the app before the compositor starts; read per frame. */
volatile int g_zero_copy;
@@ -314,11 +314,11 @@ static int g_zero_copy_fx_skip_said;
/* Compressed (UBWC) game buffers: zwp_linux_dmabuf_v1 advertises DRM_FORMAT_MOD_QCOM_COMPRESSED next
* to LINEAR for every format the renderer's own driver can import that way, so Turnip's Wayland WSI
* in the game allocates UBWC swapchain images instead of resolving each frame to a linear copy.
* BANNER_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set
* DROIDDECK_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set
* from the app before the compositor starts; read at every zwp_linux_dmabuf_v1 bind. */
volatile int g_ubwc = 1;
/* Debug (BANNER_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone
/* Debug (DROIDDECK_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone
* that exposes no /dev/dri node to apps does, to reproduce its OpenGL path here. Set from the app
* before the compositor starts. */
volatile int g_no_render_node;
@@ -544,13 +544,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
/* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below
* copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to
* change the cursor. A torn read is only a missed or an extra snapshot. */
int banner_cursor_serial(void) {
int droiddeck_cursor_serial(void) {
return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED);
}
/* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...].
* Returns the number of ints written, or 0 if out is too small. */
int banner_cursor_snapshot(int *out, int cap) {
int droiddeck_cursor_snapshot(int *out, int cap) {
int n = 0;
pthread_mutex_lock(&g_cursor_lock);
int need = 6 + (g_cursor_hidden ? 0 : g_cursor_w * g_cursor_h);
@@ -650,7 +650,7 @@ static void map_toplevel(struct surface *s) {
{
char name[160];
describe(s, name, sizeof(name));
banner_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y,
droiddeck_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y,
s->placed ? "" : " (no desktop position yet)");
}
wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */
@@ -667,12 +667,12 @@ static void unmap_toplevel(struct surface *s) {
{
char name[160];
describe(s, name, sizeof(name));
banner_log("window", "closed %s", name);
droiddeck_log("window", "closed %s", name);
}
wl_list_remove(&s->toplevel_link);
wl_list_init(&s->toplevel_link);
if (g_ime_click == s) g_ime_click = NULL;
banner_text_input_refocus();
droiddeck_text_input_refocus();
}
/* Let go of the surface's dmabuf content. paced = 1: the buffer was replaced, give it back on the
@@ -752,11 +752,11 @@ static struct surface *g_hud_surface;
static int64_t g_hud_last_ns;
/* That window committed a frame the screen has not drawn yet. */
static int g_hud_fresh;
extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
extern void banner_on_game_frame(void);
extern void droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */
extern void droiddeck_on_game_frame(void);
/* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name
* ("" when /proc gave none) - the app arms its CPU affinity on it (X11 does that from window events). */
extern void banner_on_game_program(int pid, const char *program);
extern void droiddeck_on_game_program(int pid, const char *program);
static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_resource *buffer) {
if (s->dmabuf != buffer || s->dmabuf_buf != b) {
@@ -787,7 +787,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
(s->buf_w != b->width || s->buf_h != b->height)) {
char name[160];
describe(s, name, sizeof(name));
banner_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name,
droiddeck_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name,
s->buf_w, s->buf_h, b->width, b->height);
}
s->buf_w = b->width;
@@ -803,18 +803,18 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
if (b->img)
/* Imported for the copy path. Whether its frames ALSO go on the display layer without a copy
* is decided per frame (fullscreen, zero-copy on) and counted in the 10 s lines. */
banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)",
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)",
name, b->width, b->height, b->format & 0xff, (b->format >> 8) & 0xff,
(b->format >> 16) & 0xff, (b->format >> 24) & 0xff,
vkp_modifier_name(b->modifier),
ahb_swapchain_has_ahb(b) ? "gralloc buffers: can go on the display layer without a copy"
: "dma-buf: copied into the screen swapchain");
else if (ahb_swapchain_has_ahb(b))
banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only "
droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only "
"(gralloc buffers the compositor cannot import for the copy path)",
name, b->width, b->height);
else
banner_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)",
droiddeck_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)",
name, b->width, b->height, (unsigned long long)b->modifier);
}
/* The HUD follows the window whether its frames are copied or go straight to the layer: a
@@ -830,21 +830,21 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
char name[160];
describe(s, name, sizeof(name));
g_hud_surface = s;
banner_on_game_surface(name, vkp_gpu_name());
droiddeck_on_game_surface(name, vkp_gpu_name());
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : "");
if (ci) droiddeck_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : "");
}
/* HDR session (gate open): a DXVK game switches to HDR by REBUILDING its swapchain on the same
* surface, so the one-shot line above never sees the 10-bit buffers - name every format change. */
if (b->format != s->hdr_fmt_logged && banner_color_hdr_open()) {
if (b->format != s->hdr_fmt_logged && droiddeck_color_hdr_open()) {
char name[160];
uint32_t af = ahb_swapchain_ahb_format(b);
int ten = b->format == FOURCC('A', 'B', '3', '0') || b->format == FOURCC('X', 'B', '3', '0');
describe(s, name, sizeof(name));
banner_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height,
droiddeck_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height,
b->format & 0xff, (b->format >> 8) & 0xff, (b->format >> 16) & 0xff, (b->format >> 24) & 0xff,
ten ? "10-bit A2B10G10R10" : "8-bit", vkp_modifier_name(b->modifier),
af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? banner_ahb_format_name(af) : "",
af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? droiddeck_ahb_format_name(af) : "",
b->img ? "the compositor imported it"
: af ? "the compositor's driver could NOT import it (display layer only, fullscreen)"
: "the compositor's driver could NOT import it (nothing can show it)");
@@ -854,23 +854,23 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re
* Asking whether the compositor could import it, as this used to, is a question about the
* copy path and not about whether a frame happened: a game whose buffers go straight to
* the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; banner_on_game_frame(); }
if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; droiddeck_on_game_frame(); }
}
/* ---- hooks for ahb_swapchain.c (zero-copy layers) */
struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); }
int banner_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; }
void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; }
void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; }
void banner_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; }
void banner_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); }
void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) {
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; }
void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; }
void droiddeck_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; }
void droiddeck_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); }
void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) {
if (paced && s) release_buffer(s, buffer, since_ns);
else { wl_buffer_send_release(buffer); perf_note_release(since_ns); }
}
void banner_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); }
const struct banner_color *banner_surface_color(const struct surface *s) {
return s ? banner_color_of(s->resource) : NULL;
void droiddeck_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); }
const struct droiddeck_color *droiddeck_surface_color(const struct surface *s) {
return s ? droiddeck_color_of(s->resource) : NULL;
}
/* ------------------------------------------------------------------ wl_surface */
@@ -1134,7 +1134,7 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
wl_list_insert_list(s->feedback.prev, &s->pending_feedback);
wl_list_init(&s->pending_feedback);
constraints_surface_commit(s);
banner_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */
droiddeck_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */
if (s->role == ROLE_TOPLEVEL && s->xdg_toplevel) {
if (s->has_content) map_toplevel(s);
@@ -1184,9 +1184,9 @@ static void surface_resource_destroy(struct wl_resource *r) {
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;
banner_text_input_surface_gone(r);
if (g_desktop == s) { g_desktop = NULL; banner_log("desktop", "the desktop closed"); }
if (g_hud_surface == s) { g_hud_surface = NULL; banner_on_game_surface(NULL, NULL); }
droiddeck_text_input_surface_gone(r);
if (g_desktop == s) { g_desktop = NULL; droiddeck_log("desktop", "the desktop closed"); }
if (g_hud_surface == s) { g_hud_surface = NULL; droiddeck_on_game_surface(NULL, NULL); }
constraints_surface_gone(s);
unmap_toplevel(s);
@@ -1436,7 +1436,7 @@ static void xdg_toplevel_set_title(struct wl_client *c, struct wl_resource *r, c
if (announce) {
char after[160];
describe(s, after, sizeof(after));
banner_log("window", "renamed %s -> %s", before, after);
droiddeck_log("window", "renamed %s -> %s", before, after);
}
}
static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {}
@@ -1613,7 +1613,7 @@ static void desktop_set_desktop(struct wl_client *c, struct wl_resource *r, stru
if (!s || (s->role != ROLE_NONE && s->role != ROLE_DESKTOP)) return;
s->role = ROLE_DESKTOP;
g_desktop = s;
banner_log("desktop", "Windows virtual desktop created by %s", client_name(c));
droiddeck_log("desktop", "Windows virtual desktop created by %s", client_name(c));
schedule_render();
}
static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struct wl_resource *surface,
@@ -1623,7 +1623,7 @@ static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struc
if (s->placed && (s->x != x || s->y != y) && s->mapped && log_budget()) {
char name[160];
describe(s, name, sizeof(name));
banner_log("window", "moved %s to %d,%d", name, x, y);
droiddeck_log("window", "moved %s to %d,%d", name, x, y);
}
s->hwnd = hwnd;
s->x = x;
@@ -1666,13 +1666,13 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t
if (!r) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(r, &output_impl, NULL, NULL);
wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN,
"Bannerlator", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
"DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL);
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);
if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) {
wl_output_send_name(r, "Bannerlator-1");
wl_output_send_description(r, "Bannerlator display");
wl_output_send_name(r, "DroidDeck-1");
wl_output_send_description(r, "DroidDeck display");
}
if (ver >= 2) {
wl_output_send_scale(r, 1);
@@ -1701,7 +1701,7 @@ static int g_hdr_unimported_rect[4], g_hdr_unimported_below; /* scene x,y,w,h; d
static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, int ox, int oy) {
if (!s->dmabuf_buf || s->dmabuf_buf->img || !ahb_swapchain_has_ahb(s->dmabuf_buf)) return;
const struct banner_color *c = banner_color_of(s->resource);
const struct droiddeck_color *c = droiddeck_color_of(s->resource);
if (!c || !c->dataspace) return;
if (s->src_set && (s->src[0] != 0 || s->src[1] != 0 || (int)(s->src[2] + 0.5f) != s->buf_w ||
(int)(s->src[3] + 0.5f) != s->buf_h)) return; /* cropped: the layer shows whole buffers */
@@ -1730,7 +1730,7 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
int dw, dh;
if (!img) {
if (g_zero_copy && banner_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy);
if (g_zero_copy && droiddeck_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy);
return;
}
if (s->src_set) { sx = s->src[0]; sy = s->src[1]; sw = s->src[2]; sh = s->src[3]; }
@@ -1850,7 +1850,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) {
return s;
}
/* ---- HDR (banner_color.h) in the scene
/* ---- HDR (droiddeck_color.h) in the scene
* An HDR frame is only right on the game's own display layer: everything the compositor draws itself -
* the scene blit, the effects chain, frame generation, its swapchain - is 8-bit sRGB, and a PQ-encoded
* frame pushed through it as it stands comes out washed out. So a scene with HDR in it takes one of
@@ -1866,7 +1866,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) {
* tone-mapped to SDR (correct colours instead of washed out).
* 3 a frame the compositor could not import: only its display layer can show it, so effects and
* frame generation are skipped for it (said in the log).
* The drawer's HDR output switch (banner_color_output) OFF: the same routes, tone-mapped - route 0
* The drawer's HDR output switch (droiddeck_color_output) OFF: the same routes, tone-mapped - route 0
* becomes route 1 (the whole scene composed, tone-mapped to sRGB, onto the game layer UNtagged), route 2
* tone-maps into an SDR swapchain, route 3 cannot (nothing can read those frames) and stays HDR, said
* in the log. The game is told nothing; it keeps rendering HDR.
@@ -1876,7 +1876,7 @@ struct hdr_scene {
struct vkp_hdr_frame hf;
unsigned char *is_hdr; /* per draw (calloc'd; free after the frame) */
int n_hdr;
const struct banner_color *color; /* the topmost HDR draw's description: what the picture is tagged */
const struct droiddeck_color *color; /* the topmost HDR draw's description: what the picture is tagged */
struct surface *who; /* its surface, for the log */
const char *why; /* route 1: why the game cannot be alone on its layer */
};
@@ -1891,29 +1891,29 @@ static struct surface *hdr_unimported_fullscreen(const struct draw_list *dl, int
}
static int is_hdr_surface(struct surface *s) {
const struct banner_color *c = s ? banner_color_of(s->resource) : NULL;
const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
return c && c->dataspace;
}
/* Which route this scene takes (see above); fills hs. 0 whenever the gate is closed or no HDR is drawn. */
static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int framegen, struct hdr_scene *hs) {
memset(hs, 0, sizeof(*hs));
if (!banner_color_hdr_open()) return 0;
if (!droiddeck_color_hdr_open()) return 0;
/* A fullscreen HDR frame this compositor could not import has no draw: layer only (route 3). */
struct surface *un = hdr_unimported_fullscreen(dl, w, h);
if (!un) {
struct surface *lo = ahb_layer_only_candidate(w, h);
if (lo && is_hdr_surface(lo)) un = lo;
}
if (un && g_zero_copy) { hs->color = banner_color_of(un->resource); hs->who = un; return 3; }
hs->hf.tonemap = !banner_color_output();
if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; }
hs->hf.tonemap = !droiddeck_color_output();
if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n, 1))) return 0;
for (int i = 0; i < dl->n; i++) {
struct surface *s = surface_for_image(dl->d[i].img);
if (!is_hdr_surface(s)) continue;
hs->is_hdr[i] = 1;
hs->n_hdr++;
hs->color = banner_color_of(s->resource); /* later draws are above: the topmost wins */
hs->color = droiddeck_color_of(s->resource); /* later draws are above: the topmost wins */
hs->who = s;
}
if (!hs->n_hdr) { free(hs->is_hdr); hs->is_hdr = NULL; return 0; }
@@ -1942,7 +1942,7 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra
static void hdr_note_route(int route, const struct hdr_scene *hs) {
static int said = -1, said_tm = -1;
static const char *said_why;
const int tm = route != 0 && !banner_color_output(); /* route 0 never runs with the switch off */
const int tm = route != 0 && !droiddeck_color_output(); /* route 0 never runs with the switch off */
if (route == said && tm == said_tm && (route != 1 || hs->why == said_why)) return;
int was = said;
said = route; said_why = hs->why; said_tm = tm;
@@ -1951,30 +1951,30 @@ static void hdr_note_route(int route, const struct hdr_scene *hs) {
switch (route) {
case 1:
if (tm)
banner_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR "
droiddeck_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR "
"(HDR peak %.0f nits rolled off to SDR white, screen effects applied after it) on the game's "
"display layer, untagged", name, hs->why ? hs->why : "?", hs->hf.peak_nits);
else
banner_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture "
droiddeck_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture "
"(SDR content at %.0f nits, screen effects applied in 10-bit) on the game's display layer, tagged "
"BT2020_PQ", name, hs->why ? hs->why : "?", banner_color_sdr_white());
"BT2020_PQ", name, hs->why ? hs->why : "?", droiddeck_color_sdr_white());
break;
case 2:
if (tm)
banner_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to "
droiddeck_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to "
"SDR and presented through an SDR screen swapchain", name);
else
banner_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through "
droiddeck_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through "
"the screen swapchain (HDR10 where the surface offers it, else tone-mapped to SDR)", name);
break;
case 3:
if (tm)
banner_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, "
droiddeck_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, "
"so nothing can tone-map them: they stay HDR on its own display layer", name);
break; /* otherwise hdr_note_precedence says it */
default:
if (was == 1 || was == 2)
banner_log("color", "%s is back on its own display layer (no composition needed)", name);
droiddeck_log("color", "%s is back on its own display layer (no composition needed)", name);
break;
}
}
@@ -1987,18 +1987,18 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) {
if (hs) describe(hs, name, sizeof(name));
if (fx_skip != fx_said) {
if (fx_skip)
banner_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the "
droiddeck_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the "
"compositor, so only its own display layer can show them (as they are)", name);
else if (fx_said == 1)
banner_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)");
droiddeck_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)");
fx_said = fx_skip;
}
if (fg_skip != fg_said) {
if (fg_skip)
banner_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the "
droiddeck_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the "
"compositor, so only its own display layer can show them (as they are)", name);
else if (fg_said == 1)
banner_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
droiddeck_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)");
fg_said = fg_skip;
}
}
@@ -2007,11 +2007,11 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) {
static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
for (int i = 0; i < dl->n; i++) {
struct surface *s = surface_for_image(dl->d[i].img);
const struct banner_color *c = s ? banner_color_of(s->resource) : NULL;
const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL;
if (!c || !c->dataspace) continue;
char name[160];
describe(s, name, sizeof(name));
banner_color_frame_copied(name, reason);
droiddeck_color_frame_copied(name, reason);
}
}
@@ -2028,8 +2028,8 @@ static void render_scene(void) {
wl_list_for_each(s, &g_surfaces, link) s->drawn = 0;
scene_size(&w, &h);
if (w != g_scene_w || h != g_scene_h) {
if (g_desktop) banner_log("desktop", "size %dx%d", w, h);
else banner_log("desktop", "no desktop: showing %dx%d (largest window)", w, h);
if (g_desktop) droiddeck_log("desktop", "size %dx%d", w, h);
else droiddeck_log("desktop", "no desktop: showing %dx%d (largest window)", w, h);
g_scene_w = w;
g_scene_h = h;
}
@@ -2048,7 +2048,7 @@ static void render_scene(void) {
struct hdr_scene hs;
int hdr_route = hdr_plan(&dl, w, h, fx_on, framegen, &hs);
const char *hdr_copy = NULL; /* why this scene's HDR frames (if any) took the copy path */
if (banner_color_hdr_open()) {
if (droiddeck_color_hdr_open()) {
hdr_note_route(hdr_route, &hs);
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
}
@@ -2059,12 +2059,12 @@ static void render_scene(void) {
sc_layer_hide_overlay();
if (sc_layer_present_hdr_scene(dl.d, dl.n, &hs.hf, w, h, hs.color) == 0) {
rendered = vkp_base_black(w, h) == 0; /* black under the composed picture, presented only when it is not already */
if (fell_back) { fell_back = 0; banner_log("color", "the composed picture is on the game's display layer again"); }
if (fell_back) { fell_back = 0; droiddeck_log("color", "the composed picture is on the game's display layer again"); }
} else {
hdr_route = 2; /* no layer this frame: the picture goes through the swapchain instead */
if (!fell_back) {
fell_back = 1;
banner_log("color", "the composed picture could not go on the game's display layer (no layer, or the "
droiddeck_log("color", "the composed picture could not go on the game's display layer (no layer, or the "
"10-bit pass failed): it goes through the screen swapchain instead until that changes");
}
}
@@ -2074,8 +2074,8 @@ static void render_scene(void) {
sc_layer_hide();
rendered = vkp_render_hdr(w, h, dl.d, dl.n, &hs.hf, &how) == 0;
copy = 1;
if (rendered && how == 1) banner_color_frame_shown(hs.color, BANNER_HDR_SWAPCHAIN, 0);
else if (rendered && how == 2) banner_color_frame_shown(hs.color, BANNER_HDR_TONEMAPPED, 0);
if (rendered && how == 1) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_SWAPCHAIN, 0);
else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0);
else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver";
}
if (hdr_route == 0 || hdr_route == 3) {
@@ -2104,14 +2104,14 @@ static void render_scene(void) {
if (g_zero_copy && blocked != g_zero_copy_paused) {
g_zero_copy_paused = blocked;
if (blocked == 1)
banner_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
droiddeck_log("framegen", "zero-copy paused: frame generation needs the compositor pass");
else if (blocked == 2)
banner_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
droiddeck_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene");
else if (blocked == 3)
banner_log("layer", "zero-copy paused: a window above the game would need a second display layer, "
droiddeck_log("layer", "zero-copy paused: a window above the game would need a second display layer, "
"which this display cannot compose in hardware - whole scene on the copy path");
else
banner_log("effects", "zero-copy resumed: the game is back on its own display layer");
droiddeck_log("effects", "zero-copy resumed: the game is back on its own display layer");
}
struct surface *ls = li >= 0 ? surface_for_image(dl.d[li].img) : NULL;
/* A frame this renderer could not import can only be shown on the layer, effects or not. */
@@ -2121,7 +2121,7 @@ static void render_scene(void) {
if (li < 0 && !ls) ls = hdr_unimported_fullscreen(&dl, w, h);
if (ls && pass_on && li < 0 && !g_zero_copy_fx_skip_said) {
g_zero_copy_fx_skip_said = 1;
banner_log(framegen ? "framegen" : "effects",
droiddeck_log(framegen ? "framegen" : "effects",
"the game's frames cannot be imported by the compositor: shown zero-copy, %s skipped",
framegen ? "frame generation" : "effects");
}
@@ -2148,7 +2148,7 @@ static void render_scene(void) {
r = ahb_swapchain_present(ls->dmabuf_buf, ls, w, h);
if (r != 0 && li >= 0)
r = fx_on ? sc_layer_present_pass(&dl.d[li], 1, w, h)
: sc_layer_present(dl.d[li].img, w, h, ls ? banner_surface_color(ls) : NULL);
: sc_layer_present(dl.d[li].img, w, h, ls ? droiddeck_surface_color(ls) : NULL);
if (r == 0) {
if (ls) ls->drawn = 1;
/* The one window above the game keeps the game off the copy path entirely: it goes on
@@ -2188,7 +2188,7 @@ static void render_scene(void) {
}
} /* routes 0 and 3 */
free(hs.is_hdr);
if (hdr_copy && rendered && banner_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy);
if (rendered) {
int64_t t = now_ns();
g_stat_frames++;
@@ -2370,7 +2370,7 @@ static void bind_seat(struct wl_client *c, void *data, uint32_t ver, uint32_t id
struct wl_resource *r = wl_resource_create(c, &wl_seat_interface, ver, id);
wl_resource_set_implementation(r, &seat_impl, NULL, NULL);
wl_seat_send_capabilities(r, WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD | WL_SEAT_CAPABILITY_TOUCH);
if (ver >= 2) wl_seat_send_name(r, "bannerlator-seat");
if (ver >= 2) wl_seat_send_name(r, "droiddeck-seat");
}
static struct seat_pointer *pointer_for(struct wl_client *client) {
@@ -2449,7 +2449,7 @@ static void keyboard_focus(struct wl_resource *target) {
g_mods_depressed, 0, g_mods_locked, 0);
}
wl_array_release(&keys);
banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
droiddeck_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */
}
/* ------------------------------------------------------------------ pointer constraints
@@ -2502,14 +2502,14 @@ static int g_raw_valid;
/* JNI: the app switches its touch/mouse path to deltas while a lock holds and re-syncs its
* pointer to x,y when it ends. */
extern void banner_on_pointer_lock(int locked, int x, int y);
extern void droiddeck_on_pointer_lock(int locked, int x, int y);
static void sync_lock_notify(void) {
static int last;
int locked = g_active_constraint && g_active_constraint->is_lock;
if (locked == last) return;
last = locked;
banner_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
}
static void constraint_describe(const struct constraint *k, char *out, size_t size) {
@@ -2570,7 +2570,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh
g_raw_valid = 0;
if (tell_client) constraint_send_state(k, 0);
constraint_describe(k, name, sizeof(name));
banner_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined",
name, why, (int)g_ptr_x, (int)g_ptr_y);
sync_lock_notify();
}
@@ -2590,15 +2590,15 @@ static void constraint_activate(struct constraint *k) {
constraint_send_state(k, 1);
constraint_describe(k, name, sizeof(name));
if (k->is_lock)
banner_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name,
droiddeck_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name,
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent",
(int)g_ptr_x, (int)g_ptr_y);
else if (k->region.set)
banner_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name,
droiddeck_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name,
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent",
k->region.w, k->region.h, k->region.x, k->region.y);
else
banner_log("pointer", "confined: %s (%s) to the whole window", name,
droiddeck_log("pointer", "confined: %s (%s) to the whole window", name,
k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent");
sync_lock_notify();
wl_display_flush_clients(g_display);
@@ -2652,7 +2652,7 @@ static void constraints_surface_commit(struct surface *s) {
g_ptr_x = ox + k->hint_x;
g_ptr_y = oy + k->hint_y;
if (log_budget())
banner_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y);
}
}
if (k->pending_region_set) {
@@ -2733,7 +2733,7 @@ static void constraint_create(struct wl_client *c, struct wl_resource *r, uint32
wl_list_insert(g_constraints.prev, &k->link);
if (!s) { k->defunct = 1; return; }
constraint_describe(k, name, sizeof(name));
banner_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
droiddeck_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name);
constraint_activate(k);
}
@@ -2764,7 +2764,7 @@ static void bind_pointer_constraints(struct wl_client *c, void *data, uint32_t v
static void relative_pointer_resource_destroy(struct wl_resource *r) {
struct relative_pointer *rp = wl_resource_get_user_data(r);
if (!rp) return;
banner_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
droiddeck_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r)));
wl_list_remove(&rp->link);
free(rp);
}
@@ -2783,7 +2783,7 @@ static void relative_pointer_manager_get(struct wl_client *c, struct wl_resource
rp->pointer = pointer;
wl_resource_set_implementation(res, &relative_pointer_impl, rp, relative_pointer_resource_destroy);
wl_list_insert(g_relative_pointers.prev, &rp->link);
banner_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c));
droiddeck_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c));
}
static const struct zwp_relative_pointer_manager_v1_interface relative_pointer_manager_impl = {
.destroy = relative_pointer_manager_destroy,
@@ -2890,7 +2890,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
if (button && pressed && !(k && k->is_lock)) {
struct surface *clicked = toplevel_at(g_ptr_x, g_ptr_y);
if (clicked) g_ime_click = clicked;
banner_text_input_refocus();
droiddeck_text_input_refocus();
}
wl_display_flush_clients(g_display);
}
@@ -3034,7 +3034,7 @@ static void touch_down(int id, double x, double y) {
if (!any_active) {
char name[160];
describe(target, name, sizeof(name));
banner_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
droiddeck_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)");
}
if (!has_touch) {
if (any_active) return; /* one finger drives the pointer; a second one is ignored */
@@ -3113,7 +3113,7 @@ static void deliver_touch(const struct input_msg *m) {
}
static void key_event(uint32_t evdev, int pressed);
struct wl_resource *banner_ime_target(void);
struct wl_resource *droiddeck_ime_target(void);
static void deliver_key(const struct input_msg *m) {
key_event((uint32_t)m->p1, m->p2);
}
@@ -3177,7 +3177,7 @@ static void key_event(uint32_t evdev, int pressed) {
* thread, not to the foreground program (X11 delivers keys to the focused app window, so
* this mirrors it). The desktop itself is the last resort. */
struct surface *target = NULL;
struct wl_resource *ime = banner_ime_target();
struct wl_resource *ime = droiddeck_ime_target();
if (ime) target = wl_resource_get_user_data(ime);
if (!target && g_key_target && g_key_target->mapped && g_key_target != g_desktop) target = g_key_target;
if (!target) target = g_desktop;
@@ -3220,14 +3220,14 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
/* Called from JNI (Android UI thread). Queues a pointer event; the compositor thread
* dispatches it. x/y are in INPUT_SPACE (0..1919, 0..1079) over the whole output. */
void banner_wayland_send_pointer(int action, int x, int y) {
void droiddeck_wayland_send_pointer(int action, int x, int y) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 0, action, x, y, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n;
}
void banner_wayland_send_touch(int action, int pointer_id, int x, int y) {
void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 7, action, pointer_id, x, y };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
@@ -3235,7 +3235,7 @@ void banner_wayland_send_touch(int action, int pointer_id, int x, int y) {
}
/* Called from JNI. Queues a key event. evdev = Linux input keycode (e.g. KEY_A=30); state 1=down 0=up. */
void banner_wayland_send_key(int evdev, int state) {
void droiddeck_wayland_send_key(int evdev, int state) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { 1, evdev, state, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
@@ -3243,7 +3243,7 @@ void banner_wayland_send_key(int evdev, int state) {
}
/* Called from JNI on every screen refresh (Choreographer). */
void banner_wayland_vsync(int64_t frame_time_ns) {
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 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
@@ -3253,7 +3253,7 @@ void banner_wayland_vsync(int64_t frame_time_ns) {
/* Called from JNI with the app's X-server input (on-screen controls, mouse): type 2 = motion
* to scene x,y; 3 = evdev button a pressed/released (b); 4 = a wheel steps (negative = up);
* 6 = relative motion by a,b in 1/256 pixel (the app's Relative Mouse / captured-mouse path). */
void banner_wayland_send_scene_input(int type, int a, int b) {
void droiddeck_wayland_send_scene_input(int type, int a, int b) {
if (g_input_pipe[1] < 0) return;
struct input_msg m = { type, a, b, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
@@ -3261,15 +3261,15 @@ void banner_wayland_send_scene_input(int type, int a, int b) {
}
/* ------------------------------------------------------------------ extension module hooks
* What wl_clipboard.c / wl_text_input.c need from the scene (see banner_ext.h). */
* What wl_clipboard.c / wl_text_input.c need from the scene (see droiddeck_ext.h). */
const char *banner_client_name(struct wl_client *client) { return client_name(client); }
struct wl_display *banner_get_display(void) { return g_display; }
void banner_request_redraw(void) { schedule_render(); }
const char *droiddeck_client_name(struct wl_client *client) { return client_name(client); }
struct wl_display *droiddeck_get_display(void) { return g_display; }
void droiddeck_request_redraw(void) { schedule_render(); }
/* The surface text input follows: the last clicked mapped program window, else the topmost
* mapped window not owned by the desktop's process (explorer). NULL when there is none. */
struct wl_resource *banner_ime_target(void) {
struct wl_resource *droiddeck_ime_target(void) {
struct surface *s;
if (g_ime_click && g_ime_click->mapped) return g_ime_click->resource;
struct wl_client *shell = g_desktop ? wl_resource_get_client(g_desktop->resource) : NULL;
@@ -3278,13 +3278,13 @@ struct wl_resource *banner_ime_target(void) {
return NULL;
}
void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y) {
void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y) {
struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL;
*x = 0; *y = 0;
if (s && s->placed) { *x = s->x; *y = s->y; }
}
void banner_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
void droiddeck_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); }
/* ------------------------------------------------------------------ 10 s summary */
@@ -3311,7 +3311,7 @@ static int on_stats_timer(void *data) {
* zero-copy, so they are counted apart from the line above. */
if (layer_frames) off += snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u layer frames", layer_frames);
if (generated) snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u generated frames", generated);
banner_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s",
droiddeck_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s",
g_stat_frames + generated, (g_stat_frames + generated) / 10.0, g_stat_dmabuf, g_stat_shm, windows, extra);
}
/* The perf line, next to the stats line and only when something happened: the compositor thread's
@@ -3323,7 +3323,7 @@ static int on_stats_timer(void *data) {
const unsigned drops = sc_layer_drops_take();
#define PERF_MS(sum, n) ((n) ? (double)(sum) / 1e6 / (double)(n) : 0.0)
if (g_perf.scenes || vp.presents || g_perf.releases || drops)
banner_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | "
droiddeck_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | "
"render_scene %.2f/%.2f ms | base %u black kept, %u presented | acquire %.2f/%.2f ms | "
"present %.2f/%.2f ms (%u) | fence wait %.2f/%.2f ms (%u, %u GPU release waits) | "
"release %.2f/%.2f ms (%u, %u held) | %u pool drops",
@@ -3339,7 +3339,7 @@ static int on_stats_timer(void *data) {
memset(&g_perf, 0, sizeof(g_perf));
}
g_stat_frames = g_stat_dmabuf = g_stat_shm = 0;
banner_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */
droiddeck_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */
/* A program that asked for dma-buf feedback (EGL's GPU path does, first thing) but has drawn
* only wl_shm frames since is an OpenGL program whose EGL gave up on the GPU. The software
* path it fell to has no rasteriser in the Wayland layers, so what it commits is black. */
@@ -3347,7 +3347,7 @@ static int on_stats_timer(void *data) {
if (!ci->asked_feedback || ci->dmabuf_buffers || ci->shm_gl_said || ci->shm_frames < 150)
continue;
ci->shm_gl_said = 1;
banner_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) "
droiddeck_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) "
"frames: its OpenGL fell back to software rendering, which the Wayland layer cannot "
"draw - expect a black picture (main device %u:%u)", ci->name,
(unsigned)major(g_main_device), (unsigned)minor(g_main_device));
@@ -3373,7 +3373,7 @@ static void test_input_line(const char *line) {
int n = sscanf(line, "%15s %lf %lf", cmd, &x, &y);
if (n < 2) return;
banner_log("test", "input: %s", line);
droiddeck_log("test", "input: %s", line);
if (!strcmp(cmd, "key") || !strcmp(cmd, "keydown") || !strcmp(cmd, "keyup")) {
code = (unsigned)x;
if (strcmp(cmd, "keyup")) key_event(code, 1);
@@ -3436,7 +3436,7 @@ static void start_test_input(struct wl_event_loop *loop) {
static struct wl_listener g_client_created = { .notify = on_client_created };
int banner_wayland_run(void) {
int droiddeck_wayland_run(void) {
wl_list_init(&g_surfaces);
wl_list_init(&g_toplevels);
wl_list_init(&g_constraints);
@@ -3468,7 +3468,7 @@ int banner_wayland_run(void) {
rt ? rt : "(null)");
return 1;
}
banner_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?");
droiddeck_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?");
wl_display_add_client_created_listener(display, &g_client_created);
wl_global_create(display, &wl_compositor_interface, 6, NULL, bind_compositor);
@@ -3485,13 +3485,13 @@ int banner_wayland_run(void) {
wl_global_create(display, &wp_presentation_interface, 2, NULL, bind_presentation);
wl_global_create(display, &zwp_pointer_constraints_v1_interface, 1, NULL, bind_pointer_constraints);
wl_global_create(display, &zwp_relative_pointer_manager_v1_interface, 1, NULL, bind_relative_pointer_manager);
banner_ext_init(display); /* clipboard, text input, toplevel icons (own files, see banner_ext.h) */
droiddeck_ext_init(display); /* clipboard, text input, toplevel icons (own files, see droiddeck_ext.h) */
ahb_swapchain_init(display); /* zero-copy layers: banner_ahb_v1, advertised whenever a display
* layer is possible; the mode event carries the live switch
* (ahb_swapchain.h) */
banner_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the
droiddeck_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the
* display + the layer path above; creates wp_color_manager_v1
* only when it is open (banner_color.h). Before any client can
* only when it is open (droiddeck_color.h). Before any client can
* bind zwp_linux_dmabuf_v1, whose table it widens. */
wl_list_init(&g_pending_releases);
g_release_timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
@@ -1,7 +1,7 @@
/*
* What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf
* buffer records, the per-client bookkeeping and the few functions and globals both sides use.
* Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI.
* Everything declared here is hidden: it is internal to libdroiddeckwayland, not part of its ABI.
*/
#pragma once
#include <stdint.h>
@@ -13,7 +13,7 @@
#define COMPOSITOR_INTERNAL __attribute__((visibility("hidden")))
#ifndef WLOGE
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__)
#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__)
#endif
/* ------------------------------------------------------------------ dmabuf buffers */
@@ -71,8 +71,8 @@ struct client_info {
COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client);
COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b);
COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl;
extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */
extern volatile int g_ubwc; /* DROIDDECK_WAYLAND_UBWC (waylandcomp_jni.c sets it) */
extern volatile int g_no_render_node; /* DROIDDECK_WAYLAND_NO_RENDER_NODE */
/* ---- wl_dmabuf.c */
COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */
@@ -1,5 +1,5 @@
#ifndef BANNER_COLOR_H
#define BANNER_COLOR_H
#ifndef DROIDDECK_COLOR_H
#define DROIDDECK_COLOR_H
/*
* HDR10 output on the Wayland backend (opt-in) - the compositor half of HDR_RECON.md Phase A (round 1)
* plus HDR-aware composition (round 2, hdr_compose.h), implemented in wl_color_mgmt.c. The game half
@@ -8,18 +8,18 @@
* with DXVK_HDR=1 (the app exports it when the setting is on).
*
* THE GATE. Nothing here exists for a session unless all of these hold when the compositor starts:
* - the container's / game's "HDR output" setting is on, or BANNER_WAYLAND_HDR=1 in their
* - the container's / game's "HDR output" setting is on, or DROIDDECK_WAYLAND_HDR=1 in their
* environment (which overrides the setting either way);
* - the display the game is on lists HDR10 among its supported HDR types (read by the app from
* android.view.Display - a property of the connector, not of the device);
* - display layers with dataspace control (ASurfaceTransaction_setBufferDataSpace, Android 10+)
* and the zero-copy global (banner_ahb_v1): the HDR frame (or the composed HDR picture) is shown
* on the game's own display layer, tagged BT2020_PQ.
* In game, the drawer's HDR output switch (banner_color_set_output) flips between that and the same
* In game, the drawer's HDR output switch (droiddeck_color_set_output) flips between that and the same
* frames tone-mapped to SDR, live; the gate itself never changes during a session.
* A closed gate advertises nothing - no colour-management global, no 10-bit dma-buf formats - so the
* session is byte-for-byte what it was before this file existed, and the session log says why.
* BANNER_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel;
* DROIDDECK_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel;
* SurfaceFlinger then tone-maps the layer, and says so in its composition type).
*
* WHAT AN OPEN GATE OFFERS (exactly the subset Mesa binds, HDR_RECON.md §2.2): wp_color_manager_v1
@@ -35,13 +35,13 @@
#include <wayland-server.h>
/* ADataSpace values (NDK <android/data_space.h>) the layer path emits. */
#define BANNER_ADATASPACE_UNKNOWN 0
#define BANNER_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */
#define BANNER_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */
#define DROIDDECK_ADATASPACE_UNKNOWN 0
#define DROIDDECK_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */
#define DROIDDECK_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */
/* One image description as the program made it, and what it becomes on a display layer. Immutable:
* a changed description is a new record with a new identity. */
struct banner_color {
struct droiddeck_color {
uint32_t identity; /* the id the `ready` event carried (never 0) */
uint32_t primaries, tf; /* wp_color_manager_v1 named values */
int32_t dataspace; /* ADataSpace on a display layer; 0 = none (treated as sRGB) */
@@ -54,97 +54,97 @@ struct banner_color {
};
/* ---- app -> compositor (JNI, any thread) */
/* mode 0 = off, 1 = BANNER_WAYLAND_HDR=1, 2 = BANNER_WAYLAND_HDR=force (testing). source names where
/* mode 0 = off, 1 = DROIDDECK_WAYLAND_HDR=1, 2 = DROIDDECK_WAYLAND_HDR=force (testing). source names where
* the switch came from; dxvk_hdr = DXVK_HDR=1 is in the game's environment; zero_copy_forced = the
* app turned zero-copy presentation on for this session because HDR needs it. Before the start. */
void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced);
void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced);
/* The display the game is on, as android.view.Display reports it. Before the start (it feeds the
* gate) and again whenever it changes (logged; the gate is decided once per session). */
void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
float max_avg, float min_lum, int ratio_available, float ratio, int api);
/* One reading of Display.getHdrSdrRatio() (API 34+; < 0 = not available). listener = it came from
* the display's ratio listener rather than the app's periodic sampler. */
void banner_color_ratio_sample(float ratio, int listener);
void droiddeck_color_ratio_sample(float ratio, int listener);
/* Milliseconds since an HDR frame last went onto a display layer; -1 = none this session. */
int banner_color_last_frame_age_ms(void);
int droiddeck_color_last_frame_age_ms(void);
/* -1 = the compositor has not decided yet, 0 = closed, 1 = open. */
int banner_color_gate_state(void);
int droiddeck_color_gate_state(void);
/* The session is ending: write the summary line ("HDR on screen: …"). */
void banner_color_session_end(void);
void droiddeck_color_session_end(void);
/* 1 while HDR frames are really on screen: a frame tagged BT2020_PQ reached a display layer or the HDR
* swapchain in the last 1.5 s AND, where the display reports an HDR/SDR ratio, its last reading is
* above 1.01 (the HUD badge). */
int banner_color_hdr_on_screen(void);
int droiddeck_color_hdr_on_screen(void);
/* The HUD / drawer state: 0 = no HDR frames on screen (or not confirmed yet), 1 = HDR frames on screen
* with headroom (as banner_color_hdr_on_screen), 2 = HDR frames on screen but the display has given
* with headroom (as droiddeck_color_hdr_on_screen), 2 = HDR frames on screen but the display has given
* them NO headroom (ratio <= 1.01) for 5 s or more - the brightness slider at maximum, or a screen
* recording (Android turns HDR headroom off while the screen is recorded). */
int banner_color_hdr_state(void);
int droiddeck_color_hdr_state(void);
/* Nits SDR content is placed at inside an HDR picture (default 203, BT.2408); any thread. */
void banner_color_set_sdr_white(float nits);
float banner_color_sdr_white(void);
/* The HDR session was asked for (BANNER_WAYLAND_HDR / the setting resolved on or force) - known before
void droiddeck_color_set_sdr_white(float nits);
float droiddeck_color_sdr_white(void);
/* The HDR session was asked for (DROIDDECK_WAYLAND_HDR / the setting resolved on or force) - known before
* the compositor starts, so the Vulkan instance can enable VK_EXT_swapchain_colorspace for it. */
int banner_color_requested(void);
int droiddeck_color_requested(void);
/* The drawer's live "HDR output" switch, per session, starting ON. On = HDR frames go to the display as
* HDR; off = the SAME frames are composed tone-mapped to SDR (hdr_compose.h) - the game is told nothing
* and keeps rendering HDR (DXVK_HDR and the colour-manager offer were decided at launch). Compositor
* thread (the app posts it through banner_host_hdr_output); logs the flip and redraws. */
void banner_color_set_output(int on);
* thread (the app posts it through droiddeck_host_hdr_output); logs the flip and redraws. */
void droiddeck_color_set_output(int on);
/* 1 = HDR output on (any thread). */
int banner_color_output(void);
int droiddeck_color_output(void);
/* 1 while an HDR game's frames are being shown tone-mapped to SDR (the last one < 1.5 s ago); any thread. */
int banner_color_tonemapped_on_screen(void);
int droiddeck_color_tonemapped_on_screen(void);
/* Device evidence beside the HDR/SDR headroom (the app, non-root APIs; any thread; HDR sessions only):
* PowerManager thermal status (0..6, -1 unknown) + getThermalHeadroom(10) (< 0 = not available),
* Settings.System SCREEN_BRIGHTNESS (0..255, -1 unknown) + SCREEN_BRIGHTNESS_MODE (1 auto, 0 manual).
* Changes of status and brightness are logged; every no-headroom line, the 10 s line and the verdict
* carry the last values. */
void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode);
void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode);
/* ---- the explicit HDR headroom request (Android 15+: ASurfaceTransaction_setDesiredHdrHeadroom on the game
* layer, SurfaceView.setDesiredHdrHeadroom on the screen surface). Some phones only boost HDR when asked. */
/* The ratio to ask for an HDR frame of `c`: content peak (max CLL, else mastering max, else the display's
* peak) / SDR white (203), capped at the display's highest ratio when it reports one; 0 = ask nothing
* (not HDR, or the display reports it cannot boost). `why` gets the one-line reason. Any thread. */
float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n);
float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n);
/* What was last asked for (> 0 the ratio, 0 nothing, -1 the API is missing): for the no-headroom lines. */
void banner_color_note_headroom_request(float ratio);
void droiddeck_color_note_headroom_request(float ratio);
/* Display.getHighestHdrSdrRatio() (Android 16+; <= 0 = not reported). Any thread; logged when it arrives. */
void banner_color_set_highest_ratio(float ratio);
void droiddeck_color_set_highest_ratio(float ratio);
/* The headroom the SCREEN surface should ask for (frames through the HDR10 swapchain in the last 1.5 s),
* 0 = none; `why` gets the reason. The app polls it and applies it. Any thread. */
float banner_color_screen_headroom(char *why, size_t n);
float droiddeck_color_screen_headroom(char *why, size_t n);
/* ---- compositor.c -> here */
/* Decide the gate (call after ahb_swapchain_init) and create the global when it is open. */
void banner_color_init(struct wl_display *display);
void droiddeck_color_init(struct wl_display *display);
/* The gate is open: the 10-bit dma-buf formats may be advertised, descriptions may exist. */
int banner_color_hdr_open(void);
int droiddeck_color_hdr_open(void);
/* wl_surface.commit: the pending image description (if any request touched it) becomes current. */
void banner_color_commit(struct wl_resource *surface);
void droiddeck_color_commit(struct wl_resource *surface);
/* The surface's current image description; NULL = none (sRGB, today's default). */
const struct banner_color *banner_color_of(struct wl_resource *surface);
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface);
/* A program disconnected: if it presented HDR, its part of the summary is written now. */
void banner_color_client_gone(struct wl_client *client);
void droiddeck_color_client_gone(struct wl_client *client);
/* The 10 s summary tick (one `color` line when anything HDR happened in the window). */
void banner_color_stats_tick(void);
void droiddeck_color_stats_tick(void);
/* A frame of an HDR-described surface went through the compositor's own 8-bit sRGB pass this scene
* (who = the window, reason = why it did not get the display layer). Shown untone-mapped. */
void banner_color_frame_copied(const char *who, const char *reason);
void droiddeck_color_frame_copied(const char *who, const char *reason);
/* ---- sc_layer.c / compositor.c -> here: a NEW frame of an HDR-described surface was shown, and how */
enum banner_hdr_path {
BANNER_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */
BANNER_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */
BANNER_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */
BANNER_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */
BANNER_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that
enum droiddeck_hdr_path {
DROIDDECK_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */
DROIDDECK_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */
DROIDDECK_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */
DROIDDECK_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */
DROIDDECK_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that
* cannot carry HDR) - NOT HDR */
};
/* ahb_format = the buffer's AHARDWAREBUFFER_FORMAT_* where one exists (0 otherwise). */
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format);
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format);
/* "RGBA1010102 (10-bit)" etc. for an AHARDWAREBUFFER_FORMAT_* value (static buffer). */
const char *banner_ahb_format_name(uint32_t format);
const char *droiddeck_ahb_format_name(uint32_t format);
#endif
@@ -2,7 +2,7 @@
* Host → compositor message queue plus the init of the extension modules.
*
* The app's threads (UI thread, clipboard listener) hand text to the compositor through
* banner_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event
* droiddeck_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event
* loop, which drains them on the compositor thread - the only thread that may touch
* wl_resources.
*/
@@ -13,9 +13,9 @@
#include <fcntl.h>
#include <pthread.h>
#include <android/log.h>
#include "banner_ext.h"
#include "droiddeck_ext.h"
#include "ahb_swapchain.h"
#include "banner_color.h"
#include "droiddeck_color.h"
enum host_kind { HOST_CLIPBOARD = 1, HOST_TEXT_COMMIT, HOST_TEXT_PREEDIT, HOST_TEXT_DELETE, HOST_ZERO_COPY, HOST_HDR_OUTPUT };
@@ -53,22 +53,22 @@ static void host_post(enum host_kind kind, const char *text, size_t len, int a,
}
}
void banner_host_clipboard_text(const char *utf8, int len) {
void droiddeck_host_clipboard_text(const char *utf8, int len) {
host_post(HOST_CLIPBOARD, utf8, len > 0 ? (size_t)len : 0, 0, 0);
}
void banner_host_text_commit(const char *utf8, int len) {
void droiddeck_host_text_commit(const char *utf8, int len) {
host_post(HOST_TEXT_COMMIT, utf8, len > 0 ? (size_t)len : 0, 0, 0);
}
void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) {
void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) {
host_post(HOST_TEXT_PREEDIT, utf8, len > 0 ? (size_t)len : 0, cursor_begin, cursor_end);
}
void banner_host_text_delete(int before, int after) {
void droiddeck_host_text_delete(int before, int after) {
host_post(HOST_TEXT_DELETE, NULL, 0, before, after);
}
void banner_host_zero_copy(int on, int live) {
void droiddeck_host_zero_copy(int on, int live) {
host_post(HOST_ZERO_COPY, NULL, 0, on, live);
}
void banner_host_hdr_output(int on) {
void droiddeck_host_hdr_output(int on) {
host_post(HOST_HDR_OUTPUT, NULL, 0, on, 0);
}
@@ -99,17 +99,17 @@ static int on_wake(int fd, uint32_t mask, void *data) {
ahb_swapchain_set_mode(m->a, m->b);
break;
case HOST_HDR_OUTPUT:
banner_color_set_output(m->a);
droiddeck_color_set_output(m->a);
break;
}
free(m->text);
free(m);
}
wl_display_flush_clients(banner_get_display());
wl_display_flush_clients(droiddeck_get_display());
return 0;
}
void banner_ext_init(struct wl_display *display) {
void droiddeck_ext_init(struct wl_display *display) {
struct wl_event_loop *loop = wl_display_get_event_loop(display);
if (pipe2(g_wake, O_CLOEXEC | O_NONBLOCK) == 0) {
wl_event_loop_add_fd(loop, g_wake[0], WL_EVENT_READABLE, on_wake, NULL);
@@ -118,7 +118,7 @@ void banner_ext_init(struct wl_display *display) {
pthread_mutex_unlock(&g_mu);
if (queued) { char c = 1; ssize_t n = write(g_wake[1], &c, 1); (void)n; }
} else
__android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "host queue pipe failed");
__android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "host queue pipe failed");
clipboard_init(display);
text_input_init(display);
toplevel_icon_init(display);
@@ -0,0 +1,50 @@
/*
* Interface between compositor.c and the extension modules that live in their own files:
* wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only)
* wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard)
* wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered)
* droiddeck_ext.c the host→compositor message queue shared by the modules
* Everything here runs on the compositor (dispatch) thread unless noted.
*/
#pragma once
#include <stdint.h>
#include <wayland-server.h>
void droiddeck_log(const char *tag, const char *fmt, ...);
/* ---- compositor.c → modules */
void droiddeck_ext_init(struct wl_display *display); /* creates the globals + host queue */
void droiddeck_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */
void droiddeck_text_input_refocus(void); /* the text-input target may have changed */
void droiddeck_text_input_surface_gone(struct wl_resource *surface);
/* ---- modules → compositor.c */
const char *droiddeck_client_name(struct wl_client *client);
struct wl_display *droiddeck_get_display(void);
/* The surface text input follows (the last clicked window, else the topmost program window). */
struct wl_resource *droiddeck_ime_target(void);
/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */
void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y);
/* Deliver a key press/release through the wl_keyboard path as if typed. */
void droiddeck_inject_key(uint32_t evdev, int pressed);
/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */
void droiddeck_on_clipboard_text(const char *utf8, int len);
void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h);
/* ---- app → modules (callable from any thread; queued to the compositor thread) */
void droiddeck_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */
void droiddeck_host_text_commit(const char *utf8, int len);
void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end);
void droiddeck_host_text_delete(int before, int after);
void droiddeck_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */
void droiddeck_host_hdr_output(int on); /* the drawer's HDR output switch (droiddeck_color_set_output) */
/* ---- module internals reached through the queue (droiddeck_ext.c → modules) */
void clipboard_init(struct wl_display *display);
void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */
void text_input_init(struct wl_display *display);
void text_input_host_commit(const char *utf8, size_t len);
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end);
void text_input_host_delete(int before, int after);
void toplevel_icon_init(struct wl_display *display);
@@ -2,7 +2,7 @@
#define _POSIX_C_SOURCE 200809L
#include "effects_chain.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log */
#include "vk_present.h" /* droiddeck_log */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
@@ -115,7 +115,7 @@ static void log_settings(const struct fx_settings *s) {
char line[400];
int n;
if (!spatial_mode(s->scaling) && !effect_count(s)) {
banner_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling));
droiddeck_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling));
return;
}
n = snprintf(line, sizeof(line), "scaling=%s", scaling_name(s->scaling));
@@ -133,7 +133,7 @@ static void log_settings(const struct fx_settings *s) {
if (s->crt) n += snprintf(line + n, sizeof(line) - n, ", CRT on");
if (s->deband_on) n += snprintf(line + n, sizeof(line) - n, ", Deband on %d%%", s->deband_pct);
(void)n;
banner_log("effects", "%s", line);
droiddeck_log("effects", "%s", line);
}
int vkp_effects_sync(void) {
@@ -262,7 +262,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
VkResult r = g_vk.CreateImage(g_dev, &ii, NULL, &t->img);
if (r != VK_SUCCESS) { banner_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; }
if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; }
VkMemoryRequirements req;
g_vk.GetImageMemoryRequirements(g_dev, t->img, &req);
int idx = memtype(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
@@ -271,7 +271,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) {
.memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
r = g_vk.AllocateMemory(g_dev, &mai, NULL, &t->mem);
if (r != VK_SUCCESS) {
banner_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h,
droiddeck_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h,
(unsigned long long)req.size, (int)r);
target_destroy(t); return -1;
}
@@ -312,7 +312,7 @@ static VkShaderModule shader(const uint32_t *code, size_t size) {
* viewport/scissor - the X11 renderer's createPostPipeline. */
static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code, size_t frag_size, const char *name) {
VkShaderModule frag = shader(frag_code, frag_size);
if (!frag) { banner_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; }
if (!frag) { droiddeck_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; }
VkPipelineShaderStageCreateInfo st[2] = {
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT, .module = vert, .pName = "main"},
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT, .module = frag, .pName = "main"}};
@@ -340,7 +340,7 @@ static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code,
VkPipeline p = VK_NULL_HANDLE;
VkResult r = g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p);
g_vk.DestroyShaderModule(g_dev, frag, NULL);
if (r != VK_SUCCESS) { banner_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; }
if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; }
return p;
}
@@ -367,7 +367,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) {
g_fx_fmt = target_fmt;
g_scene_fmt = scene_fmt;
if (g_dev)
banner_log("effects", "chain now works in %s (%s)",
droiddeck_log("effects", "chain now works in %s (%s)",
target_fmt == FX_FORMAT ? "8-bit RGBA"
: target_fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? "FP16 RGBA" : "10-bit RGB",
target_fmt == FX_FORMAT ? "SDR frames"
@@ -383,7 +383,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) {
* unavailable for this session (logged once; frames fall back to the plain blit). */
static int objects_ensure(void) {
if (g_ready) return g_ready == 1 ? 0 : -1;
if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; banner_log("error", "effects: no device for the chain"); return -1; }
if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; droiddeck_log("error", "effects: no device for the chain"); return -1; }
/* Render pass: one colour target, overwritten whole (DONT_CARE), left SHADER_READ_ONLY for the
* next pass. The dependencies order this pass after whoever read the target last (previous
* pass / previous frame's blit) and before whoever samples or blits it next. */
@@ -448,12 +448,12 @@ static int objects_ensure(void) {
#undef MK
g_vk.DestroyShaderModule(g_dev, vert, NULL);
g_ready = 1;
banner_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name());
droiddeck_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name());
return 0;
fail:
objects_destroy();
g_ready = -1;
banner_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session");
droiddeck_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session");
return -1;
}
@@ -11,9 +11,9 @@
#include <time.h>
#include <android/log.h>
void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#define FGLOG(...) banner_log("framegen", __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "BannerWayland", __VA_ARGS__)
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__)
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__)
/* ---- app controls (written from any thread, read on the compositor thread) ------------- */
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
@@ -20,8 +20,8 @@
#include "lsfg/lsfg_vkd.h"
#include "winfg/winfg_engine.h"
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "framegen: " __VA_ARGS__)
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
namespace {
@@ -2,7 +2,7 @@
#define _POSIX_C_SOURCE 200809L
#include "hdr_compose.h"
#include "vk_loader.h"
#include "vk_present.h" /* banner_log, vkp_gpu_name */
#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */
#include <stdio.h>
#include <string.h>
@@ -190,7 +190,7 @@ static int objects_ensure(void) {
if (!(g_out[i].pipe = make_pipe(g_out[i].rp))) goto fail;
}
g_ready = 1;
banner_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one "
droiddeck_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one "
"encoding (PQ BT.2020, or tone-mapped sRGB) instead of being shown washed out", vkp_gpu_name());
return 0;
fail:
@@ -200,7 +200,7 @@ fail:
g_dev = keep;
}
g_ready = -1;
banner_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot "
droiddeck_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot "
"stay on the game's own layer are shown the old way (washed out) this session");
return -1;
}
+83 -78
View File
@@ -5,7 +5,7 @@
#define _GNU_SOURCE
#include "sc_layer.h"
#include "ahb_swapchain.h"
#include "banner_color.h"
#include "droiddeck_color.h"
#include "vk_present.h"
#include <android/hardware_buffer.h>
#include <android/native_window.h>
@@ -34,7 +34,7 @@
/* The gralloc buffer handle behind an AHardwareBuffer (AOSP cutils/native_handle.h, which the NDK
* does not ship; the layout is a stable ABI: fds first, then ints). */
struct banner_native_handle { int version; int numFds; int numInts; int data[]; };
struct droiddeck_native_handle { int version; int numFds; int numInts; int data[]; };
/* ---- libandroid SurfaceControl API (dlsym) ---------------------------------------------------- */
@@ -44,12 +44,12 @@ typedef struct ASurfaceTransactionStats ASurfaceTransactionStats;
typedef void (*sc_complete_fn)(void *context, ASurfaceTransactionStats *stats);
/* <android/hdr_metadata.h> (NDK, API 29), declared here like the rest of this table: stable ABI. */
struct banner_color_xy { float x, y; };
struct banner_hdr_smpte2086 {
struct banner_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
struct droiddeck_color_xy { float x, y; };
struct droiddeck_hdr_smpte2086 {
struct droiddeck_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint;
float maxLuminance, minLuminance;
};
struct banner_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; };
struct droiddeck_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; };
static struct {
ASurfaceControl *(*createFromWindow)(ANativeWindow *, const char *);
@@ -69,11 +69,11 @@ static struct {
* the layer simply carries no vote and the app's surface vote is all there is. */
void (*setFrameRate)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t);
void (*setFrameRateStrategy)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t, int8_t);
/* Colour (API 29), optional: only an HDR session ever calls them (banner_color.h's gate needs
/* Colour (API 29), optional: only an HDR session ever calls them (droiddeck_color.h's gate needs
* setBufferDataSpace; the metadata calls are sent when present). */
void (*setBufferDataSpace)(ASurfaceTransaction *, ASurfaceControl *, int32_t);
void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_smpte2086 *);
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_cta861_3 *);
void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_smpte2086 *);
void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_cta861_3 *);
/* Not in the public NDK headers (vndk/hardware_buffer.h) but exported by libnativewindow.so on
* every device; Mesa's Android WSI calls it for every gralloc buffer it imports. */
const void *(*getNativeHandle)(const AHardwareBuffer *);
@@ -95,12 +95,12 @@ static int load_api(void) {
if (!nw) nw = dlopen("libnativewindow.so", RTLD_NOW);
api.getNativeHandle = nw ? dlsym(nw, "AHardwareBuffer_getNativeHandle") : NULL;
if (!api.getNativeHandle) {
banner_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle");
droiddeck_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle");
return -1;
}
void *lib = dlopen("libandroid.so", RTLD_NOW | RTLD_NOLOAD);
if (!lib) lib = dlopen("libandroid.so", RTLD_NOW);
if (!lib) { banner_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; }
if (!lib) { droiddeck_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; }
#define SYM(field, name) api.field = dlsym(lib, name)
SYM(createFromWindow, "ASurfaceControl_createFromWindow");
SYM(release, "ASurfaceControl_release");
@@ -125,7 +125,7 @@ static int load_api(void) {
if (!api.createFromWindow || !api.release || !api.txCreate || !api.txDelete || !api.txApply ||
!api.setBuffer || !api.setZOrder || !api.setVisibility || !api.setGeometry ||
!api.setBufferTransparency || !api.reparent || !api.setOnComplete || !api.prevReleaseFence) {
banner_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)");
droiddeck_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)");
return -1;
}
api.state = 1;
@@ -214,10 +214,10 @@ static AHardwareBuffer *g_blank;
static void layers_init(void) {
if (g_layers_ready) return;
g_layers_ready = 1;
g_layers[SC_LAYER_GAME] = (struct layer){.name = "banner_wayland_game", .z = 1, .pool_n = POOL_MAX,
g_layers[SC_LAYER_GAME] = (struct layer){.name = "droiddeck_wayland_game", .z = 1, .pool_n = POOL_MAX,
.cur_slot = -1, .votes_rate = 1, .fps_applied = -1.0f, .hr_applied = -1.0f,
.ds_applied = -1};
g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "banner_wayland_overlay", .z = 2, .pool_n = 3,
g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "droiddeck_wayland_overlay", .z = 2, .pool_n = 3,
.cur_slot = -1, .votes_rate = 0, .fps_applied = -1.0f,
.hr_applied = -1.0f, .ds_applied = -1};
for (int i = 0; i < SC_LAYER_COUNT; i++)
@@ -330,7 +330,7 @@ static void retire_sc(struct layer *l) {
if (l->cur_token) ahb_swapchain_layer_released(l->cur_token, -1);
api.release(l->sc);
}
banner_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win);
droiddeck_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win);
l->sc = NULL; l->win = NULL;
l->shown = 0; l->cur_slot = -1; l->cur_token = NULL; l->geo_valid = 0;
l->fps_applied = -1.0f; /* the next SurfaceControl carries no vote until it is re-applied */
@@ -368,7 +368,7 @@ static void drain_and_free_pools(void) {
/* Native-handle sniff, the same one Mesa's u_gralloc fallback uses (u_gralloc_fallback.c): a QTI
* gralloc private_handle_t carries the magic 'gmsm' as its first int and the UBWC flag
* (PRIV_FLAGS_UBWC_ALIGNED, 0x08000000) in the next one. Returns 0 when the layout is unknown. */
static int sniff_modifier(const struct banner_native_handle *h, uint64_t *mod) {
static int sniff_modifier(const struct droiddeck_native_handle *h, uint64_t *mod) {
const uint32_t gmsm = ('g' << 24) | ('m' << 16) | ('s' << 8) | 'm';
if (!h || h->numFds < 1 || h->numInts < 2) return 0;
if ((uint32_t)h->data[h->numFds] != gmsm) return 0;
@@ -387,7 +387,7 @@ static int g_alloc_failed; /* set by alloc_slot when gralloc or the import refus
* TIER_PLAIN the same without the vendor bit (a gralloc that refuses unknown bits; what we asked before).
* TIER_LINEAR plus a CPU bit, so the layout is known to be linear even when the handle is unreadable. */
enum { TIER_UBWC, TIER_PLAIN, TIER_LINEAR };
#define BANNER_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */
#define DROIDDECK_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */
static const char *tier_name(int tier) {
return tier == TIER_UBWC ? "asked for UBWC" : tier == TIER_PLAIN ? "no UBWC request" : "linear by request (CPU bit)";
@@ -404,22 +404,22 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
* composer overlay (the display scans it out). See the tiers above for the rest. */
.usage = AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT | AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE |
AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY |
(tier == TIER_UBWC ? BANNER_AHB_USAGE_VENDOR_UBWC : 0) |
(tier == TIER_UBWC ? DROIDDECK_AHB_USAGE_VENDOR_UBWC : 0) |
(tier == TIER_LINEAR ? AHARDWAREBUFFER_USAGE_CPU_READ_RARELY : 0)};
AHardwareBuffer *ahb = NULL;
if (AHardwareBuffer_allocate(&d, &ahb) != 0 || !ahb) {
if (tier < TIER_LINEAR) {
banner_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h,
droiddeck_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h,
tier_name(tier), tier_name(tier + 1));
l->alloc_tier = tier + 1;
continue;
}
banner_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt);
droiddeck_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt);
g_alloc_failed = 1;
return -1;
}
AHardwareBuffer_Desc got; AHardwareBuffer_describe(ahb, &got);
const struct banner_native_handle *nh = api.getNativeHandle(ahb);
const struct droiddeck_native_handle *nh = api.getNativeHandle(ahb);
uint64_t mod = MOD_LINEAR;
if (!sniff_modifier(nh, &mod)) {
/* Not a handle we can read: the only layout we can assume is linear, and only if the buffer
@@ -427,7 +427,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
if (tier < TIER_LINEAR) {
AHardwareBuffer_release(ahb);
l->alloc_tier = TIER_LINEAR;
banner_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers",
droiddeck_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers",
l->name, nh ? nh->numFds : -1, nh ? nh->numInts : -1);
continue;
}
@@ -439,7 +439,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
struct vkp_image *img = fd >= 0 ? vkp_image_import_dmabuf(fd, fmt == AHB_RGB10A2 ? DRM_ABGR2101010 : DRM_ABGR8888,
mod, w, h, got.stride * 4, 0, 1) : NULL;
if (!img) {
banner_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed",
droiddeck_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed",
l->name, mod == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", w, h, got.stride);
AHardwareBuffer_release(ahb);
if (tier < TIER_LINEAR) { l->alloc_tier = tier + 1; continue; }
@@ -451,7 +451,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm
if (l->logged_w != w || l->logged_h != h || l->logged_fmt != fmt || l->logged_mod != mod ||
l->logged_tier != tier) {
l->logged_w = w; l->logged_h = h; l->logged_fmt = fmt; l->logged_mod = mod; l->logged_tier = tier;
banner_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)",
droiddeck_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)",
l->name, w, h, fmt == AHB_RGB10A2 ? " 10-bit RGBA1010102" : "",
mod == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)" : "linear", tier_name(tier),
got.stride, l->pool_n, nh ? nh->numFds : -1, nh ? nh->numInts : -1);
@@ -532,10 +532,10 @@ void sc_layer_probe_dmabuf_fd(int fd) {
struct { uint32_t flags; int32_t fd; } exp = {.flags = 1u /* DMA_BUF_SYNC_READ */, .fd = -1};
/* DMA_BUF_IOCTL_EXPORT_SYNC_FILE = _IOWR('b', 2, struct dma_buf_export_sync_file) */
if (ioctl(fd, _IOWR('b', 2, exp), &exp) == 0) {
banner_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself");
droiddeck_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself");
if (exp.fd >= 0) close(exp.fd);
} else {
banner_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead",
droiddeck_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead",
strerror(errno));
}
}
@@ -549,7 +549,7 @@ static int ensure_sc(struct layer *l) {
if (l->sc && l->win != win) retire_sc(l);
if (!l->sc) {
l->sc = api.createFromWindow(win, l->name);
if (!l->sc) { banner_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; }
if (!l->sc) { droiddeck_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; }
l->win = win;
l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f;
ASurfaceTransaction *tx = api.txCreate();
@@ -559,7 +559,7 @@ static int ensure_sc(struct layer *l) {
apply_frame_rate(tx, l);
api.txApply(tx); api.txDelete(tx);
}
banner_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z);
droiddeck_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z);
}
return 0;
}
@@ -585,7 +585,7 @@ static void apply_geometry(ASurfaceTransaction *tx, struct layer *l, const int r
l->geo_src = srcR; l->geo_dst = dstR; l->geo_valid = 1;
char cov[96] = "";
if (l->ds_applied > 0) coverage_text(l, cov, sizeof(cov));
banner_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name,
droiddeck_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name,
r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], cov[0] ? " (HDR layer " : "", cov, cov[0] ? ")" : "");
}
}
@@ -606,11 +606,11 @@ static void apply_frame_rate(ASurfaceTransaction *tx, struct layer *l) {
}
int first = l->fps_applied < 0.0f;
l->fps_applied = want;
if (want > 0.0f) banner_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want);
else if (!first) banner_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name);
if (want > 0.0f) droiddeck_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want);
else if (!first) droiddeck_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name);
}
/* ---- colour (HDR, banner_color.h) -------------------------------------------------------------
/* ---- colour (HDR, droiddeck_color.h) -------------------------------------------------------------
* The layer is told what its buffer's pixels MEAN - the dataspace (BT2020_PQ for an HDR10 frame) and
* the game's mastering / content-light metadata - in the same transaction as the buffer, so the
* display never shows an HDR frame decoded as sRGB or the other way round. A layer that has never been
@@ -630,67 +630,67 @@ static void coverage_text(const struct layer *l, char *out, size_t n) {
/* API 35: ask the display for HDR headroom explicitly while the layer carries an HDR frame, and clear the
* request (0 = no preference) when it stops. Only on change; a layer never tagged is never touched. */
static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) {
static int said_missing;
if (!api.setDesiredHdrHeadroom) {
if (c && c->dataspace && !said_missing) {
said_missing = 1;
banner_color_note_headroom_request(-1.0f);
banner_log("color", "HDR headroom request not available on %s (Android < 15 has no "
droiddeck_color_note_headroom_request(-1.0f);
droiddeck_log("color", "HDR headroom request not available on %s (Android < 15 has no "
"ASurfaceTransaction_setDesiredHdrHeadroom): the layer relies on Android's default", l->name);
}
return;
}
const float want = (c && c->dataspace) ? banner_color_desired_headroom(c, NULL, 0) : 0.0f;
const float want = (c && c->dataspace) ? droiddeck_color_desired_headroom(c, NULL, 0) : 0.0f;
if (l->hr_applied < 0.0f && want == 0.0f && !(c && c->dataspace)) return; /* never asked: leave it be */
if (l->hr_applied >= 0.0f && fabsf(want - l->hr_applied) < 0.005f) return;
char why[200] = "";
if (c && c->dataspace) banner_color_desired_headroom(c, why, sizeof(why));
if (c && c->dataspace) droiddeck_color_desired_headroom(c, why, sizeof(why));
api.setDesiredHdrHeadroom(tx, l->sc, want);
const int first = l->hr_applied < 0.0f;
l->hr_applied = want;
banner_color_note_headroom_request(want);
droiddeck_color_note_headroom_request(want);
if (want > 0.0f)
banner_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
droiddeck_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why);
else if (c && c->dataspace)
banner_log("color", "no HDR headroom requested on %s: %s", l->name, why);
droiddeck_log("color", "no HDR headroom requested on %s: %s", l->name, why);
else if (!first)
banner_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name);
droiddeck_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name);
}
static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) {
int32_t want = c ? c->dataspace : BANNER_ADATASPACE_UNKNOWN;
static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) {
int32_t want = c ? c->dataspace : DROIDDECK_ADATASPACE_UNKNOWN;
uint32_t md = (c && c->dataspace) ? c->identity : 0;
if (!api.setBufferDataSpace) return;
if (l->ds_applied < 0 && want == BANNER_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */
if (l->ds_applied < 0 && want == DROIDDECK_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */
apply_headroom(tx, l, want ? c : NULL);
if (want == l->ds_applied && md == l->md_applied) return;
api.setBufferDataSpace(tx, l->sc, want);
struct banner_hdr_smpte2086 st;
struct banner_hdr_cta861_3 cta;
struct droiddeck_hdr_smpte2086 st;
struct droiddeck_hdr_cta861_3 cta;
int st_on = c && c->dataspace && c->has_st2086, cta_on = c && c->dataspace && c->has_cta861;
if (st_on) {
st = (struct banner_hdr_smpte2086){
st = (struct droiddeck_hdr_smpte2086){
.displayPrimaryRed = {c->red[0], c->red[1]}, .displayPrimaryGreen = {c->green[0], c->green[1]},
.displayPrimaryBlue = {c->blue[0], c->blue[1]}, .whitePoint = {c->white[0], c->white[1]},
.maxLuminance = c->max_lum, .minLuminance = c->min_lum};
}
if (cta_on) cta = (struct banner_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall};
if (cta_on) cta = (struct droiddeck_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall};
/* NULL clears what a previous description set: metadata never outlives the frame it belongs to. */
if (api.setHdrMetadata_smpte2086) api.setHdrMetadata_smpte2086(tx, l->sc, st_on ? &st : NULL);
if (api.setHdrMetadata_cta861_3) api.setHdrMetadata_cta861_3(tx, l->sc, cta_on ? &cta : NULL);
if (want) {
char cov[96];
coverage_text(l, cov, sizeof(cov));
banner_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 "
"%s, CTA-861.3 %s [%s]", l->name, want == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
droiddeck_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 "
"%s, CTA-861.3 %s [%s]", l->name, want == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
(unsigned)want, c->identity, cov[0] ? ", " : "", cov,
st_on ? (api.setHdrMetadata_smpte2086 ? "sent" : "not supported by this Android") : "none given",
cta_on ? (api.setHdrMetadata_cta861_3 ? "sent" : "not supported by this Android") : "none given",
c->text);
}
else
banner_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is "
droiddeck_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is "
"not an HDR frame", l->name);
l->ds_applied = want;
l->md_applied = md;
@@ -711,7 +711,7 @@ void sc_layer_hdr_symbols(char *out, size_t size) {
static void log_layer_count(void) {
if (g_two_logged) return;
g_two_logged = 1;
banner_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the "
droiddeck_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the "
"screen surface; the app's own views (drawer, HUD, pointer) stay above both. %d is the cap - more "
"would push SurfaceFlinger into GPU client composition",
SC_LAYER_COUNT, g_layers[SC_LAYER_GAME].name, (int)g_layers[SC_LAYER_GAME].z,
@@ -748,7 +748,7 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
if (!win || win != l->win) return NULL; /* the window changed: ensure_sc re-creates it anyway */
ASurfaceControl *fresh = api.createFromWindow(win, l->name);
if (!fresh) {
banner_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name);
droiddeck_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name);
return NULL;
}
ASurfaceControl *old = l->sc;
@@ -760,14 +760,14 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
l->geo_valid = 0;
l->fps_applied = -1.0f;
l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f;
banner_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above "
droiddeck_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above "
"the game (measured on this panel: hardware composition does NOT return from this alone)", l->name);
return old;
}
/* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no
* description). 0 = applied. */
static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color,
static int present_slot(struct layer *l, int idx, const int r[8], const struct droiddeck_color *color,
int translucent) {
struct slot *s = &l->slots[idx];
ASurfaceTransaction *tx = api.txCreate();
@@ -792,7 +792,7 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
api.txDelete(tx);
l->cur_slot = idx;
l->cur_token = NULL;
if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); }
if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); }
g_stat_layer_frames++;
return 0;
}
@@ -805,7 +805,7 @@ static void log_drop(struct layer *l) {
l->drops_unlogged++;
int64_t t = now_ns();
if (!l->drop_logged_ns || t - l->drop_logged_ns > 30000000000LL) {
banner_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name,
droiddeck_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name,
l->pool_n, l->drops_unlogged, l->drops_unlogged == 1 ? "" : "s",
l->drop_logged_ns ? " since the last such line" : "");
l->drop_logged_ns = t;
@@ -820,7 +820,7 @@ unsigned sc_layer_drops_take(void) {
}
int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token, int scene_w, int scene_h,
const struct banner_color *color, uint32_t ahb_format) {
const struct droiddeck_color *color, uint32_t ahb_format) {
struct layer *l = layer_of(SC_LAYER_GAME);
int r[8];
if (!ahb || !token || ensure_sc(l) != 0) goto unavailable;
@@ -836,6 +836,7 @@ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, voi
apply_geometry(tx, l, r); apply_frame_rate(tx, l); apply_colour(tx, l, color);
api.txApply(tx); api.txDelete(tx);
}
vkp_signal_first_frame();
return 0;
}
ASurfaceTransaction *tx = api.txCreate();
@@ -862,16 +863,19 @@ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, voi
api.txDelete(tx);
l->cur_slot = -1;
l->cur_token = token;
if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); }
if (!l->first_logged) { l->first_logged = 1; vkp_signal_first_frame(); }
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_ZERO_COPY, ahb_format);
if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); }
/* The notification is re-armed for each session; the layer and its logging flag can
* outlive it. Let the notification's own one-shot decide whether to fire. */
if (!l->first_logged) l->first_logged = 1;
vkp_signal_first_frame();
if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_ZERO_COPY, ahb_format);
return 0;
unavailable:
if (acquire_fd >= 0) close(acquire_fd);
return -1;
}
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color) {
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color) {
struct layer *l = layer_of(SC_LAYER_GAME);
if (!src || ensure_sc(l) != 0) return -1;
int sw = vkp_image_width(src), sh = vkp_image_height(src);
@@ -885,14 +889,14 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
if (idx < 0) { log_drop(l); return 0; }
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, color, 0) != 0) return -1;
if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8);
if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_LAYER_COPY, AHB_RGBA8);
if (!l->first_logged) {
l->first_logged = 1;
banner_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); "
droiddeck_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); "
"HUD and pointer stay Android views above it", sw, sh,
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n);
vkp_signal_first_frame();
}
vkp_signal_first_frame();
return 0;
}
@@ -912,11 +916,11 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
if (!l->first_logged) {
l->first_logged = 1;
banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
droiddeck_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); "
"HUD and pointer stay Android views above it", rw, rh,
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n);
vkp_signal_first_frame();
}
vkp_signal_first_frame();
return 0;
}
@@ -931,7 +935,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
static uint32_t g_hdr_pool_fmt = AHB_RGB10A2;
int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf,
int scene_w, int scene_h, const struct banner_color *color) {
int scene_w, int scene_h, const struct droiddeck_color *color) {
struct layer *l = layer_of(SC_LAYER_GAME);
int rw = 0, rh = 0, r[8];
if (!draws || n <= 0 || !hf || ensure_sc(l) != 0) return -1;
@@ -945,7 +949,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
int idx = take_free_slot(l, rw, rh, fmt, &wait_fd);
if (idx < 0 && g_alloc_failed && fmt == AHB_RGB10A2) {
g_hdr_pool_fmt = AHB_RGBA8;
banner_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes "
droiddeck_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes "
"on 8-bit buffers instead (still tagged BT2020_PQ; less precision)", l->name);
idx = take_free_slot(l, rw, rh, g_hdr_pool_fmt, &wait_fd);
}
@@ -953,25 +957,26 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
if (color && color->dataspace)
banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
droiddeck_color_frame_shown(color, tm ? DROIDDECK_HDR_TONEMAPPED : DROIDDECK_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt);
static int said = -1;
if (said != tm) {
said = tm;
if (tm)
banner_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged "
droiddeck_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged "
"(sRGB) - HDR output is switched off", rw, rh,
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear");
else {
char cov[96];
coverage_text(l, cov, sizeof(cov));
banner_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh,
droiddeck_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh,
l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear",
g_hdr_pool_fmt == AHB_RGB10A2 ? "10-bit" : "8-bit",
color && color->dataspace == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
color && color->dataspace == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR",
cov[0] ? ", " : "", cov);
}
}
if (!l->first_logged) { l->first_logged = 1; vkp_signal_first_frame(); }
if (!l->first_logged) l->first_logged = 1;
vkp_signal_first_frame();
return 0;
}
@@ -1020,13 +1025,13 @@ int sc_layer_overlay_affordable(void) {
int first = g_overlay_declined < 0;
g_overlay_declined = want;
if (want)
banner_log("layer", "overlay layer declined: this display rotates every layer %d° and the game "
droiddeck_log("layer", "overlay layer declined: this display rotates every layer %d° and the game "
"layer is scaled %dx%d -> %dx%d, and on that combination a second layer drops the "
"whole frame to GPU composition for the rest of the session (measured). The window "
"above the game goes on the copy path instead - same picture, one blit.",
deg, sr[0], sr[1], ds[0], ds[1]);
else if (!first)
banner_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and "
droiddeck_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and "
"scaled, so a second display layer costs nothing here");
}
return ok;
@@ -1077,7 +1082,7 @@ void sc_layer_hide(void) {
* SurfaceControl, since it goes up and down with every effects/frame-generation toggle. */
if (g_layers[SC_LAYER_OVERLAY].sc) sc_layer_hide_overlay();
if (g_layers[SC_LAYER_GAME].shown) hide_layer(&g_layers[SC_LAYER_GAME]);
if (said) banner_log("layer", "layers hidden (scene is not a single fullscreen window)");
if (said) droiddeck_log("layer", "layers hidden (scene is not a single fullscreen window)");
}
void sc_layer_hide_overlay(void) {
@@ -1093,7 +1098,7 @@ void sc_layer_hide_overlay(void) {
* buffers stay allocated for the next window. */
if (l->shown) hide_layer(l);
retire_sc(l);
banner_log("layer", "%s: gone (nothing is above the game any more)", l->name);
droiddeck_log("layer", "%s: gone (nothing is above the game any more)", l->name);
/* ...and that is the half the measurement said is not enough: arm the game layer's
* SurfaceControl swap, which the next frame performs (swap_sc_begin). */
if (g_layers[SC_LAYER_GAME].sc) g_layers[SC_LAYER_GAME].recreate_pending = 1;
+8 -8
View File
@@ -1,7 +1,7 @@
#ifndef SC_LAYER_H
#define SC_LAYER_H
/*
* Layer mode (BANNER_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small,
* Layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small,
* deliberately ordered SET of Android display layers - ASurfaceControl children of the
* compositor's SurfaceView - instead of being blitted into the compositor's own swapchain.
*
@@ -12,8 +12,8 @@
* an ASurfaceControl has no input channel, so touch and mouse keep
* reaching the SurfaceView exactly as they did.
* +-- SurfaceView ..... the compositor's Vulkan swapchain; black while layer mode is up
* +-- z=1 "banner_wayland_game" ...... the one fullscreen game window
* +-- z=2 "banner_wayland_overlay" ... at most one window drawn ABOVE the game
* +-- z=1 "droiddeck_wayland_game" ...... the one fullscreen game window
* +-- z=2 "droiddeck_wayland_overlay" ... at most one window drawn ABOVE the game
*
* TWO layers is the hard cap (SC_LAYER_COUNT), and deliberately so: HWC composes only a few
* layers before SurfaceFlinger falls back to GPU client composition, which would throw away the
@@ -71,7 +71,7 @@
struct vkp_image;
struct vkp_draw;
struct banner_color;
struct droiddeck_color;
/* The layers, bottom first. The z-order is the array order (z = id + 1). */
enum sc_layer_id { SC_LAYER_GAME = 0, SC_LAYER_OVERLAY = 1, SC_LAYER_COUNT = 2 };
@@ -84,7 +84,7 @@ int sc_layer_available(void);
* Decides where a zero-copy acquire fence can come from; result goes to the session log. */
void sc_layer_probe_dmabuf_fd(int fd);
/* HDR (banner_color.h): can a display layer be told its buffer's colour encoding here
/* HDR (droiddeck_color.h): can a display layer be told its buffer's colour encoding here
* (ASurfaceTransaction_setBufferDataSpace, Android 10+)? Part of the HDR gate. */
int sc_layer_can_tag_hdr(void);
/* Which of the colour calls this libandroid has, for the gate's log line. */
@@ -100,7 +100,7 @@ void sc_layer_hdr_symbols(char *out, size_t size);
* free buffer), -1 = the layer path is unavailable this frame and the caller must draw the old way.
* The copy into the layer buffer is 8-bit: an HDR frame keeps its tag (its colours stay right), and
* loses precision. */
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color);
int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color);
/* GAME layer, screen effects on: run the compositor pass (composite `draws` + the effects chain,
* vkp_pass_begin) and put its result on the layer. Same return values as sc_layer_present. */
@@ -113,7 +113,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
* Same return values as sc_layer_present. */
struct vkp_hdr_frame;
int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf,
int scene_w, int scene_h, const struct banner_color *color);
int scene_w, int scene_h, const struct droiddeck_color *color);
/* GAME layer, zero-copy (ahb_swapchain.c): show the game's own w x h AHardwareBuffer, gated by
* acquire_fd (a sync_file the layer waits on before reading; owned by the callee, -1 = none).
@@ -124,7 +124,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
* frame (the caller draws the old way). `color` / `ahb_format`: the frame's image description (NULL =
* none) and the buffer's AHARDWAREBUFFER_FORMAT_*, for the dataspace and the HDR evidence. */
int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token,
int scene_w, int scene_h, const struct banner_color *color, uint32_t ahb_format);
int scene_w, int scene_h, const struct droiddeck_color *color, uint32_t ahb_format);
/* Vote a panel refresh rate for the layer the game presents on (VRR / refresh-rate matching), the
* same rate and compatibility the app votes on its own surface with Surface.setFrameRate; 0 = no
+1 -1
View File
@@ -8,7 +8,7 @@
#include <sys/stat.h>
#include <android/log.h>
#define TAG "BannerWayland"
#define TAG "DroidDeckWayland"
struct vk_api g_vk;
static void *g_handle;
+49 -49
View File
@@ -8,7 +8,7 @@
#include "framegen_bridge.h"
#include "hdr_compose.h"
#include "blend_pass.h"
#include "banner_color.h"
#include "droiddeck_color.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -19,9 +19,9 @@
#include <pthread.h>
#include <android/log.h>
#define TAG "BannerWayland"
#define TAG "DroidDeckWayland"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
#define LOGE(...) banner_log("error", __VA_ARGS__)
#define LOGE(...) droiddeck_log("error", __VA_ARGS__)
#define MOD_INVALID VKP_MOD_INVALID
struct vkp_image {
@@ -73,7 +73,7 @@ static VkImage *g_images;
static uint32_t g_nimg;
static VkExtent2D g_extent;
static int g_first_frame_done; /* one-shot: fire banner_on_first_frame() on first present */
static int g_first_frame_done; /* one-shot: fire droiddeck_on_first_frame() on first present */
/* The base surface under the display layers (vkp_base_black): 1 once a plain black frame has been
* presented on the CURRENT swapchain and nothing else since. Every other present clears it, and so does
@@ -179,7 +179,7 @@ static int64_t g_swap_retry_at_ns;
static int g_swap_fail_logged;
/* Implemented in waylandcomp_jni.c - notifies Java (dismiss launch overlay). */
extern void banner_on_first_frame(void);
extern void droiddeck_on_first_frame(void);
static char g_gpu_name[VK_MAX_PHYSICAL_DEVICE_NAME_SIZE];
const char *vkp_gpu_name(void) { return g_gpu_name; }
@@ -276,7 +276,7 @@ int vkp_apply_window_request(void) {
g_window = w;
g_swap_retry_at_ns = 0;
g_swap_fail_logged = 0;
banner_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused");
return 1;
}
@@ -353,7 +353,7 @@ static void update_map(int scene_w, int scene_h) {
g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f));
}
g_map.valid = 1;
banner_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output",
mode_name(mode), align_name(align), scene_w, scene_h,
(int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f),
(int)g_map.off_x, (int)g_map.off_y, W, H);
@@ -391,10 +391,10 @@ static int dev_init(void) {
const char *inst_exts[3] = {VK_KHR_SURFACE_EXTENSION_NAME,
VK_KHR_ANDROID_SURFACE_EXTENSION_NAME, NULL};
uint32_t n_inst_exts = 2;
/* HDR sessions only (banner_color.h): the colour-space extension lets the swapchain carry HDR10 for
/* HDR sessions only (droiddeck_color.h): the colour-space extension lets the swapchain carry HDR10 for
* frame generation. Asked for only when the loader has it, and only when HDR was asked for, so
* every other session creates exactly the instance it always did. */
if (banner_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
if (droiddeck_color_requested() && g_vk.EnumerateInstanceExtensionProperties) {
uint32_t ne = 0;
g_vk.EnumerateInstanceExtensionProperties(NULL, &ne, NULL);
VkExtensionProperties *ie = ne ? calloc(ne, sizeof(*ie)) : NULL;
@@ -407,14 +407,14 @@ static int dev_init(void) {
}
}
free(ie);
banner_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame "
droiddeck_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame "
"generation %s)", g_colorspace_ext ? "enables" : "has no",
g_colorspace_ext ? "is possible where the screen surface offers one" : "is not possible: tone-mapped instead");
}
/* 1.3 like the X11 renderer's instance: the frame-generation probe (framegen_engine.cpp)
* queries VkPhysicalDeviceVulkan12Features, and a 1.1 instance may be answered as 1.1. */
VkApplicationInfo app = {.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
.pApplicationName = "banner-wayland-present",
.pApplicationName = "droiddeck-wayland-present",
.apiVersion = VK_API_VERSION_1_3};
VkInstanceCreateInfo ici = {.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
.pApplicationInfo = &app,
@@ -444,9 +444,9 @@ static int dev_init(void) {
VkPhysicalDeviceProperties props;
g_vk.GetPhysicalDeviceProperties(g_pd, &props);
snprintf(g_gpu_name, sizeof(g_gpu_name), "%s", props.deviceName);
banner_log("gpu", "compositor renders on %s with %s", props.deviceName,
droiddeck_log("gpu", "compositor renders on %s with %s", props.deviceName,
g_library_name ? g_library_name : "the system Vulkan driver");
if (g_driver_path) banner_log("gpu", "driver folder %s", g_driver_path);
if (g_driver_path) droiddeck_log("gpu", "driver folder %s", g_driver_path);
}
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
@@ -464,7 +464,7 @@ static int dev_init(void) {
LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
/* HDR sessions only: the HDR10 swapchain (frame generation) can carry the game's metadata. */
int want_hdr_md = 0;
if (banner_color_requested()) {
if (droiddeck_color_requested()) {
want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME);
if (want_hdr_md) dev_exts[n_dev_exts++] = VK_EXT_HDR_METADATA_EXTENSION_NAME;
}
@@ -487,7 +487,7 @@ static int dev_init(void) {
.enabledExtensionCount = n_dev_exts, .ppEnabledExtensionNames = dev_exts};
VkResult dr = g_vk.CreateDevice(g_pd, &dci, NULL, &g_dev);
if (dr != VK_SUCCESS && fg_features) {
banner_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
droiddeck_log("framegen", "the driver refused the LSFG feature set (%s); device created without it",
vk_result_name(dr));
dci.pNext = NULL;
fg_features = NULL;
@@ -506,13 +506,13 @@ static int dev_init(void) {
g_wait_sem = VK_NULL_HANDLE;
}
}
banner_log("perf", "layer buffers: the display's release fences are waited for %s",
droiddeck_log("perf", "layer buffers: the display's release fences are waited for %s",
g_import_sem_fd ? "on the GPU (VK_KHR_external_semaphore_fd)"
: "on the CPU (this driver has no VK_KHR_external_semaphore_fd)");
if (want_hdr_md)
g_set_hdr_metadata = (PFN_vkSetHdrMetadataEXT)g_vk.GetDeviceProcAddr(g_dev, "vkSetHdrMetadataEXT");
if (banner_color_requested())
banner_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)",
if (droiddeck_color_requested())
droiddeck_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)",
g_set_hdr_metadata ? "enables" : "has no",
g_set_hdr_metadata ? "carries the game's mastering metadata" : "goes without the game's metadata");
@@ -641,7 +641,7 @@ static void log_surface_formats(const VkSurfaceFormatKHR *f, uint32_t n) {
vk_colorspace_short(f[i].colorSpace));
else hdr_cut++;
}
banner_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
droiddeck_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per,
hdr_n ? hdr : "none", hdr_cut ? " (+more)" : "");
}
@@ -678,7 +678,7 @@ static int swap_init_locked(void) {
}
VkSurfaceFormatKHR chosen = fmts[0];
const VkSurfaceFormatKHR sdr_choice = fmts[0];
if (banner_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
if (droiddeck_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) {
g_surface_formats_said = 1;
log_surface_formats(fmts, nfmt);
}
@@ -702,13 +702,13 @@ static int swap_init_locked(void) {
g_swap_is_hdr = 1;
char fname[24];
vk_format_short(chosen.format, fname, sizeof(fname));
banner_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR "
droiddeck_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR "
"frames reach the display as PQ BT.2020%s", (int)chosen.format, fname,
vk_format_bits(chosen.format) <= 8 ? " - an 8-bit swapchain: banding possible in smooth gradients"
: "");
} else {
g_swap_hdr_unavailable = 1;
banner_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space "
droiddeck_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space "
"pairs (see the line above): frames with frame generation are tone-mapped to SDR instead",
nfmt);
}
@@ -748,7 +748,7 @@ static int swap_init_locked(void) {
if (cr != VK_SUCCESS && g_swap_is_hdr) {
/* The surface listed the HDR10 pair but refused the swapchain: say so, never ask again this
* surface, and build the ordinary one now (frames tone-mapped) - never a black screen. */
banner_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are "
droiddeck_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are "
"tone-mapped to SDR instead", vk_result_name(cr));
g_swap_is_hdr = 0;
g_swap_hdr_unavailable = 1;
@@ -767,7 +767,7 @@ static int swap_init_locked(void) {
g_images = calloc(g_nimg, sizeof(VkImage));
g_vk.GetSwapchainImagesKHR(g_dev, g_swapchain, &g_nimg, g_images);
banner_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg,
droiddeck_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg,
g_swap_extra ? " (frame generation: several presents per frame)" : "");
return 0;
}
@@ -833,7 +833,7 @@ static void init_black_image(void) {
g_vk.WaitForFences(g_dev, 1, &g_fence, VK_TRUE, 1000000000ULL) == VK_SUCCESS)
g_black_state = 1;
else
banner_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame");
droiddeck_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame");
}
/* Blit the black image over everything of swapchain image `img` (in TRANSFER_DST_OPTIMAL) OUTSIDE the
@@ -971,7 +971,7 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
LOGE("%s: vkCreateImage(%s, %dx%d, pitch %u, offset %u) -> %s%s", as_blit_dst ? "layer" : "dmabuf",
vkp_modifier_name(modifier), w, h, stride, offset, vk_result_name(cr),
(!as_blit_dst && modifier == VKP_MOD_QCOM_COMPRESSED)
? ": the game's UBWC layout was refused; BANNER_WAYLAND_UBWC=0 forces linear buffers" : "");
? ": the game's UBWC layout was refused; DROIDDECK_WAYLAND_UBWC=0 forces linear buffers" : "");
free(img); return NULL;
}
@@ -1182,7 +1182,7 @@ static int ensure_scene_image(int w, int h) {
if (r != VK_SUCCESS) { LOGE("effects: scene image %dx%d: vkAllocateMemory %s", w, h, vk_result_name(r)); destroy_scene_image(); return -1; }
g_vk.BindImageMemory(g_dev, g_scene.img, g_scene.mem, 0);
g_scene.w = w; g_scene.h = h;
banner_log("effects", "scene image %dx%d for the effect chain", w, h);
droiddeck_log("effects", "scene image %dx%d for the effect chain", w, h);
return 0;
}
@@ -1218,7 +1218,7 @@ static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImag
s->w = w; s->h = h; s->fmt = fmt;
char fname[24];
vk_format_short(fmt, fname, sizeof(fname));
banner_log("color", "%s image %dx%d (%s)", what, w, h, fname);
droiddeck_log("color", "%s image %dx%d (%s)", what, w, h, fname);
return 0;
}
@@ -1249,7 +1249,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT};
g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &mb, 0, NULL, 0, NULL);
struct hdrc_params p = {.mode = mode, .sdr_white_nits = banner_color_sdr_white(),
struct hdrc_params p = {.mode = mode, .sdr_white_nits = droiddeck_color_sdr_white(),
.peak_nits = hf->peak_nits > 0.0f ? hf->peak_nits : 1000.0f};
int lowest_hdr = -1;
for (int i = 0; i < n; i++) {
@@ -1272,7 +1272,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
}
if (i >= lowest_hdr && !truncated_said) {
truncated_said = 1;
banner_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
droiddeck_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest "
"ones are treated as SDR", HDRC_MAX_RECTS);
}
}
@@ -1287,7 +1287,7 @@ static void device_lost(const char *where) {
g_dev_state = -2;
g_base_black = 0;
vkp_framegen_device_lost();
banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL);
@@ -1385,12 +1385,12 @@ static int acquire_image(int k, int first, uint32_t *img, VkResult *ar_out) {
if (ar == VK_SUCCESS || ar == VK_SUBOPTIMAL_KHR) { *ar_out = ar; return 0; }
if (ar == VK_ERROR_DEVICE_LOST) { device_lost("acquire"); return -1; }
if (ar == VK_ERROR_OUT_OF_DATE_KHR || ar == VK_ERROR_SURFACE_LOST_KHR) {
banner_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
droiddeck_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar));
destroy_swapchain();
if (first && attempt == 0) continue;
return -1;
}
banner_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar);
droiddeck_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar);
if (ar == VK_TIMEOUT || ar == VK_NOT_READY) return -1;
destroy_swapchain(); /* anything else: start over next frame */
return -1;
@@ -1422,7 +1422,7 @@ static void check_surface_changed(void) {
if (caps.currentExtent.width == g_caps_extent.width && caps.currentExtent.height == g_caps_extent.height &&
caps.currentTransform == g_surface_transform)
return;
banner_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain",
droiddeck_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain",
g_caps_extent.width, g_caps_extent.height, caps.currentExtent.width, caps.currentExtent.height,
(int)g_surface_transform, (int)caps.currentTransform);
destroy_swapchain();
@@ -1464,7 +1464,7 @@ static int take_wait_fd(int fd) {
static int said;
if (!said) {
said = 1;
banner_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU");
droiddeck_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU");
}
}
struct pollfd p = {.fd = fd, .events = POLLIN};
@@ -1476,18 +1476,18 @@ static int take_wait_fd(int fd) {
/* The HDR10 swapchain's metadata: the game's SMPTE 2086 / CTA-861.3 values (its image description) via
* VK_EXT_hdr_metadata, once per swapchain and description. A game that gave none gets none. */
static void send_hdr_metadata(const struct banner_color *c) {
static void send_hdr_metadata(const struct droiddeck_color *c) {
static int said_none, said_nodesc;
if (!c) return;
if (!g_set_hdr_metadata) {
if (!said_none) { said_none = 1; banner_log("color", "HDR10 swapchain without the game's metadata: this device "
if (!said_none) { said_none = 1; droiddeck_log("color", "HDR10 swapchain without the game's metadata: this device "
"has no VK_EXT_hdr_metadata (the display uses its defaults)"); }
return;
}
if (!c->has_st2086 && !c->has_cta861) {
if (said_nodesc != (int)c->identity) {
said_nodesc = (int)c->identity;
banner_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), "
droiddeck_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), "
"so none is set - the display uses its defaults", c->identity);
}
return;
@@ -1506,7 +1506,7 @@ static void send_hdr_metadata(const struct banner_color *c) {
md.maxFrameAverageLightLevel = c->max_fall;
}
g_set_hdr_metadata(g_dev, 1, &g_swapchain, &md);
banner_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
droiddeck_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)",
c->identity, c->text);
}
@@ -1522,7 +1522,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
/* Frame generation queues several presents per scene frame and needs swapchain images for
* them: rebuild when that changes (armed, disarmed, another multiplier). */
if (g_swapchain && vkp_framegen_extra_images() != g_swap_extra) {
banner_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain",
droiddeck_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain",
1 + vkp_framegen_extra_images(), vkp_framegen_extra_images() ? "s" : "");
destroy_swapchain();
}
@@ -1531,7 +1531,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
* swapchain (only in HDR sessions: g_colorspace_ext). */
const int want_hdr = hf && !hf->tonemap && g_colorspace_ext && !g_swap_hdr_unavailable;
if (g_swapchain && want_hdr != g_swap_is_hdr) {
banner_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10"
droiddeck_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10"
: hf ? "HDR output switched off: rebuilding the screen swapchain as SDR (frames tone-mapped)"
: "no HDR frames through the screen swapchain any more: rebuilding it as SDR");
destroy_swapchain();
@@ -1570,10 +1570,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
char sw[24];
vk_format_short(g_swap_fmt, sw, sizeof(sw));
if (deep)
banner_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ "
droiddeck_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ "
"through interpolation, into the HDR10 swapchain (%s)", sw);
else
banner_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), "
droiddeck_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), "
"into the HDR10 swapchain (%s): banding possible in smooth gradients", sw);
}
}
@@ -1746,7 +1746,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
if (k > 0) {
VkResult ark;
if (acquire_image(k, 0, &img, &ark) != 0) {
banner_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
droiddeck_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents);
pr = VK_ERROR_OUT_OF_DATE_KHR;
break;
}
@@ -1791,7 +1791,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
if (fr == VK_ERROR_DEVICE_LOST || pr == VK_ERROR_DEVICE_LOST) { device_lost("present"); return -1; }
if (pr == VK_ERROR_OUT_OF_DATE_KHR || pr == VK_ERROR_SURFACE_LOST_KHR) {
/* The surface changed or went away under this frame: rebuild before the next one. */
banner_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr));
droiddeck_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr));
destroy_swapchain();
return -1;
} else if (pr == VK_SUBOPTIMAL_KHR || ar == VK_SUBOPTIMAL_KHR) {
@@ -1799,9 +1799,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
* swap_init), so a rotated panel reports SUBOPTIMAL on every frame. Rebuilding would change
* nothing (and a rebuild per frame would be far worse), so present as is; say so once. */
static int said;
if (!said) { said = 1; banner_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
if (!said) { said = 1; droiddeck_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); }
} else if (pr != VK_SUCCESS) {
banner_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
droiddeck_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr);
destroy_swapchain();
return -1;
}
@@ -1815,7 +1815,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
* launch/preloader overlay can dismiss (wayland has no XServer window-content hook). */
if (drawn && !g_first_frame_done) {
g_first_frame_done = 1;
banner_on_first_frame();
droiddeck_on_first_frame();
}
if (how) *how = hdr_how;
return 0;
@@ -1856,7 +1856,7 @@ void vkp_reset_first_frame(void) {
void vkp_signal_first_frame(void) {
if (g_first_frame_done) return;
g_first_frame_done = 1;
banner_on_first_frame();
droiddeck_on_first_frame();
}
int vkp_update_map(int scene_w, int scene_h) {
@@ -122,12 +122,12 @@ int vkp_can_wait_sync_fd(void);
/* ---- HDR composition (hdr_compose.h) ----
* An HDR game that cannot be alone on its display layer: the scene is composed into ONE encoding. */
struct banner_color;
struct droiddeck_color;
struct vkp_hdr_frame {
const unsigned char *is_hdr; /* per draw: 1 = its image holds PQ BT.2020 (the surface's description) */
float peak_nits; /* the HDR content's peak, for a tone-map (max CLL / mastering max; 0 = 1000) */
int tonemap; /* 1 = the drawer's HDR output switch is off: tone-map to SDR, never PQ */
const struct banner_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */
const struct droiddeck_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */
};
/* vkp_render for a scene with HDR draws (frame generation): composed into PQ and presented through an
* HDR10 swapchain where the surface offers one (and hf->tonemap is 0), else tone-mapped into the ordinary
@@ -184,6 +184,6 @@ int vkp_pass_copy_to(struct vkp_image *dst, int wait_fd);
void vkp_pass_abort(void);
// Session log (compositor.c): one line to Download/Wayland-logs and logcat.
void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#endif
@@ -16,20 +16,20 @@
#include <android/log.h>
#include <android/native_window_jni.h>
#include "vk_present.h"
#include "banner_ext.h"
#include "droiddeck_ext.h"
#include "ahb_swapchain.h"
#include "sc_layer.h"
#include "effects_chain.h"
#include "banner_color.h"
#include "droiddeck_color.h"
extern int banner_wayland_run(void);
extern void banner_wayland_send_pointer(int action, int x, int y);
extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y);
extern int banner_cursor_snapshot(int *out, int cap);
extern int banner_cursor_serial(void);
extern void banner_wayland_send_key(int evdev, int state);
extern void banner_wayland_send_scene_input(int type, int a, int b);
extern void banner_wayland_vsync(int64_t frame_time_ns);
extern int droiddeck_wayland_run(void);
extern void droiddeck_wayland_send_pointer(int action, int x, int y);
extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y);
extern int droiddeck_cursor_snapshot(int *out, int cap);
extern int droiddeck_cursor_serial(void);
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 volatile int g_fps_limit;
extern volatile int g_hide_shell;
extern volatile int g_zero_copy;
@@ -39,7 +39,7 @@ 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 "BannerWayland"
#define TAG "DroidDeckWayland"
static JavaVM *g_jvm;
static volatile pid_t g_comp_tid; /* the compositor thread, for the app's ADPF hint session */
@@ -80,7 +80,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
/* Called from vk_present.c on the compositor thread when the first client frame is
* presented. Attaches to the JVM (this thread is a bare pthread) and calls back into
* Java so the launch overlay can dismiss. Fires exactly once. */
void banner_on_first_frame(void) {
void droiddeck_on_first_frame(void) {
if (!g_jvm || !g_compositor_cls || !g_on_first_frame) return;
JNIEnv *env = NULL;
int attached = 0;
@@ -111,7 +111,7 @@ static JNIEnv *thread_env(void) {
}
/* A window started presenting GPU frames (window = its description), or NULL when it closed. */
void banner_on_game_surface(const char *window, const char *gpu) {
void droiddeck_on_game_surface(const char *window, const char *gpu) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_game_surface || !(env = thread_env())) return;
jstring jw = window ? (*env)->NewStringUTF(env, window) : NULL;
@@ -124,7 +124,7 @@ void banner_on_game_surface(const char *window, const char *gpu) {
/* The program behind the game window that just started presenting: its Linux pid and executable name
* ("" = unknown). The app arms its launch-time CPU affinity on it. Compositor thread. */
void banner_on_game_program(int pid, const char *program) {
void droiddeck_on_game_program(int pid, const char *program) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_game_program || !(env = thread_env())) return;
jstring jp = (*env)->NewStringUTF(env, program ? program : "");
@@ -134,7 +134,7 @@ void banner_on_game_program(int pid, const char *program) {
}
/* One GPU frame from that window. */
void banner_on_game_frame(void) {
void droiddeck_on_game_frame(void) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_game_frame || !(env = thread_env())) return;
(*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_game_frame);
@@ -144,7 +144,7 @@ void banner_on_game_frame(void) {
/* A program locked the pointer (locked = 1; the app's input path switches to deltas) or the lock
* ended (locked = 0; x,y = where the pointer is now, in scene coordinates, for the app to re-sync
* its own pointer to). Compositor thread. */
void banner_on_pointer_lock(int locked, int x, int y) {
void droiddeck_on_pointer_lock(int locked, int x, int y) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_pointer_lock || !(env = thread_env())) return;
(*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_pointer_lock, (jboolean)(locked != 0), (jint)x, (jint)y);
@@ -153,7 +153,7 @@ void banner_on_pointer_lock(int locked, int x, int y) {
/* A program copied text (UTF-8, not NUL-terminated for the app's sake - a byte[] so emoji and
* NULs survive JNI). Compositor thread. */
void banner_on_clipboard_text(const char *utf8, int len) {
void droiddeck_on_clipboard_text(const char *utf8, int len) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_clipboard || !(env = thread_env())) return;
jbyteArray arr = (*env)->NewByteArray(env, len);
@@ -166,7 +166,7 @@ void banner_on_clipboard_text(const char *utf8, int len) {
/* A program started (enabled) or stopped accepting IME text; x,y,w,h = its caret rectangle in
* scene pixels (all 0 = unknown). Compositor thread. */
void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h) {
void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h) {
JNIEnv *env;
if (!g_compositor_cls || !g_on_text_input || !(env = thread_env())) return;
jstring jp = (*env)->NewStringUTF(env, program ? program : "");
@@ -195,11 +195,11 @@ static void *comp_thread(void *arg) {
if (before > COMPOSITOR_NICE && setpriority(PRIO_PROCESS, tid, COMPOSITOR_NICE) != 0) refused = errno ? errno : -1;
const int after = getpriority(PRIO_PROCESS, tid);
if (refused)
banner_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)",
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)",
(int)tid, after, refused > 0 ? strerror(refused) : "?");
else
banner_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
banner_wayland_run();
droiddeck_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after);
droiddeck_wayland_run();
__android_log_print(ANDROID_LOG_INFO, TAG, "compositor thread exited");
if (t_attached) (*g_jvm)->DetachCurrentThread(g_jvm);
t_env = NULL; t_attached = 0;
@@ -272,13 +272,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStartWithSurface(
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendPointer(
JNIEnv *env, jclass clazz, jint action, jint x, jint y) {
banner_wayland_send_pointer(action, x, y);
droiddeck_wayland_send_pointer(action, x, y);
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendTouch(
JNIEnv *env, jclass clazz, jint action, jint pointer_id, jint x, jint y) {
banner_wayland_send_touch(action, pointer_id, x, y);
droiddeck_wayland_send_touch(action, pointer_id, x, y);
}
/* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor).
@@ -291,7 +291,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
jint cap = (*env)->GetArrayLength(env, out);
jint *buf = (*env)->GetIntArrayElements(env, out, NULL);
if (!buf) return 0;
int n = banner_cursor_snapshot((int *)buf, (int)cap);
int n = droiddeck_cursor_snapshot((int *)buf, (int)cap);
(*env)->ReleaseIntArrayElements(env, out, buf, 0);
return n;
}
@@ -300,27 +300,27 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
(void)env; (void)clazz;
return banner_cursor_serial();
return droiddeck_cursor_serial();
}
/* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey(
JNIEnv *env, jclass clazz, jint evdev, jint state) {
banner_wayland_send_key(evdev, state);
droiddeck_wayland_send_key(evdev, state);
}
/* App X-server input in scene (virtual desktop) coordinates; see banner_wayland_send_scene_input. */
/* App X-server input in scene (virtual desktop) coordinates; see droiddeck_wayland_send_scene_input. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput(
JNIEnv *env, jclass clazz, jint type, jint a, jint b) {
banner_wayland_send_scene_input(type, a, b);
droiddeck_wayland_send_scene_input(type, a, b);
}
/* One screen refresh (Choreographer frame callback, UI thread): the compositor draws once. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, jclass clazz, jlong frameTimeNanos) {
banner_wayland_vsync((int64_t)frameTimeNanos);
droiddeck_wayland_vsync((int64_t)frameTimeNanos);
}
/* Shortcut launches: don't draw explorer's windows (desktop, taskbar), like X11's unviewable classes. */
@@ -329,15 +329,15 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv
g_hide_shell = hide ? 1 : 0;
}
/* Zero-copy layer mode (BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen
/* Zero-copy layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen
* window on its own Android layer instead of the swapchain blit (sc_layer.c / ahb_swapchain.c). Any
* thread, any time: before the compositor starts it is the launch default; afterwards the compositor
* thread flips the state, tells the bound games to rebuild their swapchains for it and redraws. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetZeroCopy(JNIEnv *env, jclass clazz, jboolean on) {
int live = banner_get_display() != NULL;
int live = droiddeck_get_display() != NULL;
if (!live) g_zero_copy = on ? 1 : 0; /* read by ahb_swapchain_init before the queue drains */
banner_host_zero_copy(on ? 1 : 0, live);
droiddeck_host_zero_copy(on ? 1 : 0, live);
__android_log_print(ANDROID_LOG_INFO, TAG, "zero-copy layer mode %s%s", on ? "on" : "off", live ? " (live)" : "");
}
@@ -355,7 +355,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeZeroCopyFrames(JNIEn
}
/* Compressed (UBWC) game buffers: advertise DRM_FORMAT_MOD_QCOM_COMPRESSED on zwp_linux_dmabuf_v1 when
* the renderer's driver imports it (default on; BANNER_WAYLAND_UBWC=0 = off). Set before the compositor
* the renderer's driver imports it (default on; DROIDDECK_WAYLAND_UBWC=0 = off). Set before the compositor
* starts. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, jclass clazz, jboolean on) {
@@ -363,7 +363,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env,
__android_log_print(ANDROID_LOG_INFO, TAG, "compressed (UBWC) game buffers %s", on ? "on" : "off");
}
/* Debug: name no DRM device in the dma-buf feedback (BANNER_WAYLAND_NO_RENDER_NODE=1), the way a
/* Debug: name no DRM device in the dma-buf feedback (DROIDDECK_WAYLAND_NO_RENDER_NODE=1), the way a
* phone that exposes no /dev/dri node to apps does. Set before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetNoRenderNode(JNIEnv *env, jclass clazz, jboolean on) {
@@ -452,12 +452,12 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI
}
/* One "display" line in the session log, written from Java: facts that live in the Android
* framework (the panel's HDR capability) rather than in the compositor. banner_log() mirrors to
* framework (the panel's HDR capability) rather than in the compositor. droiddeck_log() mirrors to
* logcat and appends to the session file when one is open, so this is safe at any point. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("display", "%s", s);
if (s) droiddeck_log("display", "%s", s);
free(s);
}
@@ -466,18 +466,18 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *e
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("perf", "%s", s);
if (s) droiddeck_log("perf", "%s", s);
free(s);
}
/* ---- HDR10 output, round 1 (banner_color.h / wl_color_mgmt.c) ---- */
/* ---- HDR10 output, round 1 (droiddeck_color.h / wl_color_mgmt.c) ---- */
/* The opt-in: mode 0 off, 1 BANNER_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
/* The opt-in: mode 0 off, 1 DROIDDECK_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrRequest(JNIEnv *env, jclass clazz, jint mode,
jstring source, jboolean dxvkHdr, jboolean zeroCopyForced) {
char *s = dup_jstr(env, source);
banner_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
droiddeck_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0);
free(s);
}
@@ -487,7 +487,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv
jstring formats, jboolean hdr10, jfloat maxLum, jfloat maxAvg, jfloat minLum, jboolean ratioAvailable,
jfloat ratio, jint api) {
char *n = dup_jstr(env, name), *f = dup_jstr(env, formats);
banner_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum,
droiddeck_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum,
ratioAvailable ? 1 : 0, (float)ratio, (int)api);
free(n); free(f);
}
@@ -496,38 +496,38 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSdrRatioSample(JNIEnv *env, jclass clazz, jfloat ratio,
jboolean listener) {
banner_color_ratio_sample((float)ratio, listener ? 1 : 0);
droiddeck_color_ratio_sample((float)ratio, listener ? 1 : 0);
}
/* ms since an HDR frame last reached a display layer, -1 = never this session. Any thread. */
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrLastFrameAgeMs(JNIEnv *env, jclass clazz) {
return (jint)banner_color_last_frame_age_ms();
return (jint)droiddeck_color_last_frame_age_ms();
}
/* -1 = not decided yet, 0 = closed, 1 = open. Any thread. */
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrGateState(JNIEnv *env, jclass clazz) {
return (jint)banner_color_gate_state();
return (jint)droiddeck_color_gate_state();
}
/* The session is ending: the "HDR on screen: ..." summary line. Any thread, once. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSessionEnd(JNIEnv *env, jclass clazz) {
banner_color_session_end();
droiddeck_color_session_end();
}
/* HDR frames really on screen right now (the HUD badge). Any thread. */
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOnScreen(JNIEnv *env, jclass clazz) {
return banner_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
return droiddeck_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE;
}
/* One "color" line in the session log from Java. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env, jclass clazz, jstring message) {
char *s = dup_jstr(env, message);
if (s) banner_log("color", "%s", s);
if (s) droiddeck_log("color", "%s", s);
free(s);
}
@@ -536,7 +536,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jclass clazz, jstring area, jstring message) {
char *a = dup_jstr(env, area), *s = dup_jstr(env, message);
if (s) banner_log(a && a[0] ? a : "app", "%s", s);
if (s) droiddeck_log(a && a[0] ? a : "app", "%s", s);
free(a);
free(s);
}
@@ -544,65 +544,65 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jcl
/* SDR content's level inside an HDR picture, in nits (default 203). */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrSdrWhite(JNIEnv *env, jclass clazz, jfloat nits) {
banner_color_set_sdr_white((float)nits);
droiddeck_color_set_sdr_white((float)nits);
}
/* The drawer's live HDR output switch: on = HDR frames as HDR, off = the same frames tone-mapped to SDR.
* Applied on the compositor thread (logged there, with a redraw). */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrOutput(JNIEnv *env, jclass clazz, jboolean on) {
if (banner_get_display()) banner_host_hdr_output(on ? 1 : 0);
else banner_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */
if (droiddeck_get_display()) droiddeck_host_hdr_output(on ? 1 : 0);
else droiddeck_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */
}
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) {
return banner_color_output() ? JNI_TRUE : JNI_FALSE;
return droiddeck_color_output() ? JNI_TRUE : JNI_FALSE;
}
/* Device evidence for the HDR lines: thermal status + headroom, brightness + mode. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrEnvSample(JNIEnv *env, jclass clazz, jint thermal,
jfloat headroom, jint brightness, jint mode) {
banner_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
droiddeck_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom,
(int)brightness, (int)mode);
}
/* Display.getHighestHdrSdrRatio() (Android 16+), <= 0 = not reported. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrHighestRatio(JNIEnv *env, jclass clazz, jfloat ratio) {
banner_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
droiddeck_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f);
}
/* The HDR headroom the screen surface should ask for (HDR10 swapchain frames in the last 1.5 s), 0 = none. */
JNIEXPORT jfloat JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroom(JNIEnv *env, jclass clazz) {
return (jfloat)banner_color_screen_headroom(NULL, 0);
return (jfloat)droiddeck_color_screen_headroom(NULL, 0);
}
JNIEXPORT jstring JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) {
char why[200];
banner_color_screen_headroom(why, sizeof(why));
droiddeck_color_screen_headroom(why, sizeof(why));
return (*env)->NewStringUTF(env, why);
}
/* The app's screen-surface request, for the no-headroom lines: > 0 asked, 0 cleared, -1 not possible. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrNoteHeadroomRequest(JNIEnv *env, jclass clazz, jfloat ratio) {
banner_color_note_headroom_request((float)ratio);
droiddeck_color_note_headroom_request((float)ratio);
}
/* 0 none, 1 HDR frames on screen with headroom, 2 HDR frames on screen without headroom for 5 s+. */
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrState(JNIEnv *env, jclass clazz) {
return (jint)banner_color_hdr_state();
return (jint)droiddeck_color_hdr_state();
}
/* An HDR game's frames were shown tone-mapped to SDR in the last 1.5 s (the drawer's status line). */
JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrToneMappedOnScreen(JNIEnv *env, jclass clazz) {
return banner_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
return droiddeck_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE;
}
/* The Look the controls currently match (null = Custom) - only named in the session log. */
@@ -651,7 +651,7 @@ JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
char *buf = dup_bytes(env, utf8, &len);
banner_host_clipboard_text(buf ? buf : "", len);
droiddeck_host_clipboard_text(buf ? buf : "", len);
free(buf);
}
@@ -660,7 +660,7 @@ JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputCommit(JNIEnv *env, jclass clazz, jbyteArray utf8) {
int len;
char *buf = dup_bytes(env, utf8, &len);
if (buf && len) banner_host_text_commit(buf, len);
if (buf && len) droiddeck_host_text_commit(buf, len);
free(buf);
}
@@ -670,14 +670,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputPreedit(JNI
jint cursorBegin, jint cursorEnd) {
int len;
char *buf = dup_bytes(env, utf8, &len);
banner_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd);
droiddeck_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd);
free(buf);
}
/* The IME deleted characters around the caret. */
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
banner_host_text_delete(before, after);
droiddeck_host_text_delete(before, after);
}
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
+15 -15
View File
@@ -16,7 +16,7 @@
*
* Guest → Android: when a program sets a selection with a text mime, the compositor asks for it
* once over a pipe on the event loop (non-blocking, 1 MiB cap, 3 s timeout) and hands the text
* to the app (banner_on_clipboard_text → ClipboardManager). Android → guest: the app's text
* to the app (droiddeck_on_clipboard_text → ClipboardManager). Android → guest: the app's text
* becomes the HOST selection; receive requests are answered with a non-blocking writer.
*/
#define _GNU_SOURCE 1
@@ -25,7 +25,7 @@
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include "banner_ext.h"
#include "droiddeck_ext.h"
#include "wlr-data-control-unstable-v1-server-protocol.h"
#define CLIP_MAX_BYTES (1024 * 1024)
@@ -130,7 +130,7 @@ static int on_writer_writable(int fd, uint32_t mask, void *data) {
static int on_writer_timeout(void *data) {
struct writer *w = data;
banner_log("clipboard", "a program never read the clipboard text it asked for; giving up");
droiddeck_log("clipboard", "a program never read the clipboard text it asked for; giving up");
writer_free(w);
return 0;
}
@@ -179,11 +179,11 @@ static void reader_finish(struct reader *r) {
free(g_last_guest_text);
g_last_guest_text = copy;
g_last_guest_len = r->len;
banner_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
banner_on_clipboard_text(copy, (int)r->len);
droiddeck_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime);
droiddeck_on_clipboard_text(copy, (int)r->len);
}
} else {
banner_log("clipboard", "guest copied an empty text; Android clipboard left alone");
droiddeck_log("clipboard", "guest copied an empty text; Android clipboard left alone");
}
reader_free(r);
}
@@ -192,7 +192,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) {
struct reader *r = data;
for (;;) {
if (r->len >= CLIP_MAX_BYTES) {
banner_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES);
droiddeck_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES);
reader_free(r);
return 0;
}
@@ -215,7 +215,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) {
static int on_reader_timeout(void *data) {
struct reader *r = data;
banner_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len);
droiddeck_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len);
reader_free(r);
return 0;
}
@@ -251,7 +251,7 @@ static void offer_receive(struct wl_client *c, struct wl_resource *r, const char
if (!o || o->gen != g_gen) { close(fd); return; } /* stale offer */
if (g_owner == OWNER_HOST) {
if (is_text_mime(mime)) {
banner_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, banner_client_name(c));
droiddeck_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, droiddeck_client_name(c));
writer_start(fd, g_host_text, g_host_len);
} else {
close(fd);
@@ -327,7 +327,7 @@ static void broadcast_selection(void) {
wl_display_flush_clients(g_disp);
}
void banner_clipboard_keyboard_focus(struct wl_client *client) {
void droiddeck_clipboard_keyboard_focus(struct wl_client *client) {
struct device *d;
if (client == g_kb_client) return;
g_kb_client = client;
@@ -345,7 +345,7 @@ static void source_res_destroy(struct wl_resource *r) {
g_src = NULL;
g_owner = OWNER_NONE;
if (g_reader) reader_free(g_reader);
banner_log("clipboard", "%s withdrew its clipboard contents", banner_client_name(wl_resource_get_client(r)));
droiddeck_log("clipboard", "%s withdrew its clipboard contents", droiddeck_client_name(wl_resource_get_client(r)));
broadcast_selection();
}
for (int i = 0; i < s->nmimes; i++) free(s->mimes[i]);
@@ -396,9 +396,9 @@ static void set_selection(struct wl_client *c, struct wl_resource *source_res) {
if (*list) strncat(list, ", ", sizeof(list) - strlen(list) - 1);
strncat(list, s->mimes[i], sizeof(list) - strlen(list) - 1);
}
banner_log("clipboard", "selection set by %s (%s)", banner_client_name(c), *list ? list : "no mime types");
droiddeck_log("clipboard", "selection set by %s (%s)", droiddeck_client_name(c), *list ? list : "no mime types");
} else {
banner_log("clipboard", "%s cleared the clipboard", banner_client_name(c));
droiddeck_log("clipboard", "%s cleared the clipboard", droiddeck_client_name(c));
}
broadcast_selection();
read_client_text();
@@ -415,7 +415,7 @@ static void device_res_destroy(struct wl_resource *r) {
static void device_start_drag(struct wl_client *c, struct wl_resource *r, struct wl_resource *source,
struct wl_resource *origin, struct wl_resource *icon, uint32_t serial) {
banner_log("clipboard", "%s started a drag and drop; not supported, cancelled", banner_client_name(c));
droiddeck_log("clipboard", "%s started a drag and drop; not supported, cancelled", droiddeck_client_name(c));
if (source) {
struct source *s = wl_resource_get_user_data(source);
if (s && s != g_src) source_send_cancelled(s);
@@ -512,7 +512,7 @@ void clipboard_host_text(char *utf8, size_t len) {
if (!len) free(utf8);
g_host_len = len;
g_owner = len ? OWNER_HOST : OWNER_NONE;
banner_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len);
droiddeck_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len);
broadcast_selection();
}
+102 -102
View File
@@ -1,7 +1,7 @@
/*
* wp_color_manager_v1 (wayland-protocols staging color-management-v1, version 1) - the subset Mesa's
* Wayland WSI binds - plus the HDR gate, the per-surface image descriptions and the session log's
* HDR evidence. See banner_color.h for the design and HDR_RECON.md for why it is shaped this way.
* HDR evidence. See droiddeck_color.h for the design and HDR_RECON.md for why it is shaped this way.
*
* Strictness policy. A protocol error disconnects the client, and the client here is a GAME, so
* errors are only raised where the protocol leaves no choice and a conforming client can never hit
@@ -12,8 +12,8 @@
* for the same surface replaces the first, a request on an inert object is ignored.
*/
#define _GNU_SOURCE
#include "banner_color.h"
#include "banner_ext.h"
#include "droiddeck_color.h"
#include "droiddeck_ext.h"
#include "ahb_swapchain.h"
#include "sc_layer.h"
#include "color-management-v1-server-protocol.h"
@@ -30,7 +30,7 @@ extern volatile int g_zero_copy;
#define TAG "color"
/* AHARDWAREBUFFER_FORMAT_* (android/hardware_buffer.h), for the log only. */
const char *banner_ahb_format_name(uint32_t f) {
const char *droiddeck_ahb_format_name(uint32_t f) {
static char other[32];
switch (f) {
case 1: return "RGBA8888 (8-bit)";
@@ -73,9 +73,9 @@ static const char *tf_name(uint32_t tf) {
static const char *dataspace_name(int32_t ds) {
switch (ds) {
case BANNER_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
case BANNER_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
case BANNER_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)";
case DROIDDECK_ADATASPACE_BT2020_PQ: return "BT2020_PQ";
case DROIDDECK_ADATASPACE_BT2020_HLG: return "BT2020_HLG";
case DROIDDECK_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)";
default: return "other";
}
}
@@ -99,7 +99,7 @@ static _Atomic int g_gate = -1; /* -1 undecided, 0 closed, 1 open */
static char g_gate_why[320]; /* the closed gate's reason (for the summary) */
static _Atomic int g_output_on = 1; /* the drawer's HDR output switch (per session, starts on) */
void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) {
void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) {
pthread_mutex_lock(&g_mu);
g_req.mode = mode < 0 ? 0 : mode > 2 ? 2 : mode;
snprintf(g_req.source, sizeof(g_req.source), "%s", source ? source : "environment");
@@ -109,7 +109,7 @@ void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int ze
atomic_store(&g_output_on, 1); /* the drawer's switch is per session and starts on */
}
void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum,
float max_avg, float min_lum, int ratio_available, float ratio, int api) {
pthread_mutex_lock(&g_mu);
int was_known = g_req.display_known, was_hdr10 = g_req.hdr10, was_id = g_req.display_id;
@@ -127,17 +127,17 @@ void banner_color_set_display(int id, const char *name, const char *formats, int
* session is recorded, and what it means is said out loud. */
if (was_known && atomic_load(&g_gate) >= 0 && (was_hdr10 != (hdr10 ? 1 : 0) || was_id != id)) {
if (atomic_load(&g_gate) == 1 && !hdr10)
banner_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged "
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged "
"BT2020_PQ and SurfaceFlinger tone-maps them for this display (the offer to games is fixed "
"for the session)", name ? name : "?", id);
else if (atomic_load(&g_gate) == 0 && hdr10)
banner_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this "
droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this "
"session (the gate is decided when the compositor starts); relaunch to use it", name ? name : "?", id);
}
}
int banner_color_gate_state(void) { return atomic_load(&g_gate); }
int banner_color_hdr_open(void) { return atomic_load(&g_gate) == 1; }
int droiddeck_color_gate_state(void) { return atomic_load(&g_gate); }
int droiddeck_color_hdr_open(void) { return atomic_load(&g_gate) == 1; }
/* ---------------------------------------------------------------- HDR evidence (summary) */
@@ -170,7 +170,7 @@ static struct {
static _Atomic int64_t g_last_frame_ns;
static _Atomic int64_t g_last_tm_ns; /* the last HDR frame shown tone-mapped to SDR */
/* What the device says beside the headroom (the app, non-root APIs; banner_color_env_*). The Fold showed
/* What the device says beside the headroom (the app, non-root APIs; droiddeck_color_env_*). The Fold showed
* three ways to lose HDR headroom with HDR frames on screen - a screen recording, heat under load, and
* (plausibly) the brightness slider at maximum - so every no-headroom line carries this. Under g_mu. */
static struct {
@@ -241,20 +241,20 @@ static _Atomic int g_screen_hr_x1000; /* the screen surface's wanted headr
static _Atomic int64_t g_screen_hr_ns; /* the last HDR10-swapchain frame */
static char g_screen_hr_why[200]; /* under g_mu */
static float peak_of(const struct banner_color *c, float disp_max, const char **src) {
static float peak_of(const struct droiddeck_color *c, float disp_max, const char **src) {
if (c->max_cll > 0.0f) { *src = "max CLL"; return c->max_cll; }
if (c->has_st2086 && c->max_lum > 0.0f) { *src = "mastering max"; return c->max_lum; }
if (disp_max > 0.0f) { *src = "the display's peak"; return disp_max; }
*src = "assumed"; return 1000.0f;
}
float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n) {
float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n) {
if (!c || !c->dataspace) { if (why && n) why[0] = 0; return 0.0f; }
pthread_mutex_lock(&g_mu);
const float disp_max = g_req.max_lum, highest = g_req.highest_ratio;
pthread_mutex_unlock(&g_mu);
const char *src;
const float peak = peak_of(c, disp_max, &src), white = banner_color_sdr_white();
const float peak = peak_of(c, disp_max, &src), white = droiddeck_color_sdr_white();
if (highest > 0.0f && highest <= 1.01f) {
if (why && n) snprintf(why, n, "the display reports its highest HDR/SDR ratio as %.2f: no app can get a "
"boost here, so nothing is asked", highest);
@@ -273,24 +273,24 @@ float banner_color_desired_headroom(const struct banner_color *c, char *why, siz
return r;
}
void banner_color_note_headroom_request(float ratio) {
void droiddeck_color_note_headroom_request(float ratio) {
pthread_mutex_lock(&g_mu);
g_env.requested = ratio;
pthread_mutex_unlock(&g_mu);
}
void banner_color_set_highest_ratio(float ratio) {
void droiddeck_color_set_highest_ratio(float ratio) {
pthread_mutex_lock(&g_mu);
const float was = g_req.highest_ratio;
g_req.highest_ratio = ratio > 0.0f ? ratio : -1.0f;
pthread_mutex_unlock(&g_mu);
if (ratio > 0.0f && (was < 0.0f || was != ratio) && atomic_load(&g_gate) == 1)
banner_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio,
droiddeck_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio,
ratio <= 1.01f ? " - it reports no HDR boost at all: no app can raise HDR highlights above SDR white here"
: " (the most HDR headroom it can give)");
}
float banner_color_screen_headroom(char *why, size_t n) {
float droiddeck_color_screen_headroom(char *why, size_t n) {
const int64_t t = atomic_load(&g_screen_hr_ns);
if (!t || now_ns() - t > 1500000000LL) { if (why && n) why[0] = 0; return 0.0f; }
if (why && n) {
@@ -301,7 +301,7 @@ float banner_color_screen_headroom(char *why, size_t n) {
return atomic_load(&g_screen_hr_x1000) / 1000.0f;
}
int banner_color_last_frame_age_ms(void) {
int droiddeck_color_last_frame_age_ms(void) {
int64_t t = atomic_load(&g_last_frame_ns);
if (!t) return -1;
int64_t age = (now_ns() - t) / 1000000LL;
@@ -408,34 +408,34 @@ static void log_verdict(int force) {
int changed = strcmp(v, g_hdr.verdict) != 0;
if (changed) snprintf(g_hdr.verdict, sizeof(g_hdr.verdict), "%s", v);
pthread_mutex_unlock(&g_mu);
if (changed || force) banner_log(TAG, "HDR on screen: %s", v);
if (changed || force) droiddeck_log(TAG, "HDR on screen: %s", v);
}
void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format) {
void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format) {
if (!c || !c->dataspace) return;
int first8 = 0;
pthread_mutex_lock(&g_mu);
switch (path) {
case BANNER_HDR_ZERO_COPY:
case DROIDDECK_HDR_ZERO_COPY:
if (ahb_format == 0x2b) g_hdr.layer_10bit++;
g_hdr.win_zc++;
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", banner_ahb_format_name(ahb_format));
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", droiddeck_ahb_format_name(ahb_format));
break;
case BANNER_HDR_LAYER_COPY:
case DROIDDECK_HDR_LAYER_COPY:
if (!g_hdr.layer_copy8) first8 = 1;
g_hdr.layer_copy8++; g_hdr.win_copy8++;
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "RGBA8888 layer copy");
break;
case BANNER_HDR_COMPOSED:
case DROIDDECK_HDR_COMPOSED:
g_hdr.composed++; g_hdr.win_composed++;
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", banner_ahb_format_name(ahb_format));
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", droiddeck_ahb_format_name(ahb_format));
break;
case BANNER_HDR_SWAPCHAIN:
case DROIDDECK_HDR_SWAPCHAIN:
g_hdr.swapchain++; g_hdr.win_swapchain++;
snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "HDR10 swapchain");
atomic_store(&g_screen_hr_ns, now_ns());
break;
default: /* BANNER_HDR_TONEMAPPED: shown, but not as HDR */
default: /* DROIDDECK_HDR_TONEMAPPED: shown, but not as HDR */
g_hdr.tonemapped++; g_hdr.win_tonemapped++;
if (!atomic_load(&g_output_on)) g_hdr.tm_off++;
pthread_mutex_unlock(&g_mu);
@@ -446,7 +446,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
g_hdr.win_layer++;
pthread_mutex_unlock(&g_mu);
atomic_store(&g_last_frame_ns, now_ns());
if (path == BANNER_HDR_SWAPCHAIN) {
if (path == DROIDDECK_HDR_SWAPCHAIN) {
/* The screen surface's headroom request (the app applies it): worked out again when the
* description changes, else once a second. */
static uint32_t ident;
@@ -454,7 +454,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
const int64_t t = now_ns();
if (c->identity != ident || t - calc_ns > 1000000000LL) {
char why[200];
const float r = banner_color_desired_headroom(c, why, sizeof(why));
const float r = droiddeck_color_desired_headroom(c, why, sizeof(why));
ident = c->identity; calc_ns = t;
pthread_mutex_lock(&g_mu);
snprintf(g_screen_hr_why, sizeof(g_screen_hr_why), "%s", why);
@@ -463,12 +463,12 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a
}
}
if (first8)
banner_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's "
droiddeck_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's "
"buffer is not a gralloc buffer this frame): colours stay correct (the frame keeps its BT2020_PQ tag), "
"precision drops to 8 bits (banding possible)");
}
void banner_color_frame_copied(const char *who, const char *reason) {
void droiddeck_color_frame_copied(const char *who, const char *reason) {
int first = 0;
pthread_mutex_lock(&g_mu);
g_hdr.copy_frames++;
@@ -479,14 +479,14 @@ void banner_color_frame_copied(const char *who, const char *reason) {
}
pthread_mutex_unlock(&g_mu);
if (first)
banner_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT "
droiddeck_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT "
"tone mapping (washed out) until that changes", who ? who : "a window", reason ? reason : "?");
}
void banner_color_ratio_sample(float ratio, int listener) {
void droiddeck_color_ratio_sample(float ratio, int listener) {
if (atomic_load(&g_gate) != 1) return;
int64_t t = now_ns();
int age = banner_color_last_frame_age_ms();
int age = droiddeck_color_last_frame_age_ms();
int live = age >= 0 && age < 1500;
int log_it = 0, periodic = 0, nohead_now = 0, head_back = 0;
float was;
@@ -536,28 +536,28 @@ void banner_color_ratio_sample(float ratio, int listener) {
}
pthread_mutex_unlock(&g_mu);
if (nohead_now)
banner_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android "
droiddeck_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android "
"drops HDR headroom while the screen is recorded and when the device is hot, and some phones never "
"boost HDR for apps; HDR highlights look no brighter until that ends [%s]", ratio, causes, env);
if (head_back)
banner_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio);
droiddeck_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio);
if (!log_it) return;
char when[64];
if (age < 0) snprintf(when, sizeof(when), "none yet this session");
else snprintf(when, sizeof(when), "%s, last one %d ms ago", live ? "yes" : "no", age);
if (periodic)
banner_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when,
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when,
live && ratio <= 1.01f ? " - no HDR headroom: " : "", live && ratio <= 1.01f ? causes : "",
env[0] ? " [" : "", env, env[0] ? "]" : "");
else if (was > 0.0f)
banner_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when,
listener ? "display listener" : "sampler");
else
banner_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
droiddeck_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when,
listener ? "display listener" : "sampler");
}
void banner_color_stats_tick(void) {
void droiddeck_color_stats_tick(void) {
if (atomic_load(&g_gate) != 1) return;
char line[512];
int any;
@@ -582,13 +582,13 @@ void banner_color_stats_tick(void) {
g_hdr.win_zc = g_hdr.win_composed = g_hdr.win_swapchain = g_hdr.win_tonemapped = 0;
g_hdr.win_ratio_n = 0;
pthread_mutex_unlock(&g_mu);
if (any) banner_log(TAG, "%s", line);
if (any) droiddeck_log(TAG, "%s", line);
log_verdict(0);
}
int banner_color_hdr_state(void) {
int droiddeck_color_hdr_state(void) {
if (atomic_load(&g_gate) != 1) return 0;
int age = banner_color_last_frame_age_ms();
int age = droiddeck_color_last_frame_age_ms();
if (age < 0 || age >= 1500) return 0;
pthread_mutex_lock(&g_mu);
int confirmed = !g_hdr.ratio_n || g_hdr.ratio_last > 1.01f; /* no ratio on this display: the tag is all there is */
@@ -597,26 +597,26 @@ int banner_color_hdr_state(void) {
return confirmed ? 1 : nohead ? 2 : 0;
}
int banner_color_hdr_on_screen(void) { return banner_color_hdr_state() == 1; }
int droiddeck_color_hdr_on_screen(void) { return droiddeck_color_hdr_state() == 1; }
static _Atomic int g_sdr_white_x100 = 20300;
void banner_color_set_sdr_white(float nits) {
void droiddeck_color_set_sdr_white(float nits) {
if (!(nits >= 10.0f && nits <= 2000.0f)) return;
atomic_store(&g_sdr_white_x100, (int)(nits * 100.0f + 0.5f));
banner_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (BANNER_WAYLAND_HDR_SDR_NITS)", nits);
droiddeck_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (DROIDDECK_WAYLAND_HDR_SDR_NITS)", nits);
}
float banner_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
float droiddeck_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; }
int banner_color_requested(void) {
int droiddeck_color_requested(void) {
pthread_mutex_lock(&g_mu);
int m = g_req.mode;
pthread_mutex_unlock(&g_mu);
return m != 0;
}
int banner_color_output(void) { return atomic_load(&g_output_on); }
int droiddeck_color_output(void) { return atomic_load(&g_output_on); }
void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode) {
void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode) {
if (atomic_load(&g_gate) != 1) return; /* evidence for the HDR lines only */
char msg[2][200];
int nmsg = 0;
@@ -644,42 +644,42 @@ void banner_color_env_sample(int thermal, float headroom, int brightness, int bm
"brightness changes are logged as they happen)", e);
}
pthread_mutex_unlock(&g_mu);
for (int i = 0; i < nmsg; i++) banner_log(TAG, "%s", msg[i]);
for (int i = 0; i < nmsg; i++) droiddeck_log(TAG, "%s", msg[i]);
}
int banner_color_tonemapped_on_screen(void) {
int droiddeck_color_tonemapped_on_screen(void) {
if (atomic_load(&g_gate) != 1) return 0;
int64_t t = atomic_load(&g_last_tm_ns);
return t && now_ns() - t < 1500000000LL;
}
void banner_color_set_output(int on) {
void droiddeck_color_set_output(int on) {
on = on ? 1 : 0;
if (atomic_load(&g_gate) != 1) {
/* The drawer only shows the switch in sessions whose gate is open; say so if it ever gets here. */
banner_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off");
droiddeck_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off");
return;
}
if (atomic_exchange(&g_output_on, on) == on) return;
char who[160];
int age = banner_color_last_frame_age_ms(), tm = banner_color_tonemapped_on_screen();
int age = droiddeck_color_last_frame_age_ms(), tm = droiddeck_color_tonemapped_on_screen();
pthread_mutex_lock(&g_mu);
if (!on) g_hdr.output_offs++;
snprintf(who, sizeof(who), "%s", g_hdr.applied_who[0] ? g_hdr.applied_who : "no HDR program yet");
pthread_mutex_unlock(&g_mu);
if (on)
banner_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next "
droiddeck_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next "
"frame (%s; %s)", who, tm ? "they were being tone-mapped to SDR until now" : "none were on screen just now");
else
banner_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame "
droiddeck_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame "
"(%s; %s). The game is not told - it keeps rendering HDR; its own HDR setting and DXVK_HDR are "
"untouched", who, age >= 0 && age < 1500 ? "HDR frames were on screen" : "no HDR frames on screen just now");
banner_request_redraw(); /* a paused game commits nothing: show the change now */
droiddeck_request_redraw(); /* a paused game commits nothing: show the change now */
}
static _Atomic int g_session_ended;
void banner_color_session_end(void) {
void droiddeck_color_session_end(void) {
if (atomic_exchange(&g_session_ended, 1)) return;
if (atomic_load(&g_gate) < 0) return;
pthread_mutex_lock(&g_mu);
@@ -692,7 +692,7 @@ void banner_color_session_end(void) {
struct cm_desc {
int refs; /* protocol objects + surfaces (pending / current) */
struct banner_color c;
struct droiddeck_color c;
/* Set on the output / preferred descriptions the compositor serves (never on one a game built
* from parameters): get_information is answered rather than refused, with these as the target. */
int informative;
@@ -727,7 +727,7 @@ static void image_description_get_information(struct wl_client *c, struct wl_res
wl_resource_get_version(r), id);
if (!info) { wl_client_post_no_memory(c); return; }
wl_resource_set_implementation(info, NULL, NULL, NULL); /* events only */
const struct banner_color *col = &d->c;
const struct droiddeck_color *col = &d->c;
int32_t xy[8] = { (int32_t)(col->red[0] * 1e6f + 0.5f), (int32_t)(col->red[1] * 1e6f + 0.5f),
(int32_t)(col->green[0] * 1e6f + 0.5f), (int32_t)(col->green[1] * 1e6f + 0.5f),
(int32_t)(col->blue[0] * 1e6f + 0.5f), (int32_t)(col->blue[1] * 1e6f + 0.5f),
@@ -765,12 +765,12 @@ static void image_description_resource_destroy(struct wl_resource *r) {
static void make_output_description(struct wl_client *c, struct wl_resource *parent, uint32_t id, const char *what) {
struct cm_desc *d = calloc(1, sizeof(*d));
if (!d) { wl_client_post_no_memory(c); return; }
struct banner_color *col = &d->c;
struct droiddeck_color *col = &d->c;
col->identity = g_next_identity++;
if (!g_next_identity) g_next_identity = 1;
col->primaries = WP_COLOR_MANAGER_V1_PRIMARIES_BT2020;
col->tf = WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ;
col->dataspace = BANNER_ADATASPACE_BT2020_PQ;
col->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ;
col->has_st2086 = 1;
col->red[0] = 0.708f; col->red[1] = 0.292f; col->green[0] = 0.170f; col->green[1] = 0.797f;
col->blue[0] = 0.131f; col->blue[1] = 0.046f; col->white[0] = 0.3127f; col->white[1] = 0.3290f;
@@ -788,8 +788,8 @@ static void make_output_description(struct wl_client *c, struct wl_resource *par
d->refs = 1;
wl_resource_set_implementation(r, &image_description_impl, d, image_description_resource_destroy);
wp_image_description_v1_send_ready(r, col->identity);
banner_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information",
banner_client_name(c), what, col->text);
droiddeck_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information",
droiddeck_client_name(c), what, col->text);
}
static void params_create(struct wl_client *client, struct wl_resource *r, uint32_t id) {
@@ -801,15 +801,15 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3
}
struct cm_desc *d = calloc(1, sizeof(*d));
if (!d) { wl_client_post_no_memory(client); return; }
struct banner_color *c = &d->c;
struct droiddeck_color *c = &d->c;
c->identity = g_next_identity++;
if (!g_next_identity) g_next_identity = 1;
c->primaries = p->primaries;
c->tf = p->tf;
if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ)
c->dataspace = BANNER_ADATASPACE_BT2020_PQ;
c->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ;
else if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_HLG)
c->dataspace = BANNER_ADATASPACE_BT2020_HLG;
c->dataspace = DROIDDECK_ADATASPACE_BT2020_HLG;
/* HDR metadata, kept only when it makes sense. The PQ defaults stand in for what was not given
* (0.005 - 10000 cd/m², the target volume = the primary one); CTA-861.3 treats 0 as unknown. */
@@ -870,12 +870,12 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3
g_hdr.descs++;
pthread_mutex_unlock(&g_mu);
}
banner_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity,
banner_client_name(client), c->text, dataspace_name(c->dataspace));
droiddeck_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity,
droiddeck_client_name(client), c->text, dataspace_name(c->dataspace));
if (dropped[0]) {
size_t l = strlen(dropped);
if (l && dropped[l - 1] == ',') dropped[l - 1] = 0;
banner_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s "
droiddeck_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s "
"(Android gets the rest)", c->identity, dropped);
}
wl_resource_destroy(r); /* create is the creator's destructor */
@@ -982,7 +982,7 @@ static struct cm_surf *surf_of(struct wl_resource *surface) {
static void describe_surface(struct wl_resource *surface, char *out, size_t size) {
struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL;
if (s) banner_surface_describe(s, out, size);
if (s) droiddeck_surface_describe(s, out, size);
else snprintf(out, size, "a closed window");
}
@@ -1002,8 +1002,8 @@ static void cm_surface_set_image_description(struct wl_client *c, struct wl_reso
struct cm_surf *cs = wl_resource_get_user_data(r);
struct cm_desc *d = desc_res ? wl_resource_get_user_data(desc_res) : NULL;
if (!cs) {
banner_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
banner_client_name(c));
droiddeck_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored",
droiddeck_client_name(c));
return;
}
if (!d) {
@@ -1045,7 +1045,7 @@ static void cm_surface_resource_destroy(struct wl_resource *r) {
cs->pending_dirty = 1;
}
void banner_color_commit(struct wl_resource *surface) {
void droiddeck_color_commit(struct wl_resource *surface) {
if (atomic_load(&g_gate) != 1) return;
struct cm_surf *cs = surf_of(surface);
if (!cs || !cs->pending_dirty) return;
@@ -1056,7 +1056,7 @@ void banner_color_commit(struct wl_resource *surface) {
char who[160];
describe_surface(surface, who, sizeof(who));
if (cs->current) {
banner_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who,
droiddeck_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who,
cs->current->c.identity, cs->current->c.text);
if (cs->current->c.dataspace) {
pthread_mutex_lock(&g_mu);
@@ -1064,16 +1064,16 @@ void banner_color_commit(struct wl_resource *surface) {
snprintf(g_hdr.applied_who, sizeof(g_hdr.applied_who), "%s", who);
pthread_mutex_unlock(&g_mu);
if (!g_zero_copy)
banner_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture "
droiddeck_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture "
"(one extra pass per frame) - switch Zero-copy presentation on for the direct path", who);
}
} else if (old) {
banner_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
droiddeck_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity);
}
desc_unref(old);
}
const struct banner_color *banner_color_of(struct wl_resource *surface) {
const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface) {
if (atomic_load(&g_gate) != 1) return NULL;
struct cm_surf *cs = surf_of(surface);
return cs && cs->current ? &cs->current->c : NULL;
@@ -1125,14 +1125,14 @@ static void cm_get_surface(struct wl_client *c, struct wl_resource *r, uint32_t
describe_surface(surface, who, sizeof(who));
if (cs->owner) {
/* The protocol says surface_exists; a game would be disconnected for it. The newer object wins. */
banner_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over "
droiddeck_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over "
"(protocol error surface_exists not raised)", who);
wl_resource_set_user_data(cs->owner, NULL);
}
cs->owner = o;
wl_resource_set_implementation(o, &cm_surface_impl, cs, cm_surface_resource_destroy);
banner_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space "
"other than sRGB)", who, banner_client_name(c));
droiddeck_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space "
"other than sRGB)", who, droiddeck_client_name(c));
}
static void cm_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, struct wl_resource *surface) {
@@ -1186,16 +1186,16 @@ static void bind_cm(struct wl_client *c, void *data, uint32_t ver, uint32_t id)
/* One line per program: Mesa binds again for every surface-format query. */
if (last_named != c) {
last_named = c;
banner_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions "
droiddeck_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions "
"with mastering metadata; BT.2020 primaries; ST 2084 PQ) - its Vulkan driver can now list "
"VK_COLOR_SPACE_HDR10_ST2084_EXT", banner_client_name(c), ver > 1 ? 1u : ver);
"VK_COLOR_SPACE_HDR10_ST2084_EXT", droiddeck_client_name(c), ver > 1 ? 1u : ver);
}
}
void banner_color_client_gone(struct wl_client *client) {
void droiddeck_color_client_gone(struct wl_client *client) {
if (atomic_load(&g_gate) != 1 || atomic_load(&g_session_ended)) return;
/* A program that presented HDR has left: say where the session stands now, while it is fresh. */
const char *name = banner_client_name(client);
const char *name = droiddeck_client_name(client);
int mine;
pthread_mutex_lock(&g_mu);
mine = g_hdr.applied_who[0] && name && strstr(g_hdr.applied_who, name) != NULL;
@@ -1205,7 +1205,7 @@ void banner_color_client_gone(struct wl_client *client) {
/* ---------------------------------------------------------------- the gate */
void banner_color_init(struct wl_display *display) {
void droiddeck_color_init(struct wl_display *display) {
char why[320] = "";
int mode, dxvk_hdr, zc_forced, known, hdr10, id, ratio_avail, api;
char name[96], formats[96], source[64];
@@ -1229,14 +1229,14 @@ void banner_color_init(struct wl_display *display) {
/* What asked for HDR, in the words the user knows it by: the editors' setting, or the env override. */
char asked[128];
if (mode == 2) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=force (%s)", source);
else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=1 (%s)", source);
if (mode == 2) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=force (%s)", source);
else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=1 (%s)", source);
else snprintf(asked, sizeof(asked), "HDR output is on (%s)", source);
int layer_ok = sc_layer_can_tag_hdr();
int ahb_ok = ahb_swapchain_advertised();
char off_why[128];
if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "BANNER_WAYLAND_HDR=0 in the %s overrides the setting", source);
if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "DROIDDECK_WAYLAND_HDR=0 in the %s overrides the setting", source);
else snprintf(off_why, sizeof(off_why), "the HDR output setting is off");
if (mode == 0)
snprintf(why, sizeof(why), "HDR output is off (%s)", off_why);
@@ -1255,16 +1255,16 @@ void banner_color_init(struct wl_display *display) {
snprintf(g_gate_why, sizeof(g_gate_why), "%s", why);
atomic_store(&g_gate, 0);
if (mode) {
banner_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf "
droiddeck_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf "
"formats): games see an SDR display, exactly as without the switch", why);
if (dxvk_hdr)
banner_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here "
droiddeck_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here "
"(games that check fall back to SDR; some show washed-out colours) - remove it on this display");
log_verdict(1);
} else if (dxvk_hdr || (known && hdr10)) {
/* Off, and silent unless it is worth a line: a display that could show HDR10, or a DXVK
* switch that promises games an HDR display they cannot get. */
banner_log(TAG, "HDR output off for this session (%s)%s%s", off_why,
droiddeck_log(TAG, "HDR output off for this session (%s)%s%s", off_why,
(known && hdr10) ? " - this display lists HDR10, so switching HDR output on for this game would "
"offer it to games" : "",
dxvk_hdr ? " - but DXVK_HDR=1 is set, so DXGI claims an HDR display the game cannot get a "
@@ -1276,25 +1276,25 @@ void banner_color_init(struct wl_display *display) {
if (!wl_global_create(display, &wp_color_manager_v1_interface, 1, NULL, bind_cm)) {
snprintf(g_gate_why, sizeof(g_gate_why), "creating the wp_color_manager_v1 global failed");
atomic_store(&g_gate, 0);
banner_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed");
droiddeck_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed");
log_verdict(1);
return;
}
atomic_store(&g_gate, 1);
char syms[160];
sc_layer_hdr_symbols(syms, sizeof(syms));
banner_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries "
droiddeck_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries "
"+ ST 2084 PQ, parametric descriptions with mastering metadata) and 10-bit AB30/XB30 dma-buf formats; HDR "
"frames go on the game's own display layer as BT2020_PQ (%s)",
mode == 2 ? "FORCED OPEN (testing)" : "OPEN", asked, disp, syms);
if (mode == 2 && !hdr10)
banner_log(TAG, "BANNER_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map "
droiddeck_log(TAG, "DROIDDECK_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map "
"the game layer for this panel (expect GPU/CLIENT composition for it), nothing looks HDR");
if (zc_forced)
banner_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight "
droiddeck_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight "
"to its display layer (the best path; anything that needs the compositor gets the composed HDR picture)");
if (!dxvk_hdr)
banner_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI "
droiddeck_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI "
"(dxgi.enableHDR in a dxvk.conf does the same) - add DXVK_HDR=1 to the container's or shortcut's "
"environment variables");
(void)max_avg; (void)min_lum;
+12 -12
View File
@@ -16,8 +16,8 @@
#include "linux-dmabuf-v1-server-protocol.h"
#include "vk_present.h"
#include "banner_ext.h"
#include "banner_color.h"
#include "droiddeck_ext.h"
#include "droiddeck_color.h"
#include "compositor_internal.h"
/* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */
@@ -112,7 +112,7 @@ static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = {
/* The advertised format/modifier table, built at the first bind from what the renderer's driver
* can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients
* may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */
/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
/* The last two rows are HDR10's (droiddeck_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa
* lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI
* can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY
* while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that
@@ -132,7 +132,7 @@ static void dmabuf_build_formats(void) {
char line[320];
int pos = 0, compressed = 0;
g_dmabuf_fmts_ready = 1;
g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
g_dmabuf_nfmt = droiddeck_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR;
for (int f = 0; f < g_dmabuf_nfmt; f++) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0;
@@ -155,10 +155,10 @@ static void dmabuf_build_formats(void) {
vkp_modifier_name(g_dmabuf_fmts[f].mods[i]));
if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1;
}
banner_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
droiddeck_log("dmabuf", "formats: %s%s", line,
!g_ubwc ? " (DROIDDECK_WAYLAND_UBWC=0: qcom_compressed not advertised)" : "");
if (g_ubwc && !compressed)
banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
droiddeck_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: "
"game swapchains stay linear", vkp_gpu_name());
if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) {
/* The rows above always carry LINEAR; say what the compositor's own driver can really import
@@ -166,7 +166,7 @@ static void dmabuf_build_formats(void) {
uint64_t got[DMABUF_NMOD];
int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0;
for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1;
banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
droiddeck_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) "
"imports XB30 %s", vkp_gpu_name(),
n == 0 ? "with no layout it reports (copy path unlikely; display layer only)"
: ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)");
@@ -237,7 +237,7 @@ static void dmabuf_build_feedback(void) {
}
if (!n) return;
int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats",
int fd = (int)syscall(__NR_memfd_create, "droiddeck-dmabuf-formats",
MFD_CLOEXEC | MFD_ALLOW_SEALING);
if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; }
size_t size = (size_t)n * sizeof(entries[0]);
@@ -251,14 +251,14 @@ static void dmabuf_build_feedback(void) {
g_fmt_table_fd = fd;
g_fmt_table_size = size;
g_fmt_table_n = (uint16_t)n;
banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
droiddeck_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u",
n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device));
/* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without
* one it fell back to a software path that draws nothing here (black window, sound plays). */
if (!g_main_device)
banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
droiddeck_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, "
"which runs OpenGL on the GPU without a DRM node; older layers show a black window",
g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)"
g_no_render_node ? "no DRM device named (forced by DROIDDECK_WAYLAND_NO_RENDER_NODE=1)"
: "this device gives apps no display (DRM) device (/dev/dri)");
}
@@ -5,7 +5,7 @@
* Focus model. winewayland enables its text input as soon as our `enter` names a surface and
* routes every IME update to that surface's window - inside that window's process (the update
* list is per process). Keyboard focus in this compositor sits on the desktop surface (the
* desktop owner's process), so text input can't follow it; it follows banner_ime_target():
* desktop owner's process), so text input can't follow it; it follows droiddeck_ime_target():
* the program window last clicked, else the topmost program window. Clicking into notepad
* moves the text-input focus to notepad's process, which is where the edit control lives.
*
@@ -13,7 +13,7 @@
* them (enable resets the state as the protocol says). We answer with `done(serial)` only when
* we sent events (preedit/commit strings), never for every commit.
*
* Host side. banner_on_text_input(enabled, program, cursor rect in scene pixels) tells the app
* Host side. droiddeck_on_text_input(enabled, program, cursor rect in scene pixels) tells the app
* when a program starts/stops accepting IME text or moves its caret (Wine reports the rect only
* when an edit control positions its IME composition window, via ImmSetCompositionWindow).
* Typed text arrives through text_input_host_*: preedit_string / commit_string + done.
@@ -23,7 +23,7 @@
#define _GNU_SOURCE 1
#include <stdlib.h>
#include <string.h>
#include "banner_ext.h"
#include "droiddeck_ext.h"
#include "text-input-unstable-v3-server-protocol.h"
#ifndef KEY_BACKSPACE
@@ -58,7 +58,7 @@ static struct wl_resource *g_target; /* surface text input is focused
static int g_told_enabled, g_told_rect[4];
static struct text_input *g_told_ti;
static const char *ti_program(struct text_input *ti) { return banner_client_name(ti->client); }
static const char *ti_program(struct text_input *ti) { return droiddeck_client_name(ti->client); }
/* The enabled text input focused on the current target, if any. */
static struct text_input *active_input(void) {
@@ -74,22 +74,22 @@ static void tell_app(void) {
int enabled = ti != NULL, rect[4] = {0, 0, 0, 0};
if (ti) {
int ox = 0, oy = 0;
banner_surface_scene_origin(ti->entered, &ox, &oy);
droiddeck_surface_scene_origin(ti->entered, &ox, &oy);
rect[0] = ti->rect[0] + ox; rect[1] = ti->rect[1] + oy;
rect[2] = ti->rect[2]; rect[3] = ti->rect[3];
}
if (enabled == g_told_enabled && ti == g_told_ti && !memcmp(rect, g_told_rect, sizeof(rect))) return;
if (enabled && (!g_told_enabled || ti != g_told_ti))
banner_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti),
droiddeck_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti),
rect[0], rect[1], rect[2], rect[3]);
else if (enabled)
banner_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
droiddeck_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]);
else
banner_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : "");
droiddeck_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : "");
g_told_enabled = enabled;
g_told_ti = ti;
memcpy(g_told_rect, rect, sizeof(rect));
banner_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]);
droiddeck_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]);
}
/* ------------------------------------------------------------------ focus */
@@ -102,8 +102,8 @@ static void send_leave(struct text_input *ti) {
ti->has_preedit = 0;
}
void banner_text_input_refocus(void) {
struct wl_resource *t = banner_ime_target();
void droiddeck_text_input_refocus(void) {
struct wl_resource *t = droiddeck_ime_target();
struct text_input *ti;
if (t == g_target) return;
wl_list_for_each(ti, &g_inputs, link)
@@ -121,7 +121,7 @@ void banner_text_input_refocus(void) {
wl_display_flush_clients(g_disp);
}
void banner_text_input_surface_gone(struct wl_resource *surface) {
void droiddeck_text_input_surface_gone(struct wl_resource *surface) {
struct text_input *ti;
wl_list_for_each(ti, &g_inputs, link)
if (ti->entered == surface) { ti->entered = NULL; ti->enabled = 0; ti->has_preedit = 0; }
@@ -136,7 +136,7 @@ static void ti_res_destroy(struct wl_resource *r) {
if (!ti) return;
wl_list_remove(&ti->link);
if (g_told_ti == ti) { g_told_ti = NULL; g_told_enabled = 0; memset(g_told_rect, 0, sizeof(g_told_rect));
banner_on_text_input(0, "", 0, 0, 0, 0); }
droiddeck_on_text_input(0, "", 0, 0, 0, 0); }
free(ti);
}
@@ -235,13 +235,13 @@ static int32_t utf8_offset(const char *s, size_t len, int chars) {
void text_input_host_commit(const char *utf8, size_t len) {
struct text_input *ti = active_input();
if (!ti) {
banner_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len));
droiddeck_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len));
return;
}
if (ti->has_preedit) { zwp_text_input_v3_send_preedit_string(ti->res, "", 0, 0); ti->has_preedit = 0; }
zwp_text_input_v3_send_commit_string(ti->res, utf8);
zwp_text_input_v3_send_done(ti->res, ti->serial);
banner_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti));
droiddeck_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti));
}
void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end) {
@@ -264,9 +264,9 @@ void text_input_host_delete(int before, int after) {
zwp_text_input_v3_send_done(ti->res, ti->serial);
ti->has_preedit = 0;
}
for (int i = 0; i < before; i++) { banner_inject_key(KEY_BACKSPACE, 1); banner_inject_key(KEY_BACKSPACE, 0); }
for (int i = 0; i < after; i++) { banner_inject_key(KEY_DELETE, 1); banner_inject_key(KEY_DELETE, 0); }
banner_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after);
for (int i = 0; i < before; i++) { droiddeck_inject_key(KEY_BACKSPACE, 1); droiddeck_inject_key(KEY_BACKSPACE, 0); }
for (int i = 0; i < after; i++) { droiddeck_inject_key(KEY_DELETE, 1); droiddeck_inject_key(KEY_DELETE, 0); }
droiddeck_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after);
}
void text_input_init(struct wl_display *display) {
@@ -4,7 +4,7 @@
* missing. We send no icon_size (any size is fine) and `done` at bind, as the protocol asks.
*/
#include <stdlib.h>
#include "banner_ext.h"
#include "droiddeck_ext.h"
#include "xdg-toplevel-icon-v1-server-protocol.h"
static void icon_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -20,5 +20,6 @@ class App : Application() {
com.droiddeck.launcher.update.AppUpdates.init(this)
GpuClockPin.clearLeftover(this)
BwrapSpawner.start(this)
com.droiddeck.launcher.frontend.GameFileSync.start(this)
}
}
@@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.session.ProtonDefault
/**
* The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies
@@ -20,6 +21,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
var compChecking by mutableStateOf(false)
var compBusy by mutableStateOf<String?>(null)
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
private var shownDefault: String? = null
/** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */
fun refreshComponents(snapshotFirst: Boolean = false) {
@@ -28,14 +30,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
runCatching { ComponentsManager.applyQueued(activity) }
val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull()
val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull()
val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() }
ui.post {
compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList())
if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt }
if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id
if (chosen != null && chosen != shownDefault) {
shownDefault = chosen
compProton = chosen
} else if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) {
compProton = chosen ?: snap?.protons?.firstOrNull()?.proton?.id
}
}
}, "components").start()
}
fun chooseProton(id: String) {
val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return
compProton = id
Thread({
val message = runCatching {
when (ProtonDefault.request(activity, proton)) {
ProtonDefault.Outcome.LIVE -> activity.getString(R.string.comp_default_live, proton.name)
ProtonDefault.Outcome.NEXT_START -> activity.getString(R.string.comp_default_next, proton.name)
ProtonDefault.Outcome.NOT_RUNNABLE -> activity.getString(R.string.comp_default_not_runnable, proton.name)
}
}.getOrElse { e -> activity.getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) }
ui.post { android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() }
}, "components-default").start()
}
fun componentAction(label: String, work: () -> String) {
if (compBusy != null) return
compBusy = label
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionState
import java.io.File
/**
* Decky Loader's state in the launcher: what is installed, the releases on offer and an install
@@ -51,4 +52,21 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
}
}, "install-decky").start()
}
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = "Starting plugin import…"; deckyPercent = -1
Thread({
val problem = runCatching {
DeckyManager.installPluginZip(activity, archive) { label, value ->
ui.post { deckyStage = label; deckyPercent = value }
}
}.getOrElse { error -> "Plugin import failed: ${error.message ?: error.javaClass.simpleName}" }
ui.post {
deckyStage = null; deckyPercent = -1
val message = problem ?: "Plugin installed. Restart Steam to load it."
android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show()
}
}, "import-decky-plugin").start()
}
}
@@ -42,11 +42,13 @@ import com.droiddeck.launcher.ui.ComponentsPage
import com.droiddeck.launcher.session.GameSaves
import com.droiddeck.launcher.session.SessionLogShare
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.ui.ProtonPage
import com.droiddeck.launcher.core.CpuCores
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbFix
import com.droiddeck.launcher.core.WifiDiscovery
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
@@ -63,10 +65,14 @@ import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
import com.droiddeck.launcher.frontend.CoverArt
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.frontend.GameFiles
import com.droiddeck.launcher.frontend.GameFileSync
import com.droiddeck.launcher.frontend.GameLaunchIntent
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.ui.RomsDialog
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.input.SecondScreenDisplay
@@ -95,6 +101,7 @@ class MainActivity : ComponentActivity() {
private var returning by mutableStateOf<ReturningFlood?>(null)
private class ReturningFlood(val color: Int, val to: androidx.compose.ui.geometry.Rect?)
private var ready by mutableStateOf(false)
private var removalPending by mutableStateOf(false)
private var available by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var busy by mutableStateOf(false)
private var stage by mutableStateOf("")
@@ -107,6 +114,7 @@ class MainActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showProtons by mutableStateOf(false)
@@ -134,6 +142,9 @@ class MainActivity : ComponentActivity() {
private var tuSysmem by mutableStateOf(false)
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(true)
private var fastSync by mutableStateOf(false)
private var syncFallback by mutableStateOf(true)
private var fsyncFirst by mutableStateOf(false)
private var gamescopeRealtime by mutableStateOf(false)
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
@@ -144,11 +155,29 @@ class MainActivity : ComponentActivity() {
private var showPhantomGate by mutableStateOf(false)
private var directAudio by mutableStateOf(false)
private var clientDirectAudio by mutableStateOf(false)
private var forceFullscreen by mutableStateOf(true)
private var stretch16x9 by mutableStateOf(false)
private var launcherFullscreen by mutableStateOf(true)
private var animationsEnabled by mutableStateOf(true)
private var storeEnabled by mutableStateOf(false)
private var mic by mutableStateOf(false)
private var wifiDiscovery by mutableStateOf(false)
private var wifiDiscoveryPermission by mutableStateOf(false)
private var wifiDiscoveryLocation by mutableStateOf(false)
private var wifiDiscoveryAsked by mutableStateOf(false)
private var wifiDiscoveryBlocked by mutableStateOf(false)
private val wifiLocationReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context?, intent: Intent?) {
refreshWifiDiscovery()
}
}
private val wifiLocationPermission = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
}
private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this))
refreshWifiDiscovery()
}
// The app's own picker (files/), once per kind of pick: the two driver lists validate
// differently, and the reason a zip is refused names the list it belongs in.
@@ -161,6 +190,9 @@ class MainActivity : ComponentActivity() {
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
}
private val pickDeckyPluginZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { decky.importPluginZip(it) }
}
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
}
@@ -176,6 +208,28 @@ class MainActivity : ComponentActivity() {
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
}
private fun refreshWifiDiscovery() {
wifiDiscoveryPermission = WifiDiscovery.permissionGranted(this)
wifiDiscoveryLocation = WifiDiscovery.locationEnabled(this)
wifiDiscoveryAsked = SessionPrefs.wifiDiscoveryAsked(this)
wifiDiscoveryBlocked = !wifiDiscoveryPermission && wifiDiscoveryAsked &&
WifiDiscovery.permissions.none { shouldShowRequestPermissionRationale(it) }
// A revoked grant must not leave an enabled switch behind.
if (!wifiDiscoveryPermission && SessionPrefs.wifiDiscoveryEnabled(this)) {
SessionPrefs.setWifiDiscoveryEnabled(this, false)
}
wifiDiscovery = SessionPrefs.wifiDiscoveryEnabled(this)
}
private fun openWifiDiscoverySettings() {
if (!WifiDiscovery.permissionGranted(this)) {
wifiPermissionSettings.launch(Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.parse("package:$packageName")))
} else {
startActivity(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS))
}
}
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
private fun syncLossless() {
Thread({
@@ -254,15 +308,17 @@ class MainActivity : ComponentActivity() {
}
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var resolution by mutableStateOf(com.droiddeck.launcher.session.SessionDisplay.DEFAULT_RESOLUTION)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var forceSsbs by mutableStateOf(false)
private var steamChannel by mutableStateOf("steamdeck_publicbeta")
private var runSteamAtStartup by mutableStateOf(false)
private var theme by mutableStateOf("graphite")
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var appScale by mutableStateOf(com.droiddeck.launcher.core.AppUiPrefs.DEFAULT_SCALE)
private var hdrOn by mutableStateOf(false)
private var fpsLimit by mutableStateOf(0)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var hdrReason by mutableStateOf<String?>(null)
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
@@ -270,6 +326,8 @@ class MainActivity : ComponentActivity() {
private var backActionsInverted by mutableStateOf(false)
private var renderer by mutableStateOf("vulkan")
private var gameStorage by mutableStateOf("")
private var storageDiagnostics by mutableStateOf(false)
private var skipPreallocation by mutableStateOf(false)
private var storageOptions by mutableStateOf<List<Pair<String, String>>>(emptyList())
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
@@ -300,6 +358,10 @@ class MainActivity : ComponentActivity() {
override fun startActivity(intent: Intent?) {
if (intent?.component?.className == SessionActivity::class.java.name) {
when {
busy || LinuxRuntimeInstaller.isBusy() -> {
android.widget.Toast.makeText(this, "Wait for the runtime operation to finish", android.widget.Toast.LENGTH_SHORT).show()
return
}
protons.protonBusyId != null || ProtonExtras.installInProgress -> {
android.widget.Toast.makeText(this, "Wait for the compatibility tool install to finish", android.widget.Toast.LENGTH_SHORT).show()
return
@@ -327,25 +389,124 @@ class MainActivity : ComponentActivity() {
/** The page is all blue: the session opens on the same blue, with no animation of its own. */
private fun launchFlooded(f: PendingFlood) {
if (busy || LinuxRuntimeInstaller.isBusy()) { flood = null; floodProgress = 0f; return }
val signal = com.droiddeck.launcher.ui.Themes.byId(theme).signal
super.startActivity(f.intent.putExtra(com.droiddeck.launcher.ui.EXTRA_FLOOD, signal.toArgb()))
overridePendingTransition(0, 0)
}
private var shortcutPicker by mutableStateOf(false)
private var shortcutLibraryScanning by mutableStateOf(false)
@Volatile private var libraryScanGeneration = 0
private var pendingGameLink: String? = null
private var gameSyncFolder by mutableStateOf<String?>(null)
private fun pickGameExport(game: Library.SteamGame?) {
onSavePicked = { folder ->
saveAction(getString(R.string.game_frontend_files)) {
val saved = if (game != null) GameFiles.export(folder, game)
else {
GameFileSync.enable(this, folder)
folder
}
ui.post {
gameSyncFolder = GameFileSync.folder(this)
}
getString(R.string.game_file_exported, saved.path)
}
}
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
}
private fun readGameIntent(request: Intent) {
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
if (shortcutPicker) {
// The shortcut picker must only offer games from the scan started for this request.
steamGames = emptyList()
shortcutLibraryScanning = true
} else shortcutLibraryScanning = false
pendingGameLink = null
if (GameLaunchIntent.accepts(request.action)) {
val copy = Intent(request)
Thread({
val id = GameLaunchIntent.read(this, copy)
ui.post {
if (intent === request && !isDestroyed) {
pendingGameLink = id
if (id == null) {
android.widget.Toast.makeText(this, R.string.game_link_invalid, android.widget.Toast.LENGTH_LONG).show()
} else refresh()
}
}
}, "game-launch-file").start()
}
}
private fun launchGame(game: Library.SteamGame, returnHome: Boolean = false): Boolean {
if (protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null) {
android.widget.Toast.makeText(this, "Wait for the install to finish before launching a game", android.widget.Toast.LENGTH_SHORT).show()
return false
}
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)) {
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, 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)
}
private fun handleGameLink(games: List<Library.SteamGame>) {
if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return
val id = pendingGameLink ?: return
val game = games.firstOrNull { it.gameIdString == id }
if (game == null) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
android.widget.Toast.makeText(this, R.string.game_link_missing, android.widget.Toast.LENGTH_LONG).show()
} else if (launchGame(game, returnHome = true)) {
pendingGameLink = null
intent.data = null
intent.action = Intent.ACTION_MAIN
intent.removeExtra(GameLaunchIntent.EXTRA_APP_ID)
}
}
private fun chooseGameShortcut(game: Library.SteamGame) {
setResult(RESULT_OK, com.droiddeck.launcher.frontend.GameShortcuts.result(this, game))
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
readGameIntent(intent)
gameSyncFolder = GameFileSync.folder(this)
displayManager = getSystemService(DISPLAY_SERVICE) as DisplayManager
refreshPhantomStatus()
theme = SessionPrefs.theme(this)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
// Last start's game list, so the Steam wall is up on the first frame; refresh() replaces it.
steamGames = com.droiddeck.launcher.frontend.LibraryCache.load(this)
steamGames = if (shortcutPicker) emptyList() else com.droiddeck.launcher.frontend.LibraryCache.load(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
animationsEnabled = SessionPrefs.animationsEnabled(this)
storeEnabled = SessionPrefs.storeEnabled(this)
applyLauncherFullscreen()
updates.start()
setContent {
DroidDeckTheme(theme) {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
val sm = settingsMode
val page: (@Composable () -> Unit)? = when {
@@ -359,7 +520,10 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.ui.FloodBehind({ floodProgress }) {
FrontEndScreen(
FrontEndState(
installed = installed, ready = ready, available = available?.version,
installed = installed, ready = ready, available = available?.version, removalPending = removalPending, runtimeActionsBlocked = runtimeChangesBlocked(),
shortcutPicker = shortcutPicker,
shortcutLibraryScanning = shortcutLibraryScanning,
gameSyncFolder = gameSyncFolder,
busy = busy, stage = stage, percent = percent,
desktopInstalled = desktopInstalled,
offlineAccount = offlineAccount, offline = offline,
@@ -369,6 +533,7 @@ class MainActivity : ComponentActivity() {
lossless = lossless,
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
theme = theme,
appScale = appScale,
isHomeApp = homeAppSelected,
homeScreenEnabled = homeScreenEnabled,
defaultHomeLabel = defaultHomeLabel,
@@ -388,6 +553,7 @@ class MainActivity : ComponentActivity() {
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
animationsEnabled = animationsEnabled,
storeEnabled = storeEnabled,
),
FrontEndActions(
@@ -399,9 +565,20 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
},
onSteamGame = { g ->
startSession(Intent(this, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"), steamSession = true)
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else launchGame(g) },
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
onExportGameFile = { g -> pickGameExport(g) },
onSyncGameFiles = { pickGameExport(null) },
onStopGameFileSync = {
Thread({
GameFileSync.disable(this)
ui.post { gameSyncFolder = null }
}, "game-file-stop-sync").start()
},
onCopyGameLink = { g ->
(getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(
ClipData.newPlainText(g.name, com.droiddeck.launcher.frontend.GameLaunchLink.uri(g.gameIdString)))
android.widget.Toast.makeText(this, R.string.game_link_copied, android.widget.Toast.LENGTH_SHORT).show()
},
onDesktop = {
startSession(Intent(this, SessionActivity::class.java)
@@ -462,13 +639,10 @@ class MainActivity : ComponentActivity() {
logsEnabled = SessionPrefs.logsEnabled(this)
},
onShareLogs = {
Thread({
val zip = runCatching { SessionLogShare.zipLatest(this) }.getOrNull()
ui.post {
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
}, "share-logs").start()
SessionLogShare.prepare(this, { SessionLogShare.latest(this) }) { zip ->
if (zip == null) android.widget.Toast.makeText(this, "No session logs yet: run a session first.", android.widget.Toast.LENGTH_LONG).show()
else startActivity(SessionLogShare.shareIntent(this, zip))
}
},
onClearLogs = {
Thread({
@@ -486,12 +660,21 @@ class MainActivity : ComponentActivity() {
},
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onAppScale = { percent ->
com.droiddeck.launcher.core.AppUiPrefs.setScale(this, percent)
appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)
},
onLauncherFullscreen = { on ->
SessionPrefs.setLauncherFullscreen(this, on)
launcherFullscreen = on
applyLauncherFullscreen()
},
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
onAnimationsEnabled = { on ->
SessionPrefs.setAnimationsEnabled(this, on)
animationsEnabled = on
com.droiddeck.launcher.ui.Motion.refresh(this)
},
onHomeApp = { manageHomeApp() },
onHomeScreen = { on ->
HomeApp.setHomeScreenEnabled(this, on)
@@ -560,6 +743,10 @@ class MainActivity : ComponentActivity() {
onSize = { v -> ControllerPrefs.setSize(this, v); refreshController() },
onStickClick = { on -> ControllerPrefs.setStickClick(this, on); refreshController() },
onAdaptiveSticks = { on -> ControllerPrefs.setAdaptiveSticks(this, on); refreshController() },
onRumble = { on -> ControllerPrefs.setRumble(this, on); refreshController() },
onSteamButton = { on -> ControllerPrefs.setSteamButton(this, on); refreshController() },
onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() },
onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() },
onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) },
onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() },
onMapping = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = true },
@@ -591,7 +778,7 @@ class MainActivity : ComponentActivity() {
title = "Remove Linux runtime",
text = "This deletes the runtime, the Steam client inside it, and every game installed there.",
confirm = "Remove",
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
onConfirm = { showRemove = false; removeRuntime() },
onDismiss = { showRemove = false },
)
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
@@ -635,10 +822,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
readGameIntent(intent)
refresh()
startSteamAtStartupIfEnabled()
}
private fun startSteamAtStartupIfEnabled() {
if (intent.action != Intent.ACTION_MAIN) return
if (SessionPrefs.runSteamAtStartup(this) && !isFinishing && !isDestroyed) startSteamSession()
}
@@ -680,6 +870,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
refreshWifiDiscovery()
com.droiddeck.launcher.ui.Motion.refresh(this)
// Back from a session stopped behind a flood: open on its blue, before the first frame.
com.droiddeck.launcher.ui.QuitFlood.take()?.let { c ->
@@ -697,9 +888,10 @@ class MainActivity : ComponentActivity() {
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
if (applied.isNotEmpty()) ui.post {
android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show()
if (showComponents) components.refreshComponents()
}
ui.post { if (showComponents) components.refreshComponents() }
}, "components-queue").start()
else if (showComponents) components.refreshComponents()
oscMode = SessionPrefs.oscMode(this)
refreshController()
refreshHomeAppState()
@@ -717,6 +909,7 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
registerReceiver(wifiLocationReceiver, android.content.IntentFilter(android.location.LocationManager.MODE_CHANGED_ACTION))
displayManager.registerDisplayListener(secondScreenDisplayListener, ui)
refreshSecondScreenDisplays()
}
@@ -727,6 +920,7 @@ class MainActivity : ComponentActivity() {
}
override fun onStop() {
unregisterReceiver(wifiLocationReceiver)
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
flood = null
@@ -782,7 +976,7 @@ class MainActivity : ComponentActivity() {
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
private fun saveAction(label: String, work: () -> String) {
if (saveBusy != null) return
if (busy || LinuxRuntimeInstaller.isBusy() || saveBusy != null) return
saveBusy = label
Thread({
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
@@ -847,10 +1041,10 @@ class MainActivity : ComponentActivity() {
protonId = components.compProton,
comp = components.compComp,
checking = components.compChecking,
busy = components.compBusy,
busy = if (busy) stage else components.compBusy,
downloads = components.compDownloads,
requestInitialFocus = focusComponentsContent,
onProton = { components.compProton = it },
onProton = { components.chooseProton(it) },
onComp = { components.compComp = it },
onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } },
onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } },
@@ -887,7 +1081,7 @@ class MainActivity : ComponentActivity() {
busyId = protons.protonBusyId,
stage = protons.protonStage,
percent = protons.protonPercent,
runtimeReady = ready,
runtimeReady = ready && !busy,
sessionRunning = SessionState.running,
onInstall = { id -> protons.installProton(id) },
onCancel = { id -> ProtonExtras.tools.firstOrNull { it.id == id }?.let { ProtonExtras.unqueue(this, it) }; protons.refreshProtons() },
@@ -909,7 +1103,7 @@ class MainActivity : ComponentActivity() {
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null || SessionState.running) return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = "Starting…"; pkgPercent = -1
Thread({
val problem = DesktopCatalog.install(this, entry) { stage, percent ->
@@ -926,7 +1120,7 @@ class MainActivity : ComponentActivity() {
private fun removePackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (pkgStage != null || SessionState.running) return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
pkgId = id; pkgStage = if (entry.kind == "appimage") "Removing ${entry.name}…" else "Forgetting ${entry.name}…"; pkgPercent = -1
Thread({
DesktopCatalog.remove(this, entry)
@@ -942,27 +1136,40 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
mode = mode, resolution = resolution,
panelSize = com.droiddeck.launcher.session.SessionDisplay.panelSize(this),
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
gpuDrivers = drivers.summary(),
touchMode = touchMode,
suspendPolicy = suspendPolicy,
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this),
pipAutoEnter = pipAutoEnter,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
clientDirectAudio = clientDirectAudio,
forceFullscreen = if (mode == SessionService.MODE_STEAM) forceFullscreen else null,
stretch16x9 = if (mode == SessionService.MODE_STEAM) stretch16x9 else null,
mic = if (mode == SessionService.MODE_STEAM) mic else null,
renderer = if (mode == SessionService.MODE_DESKTOP) renderer else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
skipPreallocation = mode == SessionService.MODE_STEAM && skipPreallocation,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
wifiDiscovery = if (mode == SessionService.MODE_STEAM) wifiDiscovery else null,
wifiDiscoveryPermission = wifiDiscoveryPermission,
wifiDiscoveryLocation = wifiDiscoveryLocation,
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
addedGamesArt = addedGamesArt,
@@ -972,14 +1179,30 @@ class MainActivity : ComponentActivity() {
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onWifiDiscovery = { on ->
if (!on) {
SessionPrefs.setWifiDiscoveryEnabled(this, false)
refreshWifiDiscovery()
} else if (WifiDiscovery.permissionGranted(this)) {
SessionPrefs.setWifiDiscoveryEnabled(this, true)
refreshWifiDiscovery()
} else if (wifiDiscoveryBlocked) {
openWifiDiscoverySettings()
} else {
SessionPrefs.setWifiDiscoveryAsked(this)
wifiLocationPermission.launch(WifiDiscovery.permissions)
}
},
onWifiDiscoverySettings = { openWifiDiscoverySettings() },
onResolution = { value -> SessionPrefs.setResolutionChoice(this, mode, value); resolution = value },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
onUpscaler = { m -> SessionPrefs.setUpscaler(this, m); upscaler = m },
onUpscaleSharpness = { pct -> SessionPrefs.setUpscaleSharpness(this, pct); upscaleSharpness = pct },
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
@@ -987,7 +1210,6 @@ class MainActivity : ComponentActivity() {
},
onDirectAudio = { on -> SessionPrefs.setDirectAudio(this, on); directAudio = on },
onClientDirectAudio = { on -> SessionPrefs.setClientDirectAudio(this, on); clientDirectAudio = on },
onForceFullscreen = { on -> SessionPrefs.setForceFullscreen(this, on); forceFullscreen = on },
onStretch16x9 = { on -> SessionPrefs.setStretch16x9(this, on); stretch16x9 = on },
onMic = { on ->
SessionPrefs.setMicEnabled(this, on)
@@ -1003,8 +1225,24 @@ class MainActivity : ComponentActivity() {
onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, "Choose the game storage folder", gameStorage.ifEmpty { null }))
},
onStorageDiagnostics = { on ->
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
storageDiagnostics = on
},
onSkipPreallocation = { on ->
SessionPrefs.setSkipLibraryPreallocation(this, on)
skipPreallocation = on
},
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
onForceSsbs = { on -> SessionPrefs.setForceSsbs(this, on); forceSsbs = on },
onSyncBackend = { id ->
SessionPrefs.setSyncBackend(this, id)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamDeckMode = { on ->
SessionPrefs.setSteamDeckMode(this, on)
steamDeckMode = on
@@ -1032,6 +1270,9 @@ class MainActivity : ComponentActivity() {
decky.deckyInstalled = null
decky.deckySupervisor = false
},
onPickDeckyPluginZip = {
pickDeckyPluginZip.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Decky plugin ZIP"))
},
onDismiss = { settingsMode = null },
),
)
@@ -1043,6 +1284,9 @@ class MainActivity : ComponentActivity() {
cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) },
clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores,
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
fastSync = fastSync,
syncFallback = syncFallback,
fsyncFirst = fsyncFirst,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
@@ -1052,6 +1296,9 @@ class MainActivity : ComponentActivity() {
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onFastSync = { on -> SessionPrefs.setFastSync(this, on); fastSync = on },
onSyncFallback = { on -> SessionPrefs.setSyncFallback(this, on); syncFallback = on },
onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on },
onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on },
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
@@ -1099,23 +1346,31 @@ class MainActivity : ComponentActivity() {
}
}
private var pipAutoEnter by mutableStateOf(false)
private fun openModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
showPerformance = false
showProtons = false
showComponents = false
showMapping = false
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
fastSync = SessionPrefs.fastSync(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
syncFallback = SessionPrefs.syncFallback(this)
steamChannel = SessionPrefs.steamChannel(this)
steamDeckMode = SessionPrefs.steamDeckMode(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
shapeMode = SessionPrefs.shapeMode(this)
hdrOn = SessionPrefs.hdr(this, mode)
fpsLimit = SessionPrefs.fpsLimit(this, mode)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
touchMode = SessionPrefs.touchMode(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
@@ -1123,11 +1378,13 @@ class MainActivity : ComponentActivity() {
backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this)
clientDirectAudio = SessionPrefs.clientDirectAudio(this)
forceFullscreen = SessionPrefs.forceFullscreen(this)
stretch16x9 = SessionPrefs.stretch16x9(this)
mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery()
renderer = SessionPrefs.desktopRenderer(this)
gameStorage = SessionPrefs.gameStorage(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
skipPreallocation = SessionPrefs.skipLibraryPreallocation(this)
settingsMode = mode
// The page opens at once, on what was last read; the slow part (driver files, a walk of the
// added-games folders, the storage volumes) lands while it animates in.
@@ -1174,6 +1431,9 @@ class MainActivity : ComponentActivity() {
tuSysmem = SessionPrefs.tuSysmem(this)
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
fastSync = SessionPrefs.fastSync(this)
syncFallback = SessionPrefs.syncFallback(this)
fsyncFirst = SessionPrefs.fsyncFirst(this)
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
@@ -1183,12 +1443,13 @@ class MainActivity : ComponentActivity() {
}
private fun refresh() {
if (!busy && LinuxRuntimeInstaller.isInstalling()) followInstall { LinuxRuntimeInstaller.attach(it) }
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
desktopInstalled = DesktopCatalog.desktopInstalled(this)
offlineAccount = OfflineMode.account(this)
offline = OfflineMode.enabled(this)
installed = LinuxRuntimeInstaller.installedVersion(this)
ready = LinuxRuntime.isInstalled(this)
removalPending = LinuxRuntimeInstaller.hasRemovalPending(this)
frameGenLabel = FrameGen.label(this)
romsDir = SessionPrefs.romsDir(this).takeIf { it.isNotEmpty() }
logsEnabled = SessionPrefs.logsEnabled(this)
@@ -1199,23 +1460,24 @@ class MainActivity : ComponentActivity() {
else -> "Steam"
} else null
// The libraries, off the main thread: manifests and a folder scan.
val scanGeneration = ++libraryScanGeneration
if (shortcutPicker) shortcutLibraryScanning = true
Thread({
// One update with the whole list: the wall places games by their position in it, so a
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
// covers the wait.
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
)
}
} else emptyList()
val games = if (ready) Library.launchableGames(this) else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
val all = games.distinctBy { it.gameId }
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
ui.post { steamGames = all; emulatorList = emus }
ui.post {
if (scanGeneration == libraryScanGeneration) {
steamGames = all
emulatorList = emus
shortcutLibraryScanning = false
handleGameLink(all)
}
}
// Box art for the games that have none, fetched after the list is up; the list is
// rebuilt once if any was found.
if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) {
@@ -1226,9 +1488,9 @@ class MainActivity : ComponentActivity() {
}
private fun onRuntimeButton() {
if (busy) return
if (runtimeChangesBlocked()) return
val release = available
if (installed != null && release?.version == installed) {
if (removalPending || installed != null && release?.version == installed) {
// Nothing to install: offer the one destructive thing this screen can do.
showRemove = true
return
@@ -1245,15 +1507,17 @@ class MainActivity : ComponentActivity() {
}
/** Starts a session; with no runtime on a non-Adreno, the same warning Setup gives comes first, before any download. */
private fun startSession(intent: Intent, steamSession: Boolean = false) {
private fun startSession(intent: Intent, steamSession: Boolean = false): Boolean {
if (busy || LinuxRuntimeInstaller.isBusy()) return false
refreshPhantomStatus()
if (steamSession && PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
showPhantomGate = true
return
return false
}
val warn = installed == null && !com.droiddeck.launcher.core.DeviceSupport.adreno()
if (warn && available != null) showNonAdreno = available
else startActivity(intent)
if (warn && available != null) { showNonAdreno = available; return false }
startActivity(intent)
return true
}
private fun refreshPhantomStatus() {
@@ -1283,21 +1547,35 @@ class MainActivity : ComponentActivity() {
}
}
private fun runtimeChangesBlocked(): Boolean =
busy || LinuxRuntimeInstaller.isBusy() || SessionState.running ||
SessionState.phase !in setOf(SessionPhase.IDLE, SessionPhase.FAILED) ||
protons.protonBusyId != null || ProtonExtras.installInProgress || pkgStage != null ||
components.compBusy != null || saveBusy != null || com.droiddeck.launcher.store.UserAppsState.working != null
private fun removeRuntime() {
if (runtimeChangesBlocked()) return
val removal = LinuxRuntimeInstaller.beginUninstall(this) ?: return
ready = false
com.droiddeck.launcher.runtime.RuntimeInstallService.keepRemovalAlive(this)
followRuntimeOperation { removal.run(it) }
}
private fun install(release: LinuxRuntimeInstaller.Release) {
// The service keeps the process alive if the user switches away; this screen joins the
// same install (or starts it, if it gets there first) to show the progress.
com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release)
followInstall { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
followRuntimeOperation { listener -> LinuxRuntimeInstaller.install(this, release, listener) }
}
/**
* Shows an install's progress until it ends. [run] either starts one or joins the one already
* Shows a runtime operation's progress until it ends. [run] either starts one or joins the one already
* running (null: nothing was), which is how a launcher rebuilt mid-install picks it back up.
*/
private fun followInstall(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
private fun followRuntimeOperation(run: (LinuxRuntimeInstaller.ProgressListener) -> Boolean?) {
busy = true
failed = false
stage = "Starting…"
stage = if (LinuxRuntimeInstaller.isRemoving()) "Removing Linux runtime" else "Starting…"
percent = -1
Thread({
val ok = run(LinuxRuntimeInstaller.ProgressListener { s, p ->
@@ -1306,9 +1584,12 @@ class MainActivity : ComponentActivity() {
ui.post {
busy = false
if (ok != null) failed = !ok
if (ok == false) LinuxRuntimeInstaller.removalError()?.let {
android.widget.Toast.makeText(this, it, android.widget.Toast.LENGTH_LONG).show()
}
refresh()
}
}, "install").start()
}, "runtime-operation").start()
}
private fun checkCatalog() {
@@ -1,5 +1,7 @@
package com.droiddeck.launcher
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxSize
import android.hardware.input.InputManager
import android.hardware.display.DisplayManager
@@ -34,13 +36,18 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.files.InAppFilePicker
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.KeyboardHost
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.SessionClipboard
import com.droiddeck.launcher.input.OnScreenControls
import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PointerGestures
@@ -52,6 +59,8 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
import com.droiddeck.launcher.session.PerfHud
import com.droiddeck.launcher.session.PerfMode
import com.droiddeck.launcher.session.SteamRepair
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionEvents
import com.droiddeck.launcher.session.SessionArtifacts
@@ -74,7 +83,9 @@ import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import com.droiddeck.launcher.session.ComponentsManager
import com.droiddeck.launcher.ui.DRAWER_PAGES
import com.droiddeck.launcher.ui.SessionDrawerPage
import com.droiddeck.launcher.ui.sessionDrawerPages
import com.droiddeck.launcher.ui.step
import kotlin.math.abs
/**
@@ -89,6 +100,10 @@ import kotlin.math.abs
* the HUD line, the loading overlay, the drawer and its dialogs.
*/
class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private lateinit var pip: com.droiddeck.launcher.session.SessionPipController
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
private val guestKeysDown = mutableSetOf<Int>()
private lateinit var surfaceView: SurfaceView
private lateinit var sessionOverlay: ComposeView
private lateinit var loading: LoadingState
@@ -121,10 +136,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
/** Motion is read while the session shows and only if the pad is a Deck controller - which
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
private fun updatePadMotion() {
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
if (resumed && !pipUi && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
}
private var onScreenControls: OnScreenControls? = null
private var keyboard: KeyboardHost? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
private val sessionClipboard by lazy {
SessionClipboard(this) {
window.decorView.hasWindowFocus() || secondScreenPresentation?.window?.decorView?.hasWindowFocus() == true
}
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
@@ -140,6 +161,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val cursorBuf = IntArray(WaylandCompositor.CURSOR_BUF_INTS)
private var cursorSerial = 0
private var clientHidesCursor = false
/**
* A program holds a pointer lock (zwp_locked_pointer_v1: a mouse-look game, through gamescope).
* While it does, a physical mouse is captured and its raw deltas go to the program as relative
* motion. Without capture the deltas are worked out from Android's pointer position, which stops
* at the screen edge, and so did the camera (#153).
*/
private var pointerLocked = false
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
updatePointerCapture()
}
}
private var cursorImage by mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null)
private var cursorHotX by mutableStateOf(0)
private var cursorHotY by mutableStateOf(0)
@@ -187,13 +223,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var closeAtOnce = false
/** The drawer's Components tab: the Protons as last read (ComponentsManager). */
private var drawerComponents by mutableStateOf<ComponentsManager.Snapshot?>(null)
private var drawerPage by mutableIntStateOf(0)
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var fillScreen by mutableStateOf(true)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
private var textureAnisotropy by mutableStateOf(0)
private var textureLodBias by mutableStateOf(TextureFiltering.LOD_BIAS_OFF)
private var lossless by mutableStateOf(Lossless.State.NONE)
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
@@ -202,7 +242,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var fexPreset by mutableStateOf("")
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
private var onScreenButtonsVisible by mutableStateOf(false)
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
@@ -244,26 +284,38 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onInputDeviceAdded(deviceId: Int) {
logInputDevice("connected", deviceId)
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceRemoved(deviceId: Int) {
Log.i(TAG, "input device $deviceId disconnected")
updateOnScreenControls()
updatePointerCapture()
}
override fun onInputDeviceChanged(deviceId: Int) = updateOnScreenControls()
}
private var runtimeRemovalBlocked = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.isRemoving()) {
runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, "Wait for runtime removal to finish", android.widget.Toast.LENGTH_SHORT).show()
finish()
return
}
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
// A Flatpak app or AppImage is named by its arguments, which the name lookup reads here.
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
}
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
if (!SessionState.running && SessionState.phase in setOf(SessionPhase.IDLE, SessionPhase.FAILED)) {
SessionState.program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI)
SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL)
}
if (intent.action == SessionService.ACTION_AGENT_START) {
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
}
@@ -288,7 +340,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// below) and the session starts when it is in.
if (!LinuxRuntime.isInstalled(this)) Log.i(TAG, "the Linux runtime is not installed; the loading screen installs it")
val root = FrameLayout(this)
// Captured mouse events go down the focus chain, and focus sits somewhere in the overlay:
// the root sees every one of them whichever view that is.
val root = object : FrameLayout(this) {
override fun dispatchCapturedPointerEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchCapturedPointerEvent(event)
override fun dispatchPointerCaptureChanged(hasCapture: Boolean) {
super.dispatchPointerCaptureChanged(hasCapture)
Log.i(TAG, "pointer capture " + if (hasCapture) "on" else "off")
}
}
surfaceView = SurfaceView(this)
surfaceView.holder.addCallback(this)
root.addView(surfaceView)
@@ -308,7 +369,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.removeCallbacks(cursorHide)
cursorVisible = false
}
onScreenControls = OnScreenControls(this, bridge).also { root.addView(it) }
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
// One arrow, ours: Android draws a system pointer for a mouse over any window, and the
@@ -344,16 +405,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
sessionOverlay = createSessionOverlay()
root.addView(sessionOverlay)
setContentView(root)
pip = com.droiddeck.launcher.session.SessionPipController(this, ::pipBounds, ::showPipUi)
surfaceView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> pip.refresh() }
pip.refresh()
handleHomeGuideIntent(intent)
updateOnScreenControls()
WaylandCompositor.setFirstFrameListener(firstFrameListener)
WaylandCompositor.setPointerLockListener(pointerLockListener)
SessionEvents.markReadyIfPossible()
SessionState.endListener = endListener
// A single Back opens the session menu; two quick presses/swipes send the Steam QAM chord.
// The single action waits out the double-press window so the two actions stay distinct.
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (pipUi) return
// The PC keyboard closes first (B on a controller), then Back is the drawer again.
when {
::loading.isInitialized && loading.ended -> finish()
@@ -377,7 +443,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// this full-screen host and remain as a white veil after the drawer closes.
defaultFocusHighlightEnabled = false
setContent {
// The drawer, the PC keyboard and picture in picture take the mouse back.
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, pipUi) { updatePointerCapture() }
DroidDeckTheme {
if (pipUi) {
if (SessionState.suspended) androidx.compose.foundation.layout.Box(
Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.material3.Text(getString(R.string.pip_suspended),
color = androidx.compose.ui.graphics.Color.White,
modifier = Modifier.background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.7f)).padding(6.dp))
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
@@ -412,7 +489,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// The PC keyboard takes it too, for moving over the keys; the drawer opens over it.
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() }
if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard(
sendKey = { code, down -> if (CompositorHost.isStarted) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) },
sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) },
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
@@ -423,17 +500,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects, textureAnisotropy = textureAnisotropy, textureLodBias = textureLodBias,
frameGen = frameGen,
lossless = lossless,
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
oscMode = oscMode, onScreenButtonsVisible = onScreenButtonsVisible, suspendPolicy = suspendPolicy, touchMode = touchMode,
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
shapeMode = shapeMode, fexPreset = fexPreset,
fexPreset = fexPreset,
secondScreenMode = secondScreenMode,
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
// Two Looks carry a scaling mode of their own, so the mode decides which one the log names.
WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(effects, m)?.name)
},
onUpscaleSharpness = { pct ->
SessionPrefs.setUpscaleSharpness(this@SessionActivity, pct); upscaleSharpness = pct
WaylandCompositor.nativeSetUpscaleSharpness(pct)
},
onEffects = { e ->
SessionPrefs.setScreenEffects(this@SessionActivity, e); effects = e
e.push(upscaler)
},
onTextureAnisotropy = { v -> SessionPrefs.setTextureAnisotropy(this@SessionActivity, v); textureAnisotropy = v },
onTextureLodBias = { v -> SessionPrefs.setTextureLodBias(this@SessionActivity, v); textureLodBias = v },
onFrameGenPick = { mode ->
FrameGen.set(this@SessionActivity, mode)
readPrefs()
@@ -444,19 +537,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
},
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
onHardwareKeyboard = ::togglePcKeyboard,
controller = controllerSettings,
onRumble = { on -> updateControllerPrefs { ControllerPrefs.setRumble(this@SessionActivity, on) } },
onSteamButton = { on -> updateControllerPrefs { ControllerPrefs.setSteamButton(this@SessionActivity, on) } },
onQamButton = { on -> updateControllerPrefs { ControllerPrefs.setQamButton(this@SessionActivity, on) } },
onKeyboardButton = { on -> updateControllerPrefs { ControllerPrefs.setKeyboardButton(this@SessionActivity, on) } },
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onQam = if (SessionState.mode == SessionService.MODE_STEAM) ({ triggerSteamQam() }) else null,
// The desktop with Steam's desktop client in it, in this session's place: the
// session ends with status 0 and onSessionEnded starts the relaunch (Stop's
// finish() would skip it).
onSwitchToDesktop = if (SessionState.mode == SessionService.MODE_STEAM) ({
drawerOpen = false
SessionState.relaunch = Intent(this@SessionActivity, SessionActivity::class.java)
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP)
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop")
SessionService.stop(this@SessionActivity)
}) else null,
backActionsInverted = backActionsInverted,
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this@SessionActivity, inverted)
@@ -469,7 +557,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onShape = { v -> SessionPrefs.setShapeMode(this@SessionActivity, v); readPrefs() },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -483,7 +570,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Toast.makeText(this@SessionActivity, "Could not open ${app.label} on $target", Toast.LENGTH_SHORT).show()
}
},
onBackground = { drawerOpen = false; moveTaskToBack(true) },
pipSupported = com.droiddeck.launcher.session.SessionPipController.supported(this@SessionActivity),
pipAutoEnter = pipAutoEnter,
onPip = { pip.enter() },
onPipAutoEnter = { on ->
SessionPrefs.setPipAutoEnter(this@SessionActivity, on)
pipAutoEnter = on
pip.refresh(true)
},
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
onStopFrom = { onScreen ->
@@ -519,6 +614,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
}
}
}
private fun refreshHomeApp() {
@@ -539,6 +635,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
padBridge?.triggerQam()
}
private fun togglePcKeyboard() {
drawerOpen = false
keyboard?.hide()
onScreenControls?.releaseAll()
pcKeyboardOpen = !pcKeyboardOpen
}
private fun updateControllerPrefs(change: () -> Unit) {
change()
controllerSettings = ControllerPrefs.read(this)
onScreenControls?.reload()
}
private fun routeBackAction() {
pendingBackAction?.let { pending ->
uiHandler.removeCallbacks(pending)
@@ -551,8 +660,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
performBackAction(double = false)
}
pendingBackAction = pending
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
// Wait 500 ms for a second Back press before performing the single-press action.
uiHandler.postDelayed(pending, 500L)
}
private fun performBackAction(double: Boolean) {
@@ -632,33 +741,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Toast.makeText(this, "No logs for this session.", Toast.LENGTH_LONG).show()
return
}
Thread({
val zip = runCatching { SessionLogShare.zipFolder(this, folder) }
.onFailure { Log.w(TAG, "could not package current session logs", it) }
.getOrNull()
uiHandler.post {
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
SessionLogShare.prepare(this, { folder }) { zip ->
if (zip == null) {
Toast.makeText(this, "Could not create the session log archive.", Toast.LENGTH_LONG).show()
} else {
runCatching { startActivity(SessionLogShare.shareIntent(this, zip)) }
.onFailure {
Log.w(TAG, "could not share current session logs", it)
Toast.makeText(this, "Could not share the session logs.", Toast.LENGTH_LONG).show()
}
}
}, "share-session-logs").start()
}
}
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
hudOn = SessionPrefs.hudEnabled(this)
fillScreen = SessionPrefs.forceFullscreen(this)
touchMode = SessionPrefs.touchMode(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
textureAnisotropy = SessionPrefs.textureAnisotropy(this)
textureLodBias = SessionPrefs.textureLodBias(this)
val nextTouchMode = SessionPrefs.touchMode(this)
if (touchMode != nextTouchMode) {
if (::touchpad.isInitialized) touchpad.cancel()
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
}
if (nextTouchMode == SessionPrefs.TOUCH_OFF) cursorVisible = false
}
touchMode = nextTouchMode
frameGen = FrameGen.mode(this)
fexPreset = SessionPrefs.fexPreset(this)
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
oscMode = SessionPrefs.oscMode(this)
shapeMode = SessionPrefs.shapeMode(this)
backActionsInverted = SessionPrefs.backActionsInverted(this)
}
@@ -762,7 +881,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?: return "Could not reach the runtime catalog. Check the connection and press Play again."
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor("the Linux runtime", release.size / 1_000_000))
return if (ok) null else "The Linux runtime did not install. Check the connection and press Play again."
return if (ok) null else "The Linux runtime did not install. Check the runtime in Setup and press Play again."
}
/** Null when the desktop package is in, else the loading screen's closing line. */
@@ -806,9 +925,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val size = if (SessionState.running) SessionState.outputSize else outputSize()
SessionState.outputSize = size
if (!SessionState.running) SessionState.refreshHz = refreshHz()
// How far the panel enlarges the session, for texture sharpness "Auto" (TextureFiltering):
// the game environment is published again so the next launch derives it from this session.
val panel = panelBounds()
SessionState.upscaleRatio = maxOf(panel.width(), panel.height()).toFloat() / maxOf(size.first, size.second).coerceAtLeast(1)
Thread({
runCatching { GameEnvironmentStore.publish(this) }
.onFailure { Log.e(TAG, "Could not update game environment", it) }
}, "game-env-publish").start()
// Letterbox, never stretch or crop: the output can be a different shape from the panel,
// and a game's picture must keep its proportions with bars, not lose its edges.
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this))
WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this))
SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this))
// The session's folder, claimed here because the compositor starts before the service and
// opens its log once. The compositor reads the path from its environment; setting it after
// it has started changes nothing, which is why the service copies the file in at teardown.
@@ -836,7 +966,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val on = wanted && probe.reason == null
SessionState.hdr = on
if (on) {
try { android.system.Os.setenv("BANNER_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "BANNER_WAYLAND_HDR", e) }
try { android.system.Os.setenv("DROIDDECK_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "DROIDDECK_WAYLAND_HDR", e) }
WaylandCompositor.nativeSetZeroCopy(true)
}
WaylandCompositor.nativeSetHdrRequest(
@@ -857,7 +987,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// whole process, so the second Play after a session ended used to re-attach the Surface,
// start nothing, and leave the loading panel counting up over a dead session.
if (!SessionState.running) {
CompositorHost.newSession()
SessionEvents.transition(SessionPhase.STARTING_GUEST, "guest.starting")
SessionService.start(
this, intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM,
@@ -893,48 +1022,26 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* The size the session renders at. gamescope is told this and scales its output onto whatever
* the panel is, so a 1440p phone can run the client at 1080p without the client knowing.
*/
/** The panel, not the window: resources.displayMetrics is what is left after the system bars and the cutout are taken out. */
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun outputSize(): Pair<Int, Int> {
// The panel, not the window: resources.displayMetrics is what is left after the system
// bars and the cutout are taken out.
val bounds = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
val metrics = android.util.DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
val panelW = maxOf(bounds.width(), bounds.height()).toFloat()
val panelH = minOf(bounds.width(), bounds.height()).toFloat()
// Never narrower than 16:9. A foldable's inner panel is nearly square, and a game handed a
// square display draws for the frame it was made for and cuts the sides off itself.
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
// An emulator whose frames cost next to nothing (Library.drawsAtPanel) gets the panel,
// unless the user chose a resolution for the mode.
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val cap = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode)) 0
else SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
// Odd sizes upset the scaler; both dimensions even is what every mode here would be.
return Pair(width and 1.inv(), height and 1.inv())
// Lightweight emulators keep their panel-sized default until a resolution is chosen.
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
Library.drawsAtPanel(program) && !SessionPrefs.resolutionChosen(this, mode))
com.droiddeck.launcher.session.SessionDisplay.MATCH_SCREEN
else SessionPrefs.resolutionChoice(this, mode, panel)
return com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panel, choice)
}
/**
@@ -996,6 +1103,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val poll = object : Runnable {
override fun run() {
if (!watching) return
if (::pip.isInitialized) pip.refresh()
if (SessionState.stopRequested && !SessionState.running && !installingRuntime) {
SessionState.stopRequested = false
SessionEvents.transition(SessionPhase.IDLE, "session.cancelled")
@@ -1014,16 +1122,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun onSessionEnded(status: Int) {
if (isInPictureInPictureMode) {
runOnUiThread { finish() }
return
}
closeSecondScreen(reset = true)
if (status == 0) {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
// A session asked for in this one's place (the desktop's Steam launchers, the
// drawer's DESKTOP) starts here: this activity restarts on its intent, as an agent
// A session asked for in this one's place (the desktop's Steam launchers or
// Steam's Switch to Desktop) starts here: this activity restarts on its intent, as an agent
// start does. startActivity(next) could not do it - the activity is singleTop, so
// the request landed in this instance's onNewIntent and went with its finish().
SessionState.relaunch?.let { next ->
SessionState.relaunch = null
next.putExtra(EXTRA_RETURN_HOME, intent.getBooleanExtra(EXTRA_RETURN_HOME, false))
setIntent(next)
recreate()
return@runOnUiThread
@@ -1042,8 +1155,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, "session-end-hint").start()
}
private fun showEnded(status: Int, hint: String?) {
if (status == 0) {
private fun showEnded(status: Int, hint: EndHint?) {
if (status == 0 && hint?.evenOnSuccess != true) {
finish()
return
}
@@ -1054,7 +1167,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
else -> "Steam"
}
loading.showEnded(
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
)
focusEndedScreen()
@@ -1082,10 +1195,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* FEX) on some devices (a Fold 5, twice). The switch that answers it is in Performance, and a
* user who never opens the log would not know.
*/
private fun sessionEndHint(log: File?): String? {
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private fun sessionEndHint(log: File?): EndHint? {
if (log == null || !log.isFile) return null
return try {
var enosys = 0
var fexMissing = false
// The tail is where a dying session says why; 512 KB covers the storm without reading a 1 GB log.
val size = log.length()
java.io.RandomAccessFile(log, "r").use { f ->
@@ -1095,11 +1211,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
f.readFully(bytes)
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
if (line.contains(FEX_MISSING)) fexMissing = true
}
}
if (enosys >= 8) {
"The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again."
if (fexMissing) {
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
} else if (enosys >= 8) {
EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " +
"Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.")
} else null
} catch (e: Exception) {
null
@@ -1115,6 +1235,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE ->
return super.dispatchKeyEvent(event)
}
if (pipUi) return true
val fromController = event.device != null && PadBridge.isFromController(event.device)
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
@@ -1139,7 +1260,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else DRAWER_PAGES - 1) % DRAWER_PAGES
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty())
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
releaseDrawerDirection()
}
return true
@@ -1154,7 +1276,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START ||
event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE || event.keyCode == KeyEvent.KEYCODE_HOME
val resumeKeyId = event.deviceId to event.keyCode
if (fromController && resumeKey && (SessionState.suspended || resumeKeyId in resumeKeysDown)) {
if (event.action == KeyEvent.ACTION_DOWN) {
@@ -1193,11 +1316,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
if (down) {
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 1)
WaylandCompositor.nativeSendKey(evdev, 1)
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true)
sendGuestKey(evdev, true)
} else {
WaylandCompositor.nativeSendKey(evdev, 0)
if (shiftEvdev != 0) WaylandCompositor.nativeSendKey(shiftEvdev, 0)
sendGuestKey(evdev, false)
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false)
}
return true
}
@@ -1207,6 +1330,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
if (!drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
@@ -1224,7 +1348,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchGenericMotionEvent(event)
}
/** With no view focused, captured mouse events skip the root and arrive here as trackball events. */
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event)
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (pipUi) return true
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
drawerControllerActive = false
}
@@ -1426,6 +1555,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun showSecondScreen(mode: SecondScreenMode) {
if (pipUi || !started) return
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
// Not there right now - a panel still waking, say. The choice is kept; the display
@@ -1511,6 +1641,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
// Child views get the event first, so menus, keyboards and the on-screen pad still work.
if (touchMode == SessionPrefs.TOUCH_OFF) return true
if (usingTouchpad()) {
val rect = drawnRect() ?: return false
if (touchpad.bounds != rect) {
@@ -1531,6 +1663,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// the compositor still holds (an up lost to the drawer opening mid-touch) goes first.
MotionEvent.ACTION_DOWN -> {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
sendTouch(0, event.actionIndex)
}
MotionEvent.ACTION_POINTER_DOWN -> sendTouch(0, event.actionIndex)
@@ -1567,6 +1702,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
/** A physical mouse, not a touchscreen, stylus or the system's own virtual devices. */
private fun mouseAttached(): Boolean = InputDevice.getDeviceIds().any { id ->
val d = InputDevice.getDevice(id)
d != null && !d.isVirtual && d.supportsSource(InputDevice.SOURCE_MOUSE)
}
/** Capture the mouse exactly while a program holds a lock and the session has the mouse. */
private fun updatePointerCapture() {
if (!::surfaceView.isInitialized) return
val view = window.decorView
val want = pointerLocked && resumed && hasWindowFocus() && !drawerOpen && !pcKeyboardOpen && !pipUi && mouseAttached()
if (want == view.hasPointerCapture()) return
if (want) view.requestPointerCapture() else view.releasePointerCapture()
}
/**
* A captured mouse: raw motion as relative pointer deltas, buttons and the wheel as on the
* uncaptured path. Anything else (a captured touchpad's absolute fingers) is left alone.
*/
private fun onCapturedPointer(event: MotionEvent): Boolean {
if (!pointerLocked || !event.isFromSource(InputDevice.SOURCE_MOUSE_RELATIVE)) return false
when (event.actionMasked) {
MotionEvent.ACTION_MOVE -> {
var dx = event.x
var dy = event.y
for (i in 0 until event.historySize) {
dx += event.getHistoricalX(i)
dy += event.getHistoricalY(i)
}
WaylandCompositor.sendPointerDelta(dx, dy)
}
MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.ACTION_BUTTON_RELEASE -> {
val button = when (event.actionButton) {
MotionEvent.BUTTON_SECONDARY -> PointerGestures.BTN_RIGHT
MotionEvent.BUTTON_TERTIARY -> PointerGestures.BTN_MIDDLE
else -> PointerGestures.BTN_LEFT
}
WaylandCompositor.nativeSendSceneInput(3, button, if (event.actionMasked == MotionEvent.ACTION_BUTTON_PRESS) 1 else 0)
}
MotionEvent.ACTION_SCROLL -> {
val v = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
if (v != 0f) WaylandCompositor.nativeSendSceneInput(4, -Math.round(v), 0)
}
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> {} // the BUTTON_* events carry these
else -> return false
}
return true
}
/** The mode decides which controls appear; `droiddeck-osc` in Downloads still overrides. */
/** Which controller the session has, so a pad report says what was plugged in and when. */
private fun logInputDevice(what: String, deviceId: Int) {
@@ -1582,6 +1766,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
@@ -1593,6 +1783,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// started from the rail, or Steam, gets it; the drawer turns it on anywhere.
else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
@@ -1603,10 +1794,81 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
})
}
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
val rect = android.graphics.Rect()
if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect
val (w, h) = SessionState.outputSize
if (w <= 0 || h <= 0) return rect
val scale = minOf(rect.width().toFloat() / w, rect.height().toFloat() / h)
val fittedW = (w * scale).toInt()
val fittedH = (h * scale).toInt()
rect.inset((rect.width() - fittedW) / 2, (rect.height() - fittedH) / 2)
return rect
}
private fun showPipUi(show: Boolean) {
pipUi = show
if (show) {
drawerOpen = false
pcKeyboardOpen = false
pendingBackAction?.let(uiHandler::removeCallbacks)
pendingBackAction = null
releaseDrawerDirection()
onScreenControls?.releaseAll()
padBridge?.releaseAll()
touchpad.cancel()
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
keyboard?.hide()
sessionClipboard.stop()
padMotion?.stop()
cursorVisible = false
closeSecondScreen(reset = false)
onScreenControls?.visibility = View.GONE
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
listOf(PointerGestures.BTN_LEFT, PointerGestures.BTN_RIGHT, PointerGestures.BTN_MIDDLE).forEach {
WaylandCompositor.nativeSendSceneInput(3, it, 0)
}
WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
}
} else if (!isFinishing && !isDestroyed) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
updateOnScreenControls()
if (resumed) { sessionClipboard.start(); updatePadMotion() }
if (started) refreshSecondScreenDisplays()
if (CompositorHost.isStarted) applyFrameGen()
}
}
override fun onUserLeaveHint() {
if (::pip.isInitialized) pip.leaving()
super.onUserLeaveHint()
}
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: android.content.res.Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (::pip.isInitialized) pip.changed(inPip)
}
override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) {
if (::pip.isInitialized && (requestCode >= 0 || intent.component?.packageName == packageName ||
intent.action in setOf(Intent.ACTION_CHOOSER, Intent.ACTION_SEND, Intent.ACTION_OPEN_DOCUMENT,
Intent.ACTION_GET_CONTENT, "android.settings.PICTURE_IN_PICTURE_SETTINGS"))) pip.background()
super.startActivityForResult(intent, requestCode, options)
}
// ── Lifecycle ───────────────────────────────────────────────────────────────────────────
override fun onStart() {
super.onStart()
if (runtimeRemovalBlocked) return
started = true
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
@@ -1615,6 +1877,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onStop() {
if (runtimeRemovalBlocked) { super.onStop(); return }
started = false
displayManager.unregisterDisplayListener(displayListener)
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
@@ -1635,6 +1898,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
recreate()
return
}
// Home/notification resumes must retain the external frontend's return destination.
if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true)
setIntent(intent)
if (intent.action == SessionService.ACTION_HOME_GUIDE) {
handleHomeGuideIntent(intent)
@@ -1646,6 +1911,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
@@ -1655,14 +1921,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
(getSystemService(INPUT_SERVICE) as? InputManager)
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
onScreenControls?.reload()
controllerSettings = ControllerPrefs.read(this)
updateOnScreenControls()
resumed = true
updatePointerCapture()
if (!pipUi) sessionClipboard.start()
updatePadMotion()
readPrefs()
if (::pip.isInitialized) pip.resumed()
if (CompositorHost.isStarted) applyFrameGen()
}
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
// Do not carry transient session UI across an app/display transition. In particular, the
// drawer's dim layer can otherwise remain over Steam when this activity returns.
drawerOpen = false
@@ -1672,6 +1944,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// A button held when the app goes away would stay held in the ring for the whole session.
onScreenControls?.releaseAll()
resumed = false
updatePointerCapture()
updatePadMotion()
keyboard?.takeIf { it.shown }?.hide()
super.onPause()
@@ -1687,7 +1960,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) goFullscreen()
if (runtimeRemovalBlocked) return
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
}
// Android drops a capture with the focus; a lock still held takes it again on return.
updatePointerCapture()
}
/** The keyboard/trackpad presentation can own focus instead of the main display. */
fun refreshClipboard() {
if (resumed) sessionClipboard.refresh()
}
private fun goFullscreen() {
@@ -1710,6 +1994,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val endListener: (Int) -> Unit = { status -> onSessionEnded(status) }
private val firstFrameListener = Runnable {
SessionEvents.firstFrame()
if (SessionState.mode == SessionService.MODE_STEAM) SteamRepair.clientShown(this)
runOnUiThread {
loading.visible = false
hud.start()
@@ -1717,19 +2002,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
if (runtimeRemovalBlocked) { super.onDestroy(); return }
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
watching = false
releaseDrawerDirection()
pendingBackAction?.let(uiHandler::removeCallbacks)
pendingBackAction = null
closeSecondScreen(reset = true)
if (::pip.isInitialized) pip.destroyed()
closeSecondScreen(reset = false)
if (::hud.isInitialized) hud.stop()
padBridge?.stop()
padMotion?.stop()
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
if (SessionState.endListener === endListener) SessionState.endListener = null
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
WaylandCompositor.clearPointerLockListener(pointerLockListener)
super.onDestroy()
}
@@ -1752,6 +2040,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
* off, it just closes.
*/
override fun finish() {
if (isFinishing) return
if (!quitFlooded && !closeAtOnce && com.droiddeck.launcher.ui.Motion.scale != 0f &&
lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) {
if (!leaving) {
@@ -1762,6 +2051,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
super.finish()
if (!closeAtOnce && intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) {
com.droiddeck.launcher.ui.QuitFlood.take()
com.droiddeck.launcher.ui.LaunchOrigin.flooding = null
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
if (quitFlooded) overridePendingTransition(0, 0)
else overridePendingTransition(R.anim.session_hold, R.anim.session_sink)
}
@@ -1777,9 +2072,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
/** How long the leaving flood may take before the session closes without it. */
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
private const val CURSOR_PAD_HOLD_MS = 1200L
private const val DRAWER_HAT_THRESHOLD = 0.5f
@@ -73,6 +73,7 @@ class AgentBridgeProvider : ContentProvider() {
.put("steamUi", SessionState.steamUi ?: JSONObject.NULL)
.put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL)
.put("suspended", SessionState.suspended)
.put("pip", SessionState.pipActive)
.put("firstFrame", SessionState.firstFrameSeen)
.put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second))
.put("refreshHz", SessionState.refreshHz.toDouble())
@@ -0,0 +1,19 @@
package com.droiddeck.launcher.core
import android.content.Context
object AppUiPrefs {
const val DEFAULT_SCALE = 100
val scales = listOf(75, 85, 100, 115, 125, 150)
internal fun normalizeScale(percent: Int): Int = percent.takeIf { it in scales } ?: DEFAULT_SCALE
fun scale(context: Context): Int = normalizeScale(
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).getInt("scale", DEFAULT_SCALE),
)
fun setScale(context: Context, percent: Int) {
context.getSharedPreferences("app-ui", Context.MODE_PRIVATE).edit()
.putInt("scale", normalizeScale(percent)).apply()
}
}
@@ -12,6 +12,7 @@ import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.runtime.DesktopCatalog
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
import com.droiddeck.launcher.session.EsyncPacks
import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SessionService
import com.droiddeck.launcher.session.SessionState
@@ -107,9 +108,12 @@ 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))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
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)
val size = com.droiddeck.launcher.session.SessionDisplay.resolveChoice(panelSize, choice)
k(label, "$choice (${size.first}x${size.second})")
}
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -150,14 +154,18 @@ object DeviceReport {
k("No GL error checks", SessionPrefs.noGlError(context))
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
k("Steam controller", SessionPrefs.steamController(context))
k("Upscaler", (SessionPrefs.upscalerChoices.firstOrNull { it.first == SessionPrefs.upscaler(context) }?.second ?: "Off") +
" (sharpness ${SessionPrefs.upscaleSharpness(context)}%)")
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("Wine sync", SessionPrefs.syncBackend(context))
EsyncPacks.status(LinuxRuntime.rootDir(context)).let { k("droiddeck-esync packs", "${it.installed} installed, ${it.wanted} wanted") }
k("Linux x86 (FEX)", if (com.droiddeck.launcher.runtime.LinuxFex.ready(context)) "ready" else "not set up")
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("proot fast path", SessionPrefs.prootFastPath(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
k("Microphone", SessionPrefs.micEnabled(context))
@@ -169,6 +177,7 @@ object DeviceReport {
SessionPrefs.GAME_STORAGE_OFF -> "internal only"
else -> g
})
k("Skip space reservation", SessionPrefs.skipLibraryPreallocation(context))
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
@@ -14,7 +14,7 @@ object GameEnvironment {
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val unused = setOf(
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
"WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
@@ -25,14 +25,24 @@ object GameEnvironment {
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
// .NET 7 and later reserve 256 GB of address space for the GC heap at startup, more than an
// Android process has free, and the runtime fails to start. 32 GB is plenty for a game.
"DOTNET_GCRegionRange" to "0x800000000",
// .NET's W^X writes JIT code through a second mapping that FEX does not watch, so FEX keeps
// running the stale translation and the game hangs at startup.
"DOTNET_EnableWriteXorExecute" to "0",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
// Steam settings' Force SSBS: droiddeck-game-env preloads libssbs.so into the game's Wine.
if (forceSsbs) put(FORCE_SSBS, "1")
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
const val FORCE_SSBS = "DROIDDECK_FORCE_SSBS"
fun effective(config: Config, preset: String, scope: String, forceSsbs: Boolean = false): Map<String, String?> =
defaults(preset, forceSsbs) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -15,6 +15,10 @@ object GameEnvironmentOptions {
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_NO_NTSYNC", "1", R.string.game_env_no_ntsync, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_NO_ESYNC", "1", R.string.game_env_no_esync, Type.TOGGLE, listOf("0", "1")),
Option("PROTON_NO_FSYNC", "1", R.string.game_env_no_fsync, Type.TOGGLE, listOf("0", "1")),
Option("BL_FSYNC_FIRST", "1", R.string.game_env_fsync_first, Type.TOGGLE, listOf("0", "1")),
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
@@ -18,7 +18,7 @@ package com.droiddeck.launcher.core
*
* The device's OWN addresses are not kept: the client's IPv6 check logs "external address <ours>"
* about twenty times a session, and a public IPv6 address is the user's. [learnOwnAddresses] reads
* the link the session was given (etc/bannerlator-net) and every address on the same /64 - privacy
* the link the session was given (etc/droiddeck-net) and every address on the same /64 - privacy
* addresses rotate inside it - or an own public IPv4 is replaced wherever it appears. Likewise every
* Steam account and persona name on the device ([learnAccounts]), for users who sign in with an
* account name rather than an email.
@@ -95,7 +95,7 @@ object LogRedactor {
/** Everything the session's runtime can tell about whose logs these are: addresses and accounts. */
fun learnFromRuntime(root: java.io.File) {
learnOwnAddresses(java.io.File(root, "etc/bannerlator-net"))
learnOwnAddresses(java.io.File(root, "etc/droiddeck-net"))
learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf"))
}
@@ -190,6 +190,14 @@ object LogRedactor {
false
}
/**
* Bump whenever a pattern is added or tightened. A session folder records the version it was
* scrubbed under (SessionArtifacts.SCRUBBED_TREE_MARKER), and a share copies a file as it is
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 1
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
@@ -14,10 +14,8 @@ import java.net.Inet6Address
* `network.txt`: what the session was given to work with, for the reports that say "the client
* thinks WiFi is off" or "Firefox cannot resolve anything".
*
* The runtime has no NetworkManager, so the client's own network panel is blind by design and says
* so in ways that read like a fault. What actually matters is further down: whether the phone had
* a validated link, which DNS servers the app wrote into the runtime's `resolv.conf`, and whether
* the session can reach a name and an address at all.
* The runtime's NetworkManager bridge reports Android's active link. Include whether the phone
* had a validated link and which DNS servers the app wrote into the runtime's `resolv.conf`.
*
* **No network names.** The SSID is identifying and is deliberately not collected; the transport
* type, the addresses' families and the DNS servers are what a diagnosis needs.
@@ -93,14 +91,14 @@ object NetworkReport {
} else {
append("etc/resolv.conf does not exist yet - written when the session's link is published.\n")
}
val netdev = File(LinuxRuntime.rootDir(context), "etc/bannerlator-net")
val netdev = File(LinuxRuntime.rootDir(context), "etc/droiddeck-net")
if (netdev.isFile) {
append("\netc/bannerlator-net (the link as the session sees it):\n")
append("\netc/droiddeck-net (the link as the session sees it):\n")
FileUtils.readString(netdev)?.lines()?.forEach { append(" ").append(it).append('\n') }
}
append("\nproot makes no network namespace, so the session uses the phone's connection\n")
append("directly: IPv4 and IPv6 both pass through, and there is nothing to forward.\n")
append("The Steam client's own network panel drives NetworkManager over D-Bus, which this\n")
append("runtime does not have - \"wifi is off\" there is cosmetic and not evidence.\n")
append("Steam reads Android's transport, connectivity and metering through the runtime's\n")
append("NetworkManager D-Bus bridge. Android settings manage Wi-Fi and cellular connections.\n")
}
}
@@ -0,0 +1,59 @@
package com.droiddeck.launcher.core
import java.util.Locale
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
/**
* Texture filtering for DirectX 9-11 games, as DXVK configuration options
* (d3d9/d3d11.samplerAnisotropy and samplerLodBias). VKD3D-Proton and WineD3D do not read them.
* Published into the game environment and read by DXVK when a game starts, so a change lands on
* the next launch, not the running game.
*/
object TextureFiltering {
/** 0 = the game's own filtering; otherwise that anisotropy on every linearly filtered sampler. */
val ANISOTROPY = listOf(0, 2, 4, 8, 16)
/** Negative mip LOD bias added to the game's own: sharper textures at the cost of some shimmer. */
const val LOD_BIAS_OFF = "0"
/** Derived from how far the session is enlarged to the panel (see [autoLodBias]). */
const val LOD_BIAS_AUTO = "auto"
val LOD_BIAS = listOf(LOD_BIAS_OFF, LOD_BIAS_AUTO, "-0.25", "-0.5", "-0.75", "-1.0")
private const val LOD_BIAS_MIN = -2f
/**
* The mip LOD bias for a picture enlarged by [scale] (panel size over session size, long side
* to long side): -log2(scale), AMD's FSR 1 guidance (a 1.5x enlargement - 720p on a 1080p
* panel - gives -0.58). 0 when the session is not enlarged or the scale is unknown.
*/
fun autoLodBias(scale: Float): Float {
if (scale.isNaN() || scale <= 1f) return 0f
return max((-ln(scale.toDouble()) / ln(2.0)).toFloat(), LOD_BIAS_MIN)
}
fun lodBias(choice: String, autoBias: Float): Float = when (choice) {
LOD_BIAS_OFF, "" -> 0f
LOD_BIAS_AUTO -> autoBias
else -> choice.toFloatOrNull()?.let { max(min(it, 0f), LOD_BIAS_MIN) } ?: 0f
}
/** The DXVK_CONFIG options for the two settings, "" when both are left to the game. */
fun dxvkOptions(anisotropy: Int, lodBiasChoice: String, autoBias: Float): String {
val parts = ArrayList<String>(4)
val aniso = if (anisotropy in ANISOTROPY) anisotropy else 0
if (aniso > 0) {
parts += "d3d9.samplerAnisotropy = $aniso"
parts += "d3d11.samplerAnisotropy = $aniso"
}
val bias = lodBias(lodBiasChoice, autoBias)
if (bias < 0f) {
// Locale.US: DXVK parses "-0.58", never "-0,58".
val b = String.format(Locale.US, "%.2f", bias)
parts += "d3d9.samplerLodBias = $b"
parts += "d3d11.samplerLodBias = $b"
}
return parts.joinToString("; ")
}
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import android.os.Build
import com.droiddeck.launcher.session.SessionPrefs
/** Location is used only for Android's Wi-Fi names and scan results, never coordinates. */
object WifiDiscovery {
val permissions = arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
// targetSdk 28 accepts either grant for Wi-Fi scanning.
fun permissionGranted(context: Context): Boolean = permissions.any {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
fun locationEnabled(context: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.P ||
(context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager)?.isLocationEnabled == true
fun available(context: Context): Boolean = SessionPrefs.wifiDiscoveryEnabled(context) &&
permissionGranted(context) && locationEnabled(context)
}
@@ -22,7 +22,7 @@ class FileManagerActivity : ComponentActivity() {
}
val initialDir = intent.getStringExtra(EXTRA_START_DIR)?.let(::File)
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(initialDir = initialDir)
}
@@ -43,7 +43,7 @@ class FilePickerActivity : ComponentActivity() {
?.let { File(it) }?.takeIf { it.isDirectory }
setContent {
DroidDeckTheme {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
FileManagerScreen(
pickMode = true,
@@ -0,0 +1,253 @@
package com.droiddeck.launcher.frontend
import java.io.File
import java.util.Locale
/**
* Best-effort detection of the Windows redistributables a game bundles, by scanning its install
* folder for the redist installers Steam/Epic/GOG ship (`_CommonRedist/`, `_Redist/`,
* `Redistributables/`, loose `vcredist_x64.exe`, …) and mapping them to entries in our component
* catalog (WinComponents, Bannerlator's components.json `name` keys). The result drives the
* Recommended part of a game's Windows components; the user still switches each one on.
* Bannerlator's detector, unchanged but for this note and its package.
*
* Wine-aware: keeps the redists that actually matter under Wine/DXVK (VC++ runtimes, .NET, PhysX,
* OpenAL, XACT, XNA, GFWL) and SKIPS the ones DXVK/VKD3D already provide (DX12 Agility SDK) or that
* aren't our components (anti-cheat, Rockstar/Social-Club/launcher setups). Never throws; returns an
* empty list on anything unexpected. Pure filesystem — no network, no Android deps (unit-testable).
*/
object DependencyDetector {
/** How a recommendation was found — drives the UI's "recommended vs optional" split. */
enum class Kind {
/** The game bundles the redist installer (in _CommonRedist etc.) — meant to be installed. */
BUNDLED,
/** A runtime DLL the game ships loose — usually already works from the game folder; install
* only if the game misbehaves. */
SHIPPED,
/** Steam installs it with the game from Steamworks Shared ([SteamRedists]). */
STEAM,
}
/** A recommended component: the catalog [componentName] to install, a display [label], the on-disk
* redist that triggered it ([reason]), and [kind] (bundled installer vs shipped DLL). */
data class Recommendation(val componentName: String, val label: String, val reason: String, val kind: Kind = Kind.BUNDLED)
private const val MAX_DEPTH = 5
private const val MAX_ENTRIES = 8000 // cost guard against giant game trees
/** A loose VC++ installer with no year in its path — resolved to "latest" only if no specific
* vcredistYYYY was found anywhere (else it's just a duplicate of the versioned one). */
private const val VCREDIST_LOOSE = "__vcredist_loose"
/** Directory names that are redist containers worth descending into. */
private val REDIST_DIR_RE = Regex(
"""(?i)^(_?commonredist|_?redist(ributables)?|__?installer|vcredist|vc_redist|directx|direct_x|physx|openal|dotnet(fx)?|\.net|xna|xact|redist|support)$"""
)
/** Skip whole subtrees that are never our components. */
private val SKIP_DIR_RE = Regex("""(?i)^(d3d12.*|.*agility.*|dxvk|vkd3d|battleye|easyanticheat|eac|anticheat)$""")
private val YEAR_TO_VCREDIST = mapOf(
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010",
"2012" to "vcredist2012", "2013" to "vcredist2013", "2015" to "vcredist2015",
"2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
)
private val LABELS = mapOf(
"vcredist2005" to "Visual C++ 2005", "vcredist2008" to "Visual C++ 2008",
"vcredist2010" to "Visual C++ 2010", "vcredist2012" to "Visual C++ 2012",
"vcredist2013" to "Visual C++ 2013", "vcredist2015" to "Visual C++ 2015-2017",
"vcredist2019" to "Visual C++ 2019", "vcredist2022" to "Visual C++ 2015-2022",
"physx" to "NVIDIA PhysX", "oalinst" to "OpenAL", "xact" to "DirectX XACT audio",
"xact_x64" to "DirectX XACT audio (x64)", "xna40" to "XNA Framework 4.0",
"xna31" to "XNA Framework 3.1", "d3dx9" to "DirectX 9 (d3dx9)", "d3dx11" to "DirectX 11 (d3dx11)",
"XLiveRedist" to "Games for Windows Live", "dotnet40" to ".NET Framework 4.0",
"dotnet45" to ".NET Framework 4.5", "dotnet452" to ".NET Framework 4.5.2",
"dotnet46" to ".NET Framework 4.6", "dotnet472" to ".NET Framework 4.7.2",
"dotnet48" to ".NET Framework 4.8", "dotnet35" to ".NET Framework 3.5",
"dotnetcoredesktop6" to ".NET Desktop 6", "dotnetcoredesktop7" to ".NET Desktop 7",
"dotnetcoredesktop8" to ".NET Desktop 8",
)
/** Preferred install order (runtimes others may depend on first). */
private val INSTALL_ORDER = listOf(
"vcredist2005", "vcredist2008", "vcredist2010", "vcredist2012", "vcredist2013",
"vcredist2015", "vcredist2019", "vcredist2022",
"dotnet35", "dotnet40", "dotnet45", "dotnet452", "dotnet46", "dotnet472", "dotnet48",
"dotnetcoredesktop6", "dotnetcoredesktop7", "dotnetcoredesktop8",
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
)
/**
* Detect from the .exe the user picked, resolving the game root first: walk up from the exe's
* folder (bounded) to the nearest ancestor that holds a redist container (`_CommonRedist`, …) at
* its top level, and scan from there — so a nested exe (`…/Game/bin/x.exe`) still finds a root-level
* `_CommonRedist`, without over-reaching into a shared `steamapps/common` (which has no redist
* container of its own). Falls back to scanning the exe's own folder.
*/
fun detectForExe(exeFile: File): List<Recommendation> {
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
var d: File? = start
var depth = 0
while (d != null && depth < 4) {
val hasRedistContainer = try {
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
} catch (_: Throwable) { false }
if (hasRedistContainer) return detect(d)
d = d.parentFile
depth++
}
return detect(start)
}
/**
* Scan [gameDir] and return the recommended components, de-duplicated (first reason wins), in a
* stable install-friendly order. Empty when nothing is detected or [gameDir] isn't a directory.
*/
fun detect(gameDir: File): List<Recommendation> {
if (!gameDir.isDirectory) return emptyList()
val hits = LinkedHashMap<String, Recommendation>() // componentName -> first recommendation
var visited = 0
fun consider(f: File, isDir: Boolean, insideRedist: Boolean) {
val name = f.name.lowercase(Locale.US)
val path = f.path.replace('\\', '/').lowercase(Locale.US)
// Bundled = in a redist container or an installer (exe/msi/cab); shipped = a loose runtime DLL.
val kind = if (insideRedist || isDir || name.endsWith(".exe") || name.endsWith(".msi") || name.endsWith(".cab"))
Kind.BUNDLED else Kind.SHIPPED
for (comp in classify(path, name, isDir)) {
val existing = hits[comp]
if (existing == null) {
hits[comp] = Recommendation(comp, LABELS[comp] ?: comp, relativeReason(gameDir, f), kind)
} else if (existing.kind == Kind.SHIPPED && kind == Kind.BUNDLED) {
hits[comp] = existing.copy(kind = Kind.BUNDLED) // a bundled installer outranks a shipped DLL
}
}
}
fun walk(dir: File, depth: Int, insideRedist: Boolean) {
if (depth > MAX_DEPTH || visited > MAX_ENTRIES) return
val entries = dir.listFiles() ?: return
for (f in entries) {
if (visited++ > MAX_ENTRIES) return
val lower = f.name.lowercase(Locale.US)
if (f.isDirectory) {
if (SKIP_DIR_RE.matches(lower)) continue
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
// tree a few levels deep so runtime libraries the game ships (in bin/, etc.) are seen.
if (isRedistDir || depth < 3) walk(f, depth + 1, isRedistDir)
} else {
// Classify installers + runtime libraries (.dll/.exe/.msi) anywhere in the scanned
// tree; other files are ignored. Recommendations are advisory, so cast a wide net.
val ext = lower.substringAfterLast('.', "")
if (insideRedist || ext == "dll" || ext == "exe" || ext == "msi") consider(f, isDir = false, insideRedist = insideRedist)
}
}
}
return try {
walk(gameDir, 0, false)
resolveLooseVcredist(hits)
hits.values.sortedBy { INSTALL_ORDER.indexOf(it.componentName).let { i -> if (i < 0) 999 else i } }
} catch (_: Throwable) {
emptyList()
}
}
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
val loose = hits.remove(VCREDIST_LOOSE) ?: return
val hasSpecific = hits.keys.any { it in YEAR_TO_VCREDIST.values }
if (!hasSpecific) hits["vcredist2022"] = loose.copy(componentName = "vcredist2022", label = LABELS["vcredist2022"]!!)
}
private fun relativeReason(gameDir: File, f: File): String {
val g = gameDir.path.replace('\\', '/')
val p = f.path.replace('\\', '/')
return if (p.startsWith("$g/")) p.substring(g.length + 1) else f.name
}
/**
* Map a redist file/dir (its full lowercased [path], leaf [name], and whether it [isDir]) to
* catalog component name(s). Empty for things we deliberately skip.
*/
private fun classify(path: String, name: String, isDir: Boolean): List<String> {
// Hard skips first — DX12 is provided by DXVK/VKD3D; these aren't our components.
if (path.contains("d3d12") || path.contains("agility") ||
path.contains("battleye") || path.contains("anticheat") || path.contains("easyanticheat") ||
name.contains("socialclub") || name.contains("social-club") ||
name.contains("rockstar") || name == "setup.exe"
) return emptyList()
// Visual C++ runtimes — most important. Year comes from the path context.
if (path.contains("vcredist") || path.contains("vc_redist") || name.startsWith("vcredist") || name.startsWith("vc_redist")) {
val year = YEAR_TO_VCREDIST.keys.firstOrNull { path.contains(it) }
return when {
year != null -> listOf(YEAR_TO_VCREDIST.getValue(year))
isDir -> emptyList() // bare "vcredist" container — wait for the year subfolder / installer
else -> listOf(VCREDIST_LOOSE) // loose installer, no year → resolved post-walk
}
}
// VC++ runtime DLL the game ships (msvcp140.dll → 2015-2022, msvcp120.dll → 2013, …).
VCRUNTIME_DLL.entries.firstOrNull { name.startsWith(it.key) }?.let { return listOf(it.value) }
if (path.contains("openal") || name.startsWith("oalinst") || name == "openal32.dll" || name == "wrap_oal.dll") return listOf("oalinst")
if (path.contains("physx") || name.startsWith("physx")) return listOf("physx")
if (path.contains("xact") || name.startsWith("xactengine") || name.startsWith("x3daudio")) return listOf("xact", "xact_x64")
// Games for Windows Live — the redist folder, OR a GFWL library the game ships (xlive.dll,
// Codemasters' dbxLive32/64, gfwl*): GFWL titles need XLiveRedist to run even when they don't
// bundle the redist itself.
if (path.contains("xliveredist") || name.contains("xlive") || name.startsWith("gfwl") || name.startsWith("dbxlive")) {
return listOf("XLiveRedist")
}
if (path.contains("xna")) return listOf(if (path.contains("3.1") || path.contains("3_1") || path.contains("v31")) "xna31" else "xna40")
// .NET — Framework and Core/Desktop runtimes.
if (path.contains("dotnet") || path.contains(".net") || name.startsWith("ndp") || name.startsWith("dotnetfx") || name.contains("windowsdesktop-runtime")) {
val comp = dotnetComponent("$path $name")
return when {
comp != null -> listOf(comp)
isDir -> emptyList() // bare ".NET" container — wait for the versioned installer
else -> listOf("dotnet48") // unlabeled .NET Framework installer file → sensible default
}
}
// DirectX 11 helper DLLs the game ships (d3dx11_43.dll).
if (D3DX11_RE.matches(name)) return listOf("d3dx11")
// DirectX 9 helper DLLs / d3dcompiler / June-2010 DX redist (DXVK provides core d3d9 but NOT
// these helper DLLs).
if (name.startsWith("dxsetup") || name.startsWith("dxwebsetup") ||
D3DX9_RE.matches(name) || D3DCOMPILER_RE.matches(name) ||
(path.contains("directx") && name.endsWith(".exe"))
) return listOf("d3dx9")
return emptyList()
}
/** Runtime DLL prefix → the VC++ redistributable that provides it (checked with startsWith). */
private val VCRUNTIME_DLL = linkedMapOf(
"vcruntime140" to "vcredist2022", "msvcp140" to "vcredist2022", "msvcr140" to "vcredist2022", "concrt140" to "vcredist2022",
"msvcp120" to "vcredist2013", "msvcr120" to "vcredist2013",
"msvcp110" to "vcredist2012", "msvcr110" to "vcredist2012",
"msvcp100" to "vcredist2010", "msvcr100" to "vcredist2010",
"msvcp90" to "vcredist2008", "msvcr90" to "vcredist2008",
"msvcp80" to "vcredist2005", "msvcr80" to "vcredist2005",
)
private val D3DX9_RE = Regex("""d3dx9_\d+\.dll""")
private val D3DX11_RE = Regex("""d3dx11_\d+\.dll""")
private val D3DCOMPILER_RE = Regex("""d3dcompiler_\d+\.dll""")
/** Resolve a .NET component from installer/path text, or null when no version is discernible. */
private fun dotnetComponent(s: String): String? = when {
s.contains("desktop-runtime-8") || Regex("""\b8\.\d""").containsMatchIn(s) -> "dotnetcoredesktop8"
s.contains("desktop-runtime-7") || Regex("""\b7\.\d""").containsMatchIn(s) -> "dotnetcoredesktop7"
s.contains("desktop-runtime-6") || Regex("""\b6\.\d""").containsMatchIn(s) -> "dotnetcoredesktop6"
s.contains("ndp48") || s.contains("fx48") || s.contains("4.8") -> "dotnet48"
s.contains("ndp472") || s.contains("4.7.2") -> "dotnet472"
s.contains("ndp46") || s.contains("4.6") -> "dotnet46"
s.contains("ndp452") || s.contains("4.5.2") -> "dotnet452"
s.contains("dotnetfx45") || s.contains("ndp45") || s.contains("4.5") -> "dotnet45"
s.contains("dotnetfx40") || s.contains("ndp40") || s.contains("4.0") -> "dotnet40"
s.contains("dotnetfx35") || s.contains("3.5") -> "dotnet35"
else -> null // no version signal (e.g. a bare "DotNet" container dir)
}
}
@@ -0,0 +1,54 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** Runs while DroidDeck's process is alive, including downloads in a background Steam session. */
object GameFileSync {
private val worker = Executors.newSingleThreadScheduledExecutor { task -> Thread(task, "game-file-sync").apply { isDaemon = true } }
private fun prefs(context: Context) = context.getSharedPreferences("game_files", Context.MODE_PRIVATE)
fun folder(context: Context): String? = prefs(context).getString("folder", null)
fun start(context: Context) {
val app = context.applicationContext
worker.scheduleWithFixedDelay({
runCatching { syncConfigured(app) }.onFailure { Log.w("GameFileSync", "Sync failed: ${it.message}") }
}, 0, 30, TimeUnit.SECONDS)
}
@Synchronized internal fun syncConfigured(context: Context) {
val path = folder(context) ?: return
sync(context, File(path))
}
@Synchronized fun enable(context: Context, folder: File) {
sync(context, folder)
prefs(context).edit().putString("folder", folder.absolutePath).apply()
}
@Synchronized fun disable(context: Context) {
prefs(context).edit().remove("folder").apply()
}
@Synchronized private fun sync(context: Context, folder: File) {
check(LinuxRuntime.isInstalled(context)) { "Install the runtime first" }
// A disconnected library must not be interpreted as a mass uninstall.
check(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps").isDirectory) { "Steam library is unavailable" }
val storage = GameStorage.effective(context)
val storageSetting = SessionPrefs.gameStorage(context)
val previousStorage = prefs(context).getString("storagePath", null)
if (storageSetting.isEmpty() && prefs(context).getString("storageSetting", null) == storageSetting && previousStorage != null) {
check(File(previousStorage).isDirectory) { "Previous SD library is unavailable" }
}
storage?.let { check(File(it.path).listFiles() != null) { "Game storage is unavailable" } }
SessionPrefs.addedGamesDirs(context).forEach { check(File(it).listFiles() != null) { "Added games folder is unavailable" } }
GameFiles.sync(folder, Library.launchableGames(context, strictRead = true))
prefs(context).edit().putString("storageSetting", storageSetting).putString("storagePath", storage?.path).apply()
}
}
@@ -0,0 +1,165 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
import android.util.AtomicFile
import androidx.core.util.readText
import androidx.core.util.writeBytes
import androidx.core.util.writeText
import org.json.JSONObject
import java.io.File
import java.io.InputStream
/** Small, portable launch files for frontends that scan ROM folders. No guest paths or commands. */
object GameFiles {
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
/** Called on a worker: content providers and shared storage may block. */
fun readIntent(context: Context, request: Intent): String? = runCatching {
if (request.action != Intent.ACTION_VIEW) return null
val uri = request.data ?: return null
GameLaunchLink.parse(uri.toString())?.let { return it }
val stream = when (uri.scheme) {
"content" -> context.contentResolver.openInputStream(uri)
"file" -> {
if (!uri.authority.isNullOrEmpty() || uri.query != null || uri.fragment != null) return null
File(uri.path ?: return null).inputStream()
}
null -> File(uri.toString()).takeIf { it.isAbsolute }?.inputStream()
else -> null
} ?: return null
stream.use(::read)
}.getOrNull()
fun read(stream: InputStream): String? {
// An untrusted URI must not make us buffer an arbitrary ROM or parse a command.
val bytes = ByteArray(MAX_BYTES + 1)
var count = 0
while (count < bytes.size) {
val n = stream.read(bytes, count, bytes.size - count)
if (n < 0) break
if (n == 0) return null
count += n
}
if (count > MAX_BYTES) return null
val text = String(bytes, 0, count, Charsets.UTF_8).trim()
return text.takeIf(GameLaunchLink::validId)
}
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 suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
private fun contents(id: String): String {
require(GameLaunchLink.validId(id))
return id
}
@Synchronized fun export(folder: File, game: Library.SteamGame): File =
exportNamed(folder, game, filename(game))
private fun exportNamed(folder: File, game: Library.SteamGame, name: String): File {
require(folder.isDirectory) { "Folder is unavailable" }
val text = contents(game.gameIdString)
val target = exportTarget(folder, game, name)
require(!target.exists() || matches(target, text)) { "A different file already uses ${target.name}" }
if (!target.exists()) write(target, text)
exportArt(folder, game)
return target
}
private fun exportTarget(folder: File, game: Library.SteamGame, name: String): File {
val target = child(folder, name)
return if (target.exists() && !matches(target, contents(game.gameIdString))) {
child(folder, filename(game, disambiguate = true))
} else target
}
/** Cocoon matches icons in its data folder's images directory. Never replace custom art. */
private fun exportArt(folder: File, game: Library.SteamGame) {
val art = listOfNotNull(game.icon, game.art).firstOrNull {
it.isFile && it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp")
} ?: return
val title = filename(game).substringBeforeLast(".")
.replace(Regex("\\[[^]]*]|\\([^)]*\\)"), "")
.replace(Regex("[^a-zA-Z0-9\\s]"), "").trim()
.replace(Regex("\\s+"), "-").lowercase(java.util.Locale.ROOT).take(50).trimEnd('-')
if (title.isEmpty()) return
val images = child(folder, "images")
check(images.isDirectory || images.mkdir()) { "Artwork folder is unavailable" }
val target = child(images, "$title-icon.${art.extension.lowercase()}")
if (!target.exists()) AtomicFile(target).writeBytes(art.readBytes())
}
/** Only remove files in our inventory whose content is still exactly what we wrote. */
@Synchronized fun sync(folder: File, games: List<Library.SteamGame>) {
require(folder.isDirectory) { "Folder is unavailable" }
val inventory = child(folder, INVENTORY)
val previous = if (inventory.exists()) {
val record = JSONObject(AtomicFile(inventory).readText())
require(record.optString("format") == FORMAT) { "Folder has an unrelated sync inventory" }
record.getJSONObject("games")
} else JSONObject()
val next = JSONObject()
val unique = games.distinctBy { it.gameId }
val names = unique.groupingBy { filename(it).lowercase(java.util.Locale.ROOT) }.eachCount()
val exports = unique.map { game ->
val name = filename(game, disambiguate = names.getValue(filename(game).lowercase(java.util.Locale.ROOT)) > 1)
val preferred = child(folder, name)
val target = if (inventoryOwns(folder, previous, preferred, game.gameIdString)) preferred
else exportTarget(folder, game, name)
val text = contents(game.gameIdString)
val existed = target.exists()
// Keep an edited export without letting it block the rest of the library sync.
val owned = inventoryOwns(folder, previous, target, game.gameIdString)
val file = if (owned && target.exists() &&
!matches(target, text)) target else exportNamed(folder, game, target.name)
if (!existed || owned) next.put(file.name, game.gameIdString)
file
}
// Write new exports before pruning; a failed write must not remove old launch files.
for (name in previous.keys()) {
val id = previous.getString(name)
if (!next.has(name) && name.endsWith(".$EXTENSION") && GameLaunchLink.validId(id)) {
val file = child(folder, name)
if (matches(file, contents(id))) {
// Android shared storage is often case insensitive. A case-only rename may
// still resolve to the live export, which must not be pruned as an old file.
val retained = exports.any { java.nio.file.Files.isSameFile(it.toPath(), file.toPath()) }
if (!retained) check(file.delete()) { "Could not remove $name" }
}
}
}
if (previous.toString() != next.toString()) write(inventory, JSONObject().put("format", FORMAT).put("games", next).toString())
}
private fun inventoryOwns(folder: File, record: JSONObject, file: File, id: String): Boolean {
if (record.optString(file.name) == id) return true
if (!file.exists()) return false
return record.keys().asSequence().any { name ->
record.optString(name) == id && child(folder, name).let { old ->
old.exists() && java.nio.file.Files.isSameFile(old.toPath(), file.toPath())
}
}
}
private fun child(folder: File, name: String): File {
require(name.isNotEmpty() && '/' !in name && '\\' !in name && name != "." && name != "..")
val file = File(folder, name)
require(file.canonicalFile.parentFile == folder.canonicalFile) { "File leaves the export folder" }
return file
}
private fun matches(file: File, text: String) = file.isFile && file.length() == text.toByteArray().size.toLong() &&
file.readText() == text
private fun write(file: File, text: String) = AtomicFile(file).writeText(text)
}
@@ -0,0 +1,32 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.content.Intent
object GameLaunchIntent {
const val ACTION = "com.droiddeck.launcher.LAUNCH_GAME"
const val EXTRA_APP_ID = "app_id"
const val EXTRA_GAME_SOURCE = "game_source"
private const val GAMENATIVE_ACTION = "app.gamenative.LAUNCH_GAME"
fun accepts(action: String?): Boolean = action == Intent.ACTION_VIEW || action == ACTION || action == GAMENATIVE_ACTION
fun read(context: Context, intent: Intent): String? = when (intent.action) {
Intent.ACTION_VIEW -> GameFiles.readIntent(context, intent)
ACTION, GAMENATIVE_ACTION -> readId(intent)
else -> null
}
@Suppress("DEPRECATION")
internal fun readId(intent: Intent): String? = runCatching {
if (intent.action != ACTION && intent.action != GAMENATIVE_ACTION) return null
val source = intent.extras?.get(EXTRA_GAME_SOURCE)
if (source != null && (source !is String || !source.equals("STEAM", ignoreCase = true))) return null
val id = when (val value = intent.extras?.get(EXTRA_APP_ID)) {
is Int -> value.takeIf { it > 0 }?.toString()
is String -> value.takeIf(GameLaunchLink::validId)
else -> null
}
id
}.getOrNull()
}
@@ -0,0 +1,13 @@
package com.droiddeck.launcher.frontend
/** External callers can select a game, never a guest path or arbitrary Steam command. */
object GameLaunchLink {
private val idPattern = Regex("[1-9][0-9]{0,19}")
private val linkPattern = Regex("droiddeck://game/([1-9][0-9]{0,19})")
fun validId(id: String): Boolean = idPattern.matches(id) && id.toULongOrNull() != null
fun parse(link: String?): String? = link?.let { linkPattern.matchEntire(it)?.groupValues?.get(1) }
?.takeIf(::validId)
fun uri(id: String): String { require(validId(id)); return "droiddeck://game/$id" }
fun steamUrl(id: String): String { require(validId(id)); return "steam://rungameid/$id" }
}
@@ -0,0 +1,48 @@
package com.droiddeck.launcher.frontend
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
object GameShortcuts {
fun launchIntent(context: Context, gameId: String) = Intent(Intent.ACTION_VIEW,
Uri.parse(GameLaunchLink.uri(gameId)), context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
private fun info(context: Context, game: Library.SteamGame): ShortcutInfo {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = listOfNotNull(game.icon, game.art).firstNotNullOfOrNull { file -> runCatching {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@runCatching null
val options = BitmapFactory.Options().apply {
inSampleSize = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / inSampleSize > 256) inSampleSize *= 2
}
BitmapFactory.decodeFile(file.path, options)?.let(Icon::createWithBitmap)
}.getOrNull() } ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
fun pin(activity: Activity, game: Library.SteamGame) {
val manager = activity.getSystemService(ShortcutManager::class.java)
val requested = runCatching { manager.isRequestPinShortcutSupported &&
manager.requestPinShortcut(info(activity, game), null) }.getOrDefault(false)
if (!requested) Toast.makeText(activity, R.string.game_shortcut_unsupported, Toast.LENGTH_LONG).show()
}
/** Standard shortcut picker result, understood by launchers that import Android shortcuts. */
fun result(activity: Activity, game: Library.SteamGame): Intent =
activity.getSystemService(ShortcutManager::class.java).createShortcutResultIntent(info(activity, game))
}
@@ -23,7 +23,11 @@ object Library {
val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong(),
val hero: File? = null, val lastPlayed: Long = 0L,
val gameFiles: File? = null, val protonPrefix: File? = null,
)
val icon: File? = null,
) {
/** Decimal form used by Steam links and Android shortcuts, including unsigned shortcut ids. */
val gameIdString: String get() = java.lang.Long.toUnsignedString(gameId)
}
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
const val ADDED = "added"
@@ -68,6 +72,7 @@ object Library {
)
private val STEAM_CAPSULES = listOf("library_capsule.jpg", "library_600x900.jpg")
private val STEAM_HEROES = listOf("library_hero.jpg")
private val STEAM_ICON = Regex("[a-fA-F0-9]{40}\\.(jpg|png)")
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)
@@ -96,18 +101,19 @@ object Library {
.firstOrNull { it.isDirectory }
}
fun steamGames(context: Context): List<SteamGame> {
fun steamGames(context: Context, strictRead: Boolean = false): List<SteamGame> {
val root = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val cache = File(root, "appcache/librarycache")
val libraries = steamLibraries(context)
val out = LinkedHashMap<Int, SteamGame>()
for ((library, label) in libraries) {
val steamapps = File(library, "steamapps")
steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
?.sortedBy { it.name }?.forEach { manifest ->
val manifests = steamapps.listFiles { f -> f.isFile && f.name.startsWith("appmanifest_") && f.name.endsWith(".acf") }
if (strictRead && steamapps.isDirectory) check(manifests != null) { "Steam manifests are unavailable" }
manifests?.sortedBy { it.name }?.forEach { manifest ->
val appId = manifest.name.removePrefix("appmanifest_").removeSuffix(".acf").toIntOrNull() ?: return@forEach
if (appId in NOT_GAMES || out.containsKey(appId)) return@forEach
val text = try { manifest.readText() } catch (e: Exception) { return@forEach }
val text = try { manifest.readText() } catch (e: Exception) { if (strictRead) throw e; return@forEach }
val name = NAME.find(text)?.groupValues?.get(1)?.trim().orEmpty()
val flags = STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
// StateFlags 4 = fully installed; anything else is downloading, updating or broken.
@@ -125,12 +131,26 @@ object Library {
appId, name, art, label, hero = hero, lastPlayed = lastPlayed,
gameFiles = gameFiles,
protonPrefix = protonPrefix(context, appId.toLong(), library),
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()
}
/** The same installed-game inventory used for links, shortcuts and file exports. */
fun launchableGames(context: Context, strictRead: Boolean = false): List<SteamGame> = (steamGames(context, strictRead) + AddedGames.scan(context).map { g ->
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, ADDED, g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = protonPrefix(context, g.steamAppId?.toLong() ?: g.appId),
icon = art.icon?.takeIf { it.extension.lowercase() != "ico" },
)
}
}).distinctBy { it.gameId }
/** Steam stores current library art inside hash-named folders under the app's cache dir. */
private fun steamCacheImage(cache: File, appId: Int, names: List<String>): File? {
val appDir = File(cache, appId.toString())
@@ -184,7 +204,7 @@ object Library {
* Whether a program from the rail runs at the panel's own resolution rather than the session's
* 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it
* nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set
* up for (bannerlator-pad-defaults).
* up for (droiddeck-pad-defaults).
*/
fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel }
@@ -321,7 +341,7 @@ object Library {
// Dolphin: full screen, drawn inside its own main window, without its "stop the emulation?"
// question; no warning boxes either, which wait for a click a controller cannot give
// (they still go to Dolphin's log). The guide button is Dolphin's Toggle Fullscreen hotkey
// (bannerlator-pad-defaults), which shows Dolphin's window and its settings - so no -b, which
// (droiddeck-pad-defaults), which shows Dolphin's window and its settings - so no -b, which
// hides that window; closing Dolphin ends the session. Under gamescope Dolphin does not
// always see its window as focused, and by default both its hotkeys and the game's
// controller then stop: HotkeysRequireFocus off, BackgroundInput on. -C sets a Dolphin.ini
@@ -333,10 +353,10 @@ object Library {
"-e", guestPath,
)
// Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started
// dialog away (bannerlator-pad-defaults).
// dialog away (droiddeck-pad-defaults).
"cemu" -> listOf("-f", "-g", guestPath)
// Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and
// comes back (HK_FullscreenToggle, bannerlator-pad-defaults).
// comes back (HK_FullscreenToggle, droiddeck-pad-defaults).
"melonds" -> listOf("-f", guestPath)
else -> listOf(guestPath)
}
@@ -25,6 +25,7 @@ object LibraryCache {
appId = o.getInt("appId"), name = o.getString("name"), art = o.file("art"),
library = o.getString("library"), gameId = o.getLong("gameId"), hero = o.file("hero"),
lastPlayed = o.optLong("lastPlayed"), gameFiles = o.file("gameFiles"), protonPrefix = o.file("protonPrefix"),
icon = o.file("icon"),
)
}
}.getOrElse { e -> Log.w(TAG, "unreadable, ignored: ${e.message}"); emptyList() }
@@ -35,7 +36,7 @@ object LibraryCache {
for (g in games) array.put(
JSONObject().put("appId", g.appId).put("name", g.name).put("library", g.library).put("gameId", g.gameId)
.put("lastPlayed", g.lastPlayed).putFile("art", g.art).putFile("hero", g.hero)
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix),
.putFile("gameFiles", g.gameFiles).putFile("protonPrefix", g.protonPrefix).putFile("icon", g.icon),
)
val f = file(context)
val tmp = File(f.path + ".tmp")
@@ -0,0 +1,66 @@
package com.droiddeck.launcher.frontend
import org.json.JSONObject
import java.io.File
/**
* Which catalog components a game's prefix already has from elsewhere - a real installer, the game,
* the user - so the list can say so instead of recommending them again. Bannerlator's detector,
* on DroidDeck's compatdata/<id>: only markers absent from a fresh Proton prefix count (OpenAL32,
* Mono, PhysX, Games for Windows Live); msvcp140, d3dx9 and the like ship with Proton and cannot
* tell. Files DroidDeck put there itself (droiddeck-wincomponents' state) are not counted.
* Pure filesystem reads; never throws.
*/
object PrefixInstalledDetector {
private const val STATE = ".droiddeck-wincomponents.json"
/** Detector names (oalinst, mono, physx, XLiveRedist) present in [compat]/pfx. */
fun detect(compat: File?): Set<String> = try {
val pfx = compat?.let { File(it, "pfx") }
if (pfx == null || !pfx.isDirectory) emptySet() else {
val ours = ours(compat)
val out = HashSet<String>()
val openal = listOf("system32", "syswow64").map { "drive_c/windows/$it/openal32.dll" }
if (openal.any { it !in ours && fileIgnoringCase(pfx, it) }) out += "oalinst"
if (File(pfx, "drive_c/windows/mono").isDirectory) out += "mono"
if (hasPhysX(pfx)) out += "physx"
if (registryContains(pfx, "Games for Windows")) out += "XLiveRedist"
out
}
} catch (_: Throwable) {
emptySet()
}
private fun ours(compat: File): Set<String> = try {
val files = JSONObject(File(compat, STATE).readText()).optJSONObject("files")
files?.keys()?.asSequence()?.map { it.lowercase() }?.toSet().orEmpty()
} catch (_: Exception) {
emptySet()
}
/** Windows names ignore case; the prefix's folder may spell them either way. */
private fun fileIgnoringCase(pfx: File, rel: String): Boolean {
val target = File(pfx, rel)
return target.parentFile?.listFiles()?.any { it.isFile && it.name.equals(target.name, ignoreCase = true) } == true
}
private fun hasPhysX(pfx: File): Boolean {
for (pf in arrayOf("drive_c/Program Files", "drive_c/Program Files (x86)")) {
val base = File(pfx, pf)
if (File(base, "NVIDIA Corporation/PhysX").isDirectory) return true
if (base.listFiles()?.any { it.isDirectory && it.name.contains("PhysX", ignoreCase = true) } == true) return true
}
return false
}
private fun registryContains(pfx: File, needle: String): Boolean =
arrayOf("system.reg", "user.reg").any { name ->
val f = File(pfx, name)
try {
f.isFile && f.length() < 32L * 1024 * 1024 &&
f.bufferedReader().use { r -> r.lineSequence().any { it.contains(needle, ignoreCase = true) } }
} catch (_: Throwable) {
false
}
}
}
@@ -0,0 +1,46 @@
package com.droiddeck.launcher.frontend
import java.io.File
/**
* What Steam itself installs alongside a game: the Steamworks Shared redistributables (app 228980)
* listed under "SharedDepots" in the game's appmanifest. Those live in Steamworks Shared rather than
* the game's folder, so the folder scan ([DependencyDetector]) cannot see them; this reads Steam's
* list instead. Depots are matched to Bannerlator's catalog names; unknown ones are left out.
*/
object SteamRedists {
/** Steamworks Shared depot -> (catalog component, what Steam calls it). 2015-2019 runtimes are one family. */
val DEPOTS = linkedMapOf(
"228981" to ("vcredist2005_dll" to "VC++ 2005"),
"228982" to ("vcredist2008_dll" to "VC++ 2008"),
"228983" to ("vcredist2010_dll" to "VC++ 2010"),
"228984" to ("vcredist2012_dll" to "VC++ 2012"),
"228985" to ("vcredist2013_dll" to "VC++ 2013"),
"228986" to ("vcredist2015_dll" to "VC++ 2015"),
"228987" to ("vcredist2019_dll" to "VC++ 2017"),
"228988" to ("vcredist2019_dll" to "VC++ 2019"),
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"),
)
private val BLOCK = Regex("\"SharedDepots\"\\s*\\{([^}]*)\\}", RegexOption.IGNORE_CASE)
private val ENTRY = Regex("\"(\\d+)\"\\s+\"\\d+\"")
/** The game's appmanifest beside its install folder (steamapps/common/<dir> -> steamapps/). */
fun manifest(gameDir: File?, appId: Int): File? {
val steamapps = gameDir?.parentFile?.parentFile ?: return null
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
}
fun detect(gameDir: File?, appId: Int): List<DependencyDetector.Recommendation> {
val text = try {
manifest(gameDir, appId)?.takeIf { it.length() < 1L shl 20 }?.readText() ?: return emptyList()
} catch (_: Exception) {
return emptyList()
}
val block = BLOCK.find(text)?.groupValues?.get(1) ?: return emptyList()
return ENTRY.findAll(block).mapNotNull { DEPOTS[it.groupValues[1]] }
.distinctBy { it.first }
.map { (component, label) -> DependencyDetector.Recommendation(component, label, label, DependencyDetector.Kind.STEAM) }
.toList()
}
}
@@ -0,0 +1,166 @@
package com.droiddeck.launcher.gpu
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.Locale
/**
* Everything the device info window shows about the phone's chip: SoC, GPU, CPU, memory, system,
* the phone's own Vulkan driver and the drivers DroidDeck uses. Reads files and asks Vulkan once
* ([VulkanInfo]), so [collect] belongs off the main thread. Values the phone does not give are
* left out rather than guessed.
*/
object DeviceInfo {
class Section(val title: String, val rows: List<Pair<String, String>>)
fun collect(context: Context): List<Section> {
val vk = VulkanInfo.query()
// After Vulkan has been asked, detection can use its name too.
val gpu = GpuInfo.detect()
return listOf(
chip(), gpu(gpu), cpu(), memory(context), system(), vulkan(context, vk), drivers(context, gpu),
).filter { it.rows.isNotEmpty() }
}
/** The whole window as plain text, for a bug report. */
fun text(sections: List<Section>): String = buildString {
append("DroidDeck device info\n")
for (s in sections) {
append('\n').append(s.title).append('\n')
for ((k, v) in s.rows) append(" ").append(k).append(": ").append(v).append('\n')
}
}
private fun MutableList<Pair<String, String>>.add(key: String, value: String?) {
if (!value.isNullOrBlank() && value != Build.UNKNOWN) add(key to value.trim())
}
private fun read(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null }
private fun prop(name: String): String = GpuInfo.systemProperty(name)
private fun chip() = Section("Chip (SoC)", buildList<Pair<String, String>> {
val model = SocNames.model()
val platform = prop("ro.board.platform")
add("Name", SocNames.name(model, platform))
add("Model", model)
if (Build.VERSION.SDK_INT >= 31) add("Maker", Build.SOC_MANUFACTURER)
add("Platform", platform)
add("Hardware", Build.HARDWARE)
add("Board", Build.BOARD)
val id = read("/sys/devices/soc0/soc_id")
val machine = read("/sys/devices/soc0/machine")
add("SoC id", listOfNotNull(id, machine).joinToString(" · "))
add("Family", read("/sys/devices/soc0/family"))
})
private fun gpu(gpu: GpuInfo) = Section("GPU", buildList<Pair<String, String>> {
add("GPU", gpu.name)
add("Family", gpu.family.label)
add("Model found from", when (gpu.modelSource) {
"kernel" -> "the kernel's GPU name"
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
"platform" -> "the platform (the kernel's name has no model)"
else -> "unknown"
})
add("Kernel name", gpu.kgslName)
val kgsl = "/sys/class/kgsl/kgsl-3d0"
add("Clock range", gpuRange())
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
add("Temperature", read("$kgsl/temp")?.toLongOrNull()?.let { if (it > 1000) "${it / 1000} °C" else "$it °C" })
add("Support", gpu.supportText)
})
private fun cpu() = Section("CPU", buildList<Pair<String, String>> {
add("Cores", Runtime.getRuntime().availableProcessors().toString())
// Each cpufreq policy is one cluster: the cores it covers and their speed range.
val policies = File("/sys/devices/system/cpu/cpufreq").listFiles { f -> f.name.matches(Regex("policy\\d+")) }
?.sortedBy { it.name.removePrefix("policy").toInt() }.orEmpty()
for (p in policies) {
val cpus = (read("${p.path}/related_cpus") ?: read("${p.path}/affected_cpus"))?.split(Regex("\\s+"))?.filter { it.isNotEmpty() }.orEmpty()
val min = read("${p.path}/cpuinfo_min_freq")?.toLongOrNull()
val max = read("${p.path}/cpuinfo_max_freq")?.toLongOrNull() ?: continue
val cores = if (cpus.size > 1) "cores ${cpus.first()}-${cpus.last()}" else "core ${cpus.firstOrNull() ?: p.name.removePrefix("policy")}"
add("Cluster ${p.name.removePrefix("policy")}", "${cpus.size.coerceAtLeast(1)} × $cores · " +
(if (min != null) ghz(min) + "–" else "") + ghz(max))
}
add("ABI", Build.SUPPORTED_ABIS.joinToString(", "))
})
private fun ghz(khz: Long) = String.format(Locale.US, "%.2f GHz", khz / 1e6)
/** The GPU's slowest and fastest step: KGSL's list, else a devfreq GPU node (Mali and others). */
private fun gpuRange(): String? {
fun mhz(hz: Long) = "${hz / 1_000_000} MHz"
val kgsl = read("/sys/class/kgsl/kgsl-3d0/gpu_available_frequencies")
?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (kgsl.isNotEmpty()) return "${mhz(kgsl.min())}–${mhz(kgsl.max())} (${kgsl.size} steps)"
read("/sys/class/kgsl/kgsl-3d0/max_gpuclk")?.toLongOrNull()?.let { return "up to ${mhz(it)}" }
val node = File("/sys/class/devfreq").listFiles()?.firstOrNull { f ->
val n = f.name.lowercase()
(n.contains("gpu") || n.contains("mali") || n.contains("kgsl-3d")) && listOf("bus", "bw", "memlat").none { n.contains(it) }
} ?: return null
val steps = read("${node.path}/available_frequencies")?.split(Regex("\\s+"))?.mapNotNull { it.toLongOrNull() }?.filter { it > 0 }.orEmpty()
if (steps.isNotEmpty()) return "${mhz(steps.min())}–${mhz(steps.max())} (${steps.size} steps)"
val min = read("${node.path}/min_freq")?.toLongOrNull()
val max = read("${node.path}/max_freq")?.toLongOrNull() ?: return null
return (if (min != null) "${mhz(min)}–" else "up to ") + mhz(max)
}
private fun memory(context: Context) = Section("Memory", buildList<Pair<String, String>> {
val am = context.getSystemService(ActivityManager::class.java) ?: return@buildList
val mi = ActivityManager.MemoryInfo().also(am::getMemoryInfo)
add("Total", FileUtils.sizeToString(mi.totalMem))
add("Available now", FileUtils.sizeToString(mi.availMem))
})
private fun system() = Section("System", buildList<Pair<String, String>> {
add("Device", "${Build.MANUFACTURER} ${Build.MODEL}")
add("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
add("Security patch", Build.VERSION.SECURITY_PATCH)
add("Kernel", System.getProperty("os.version"))
add("Build", Build.DISPLAY)
})
private fun vulkan(context: Context, vk: Map<String, String>) = Section("Vulkan (the phone's own driver)", buildList<Pair<String, String>> {
vk["error"]?.let { add("Error", it) }
add("Device", vk["device"])
add("Type", vk["type"])
add("API version", vk["api"])
add("Loader version", vk["loader"])
add("Driver", listOfNotNull(vk["driver_name"], vk["driver_version"]).joinToString(" "))
// Qualcomm's build string starts with its own label ("Driver Build: 43c70540de, ...").
add("Driver build", vk["driver_info"]?.replace(Regex("(?i)^driver build:\\s*"), ""))
add("Conformance", vk["conformance"])
add("Vendor / device id", listOfNotNull(vk["vendor_id"], vk["device_id"]).joinToString(" / "))
add("Max texture size", vk["max_image_2d"])
add("GPU memory heap", vk["device_heap_mb"]?.let { "$it MB" })
add("Extensions", vk["extension_count"])
val features = listOf("geometry_shader" to "Geometry shaders", "tessellation_shader" to "Tessellation", "texture_bc" to "BC textures")
for ((key, label) in features) add(label, vk[key])
for ((key, value) in vk) if (key.startsWith("ext.")) add(key.removePrefix("ext."), value)
// What Android certifies the phone for.
val pm = context.packageManager
val hw = pm.systemAvailableFeatures.associate { it.name to it.version }
hw[PackageManager.FEATURE_VULKAN_HARDWARE_VERSION]?.let {
add("Android-certified version", "${it shr 22}.${(it shr 12) and 0x3ff}.${it and 0xfff}")
}
hw[PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL]?.let { add("Android hardware level", it.toString()) }
})
private fun drivers(context: Context, gpu: GpuInfo) = Section("Drivers DroidDeck uses", buildList<Pair<String, String>> {
add("Driver mode", SessionPrefs.gpuDriverMode(context))
val turnip = TurnipDriver(context)
val choice = SessionPrefs.androidDriver(context).ifEmpty { turnip.autoId() }
add("Display driver", "${turnip.displayName(choice)} ${turnip.driverVersion(choice)}".trim())
val linux = LinuxVulkanDriverManager(context)
val id = SessionPrefs.linuxDriver(context)
add("Game driver", if (id.isEmpty()) "the runtime's own Turnip" else "${linux.getDriverName(id)} ${linux.getDriverVersion(id)}".trim())
if (gpu.oneUi8Gen2) add("Note", "One UI on an 8 Gen 2: needs the OneUI Turnip build")
})
}
@@ -16,10 +16,14 @@ data class GpuInfo(
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
val model: Int,
val family: Family,
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
/** The SoC as a person would say it ("Snapdragon 8 Gen 2 (QCS8550)"), or "" when the phone does not say. */
val soc: String,
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
val oneUi8Gen2: Boolean,
/** KGSL's own name for the GPU, as the kernel spells it ("Adreno740v2", "Adreno33v2"). */
val kgslName: String = "",
/** Where [model] came from: "kernel", "vulkan" or "platform", "" when unknown. */
val modelSource: String = "",
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -56,17 +60,49 @@ data class GpuInfo(
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
// Where vendors put the chip's model, named when it is known: "Snapdragon 8 Gen 2 (QCS8550)".
val soc = if (adreno) SocNames.label() else ""
val fromKernel = raw?.let { threeDigits(it) } ?: 0
// Some kernels name the GPU without its model (AYANEO's Pocket FIT: "Adreno33v2"). The
// Vulkan driver's own name, when this process has asked it, then the platform's code
// name stand in - the GPU is the same on every phone of a platform.
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
val source = when {
fromKernel > 0 -> "kernel"
fromVulkan > 0 -> "vulkan"
fromPlatform > 0 -> "platform"
else -> ""
}
val family = familyOf(adreno, model)
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
return GpuInfo(
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source,
)
}
private fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
/** Qualcomm platform code names whose GPU is known for certain. */
private val PLATFORMS = mapOf(
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
"kalama" to 740, "pineapple" to 750, "sun" to 830,
)
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
/** android.os.SystemProperties.get, which apps may read but not call directly. */
fun systemProperty(name: String): String = try {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
} catch (_: Exception) {
""
}
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
!adreno -> Family.NOT_ADRENO
model == 0 -> Family.ADRENO_UNKNOWN
@@ -6,7 +6,7 @@ package com.droiddeck.launcher.gpu;
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
*/
final class LsfgNative {
static { System.loadLibrary("bannerwayland"); }
static { System.loadLibrary("droiddeckwayland"); }
private LsfgNative() {}
@@ -0,0 +1,136 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.wayland.WaylandCompositor
import org.json.JSONObject
import kotlin.math.abs
/**
* The compositor's post chain (waylandcomp/src/effects_chain.c) as the session menu drives it:
* the colour grade, the sharpening, the shader toggles and terminal debanding. Units are the
* menu's own, so a value goes straight from a row into the setter it drives:
*
* - [brightness] / [contrast] -100..100, 0 = neutral
* - [gamma] 0.5..3.0, 1.0 = neutral
* - [saturation] 0..200 percent, 100 = neutral
* - [casLevel] 0..100; the pass runs only while [cas] is on
* - [debandStrength] 0..200, 100 = one LSB
*
* Applies live: the compositor picks the change up on its next frame. While any pass is on, a
* zero-copy game goes through the compositor pass instead, which costs a little GPU time.
*/
data class ScreenEffects(
val brightness: Int = 0,
val contrast: Int = 0,
val gamma: Float = 1f,
val saturation: Int = 100,
val cas: Boolean = false,
val casLevel: Int = 50,
val hdr: Boolean = false,
val deband: Boolean = false,
val debandStrength: Int = 100,
val fxaa: Boolean = false,
val toon: Boolean = false,
val crt: Boolean = false,
val ntsc: Boolean = false,
) {
/** The sharpening level the picture gets: 0 while the pass is off, whatever its level is parked at. */
val effectiveCas: Int get() = if (cas) casLevel else 0
/** Whether any pass runs, so the menu can say when the picture is untouched. */
val any: Boolean get() = brightness != 0 || contrast != 0 || abs(gamma - 1f) > 0.005f || saturation != 100 ||
effectiveCas > 0 || hdr || deband || fxaa || toon || crt || ntsc
/** Hands every pass to the compositor, with the Look's name for its log line. */
fun push(upscaler: Int) {
WaylandCompositor.nativeSetCas(cas, casLevel)
WaylandCompositor.nativeSetHdr(hdr)
WaylandCompositor.nativeSetDeband(deband, debandStrength)
WaylandCompositor.nativeSetScreenEffects(brightness.toFloat(), contrast.toFloat(), gamma, saturation.toFloat(), fxaa, toon, crt, ntsc)
WaylandCompositor.nativeSetLookName(ScreenEffectLooks.match(this, upscaler)?.name)
}
fun encode(): JSONObject = JSONObject()
.put("brightness", brightness).put("contrast", contrast).put("gamma", gamma.toDouble()).put("saturation", saturation)
.put("cas", cas).put("casLevel", casLevel).put("hdr", hdr).put("deband", deband).put("debandStrength", debandStrength)
.put("fxaa", fxaa).put("toon", toon).put("crt", crt).put("ntsc", ntsc)
companion object {
val OFF = ScreenEffects()
fun decode(json: JSONObject): ScreenEffects = ScreenEffects(
brightness = json.optInt("brightness", 0).coerceIn(-100, 100),
contrast = json.optInt("contrast", 0).coerceIn(-100, 100),
gamma = json.optDouble("gamma", 1.0).toFloat().coerceIn(0.5f, 3f),
saturation = json.optInt("saturation", 100).coerceIn(0, 200),
cas = json.optBoolean("cas", false),
casLevel = json.optInt("casLevel", 50).coerceIn(0, 100),
hdr = json.optBoolean("hdr", false),
deband = json.optBoolean("deband", false),
debandStrength = json.optInt("debandStrength", 100).coerceIn(0, 200),
fxaa = json.optBoolean("fxaa", false),
toon = json.optBoolean("toon", false),
crt = json.optBoolean("crt", false),
ntsc = json.optBoolean("ntsc", false),
)
}
}
/**
* One Look: a named set of values for the rows that already exist on the Display page. Picking
* a Look moves those rows; each stays adjustable afterwards, and the moment one moves the Look
* row reads Custom. [scalingMode] null leaves the scaling mode alone; otherwise it is a
* SessionPrefs.upscalerChoices mode the Look switches to.
*/
data class Look(
val name: String,
val effects: ScreenEffects,
val scalingMode: Int? = null,
/** One line under the row while this Look is picked. */
val desc: String,
)
object ScreenEffectLooks {
// Scaling modes as the compositor numbers them (WaylandCompositor.nativeSetUpscaler).
private const val SCALING_NEAREST = 2
private const val SCALING_FSR = 4
/** Index 0 is the neutral state a fresh session matches, so the row always names something. */
val LOOKS: List<Look> = listOf(
Look("Off", ScreenEffects.OFF, desc = "Nothing applied."),
Look("Game Clarity", ScreenEffects(brightness = 2, contrast = 12, saturation = 108, cas = true, casLevel = 55),
desc = "Sharpening with a touch of contrast - the everyday one."),
Look("Vivid", ScreenEffects(brightness = 3, contrast = 10, gamma = 0.98f, saturation = 145, cas = true, casLevel = 30, deband = true),
desc = "Colour pushed hard. Deband on so skies don't stripe."),
Look("Cinematic", ScreenEffects(brightness = -4, contrast = 22, gamma = 1.10f, saturation = 88, cas = true, casLevel = 20, fxaa = true, deband = true),
desc = "Deeper contrast, cooler colour, edges smoothed."),
Look("Competitive", ScreenEffects(brightness = 16, contrast = 18, gamma = 0.92f, saturation = 82, cas = true, casLevel = 75),
desc = "Shadows lifted and heavy sharpening - spot people first."),
Look("Adaptive Sharpen", ScreenEffects(cas = true, casLevel = 85),
desc = "Sharpening only, colour untouched."),
Look("Filmic", ScreenEffects(brightness = -2, contrast = 16, gamma = 1.14f, saturation = 92, cas = true, casLevel = 15, fxaa = true, deband = true),
desc = "Film-like curve, slightly muted."),
Look("Arcade", ScreenEffects(brightness = 6, contrast = 20, gamma = 0.95f, saturation = 170, cas = true, casLevel = 45, deband = true),
desc = "Loud and punchy."),
Look("Retro CRT", ScreenEffects(brightness = 6, contrast = 14, gamma = 1.05f, saturation = 115, crt = true, ntsc = true),
desc = "The CRT tube and analogue signal passes together."),
Look("Upscale Sharp", ScreenEffects(contrast = 6, saturation = 104, cas = true, casLevel = 70), scalingMode = SCALING_FSR,
desc = "For running below panel resolution."),
Look("Pixel Clean", ScreenEffects(contrast = 8, deband = true), scalingMode = SCALING_NEAREST,
desc = "Crisp pixels for 2D and old titles."),
Look("Anime Edge", ScreenEffects(brightness = 2, contrast = 10, saturation = 125, cas = true, casLevel = 50, toon = true, deband = true),
desc = "Toon outlines plus sharpening."),
)
/**
* The Look the live values are, or null for Custom. A Look's sharpening is compared as the
* effective level, so a pass switched off matches a Look without one whatever its level is
* parked at; a Look with no scaling mode of its own matches under any.
*/
fun match(e: ScreenEffects, upscaler: Int): Look? = LOOKS.firstOrNull { l ->
val t = l.effects
t.brightness == e.brightness && t.contrast == e.contrast && abs(t.gamma - e.gamma) < 0.005f &&
t.saturation == e.saturation && t.effectiveCas == e.effectiveCas &&
t.fxaa == e.fxaa && t.crt == e.crt && t.toon == e.toon && t.ntsc == e.ntsc && t.deband == e.deband &&
(l.scalingMode == null || l.scalingMode == upscaler)
}
}
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.gpu
import android.os.Build
/**
* Which chip this is, as a person would say it. Handhelds often leave Android's SoC model blank
* (the AYANEO Pocket FIT does), so the model is looked for where vendors put it, the same order
* ClusterTune uses; then it is named from a table of models we are sure of, or from the platform
* code name as a family ("Snapdragon 8 Gen 3 family") when the model itself is a vendor variant.
*/
object SocNames {
/** Model codes whose product name is certain. */
private val MODELS = mapOf(
"SM8150" to "Snapdragon 855", "SM8250" to "Snapdragon 865", "SM8250-AC" to "Snapdragon 870",
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
"SM6375" to "Snapdragon 695",
)
/** Platform code names: every chip of one platform shares its CPU and GPU design. */
private val PLATFORMS = mapOf(
"msmnile" to "Snapdragon 855/860", "kona" to "Snapdragon 865/870", "lahaina" to "Snapdragon 888",
"taro" to "Snapdragon 8 Gen 1", "cape" to "Snapdragon 8+ Gen 1", "kalama" to "Snapdragon 8 Gen 2",
"pineapple" to "Snapdragon 8 Gen 3", "sun" to "Snapdragon 8 Elite",
)
/** The chip's model code ("QCS8550", "SG8350P"), or "" when the phone does not say. */
fun model(prop: (String) -> String = GpuInfo::systemProperty): String {
val sdk = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it != Build.UNKNOWN }.orEmpty() else ""
return listOf(prop("ro.soc.model"), prop("ro.vendor.qti.soc_model"), sdk, prop("ro.fota.platform"))
.map { it.trim() }
// ro.fota.platform is often a branch name ("MSM_13.0"), not a chip.
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
}
/** "Snapdragon 8 Gen 2", "Snapdragon 8 Gen 3 family", or null when neither the model nor the platform is known. */
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
/** For the GPU card: "Snapdragon 8 Gen 2 (QCS8550)", the bare model, or "". */
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
val name = name(model, platform)
return when {
name != null && model.isNotEmpty() -> "$name ($model)"
name != null -> name
else -> model
}
}
}
@@ -0,0 +1,36 @@
package com.droiddeck.launcher.gpu
import android.util.Log
/**
* The phone's own Vulkan driver, as it describes itself (native/deviceinfo): device name, API and
* driver versions, a few limits and the extensions DXVK/VKD3D lean on. Asked once per process and
* kept; the first [query] creates a Vulkan instance, so it belongs off the main thread.
*/
object VulkanInfo {
@Volatile private var cached: Map<String, String>? = null
@JvmStatic private external fun nativeQuery(): String
/** Blocking on first use. Empty-ish map with an "error" key when Vulkan cannot be asked. */
@Synchronized
fun query(): Map<String, String> {
cached?.let { return it }
val text = try {
System.loadLibrary("deviceinfo")
nativeQuery()
} catch (t: Throwable) {
Log.w("VulkanInfo", "query failed", t)
"error=" + (t.message ?: t.javaClass.simpleName)
}
val map = text.lineSequence().mapNotNull { line ->
val eq = line.indexOf('=')
if (eq <= 0) null else line.substring(0, eq) to line.substring(eq + 1)
}.toMap(LinkedHashMap())
cached = map
return map
}
/** What [query] found, if it has run in this process; never blocks. */
fun cachedOrNull(): Map<String, String>? = cached
}
@@ -41,11 +41,15 @@ object ControllerPrefs {
val size: Int,
val stickClick: Boolean,
val adaptiveSticks: Boolean,
val rumble: Boolean,
val steamButton: Boolean,
val qamButton: Boolean,
val keyboardButton: Boolean,
val customLayout: Boolean,
val mapping: Map<String, String>,
)
private fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE)
internal fun prefs(context: Context) = context.getSharedPreferences("controller", Context.MODE_PRIVATE)
fun read(context: Context): Settings {
val p = prefs(context)
@@ -55,6 +59,10 @@ object ControllerPrefs {
size = p.getInt("size", 100).takeIf { it in sizes } ?: 100,
stickClick = p.getBoolean("stickClick", true),
adaptiveSticks = p.getBoolean("adaptiveSticks", true),
rumble = rumbleEnabled(context),
steamButton = p.getBoolean("steamButton", true),
qamButton = p.getBoolean("qamButton", true),
keyboardButton = p.getBoolean("keyboardButton", true),
customLayout = p.all.keys.any { it.startsWith("layout.") },
mapping = mappable.associate { (id, _) -> id to target(context, id) },
)
@@ -92,6 +100,24 @@ object ControllerPrefs {
prefs(context).edit().putBoolean("adaptiveSticks", on).apply()
}
fun rumbleEnabled(context: Context): Boolean = prefs(context).getBoolean("rumble", true)
fun setRumble(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("rumble", on).apply()
}
fun setSteamButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("steamButton", on).apply()
}
fun setQamButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("qamButton", on).apply()
}
fun setKeyboardButton(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("keyboardButton", on).apply()
}
fun layout(context: Context, width: Int, height: Int): Map<String, Pair<Float, Float>> {
val raw = prefs(context).getString("layout.${width}x$height", null) ?: return emptyMap()
return raw.split(';').mapNotNull { entry ->
@@ -111,7 +137,8 @@ object ControllerPrefs {
}
fun resetAll(context: Context) {
prefs(context).edit().clear().apply()
// On older Android versions clear() does not notify listeners; restore rumble explicitly.
prefs(context).edit().clear().putBoolean("rumble", true).apply()
}
fun resetAllLayouts(context: Context) {
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.input;
import java.io.File;
/**
* Where the fake input rings live and how the guest is told about them. Kept apart from
* {@link FakeInputWriter}, which loads native code, so it can be tested on the JVM.
*/
final class FakeInputRingPaths {
static final String RING_DIR_NAME = "fakeinput-rings";
private FakeInputRingPaths() {}
/** The rings sit beside the fake evdev nodes: session/dev/input -> session/dev/fakeinput-rings. */
static File ringDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
static File ringFile(File fakeInputDir, int slot) {
File ringDir = ringDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
/**
* The ring's path as the guest opens it. The session tree is bound into the guest at its own
* host path, spelled the way {@code Context.getFilesDir()} spells it (LinuxRuntime.binds), so
* the export has to use that same spelling. A canonical path is not that: where
* /data/user/0 is a link, or a separate mount, onto /data/data (ColorOS 16, #166) it resolves
* to a prefix nothing is bound at, and every pad, physical and on-screen, goes dead.
*/
static String exportPath(File ringFile) {
return ringFile.getAbsolutePath();
}
}
@@ -94,7 +94,6 @@ public class FakeInputWriter {
private static final int RING_SNAPSHOT_SEQ_OFFSET = 32;
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
private static final String RING_DIR_NAME = "fakeinput-rings";
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
@@ -198,26 +197,8 @@ public class FakeInputWriter {
return builder.toString();
}
private static File getRingDir(File fakeInputDir) {
if (fakeInputDir == null) {
return null;
}
File inputDir = fakeInputDir.getAbsoluteFile();
File parent = inputDir.getParentFile();
return new File(parent != null ? parent : inputDir, RING_DIR_NAME);
}
private static File getRingFile(File fakeInputDir, int slot) {
File ringDir = getRingDir(fakeInputDir);
return ringDir != null ? new File(ringDir, "ring" + slot) : null;
}
private static String getCanonicalOrAbsolutePath(File file) {
try {
return file.getCanonicalPath();
} catch (IOException e) {
return file.getAbsolutePath();
}
return FakeInputRingPaths.ringFile(fakeInputDir, slot);
}
private static void initializeRingHeader(ByteBuffer data) {
@@ -325,7 +306,7 @@ public class FakeInputWriter {
ringSlot.ringFile = ringFile.getAbsoluteFile();
ringSlot.ringRaf = raf;
ringSlot.ringChannel = channel;
ringSlot.exportPath = getCanonicalOrAbsolutePath(ringFile);
ringSlot.exportPath = FakeInputRingPaths.exportPath(ringFile);
Log.i(TAG, "Created fake input file ring for slot " + slot + ": " + ringSlot.exportPath);
return ringSlot;
} catch (IOException e) {
@@ -19,7 +19,12 @@ import kotlin.math.sin
import kotlin.math.sqrt
@SuppressLint("ViewConstructor")
class OnScreenControls(context: Context, private val pad: PadBridge?, private val editing: Boolean = false) : View(context) {
class OnScreenControls(
context: Context,
private val pad: PadBridge?,
private val editing: Boolean = false,
private val onKeyboard: (() -> Unit)? = null,
) : View(context) {
private class Control(
val id: String,
@@ -86,6 +91,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
private val groups = controls.map { it.group }.distinct()
private var buttonsOnly = false
private var quickHidden = false
private var quickPressedBy = -1
private var keyboardPressedBy = -1
private var settings = ControllerPrefs.read(context)
private var safe = Rect()
private var selected: String? = null
@@ -159,9 +167,18 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
if (buttonsOnly == enabled) return
releaseAll()
buttonsOnly = enabled
quickHidden = false
relayout()
}
/** Hide the full touch pad for an unobstructed screen while keeping its quick toggle available. */
fun setQuickHidden(hidden: Boolean) {
if (buttonsOnly || editing || quickHidden == hidden) return
if (hidden) releaseAll()
quickHidden = hidden
invalidate()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
relayout()
@@ -348,7 +365,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
override fun onDraw(canvas: Canvas) {
for (control in controls) {
if (!quickHidden) for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
@@ -394,15 +411,64 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
canvas.drawText(name, control.cx, control.cy + text.textSize * 0.35f, text)
}
}
val group = selected ?: return
val (x, y) = centre(group)
stroke.color = heldStroke
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
if (editing) {
val group = selected ?: return
val (x, y) = centre(group)
stroke.color = heldStroke
val single = members(group).singleOrNull()
if (single?.wide == true) {
val pad = dp(6f)
box.set(x - single.halfW - pad, y - single.halfH - pad, x + single.halfW + pad, y + single.halfH + pad)
canvas.drawRoundRect(box, single.halfH, single.halfH, stroke)
} else canvas.drawCircle(x, y, extent(group) + dp(6f), stroke)
} else if (!buttonsOnly) {
val (x, y) = quickCenter()
val radius = dp(18f)
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, radius, fill)
stroke.color = idleStroke
canvas.drawCircle(x, y, radius, stroke)
text.color = idleText
text.textSize = dp(22f)
canvas.drawText(if (quickHidden) "+" else "−", x, y + text.textSize * 0.35f, text)
}
if (!editing && settings.keyboardButton && onKeyboard != null) {
val (x, y) = keyboardCenter()
fill.color = Color.argb(190, Color.red(idleFill), Color.green(idleFill), Color.blue(idleFill))
canvas.drawCircle(x, y, dp(18f), fill)
stroke.color = if (keyboardPressedBy == -1) idleStroke else heldStroke
canvas.drawCircle(x, y, dp(18f), stroke)
box.set(x - dp(10f), y - dp(7f), x + dp(10f), y + dp(7f))
canvas.drawRoundRect(box, dp(2f), dp(2f), stroke)
for (row in 0..1) for (col in 0..2) {
val kx = x + dp(-6f + col * 5f)
val ky = y + dp(-3f + row * 4f)
canvas.drawLine(kx, ky, kx + dp(2f), ky, stroke)
}
canvas.drawLine(x - dp(5f), y + dp(4f), x + dp(5f), y + dp(4f), stroke)
}
}
private fun quickCenter() = (safe.left + dp(34f)) to (height - safe.bottom - dp(34f))
private fun keyboardCenter() = if (buttonsOnly)
width / 2f to (height - safe.bottom - dp(44f))
else (safe.left + dp(90f)) to (height - safe.bottom - dp(34f))
private fun keyboardContains(x: Float, y: Float): Boolean {
if (!settings.keyboardButton || onKeyboard == null) return false
val (cx, cy) = keyboardCenter()
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= dp(26f) * dp(26f)
}
private fun quickContains(x: Float, y: Float): Boolean {
val (cx, cy) = quickCenter()
val radius = dp(26f)
val dx = x - cx
val dy = y - cy
return dx * dx + dy * dy <= radius * radius
}
private fun drawArrow(canvas: Canvas, x: Float, y: Float, size: Float, direction: Int) {
@@ -429,6 +495,18 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
val index = event.actionIndex
val x = event.getX(index)
val y = event.getY(index)
if (event.actionMasked == MotionEvent.ACTION_DOWN && keyboardContains(x, y)) {
keyboardPressedBy = event.getPointerId(index)
invalidate()
return true
}
if (keyboardPressedBy != -1) return true
if (!buttonsOnly && event.actionMasked == MotionEvent.ACTION_DOWN && quickContains(x, y)) {
quickPressedBy = event.getPointerId(index)
return true
}
if (quickPressedBy != -1) return true
if (quickHidden) return false
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
@@ -444,6 +522,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
return true
}
MotionEvent.ACTION_MOVE -> {
if (keyboardPressedBy != -1) return true
if (quickPressedBy != -1) return true
var changed = false
for (index in 0 until event.pointerCount) {
val pointer = event.getPointerId(index)
@@ -468,6 +548,24 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL -> {
val pointer = event.getPointerId(event.actionIndex)
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
releaseAll()
return true
}
if (keyboardPressedBy == pointer) {
val tapped = event.actionMasked == MotionEvent.ACTION_UP && keyboardContains(event.x, event.y)
keyboardPressedBy = -1
invalidate()
if (tapped) { releaseAll(); onKeyboard?.invoke() }
return true
}
if (quickPressedBy == pointer) {
val toggled = event.actionMasked == MotionEvent.ACTION_UP && quickContains(event.x, event.y)
quickPressedBy = -1
if (toggled) setQuickHidden(!quickHidden)
return true
}
if (quickHidden) return false
var changed = false
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
@@ -510,11 +608,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull {
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
!quickHidden && isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
}
private fun adaptiveStickAt(x: Float, y: Float): Control? {
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
if (!settings.adaptiveSticks || editing || buttonsOnly || quickHidden) return null
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
return controls.filter {
@@ -530,6 +628,8 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
private fun isVisible(control: Control): Boolean = when {
!editing && control.id == "guide" && !settings.steamButton -> false
!editing && control.id == "qam" && !settings.qamButton -> false
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
else -> control.target != ControllerPrefs.OFF
}
@@ -592,6 +692,9 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
fun releaseAll() {
quickPressedBy = -1
keyboardPressedBy = -1
invalidate()
if (controls.none { it.pressedBy != -1 || it.clicked } && !buttonsOnly) return
controls.forEach {
it.pressedBy = -1
@@ -76,6 +76,17 @@ public final class PadBridge {
private final java.util.Set<Integer> seenDevices = new java.util.HashSet<>();
private int lastDeviceId = Integer.MIN_VALUE;
/** No physical controller is driving the pad: nothing yet, or the on-screen controls were last. */
public static final int NO_CONTROLLER = -1;
/**
* The Android input device id of the physical controller that last drove the pad, or
* {@link #NO_CONTROLLER}. Every pad feeds the one exported slot, so this is how a game's rumble
* on that slot finds the motors in the player's hands (RumbleComponent).
*/
private static volatile int activeControllerId = NO_CONTROLLER;
public static int activeControllerId() { return activeControllerId; }
public PadBridge(File fakeInputDir) {
writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), SLOT);
}
@@ -90,6 +101,7 @@ public final class PadBridge {
}
public synchronized void stop() {
activeControllerId = NO_CONTROLLER;
systemGuidePressed = false;
systemQamPressed = false;
qamChordActive = false;
@@ -218,6 +230,7 @@ public final class PadBridge {
*/
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
mutation.accept(state);
activeControllerId = NO_CONTROLLER;
notePlayerInput();
statOnScreen++;
scheduleStats();
@@ -299,6 +312,7 @@ public final class PadBridge {
/** Per event, so the common case - the same pad as last time - is a single compare. */
private void noteDevice(InputDevice device) {
activeControllerId = device.getId();
if (device.getId() == lastDeviceId) return;
lastDeviceId = device.getId();
statDevice = device.getName();
@@ -0,0 +1,81 @@
package com.droiddeck.launcher.input
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.system.Os
import android.util.Log
import com.droiddeck.launcher.wayland.WaylandCompositor
import java.io.File
import java.io.IOException
/** Text selections shared by Android and the guest while the session is visible. */
class SessionClipboard(context: Context, private val hasFocus: () -> Boolean) {
private val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
private val handler = Handler(Looper.getMainLooper())
private val hostFile = File(context.filesDir, "session/android-clipboard")
private var active = false
private val androidListener = ClipboardManager.OnPrimaryClipChangedListener { refresh() }
private val guestListener = object : WaylandCompositor.ClipboardListener {
override fun onGuestClipboardText(text: String) {
// Native callbacks arrive on the compositor thread. Do not publish a delayed callback
// after this activity has paused or been replaced.
handler.post {
if (active && hasFocus() && WaylandCompositor.isClipboardListener(this)) {
val current = clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text
if (current?.toString() != text) {
clipboard.setPrimaryClip(ClipData.newPlainText("DroidDeck", text))
}
}
}
}
}
fun start() {
if (active) return
active = true
clipboard.addPrimaryClipChangedListener(androidListener)
WaylandCompositor.setClipboardListener(guestListener)
refresh()
}
fun stop() {
if (!active) return
active = false
clipboard.removePrimaryClipChangedListener(androidListener)
WaylandCompositor.clearClipboardListener(guestListener)
handler.removeCallbacksAndMessages(null)
}
/** Android 10+ permits clipboard reads only with window focus. Resume can precede focus. */
fun refresh() {
if (!active || !hasFocus()) return
val clip = clipboard.primaryClip
// Only share literal text, never resolve content URIs or load copied files.
if (clip == null) {
publish(null)
} else if (clip.itemCount > 0) {
publish(clip.getItemAt(0).text?.toString())
}
}
private fun publish(text: String?) {
WaylandCompositor.setClipboardText(text)
// Gamescope needs the same selection in its own Xwayland. Rename complete UTF-8 text
// into place so its helper never sees half a launch command. Kept in private app data.
val pending = File(hostFile.parentFile, "android-clipboard.pending")
try {
hostFile.parentFile?.mkdirs()
pending.writeText(text.orEmpty(), Charsets.UTF_8)
Os.rename(pending.path, hostFile.path)
} catch (e: IOException) {
Log.w("SessionClipboard", "Could not share text with Xwayland", e)
} catch (e: android.system.ErrnoException) {
Log.w("SessionClipboard", "Could not publish text for Xwayland", e)
} finally {
pending.delete()
}
}
}
@@ -66,6 +66,17 @@ class TouchpadGestures(
listener.onButton(PointerGestures.BTN_LEFT, true, x, y)
}
fun cancel() {
handler.removeCallbacks(longPress)
if (dragging) listener.onButton(PointerGestures.BTN_LEFT, false, x, y)
dragging = false
held = false
holdDrag = false
anchorId = -1
moverId = -1
lastTapAt = 0L
}
fun place(px: Float, py: Float) {
x = px.coerceIn(bounds.left, bounds.right)
y = py.coerceIn(bounds.top, bounds.bottom)
@@ -13,16 +13,14 @@ import java.io.RandomAccessFile
* AppImages the user brings: picked from storage, checked, and extracted once into the runtime at
* /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every
* start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux
* desktop, and the front end starts it under gamescope through bannerlator-appimage-run.
* desktop, and the front end starts it under gamescope through droiddeck-appimage-run.
*
* <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one.
*/
object AppImageManager {
private const val TAG = "AppImageManager"
const val GUEST_DIR = "/opt/appimages/user"
const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run"
private const val ELF_AARCH64 = 183
private const val ELF_X86_64 = 62
const val LAUNCHER = "/usr/local/bin/droiddeck-appimage-run"
private const val SHARP_ICON = 128
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
@@ -59,9 +57,8 @@ object AppImageManager {
}
val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
return when {
machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download."
machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage"
machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs"
head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage"
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
else -> null
}
@@ -96,6 +93,7 @@ object AppImageManager {
val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet()))
try {
extract(context, dir, file, onProgress)?.let { FileUtils.delete(dir); return it }
LinuxFex.writeArch(dir, LinuxFex.elfMachine(file))
describe(dir, file)
if (name != null) FileUtils.writeString(File(dir, "name"), name)
if (icon != null && !UserApps.saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read")
@@ -119,6 +117,7 @@ object AppImageManager {
}
return try {
extract(context, dir, file, onProgress) ?: run {
LinuxFex.writeArch(dir, LinuxFex.elfMachine(file))
meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) }
null
}
@@ -140,12 +139,14 @@ object AppImageManager {
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
image.setExecutable(true, false)
onProgress("Extracting ${file.name}")
val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage"
else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }"
val out = StringBuilder()
// uruntime's DwarFS images unpack into AppDir with squashfs-root a link to it, and keep
// one program under several names as hard links (sharun), which Android denies apps.
val status = GuestCommand.run(context, listOf(
"/bin/bash", "-c",
"cd \"$1\" && rm -rf squashfs-root AppDir && ./image.AppImage --appimage-extract >/dev/null && " +
"cd \"$1\" && rm -rf squashfs-root AppDir && { $unpack; } >/dev/null && " +
"rm -rf app && mv \"$(readlink -f squashfs-root)\" app && rm -f squashfs-root",
"extract", "$GUEST_DIR/${dir.name}",
), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) }
@@ -19,7 +19,7 @@ import java.nio.file.Files
import java.util.concurrent.atomic.AtomicInteger
/**
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap).
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (droiddeck-bwrap).
*
* A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount
* namespaces Android gives no app. proot can show the same picture, and started here - beside the
@@ -300,7 +300,10 @@ object BwrapSpawner {
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX",
// Android 16 refuses the size seal Firefox puts on a tab's shared
// memory, and the tab then dies on every page (droiddeck-desktop).
"MOZ_SHM_NO_SEALS")) {
env.putIfAbsent(name, "1")
}
}
@@ -5,8 +5,15 @@ import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.core.ArchivePaths
import java.io.BufferedInputStream
import java.io.FileOutputStream
import java.io.File
import java.io.FileInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
import org.json.JSONArray
import org.json.JSONObject
/** Optional Decky Loader installation for the ARM64 Linux guest. */
object DeckyManager {
@@ -74,8 +81,7 @@ object DeckyManager {
val assets = release.optJSONArray("assets") ?: continue
val rows = (0 until assets.length()).map { assets.getJSONObject(it) }
val arm = rows.firstOrNull { it.optString("name") == "PluginLoader-arm64" }
val guest = LinuxRuntime.rootDir(context)
val fexReady = File(guest, "usr/share/guestos/fex-mesa").isDirectory && File(guest, "usr/bin/FEX").canExecute()
val fexReady = LinuxFex.ready(context)
val binary = arm ?: if (fexReady) rows.firstOrNull { it.optString("name") == "PluginLoader" } else null
val name = binary?.optString("name") ?: continue
val url = binary.optString("browser_download_url").takeIf { it.startsWith("https://") } ?: continue
@@ -137,6 +143,123 @@ object DeckyManager {
return null
}
/** Imports a Decky distribution ZIP into homebrew/plugins for the next Steam session. */
fun installPluginZip(context: Context, archive: File, progress: (String, Int) -> Unit): String? {
if (SessionState.running) return "Stop the active session before installing a plugin"
if (installed(context) == null) return "Install Decky Loader before importing a plugin"
if (!archive.isFile || !archive.canRead()) return "Could not read the selected ZIP file"
val plugins = File(LinuxRuntime.rootDir(context), "root/homebrew/plugins")
if (!plugins.exists() && !plugins.mkdirs()) return "Could not create the Decky plugins directory"
val staging = File(plugins, ".droiddeck-import-${UUID.randomUUID()}")
val backupSuffix = ".droiddeck-backup-${UUID.randomUUID()}"
try {
staging.mkdirs()
progress("Extracting plugin ZIP", -1)
extractPluginZip(archive, staging)
val roots = staging.listFiles()?.filter { it.isDirectory } ?: emptyList()
if (roots.size != 1 || staging.listFiles()?.size != 1) return "Plugin ZIP must contain one top-level plugin folder"
val plugin = roots.single()
if (!File(plugin, "plugin.json").isFile || !File(plugin, "package.json").isFile || !File(plugin, "dist/index.js").isFile) {
return "Plugin ZIP is missing plugin.json, package.json, or dist/index.js"
}
val name = JSONObject(File(plugin, "plugin.json").readText()).getString("name")
require(name.isNotBlank()) { "Plugin name is missing" }
val metadata = JSONObject(File(plugin, "package.json").readText())
downloadRemoteBinaries(metadata, plugin, progress)
if (SessionState.running) return "A session started during installation; stop it before installing a plugin"
DeckyPluginInstall.activate(plugin, plugins, name,
File(plugins.parentFile, "settings/loader.json"), backupSuffix)
progress("Plugin installed · restart Steam to load it", 100)
return null
} catch (e: Exception) {
Log.w(TAG, "plugin ZIP import", e)
return e.message?.takeIf { it.isNotBlank() } ?: "Plugin ZIP installation failed"
} finally {
staging.deleteRecursively()
}
}
private fun extractPluginZip(archive: File, destination: File) {
var totalBytes = 0L
var entries = 0
val seen = HashSet<String>()
ZipInputStream(BufferedInputStream(FileInputStream(archive))).use { zip ->
while (true) {
val entry = zip.nextEntry ?: break
entries++
require(entries <= 10_000) { "Plugin ZIP contains too many entries" }
val relative = entry.name.replace('\\', '/')
require(relative.isNotBlank() && !relative.startsWith('/')) { "Plugin ZIP contains an invalid path" }
require(seen.add(relative)) { "Plugin ZIP contains duplicate paths" }
val output = ArchivePaths.inside(destination, relative)
?: throw IllegalArgumentException("Plugin ZIP contains an unsafe path")
if (entry.isDirectory) {
require(output.mkdirs() || output.isDirectory) { "Could not create plugin folder" }
} else {
require(output.parentFile?.let { it.mkdirs() || it.isDirectory } == true) { "Could not create plugin folder" }
FileOutputStream(output).use { out ->
val buffer = ByteArray(64 * 1024)
while (true) {
val read = zip.read(buffer)
if (read < 0) break
totalBytes += read
require(totalBytes <= MAX_PLUGIN_ZIP_BYTES) { "Plugin ZIP expands beyond the 1 GiB limit" }
out.write(buffer, 0, read)
}
}
// Decky distributions place plugin executables under bin/. Java's ZIP reader
// does not expose Unix mode bits, so restore execution on those payload files.
if (relative.split('/').dropLast(1).any { it == "bin" } && !output.setExecutable(true, false)) {
throw IllegalStateException("Could not mark bundled plugin binary executable")
}
}
zip.closeEntry()
}
}
}
private fun downloadRemoteBinaries(metadata: JSONObject, plugin: File, progress: (String, Int) -> Unit) {
val binaries = metadata.optJSONArray("remote_binary") ?: return
val bin = File(plugin, "bin")
for (i in 0 until binaries.length()) {
val binary = binaries.optJSONObject(i) ?: throw IllegalArgumentException("Invalid remote_binary entry")
val name = binary.optString("name")
val url = binary.optString("url")
val hash = binary.optString("sha256hash")
require(name.isNotBlank() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')) { "Invalid remote binary name" }
require(url.startsWith("https://")) { "Remote binary URL must use HTTPS" }
require(hash.matches(Regex("(?i)[0-9a-f]{64}"))) { "Remote binary $name has an invalid SHA-256 hash" }
require(bin.mkdirs() || bin.isDirectory) { "Could not create plugin bin directory" }
val temp = File(bin, ".$name.download")
progress("Downloading plugin binary · $name", -1)
if (!Downloader.downloadFile(url, temp, false) { fraction ->
progress("Downloading plugin binary · $name", if (fraction < 0) -1 else (fraction * 100).toInt().coerceIn(0, 100))
}) {
temp.delete()
throw IllegalStateException("Could not download plugin binary $name")
}
if (!hash.equals(Hashes.sha256(temp), ignoreCase = true)) {
temp.delete()
throw IllegalStateException("Plugin binary checksum mismatch · $name")
}
if (!temp.setExecutable(true, false)) {
temp.delete()
throw IllegalStateException("Could not mark plugin binary executable · $name")
}
val target = File(bin, name)
if (target.exists() && !target.delete()) {
temp.delete()
throw IllegalStateException("Could not replace plugin binary · $name")
}
if (!temp.renameTo(target)) {
temp.delete()
throw IllegalStateException("Could not install plugin binary · $name")
}
}
}
fun uninstall(context: Context, wipeData: Boolean) {
val root = LinuxRuntime.rootDir(context)
setSupervisorEnabled(context, false)
@@ -155,4 +278,6 @@ object DeckyManager {
h[4] == 2.toByte() && h[5] == 1.toByte() && (h[18].toInt() and 255) + ((h[19].toInt() and 255) shl 8) == machine
}
}.getOrDefault(false)
private const val MAX_PLUGIN_ZIP_BYTES = 1024L * 1024L * 1024L
}
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.runtime
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Offline equivalent of Decky's identity-based replacement and loader settings update. */
internal object DeckyPluginInstall {
fun activate(staged: File, plugins: File, name: String, settingsFile: File, backupSuffix: String) {
val settings = if (settingsFile.exists()) JSONObject(settingsFile.readText()) else JSONObject()
val existing = plugins.listFiles().orEmpty().filter { folder ->
folder.isDirectory && !folder.name.startsWith(".droiddeck-") && runCatching {
JSONObject(File(folder, "plugin.json").readText()).getString("name") == name
}.getOrDefault(false)
}
val target = File(plugins, staged.name)
require(!target.exists() || target in existing) { "Plugin folder belongs to another plugin" }
val order = settings.optJSONArray("pluginOrder") ?: JSONArray(plugins.listFiles().orEmpty()
.filter { it.isDirectory && !it.name.startsWith(".droiddeck-") }
.mapNotNull { runCatching { JSONObject(File(it, "plugin.json").readText()).getString("name") }.getOrNull() }
.distinct())
if ((0 until order.length()).none { order.optString(it) == name }) order.put(name)
settings.put("pluginOrder", order)
// Decky's reinstall removes these flags while retaining the plugin's order and data.
for (key in listOf("frozenPlugins", "hiddenPlugins", "disabled_plugins")) {
val values = settings.optJSONArray(key) ?: continue
settings.put(key, JSONArray((0 until values.length()).map { values.get(it) }.filter { it != name }))
}
settingsFile.parentFile?.mkdirs()
val pending = File(settingsFile.parentFile, "loader.json$backupSuffix")
val backups = mutableListOf<Pair<File, File>>()
var activated = false
try {
pending.writeText(settings.toString(4))
for (old in existing) {
val backup = File(plugins, ".droiddeck-backup-${old.name}$backupSuffix")
check(old.renameTo(backup)) { "Could not preserve the existing plugin" }
backups.add(old to backup)
}
check(staged.renameTo(target)) { "Could not activate the imported plugin" }
activated = true
check(pending.renameTo(settingsFile)) { "Could not update Decky plugin settings" }
} catch (error: Exception) {
if (activated) target.deleteRecursively()
for ((old, backup) in backups.asReversed()) check(backup.renameTo(old)) { "Could not restore the existing plugin" }
throw error
} finally {
pending.delete()
}
backups.forEach { (_, backup) -> backup.deleteRecursively() }
}
}
@@ -7,8 +7,8 @@ import org.json.JSONObject
import java.io.File
/**
* Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed
* from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user
* Flatpak in the Linux runtime: putting it there (droiddeck-flatpak-setup), the apps installed
* from Flathub, and installing, removing and updating them (droiddeck-flatpak). One per-user
* installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit.
*
* Every command runs in a proot of its own, outside any session: the store works whether or not
@@ -17,11 +17,11 @@ import java.io.File
object FlatpakManager {
private const val TAG = "FlatpakManager"
const val FLATPAK = "/usr/bin/flatpak"
const val BWRAP = "/usr/local/bin/bannerlator-bwrap"
const val BWRAP = "/usr/local/bin/droiddeck-bwrap"
/** The program the front end starts a Flatpak app through, in a session of its own. */
const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run"
private const val HELPER = "/usr/local/bin/bannerlator-flatpak"
private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup"
const val LAUNCHER = "/usr/local/bin/droiddeck-flatpak-run"
private const val HELPER = "/usr/local/bin/droiddeck-flatpak"
private const val SETUP = "/usr/local/bin/droiddeck-flatpak-setup"
private const val USER_DIR = "root/.local/share/flatpak"
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
@@ -0,0 +1,109 @@
package com.droiddeck.launcher.runtime
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import java.io.File
import java.nio.file.Files
import java.nio.file.Paths
object LegacyNames {
private const val TAG = "LegacyNames"
const val SESSION_SCRIPT = "/usr/local/bin/bannerlator-session"
val SCRIPTS = listOf(
"appimage-run", "bwrap", "desktop-games", "flatpak", "flatpak-run", "flatpak-setup",
"game-env", "login1", "netmanager", "pad-defaults", "proton-extra", "script-run",
"seed-redists", "session", "steam-games", "steam-install", "steam-launch",
"steam-library", "steam-shim", "steam-shortcuts",
)
private val LINKED_DIRS = listOf("etc/bannerlator" to "etc/droiddeck", "usr/local/share/bannerlator" to "usr/local/share/droiddeck")
private val MOVED_FILES = listOf(
"etc/bannerlator-net" to "etc/droiddeck-net",
"etc/bannerlator-network.json" to "etc/droiddeck-network.json",
"etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request",
)
private const val MENU_DIR = "usr/local/share/applications"
private const val OLD_BIN = "/usr/local/bin/bannerlator-"
private const val NEW_BIN = "/usr/local/bin/droiddeck-"
@JvmStatic
@Synchronized
fun migrateEtc(root: File) {
for ((old, new) in LINKED_DIRS) moveAndLink(File(root, old), File(root, new))
File(root, "etc/droiddeck/empty").mkdirs()
}
@Synchronized
fun migrateRootfs(root: File) {
migrateEtc(root)
for ((old, new) in MOVED_FILES) move(File(root, old), File(root, new))
linkScripts(root)
rewriteMenuEntries(root)
}
private fun isLink(f: File) = Files.isSymbolicLink(f.toPath())
private fun move(legacy: File, target: File) {
if (isLink(legacy) || !legacy.exists()) return
if (legacy.isDirectory && !isLink(target) && (target.isDirectory || !target.exists())) {
target.mkdirs()
legacy.listFiles()?.forEach { move(it, File(target, it.name)) }
if (!legacy.delete()) Log.w(TAG, "could not empty $legacy")
return
}
if (target.exists() || isLink(target)) {
FileUtils.delete(legacy)
} else if (!legacy.renameTo(target)) {
Log.w(TAG, "could not move $legacy to $target")
}
}
private fun moveAndLink(legacy: File, target: File) {
if (isLink(legacy) || !legacy.exists()) return
move(legacy, target)
if (!legacy.exists()) replaceWithLink(legacy, target)
}
private fun replaceWithLink(legacy: File, target: File) {
val link = File(legacy.parentFile, legacy.name + ".link")
FileUtils.delete(link)
try {
Files.createSymbolicLink(link.toPath(), Paths.get(target.name))
if (!link.renameTo(legacy)) {
FileUtils.delete(link)
Log.w(TAG, "could not link $legacy to ${target.name}")
}
} catch (e: Exception) {
FileUtils.delete(link)
Log.w(TAG, "could not link $legacy to ${target.name}", e)
}
}
private fun linkScripts(root: File) {
val bin = File(root, "usr/local/bin")
for (name in SCRIPTS) {
val target = File(bin, "droiddeck-$name")
val legacy = File(bin, "bannerlator-$name")
if (!target.isFile || isLink(target)) continue
if (isLink(legacy)) {
val points = runCatching { Files.readSymbolicLink(legacy.toPath()).toString() }.getOrNull()
if (points == target.name) continue
} else if (!legacy.exists()) {
continue
}
replaceWithLink(legacy, target)
}
}
private fun rewriteMenuEntries(root: File) {
val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return
for (entry in entries) {
val text = FileUtils.readString(entry) ?: continue
if (!text.contains(OLD_BIN)) continue
val staged = File(entry.parentFile, entry.name + ".staged")
if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) {
staged.delete()
Log.w(TAG, "could not rewrite $entry")
}
}
}
}
@@ -0,0 +1,62 @@
package com.droiddeck.launcher.runtime
import android.content.Context
import com.droiddeck.launcher.core.FileUtils
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
object LinuxFex {
const val TOOL = "/usr/local/bin/droiddeck-fex"
const val AUTO = "auto"
const val ON = "on"
const val OFF = "off"
val MODES = listOf(AUTO, ON, OFF)
const val ELF_I386 = 3
const val ELF_X86_64 = 62
const val ELF_AARCH64 = 183
private const val MODE_FILE = "fex"
private const val ARCH_FILE = "arch"
private const val STATUS = "root/.local/share/droiddeck-fex/status.json"
fun ready(context: Context): Boolean =
FileUtils.readString(File(LinuxRuntime.rootDir(context), STATUS))
?.let { runCatching { JSONObject(it).optBoolean("ready") }.getOrNull() } == true
fun mode(dir: File): String = FileUtils.readString(File(dir, MODE_FILE))?.trim()?.takeIf { it in MODES } ?: AUTO
fun setMode(dir: File, mode: String): Boolean {
val file = File(dir, MODE_FILE)
return when (mode) {
AUTO -> !file.exists() || file.delete()
ON, OFF -> FileUtils.writeString(file, mode)
else -> false
}
}
fun elfMachine(file: File): Int? = try {
RandomAccessFile(file, "r").use { f ->
val head = ByteArray(20)
if (f.read(head) < head.size) return null
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) return null
if (head[5].toInt() == 2) ((head[18].toInt() and 0xff) shl 8) or (head[19].toInt() and 0xff)
else (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8)
}
} catch (e: Exception) {
null
}
fun isX86(machine: Int?): Boolean = machine == ELF_X86_64 || machine == ELF_I386
fun archName(machine: Int?): String? = when (machine) {
ELF_X86_64 -> "x86_64"
ELF_I386 -> "i386"
ELF_AARCH64 -> "arm64"
else -> null
}
fun writeArch(dir: File, machine: Int?) {
val name = archName(machine)
if (name == null) File(dir, ARCH_FILE).delete() else FileUtils.writeString(File(dir, ARCH_FILE), name)
}
}
@@ -1,17 +1,30 @@
package com.droiddeck.launcher.runtime
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.location.LocationManager
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.core.WifiDiscovery
import java.io.File
import java.io.IOException
import java.net.Inet4Address
import java.net.Inet6Address
import org.json.JSONArray
import org.json.JSONObject
/**
* The device's network link, published for the Linux runtime's processes.
@@ -34,20 +47,94 @@ class LinuxNetworkLinkComponent(
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val lock = Any()
private var callback: ConnectivityManager.NetworkCallback? = null
private var currentNetwork: Network? = null
private var currentProperties: LinkProperties? = null
private var currentCapabilities: NetworkCapabilities? = null
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
private val scanHandler = Handler(Looper.getMainLooper())
private var wifiReceiver: BroadcastReceiver? = null
private var scanRequestStamp = 0L
private var lastScanRequest = -SCAN_INTERVAL_MS
@Volatile private var lastNamesAllowed = false
private val scanRequests = object : Runnable {
override fun run() {
if (canReadWifiNames() != lastNamesAllowed) {
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
requestWifiScan()
}
val stamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
if (stamp != 0L && stamp != scanRequestStamp) {
scanRequestStamp = stamp
requestWifiScan()
}
if (wifiReceiver != null) scanHandler.postDelayed(this, 1000)
}
}
/** Called before the session starts, so its first process already sees the link. */
fun publish() = write(connectivity.activeNetwork?.let(connectivity::getLinkProperties))
fun publish() = synchronized(lock) {
currentNetwork = connectivity.activeNetwork
currentProperties = currentNetwork?.let(connectivity::getLinkProperties)
currentCapabilities = currentNetwork?.let(connectivity::getNetworkCapabilities)
write(currentProperties, currentCapabilities)
}
override fun start() {
val registered = object : ConnectivityManager.NetworkCallback() {
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = write(properties)
override fun onLost(network: Network) = write(null)
override fun onAvailable(network: Network) = synchronized(lock) {
currentNetwork = network
currentProperties = null
currentCapabilities = null
}
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) = synchronized(lock) {
if (network == currentNetwork) {
currentCapabilities = capabilities
if (currentProperties != null) write(currentProperties, capabilities)
}
}
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) = synchronized(lock) {
if (network == currentNetwork) {
currentProperties = properties
write(properties, currentCapabilities)
}
}
override fun onLost(network: Network) = synchronized(lock) {
// A handover can report the old link lost after the new default arrived.
if (network == currentNetwork) {
currentNetwork = null
currentProperties = null
currentCapabilities = null
write(null, null)
}
}
}
synchronized(lock) { callback = registered }
connectivity.registerDefaultNetworkCallback(registered)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
}
}
wifiReceiver = receiver
appContext.registerReceiver(receiver, IntentFilter().apply {
addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION)
addAction(WifiManager.RSSI_CHANGED_ACTION)
addAction(WifiManager.WIFI_STATE_CHANGED_ACTION)
addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION)
addAction(LocationManager.MODE_CHANGED_ACTION)
})
scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified()
scanHandler.post(scanRequests)
requestWifiScan()
}
override fun stop() {
scanHandler.removeCallbacks(scanRequests)
wifiReceiver?.let { appContext.unregisterReceiver(it) }
wifiReceiver = null
val registered = synchronized(lock) { callback.also { callback = null } } ?: return
try {
connectivity.unregisterNetworkCallback(registered)
@@ -56,7 +143,22 @@ class LinuxNetworkLinkComponent(
}
}
private fun write(properties: LinkProperties?) {
private fun canReadWifiNames(): Boolean = WifiDiscovery.available(appContext)
@Suppress("DEPRECATION")
private fun requestWifiScan() {
val now = SystemClock.elapsedRealtime()
if (!canReadWifiNames() || wifiManager?.isWifiEnabled != true || now - lastScanRequest < SCAN_INTERVAL_MS) return
lastScanRequest = now
try {
Log.i(TAG, "Wi-Fi scan requested: accepted=${wifiManager.startScan()}")
} catch (e: SecurityException) {
Log.w(TAG, "Wi-Fi scan permission unavailable")
}
synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) }
}
private fun write(properties: LinkProperties?, capabilities: NetworkCapabilities?) {
val file = File(rootDir, LINK_FILE)
synchronized(lock) {
try {
@@ -67,10 +169,99 @@ class LinuxNetworkLinkComponent(
} catch (e: IOException) {
Log.w(TAG, "Could not publish the network link", e)
}
writeNetworkState(properties, capabilities)
writeResolver(properties)
}
}
/** NetworkManager's view of Android. Kept separate from netif.c's legacy adapter format. */
@Suppress("DEPRECATION")
private fun writeNetworkState(properties: LinkProperties?, capabilities: NetworkCapabilities?) {
val namesAllowed = canReadWifiNames()
lastNamesAllowed = namesAllowed
val scans = if (namesAllowed && wifiManager?.isWifiEnabled == true) {
try { wifiManager.scanResults.orEmpty() } catch (e: SecurityException) { emptyList() }
} else emptyList()
val transport = when {
capabilities == null -> "none"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
val state = JSONObject().apply {
put("transport", transport)
put("interface", properties?.interfaceName ?: "android")
put("mtu", properties?.mtu?.takeIf { it > 0 } ?: DEFAULT_MTU)
put("connected", properties != null && capabilities != null)
put("validated", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true)
put("captivePortal", capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) == true)
put("metered", capabilities?.let { !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) })
put("wifiEnabled", wifiManager?.isWifiEnabled == true)
put("scanAllowed", namesAllowed)
put("lastScan", scans.maxOfOrNull { it.timestamp / 1000 } ?: -1L)
put("accessPoints", JSONArray().apply {
scans.filter { it.SSID.isNotEmpty() }.forEach {
put(JSONObject().apply {
put("ssid", it.SSID)
put("bssid", it.BSSID)
put("strength", WifiManager.calculateSignalLevel(it.level, 101))
put("frequency", it.frequency)
put("capabilities", it.capabilities)
put("lastSeen", it.timestamp / 1_000_000)
})
}
})
put("addresses", JSONArray().apply {
properties?.linkAddresses.orEmpty().forEach {
put(JSONObject().put("address", it.address.hostAddress?.substringBefore('%')).put("prefix", it.prefixLength))
}
})
put("gateways", JSONArray().apply {
properties?.routes.orEmpty().filter { it.isDefaultRoute }.forEach {
it.gateway?.takeUnless { address -> address.isAnyLocalAddress }?.let { address ->
put(address.hostAddress?.substringBefore('%'))
}
}
})
put("dns", JSONArray().apply {
properties?.dnsServers.orEmpty().filter { !it.isLinkLocalAddress }.forEach {
put(it.hostAddress?.substringBefore('%'))
}
})
if (transport == "wifi") {
val connectionInfo = try { wifiManager?.connectionInfo } catch (e: SecurityException) { null }
val info = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
(capabilities?.transportInfo as? WifiInfo) ?: connectionInfo
} else {
connectionInfo
}
if (info != null) {
// SSIDs can be redacted by Android. No location permission is needed to
// report the transport; a hidden name simply displays as Wi-Fi in Steam.
if (namesAllowed) {
val namedInfo = connectionInfo ?: info
namedInfo.ssid?.takeUnless { it == WifiManager.UNKNOWN_SSID || it.isEmpty() }
?.removeSurrounding("\"")?.let { put("ssid", it) }
namedInfo.bssid?.takeUnless { it == "02:00:00:00:00:00" }?.let { put("bssid", it) }
}
put("strength", WifiManager.calculateSignalLevel(info.rssi, 101))
put("frequency", info.frequency.coerceAtLeast(0))
put("bitrate", info.linkSpeed.coerceAtLeast(0) * 1000)
}
}
}
try {
val file = File(rootDir, "etc/droiddeck-network.json")
val staged = File(file.path + ".staged")
staged.writeText(state.toString())
if (!staged.renameTo(file)) throw IOException("Could not replace network state")
} catch (e: IOException) {
Log.w(TAG, "Could not publish the network state", e)
}
}
/**
* The guest's resolver, from the network it is actually on. glibc reads /etc/resolv.conf,
* and the image ships two public servers there, which is why Steam resolves at all - but a
@@ -144,10 +335,11 @@ class LinuxNetworkLinkComponent(
companion object {
private const val TAG = "LinuxNetworkLink"
private const val LINK_FILE = "etc/bannerlator-net"
private const val LINK_FILE = "etc/droiddeck-net"
private const val LINK_INDEX = 2
private const val DEFAULT_MTU = 1500
private const val OFFLINE_NAME = "eth0"
private const val OFFLINE_ADDRESS = "10.0.0.2 24"
private const val SCAN_INTERVAL_MS = 30_000L
}
}
@@ -27,7 +27,7 @@ import java.util.List;
*/
public final class LinuxRuntime {
public static final String DIR = "linuxfs";
public static final String SESSION_SCRIPT = "/usr/local/bin/bannerlator-session";
public static final String SESSION_SCRIPT = "/usr/local/bin/droiddeck-session";
public static final String MODE_DESKTOP = "desktop";
public static final String MODE_STEAM = "steam";
public static final String MODE_RUN = "run";
@@ -88,9 +88,11 @@ public final class LinuxRuntime {
/** The rootfs is present with gamescope and the session script the launcher hands control to. */
public static boolean isInstalled(Context context) {
if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false;
File root = rootDir(context);
return new File(root, "usr/bin/gamescope").isFile()
&& new File(root, SESSION_SCRIPT.substring(1)).isFile()
&& (new File(root, SESSION_SCRIPT.substring(1)).isFile()
|| new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile())
&& prootBinary(context).isFile()
&& prootLoader(context).isFile();
}
@@ -181,6 +183,7 @@ public final class LinuxRuntime {
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
File externalStorage, List<String> extraBinds) {
File root = rootDir(context);
LegacyNames.migrateEtc(root);
List<String> cmd = new ArrayList<>();
bind(cmd, "/dev");
bind(cmd, "/proc");
@@ -190,7 +193,7 @@ public final class LinuxRuntime {
bind(cmd, "/proc/self/fd/0:/dev/stdin");
bind(cmd, "/proc/self/fd/1:/dev/stdout");
bind(cmd, "/proc/self/fd/2:/dev/stderr");
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/fs/selinux");
bind(cmd, context.getFilesDir().getPath());
bind(cmd, context.getCacheDir().getPath());
if (runtimeDir != null) {
@@ -206,7 +209,7 @@ public final class LinuxRuntime {
bind(cmd, shm.getPath() + ":/dev/shm");
// Android denies apps these; glibc, Steam and libcap read them at startup.
File fakeProc = new File(root, "etc/bannerlator/proc");
File fakeProc = new File(root, "etc/droiddeck/proc");
// libpci picks its procfs backend on whether it can read the /proc/bus/pci directory, which
// the app can, then die()s - exit(1) on the calling process - on the devices file inside
// it, which the app cannot. Chromium loads libpci in its GPU process to name the video
@@ -229,6 +232,17 @@ public final class LinuxRuntime {
// It then fails the isFile() test below and the session runs as it did before.
}
}
// protonfixes, which GE-Proton and proton-cachyos run before every game, opens this to
// check the esync file limit and dies on the PermissionError, so no game starts with them.
// The value is the 64-bit kernel default.
File fileMax = new File(fakeProc, "file-max");
if (!fileMax.isFile()) {
try {
Files.write(fileMax.toPath(), "9223372036854775807\n".getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
// Left unbound, as above.
}
}
String[][] procFiles = {
{"pci_devices", "/proc/bus/pci/devices"},
{"stat", "/proc/stat"},
@@ -239,6 +253,7 @@ public final class LinuxRuntime {
{"cap_last_cap", "/proc/sys/kernel/cap_last_cap"},
{"overflowuid", "/proc/sys/kernel/overflowuid"},
{"overflowgid", "/proc/sys/kernel/overflowgid"},
{"file-max", "/proc/sys/fs/file-max"},
};
for (String[] entry : procFiles) {
File fake = new File(fakeProc, entry[0]);
@@ -405,7 +420,7 @@ public final class LinuxRuntime {
* under the mainline name.
*/
private static void bindCpuTemps(List<String> cmd, File root) {
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
File name = new File(root, "etc/droiddeck/cpu-thermal-type");
try {
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
} catch (IOException e) {
@@ -64,7 +64,7 @@ public final class LinuxRuntimeInstaller {
}
/**
* One install per process. The launcher and the session screen can both start one, and a
* One runtime operation per process. The launcher and session can both install, and a
* launcher rebuilt mid-install (a pad plugged in, dark mode switched) forgets it had: a second
* install would share the first one's archive and staging directory and wreck both.
*/
@@ -74,11 +74,13 @@ public final class LinuxRuntimeInstaller {
volatile String stage = "Starting\u2026";
volatile int percent = -1;
volatile boolean ok;
final boolean removal;
Job(boolean removal) { this.removal = removal; }
}
private static final Object JOB_LOCK = new Object();
private static Job running;
private static boolean removing;
private static volatile String removalError;
private LinuxRuntimeInstaller() {}
@@ -127,9 +129,9 @@ public final class LinuxRuntimeInstaller {
Job job;
boolean owner;
synchronized (JOB_LOCK) {
if (removing) return false;
if (running != null && running.removal) return false;
owner = running == null;
if (owner) running = new Job();
if (owner) { running = new Job(false); removalError = null; }
job = running;
if (listener != null) job.listeners.add(listener);
}
@@ -152,13 +154,31 @@ public final class LinuxRuntimeInstaller {
/** True while an install is running in this process, whoever started it. */
public static boolean isInstalling() {
synchronized (JOB_LOCK) {
return running != null;
return running != null && !running.removal;
}
}
public static boolean isBusy() {
synchronized (JOB_LOCK) { return running != null; }
}
public static boolean isRemoving() {
synchronized (JOB_LOCK) { return running != null && running.removal; }
}
public static String removalError() { return removalError; }
public static boolean hasRemovalPending(Context context) {
return removalDirectory(LinuxRuntime.rootDir(context)).exists();
}
private static File removalDirectory(File root) {
return new File(root.getParentFile(), root.getName() + ".removing");
}
/**
* Follows the install already running, for a screen that was rebuilt mid-install. Blocks until
* it ends and returns its result, or returns null at once when nothing is installing.
* Follows the runtime operation already running, including removal, after a screen rebuild.
* Blocks until it ends and returns its result, or null when there is no current operation.
*/
public static Boolean attach(ProgressListener listener) {
Job job;
@@ -186,6 +206,11 @@ public final class LinuxRuntimeInstaller {
private static boolean installOnce(Context context, Release release, ProgressListener listener) {
File archive = new File(context.getCacheDir(), "linuxfs.tar.zst");
try {
// A failed/interrupted explicit removal must finish before a fresh installation.
// Its quarantined home is not user data to carry into an update.
File pendingRemoval = removalDirectory(LinuxRuntime.rootDir(context));
if (pendingRemoval.exists() && listener != null) listener.onProgress("Removing runtime leftovers", -1);
RuntimeFileTree.delete(pendingRemoval, null);
if (listener != null) listener.onProgress("Downloading", 0);
// Downloader reports a 0..1 fraction, or -1 while the total size is unknown.
boolean ok = Downloader.downloadFile(release.url, archive, true, (fraction) -> {
@@ -212,18 +237,18 @@ public final class LinuxRuntimeInstaller {
File staging = new File(root.getParentFile(), LinuxRuntime.DIR + ".new");
File old = new File(root.getParentFile(), LinuxRuntime.DIR + ".old");
recoverInterruptedSwap(root, staging, old);
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
if (!staging.mkdirs()) return false;
if (listener != null) listener.onProgress("Extracting", -1);
if (!extract(archive, staging, listener)) {
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
return false;
}
FileUtils.writeString(new File(staging, VERSION_FILE), release.version);
FileUtils.delete(old);
RuntimeFileTree.delete(old, null);
if (root.isDirectory() && !root.renameTo(old)) {
FileUtils.delete(staging);
RuntimeFileTree.delete(staging, null);
return false;
}
@@ -232,16 +257,15 @@ public final class LinuxRuntimeInstaller {
// own empty /root is dropped first so the rename has somewhere to land. If this fails
// the update is abandoned and the previous runtime is put back untouched - shipping a
// working system with the user's games gone is the worse outcome.
File keptFrom = new File(old, USER_DATA);
if (keptFrom.isDirectory()) {
File keptTo = new File(staging, USER_DATA);
FileUtils.delete(keptTo);
if (!keptFrom.renameTo(keptTo)) {
Log.w(TAG, "could not carry " + USER_DATA + " across the update; rolling back");
FileUtils.delete(staging);
old.renameTo(root);
return false;
}
try {
RuntimeFileTree.carryHome(old, staging);
} catch (IOException e) {
Log.w(TAG, "could not carry root across the update; rolling back", e);
// Restore the live name even if cleaning the failed staging tree is denied.
old.renameTo(root);
try { RuntimeFileTree.delete(staging, null); }
catch (IOException cleanup) { Log.w(TAG, "could not clear the failed staging tree", cleanup); }
return false;
}
if (!staging.renameTo(root)) {
@@ -250,7 +274,7 @@ public final class LinuxRuntimeInstaller {
if (old.isDirectory()) old.renameTo(root);
return false;
}
FileUtils.delete(old);
RuntimeFileTree.delete(old, null);
return LinuxRuntime.isInstalled(context);
} catch (Exception e) {
Log.e(TAG, "install", e);
@@ -260,13 +284,13 @@ public final class LinuxRuntimeInstaller {
}
}
private static void recoverInterruptedSwap(File root, File staging, File old) {
static void recoverInterruptedSwap(File root, File staging, File old) throws IOException {
File target = root.isDirectory() ? root : old;
File stagedHome = new File(staging, USER_DATA);
File home = new File(target, USER_DATA);
if (target.isDirectory() && stagedHome.isDirectory() && (!home.exists() || isEmptyDir(home))) {
FileUtils.delete(home);
if (stagedHome.renameTo(home)) Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
RuntimeFileTree.carryHome(staging, target);
Log.w(TAG, "recovered " + USER_DATA + " from an interrupted update");
}
if (!root.isDirectory() && old.isDirectory() && old.renameTo(root)) {
Log.w(TAG, "restored the previous runtime after an interrupted update");
@@ -278,20 +302,76 @@ public final class LinuxRuntimeInstaller {
return dir.isDirectory() && names != null && names.length == 0;
}
/** Removes the runtime; returns false without touching it while an install is running. */
public static boolean uninstall(Context context) {
/** Reserve removal before returning to the UI, so Play cannot race the worker. */
public static Removal beginUninstall(Context context) {
if (com.droiddeck.launcher.session.SessionState.INSTANCE.getRunning()) return null;
com.droiddeck.launcher.session.SessionPhase phase = com.droiddeck.launcher.session.SessionState.INSTANCE.getPhase();
if (phase != com.droiddeck.launcher.session.SessionPhase.IDLE && phase != com.droiddeck.launcher.session.SessionPhase.FAILED) return null;
return beginUninstall(LinuxRuntime.rootDir(context));
}
static Removal beginUninstall(File root) {
synchronized (JOB_LOCK) {
if (running != null || removing) return false;
removing = true;
if (running != null) return null;
removalError = null;
running = new Job(true);
running.stage = "Removing Linux runtime";
return new Removal(root, running);
}
try {
FileUtils.delete(LinuxRuntime.rootDir(context));
return true;
} finally {
synchronized (JOB_LOCK) {
removing = false;
}
public static final class Removal {
private final File root;
private final Job job;
private final java.util.concurrent.atomic.AtomicBoolean started = new java.util.concurrent.atomic.AtomicBoolean();
private Removal(File root, Job job) { this.root = root; this.job = job; }
public boolean run(ProgressListener listener) {
if (!started.compareAndSet(false, true)) return join(job, listener);
if (listener != null) job.listeners.add(listener);
try {
report("Removing Linux runtime", -1);
final long[] removed = {0};
final long[] lastShown = {0};
java.util.function.LongConsumer progress = ignored -> {
removed[0]++;
long now = System.nanoTime();
if (removed[0] == 1 || now - lastShown[0] >= 200_000_000L) {
lastShown[0] = now;
report("Removing Linux runtime · " + removed[0] + " entries", -1);
}
};
File pending = removalDirectory(root);
// Invalidate the installation before traversing it. A process killed halfway
// through leaves a named removal to resume, never a launchable partial runtime.
if (root.exists()) {
RuntimeFileTree.delete(pending, progress);
if (!root.renameTo(pending)) throw new IOException("Cannot prepare the runtime for removal");
} else if (!pending.isDirectory() && !pending.mkdirs()) {
throw new IOException("Cannot prepare the runtime for removal");
}
// Keep the quarantine until all leftovers are gone, so a failure stays retryable.
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".new"), progress);
RuntimeFileTree.delete(new File(root.getParentFile(), root.getName() + ".old"), progress);
RuntimeFileTree.delete(pending, progress);
job.ok = true;
report("Linux runtime removed", 100);
return true;
} catch (IOException e) {
removalError = "Could not finish removing the Linux runtime. Retry removal in Setup.";
Log.w(TAG, "runtime removal incomplete", e);
report(removalError, -1);
return false;
} finally {
synchronized (JOB_LOCK) { running = null; }
job.done.countDown();
}
}
private void report(String stage, int percent) {
job.stage = stage;
job.percent = percent;
for (ProgressListener listener : job.listeners) listener.onProgress(stage, percent);
}
}
/**
@@ -21,13 +21,15 @@ import java.security.MessageDigest
*/
object ProotFastPath {
const val LIBRARY = "/usr/local/lib/libblfastpath.so"
/** MAX_BINDS in fastpath.c: past it the library turns itself off, so the session keeps it off. */
const val MAX_BINDS = 256
fun enabled(context: Context): Boolean =
SessionPrefs.prootFastPath(context) && !SessionPrefs.prootNoSeccomp(context)
/** The key for this rootfs and these binds, or null when the library cannot be told them. */
fun key(root: File, binds: List<String>): String? {
if (binds.any { it.contains('|') } || root.path.contains('|')) return null
if (binds.size > MAX_BINDS || binds.any { it.contains('|') } || root.path.contains('|')) return null
val digest = MessageDigest.getInstance("SHA-256")
.digest((root.path + "\n" + binds.joinToString("\n")).toByteArray())
return digest.take(12).joinToString("") { "%02x".format(it) }
@@ -0,0 +1,51 @@
package com.droiddeck.launcher.runtime;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.function.LongConsumer;
/** Checked removal of an app-owned runtime tree, including read-only directories. */
final class RuntimeFileTree {
private RuntimeFileTree() {}
static void delete(File root, LongConsumer progress) throws IOException {
delete(root.toPath(), progress, new long[]{0});
}
private static void delete(Path path, LongConsumer progress, long[] removed) throws IOException {
BasicFileAttributes attrs;
try { attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); }
catch (NoSuchFileException e) { return; }
if (attrs.isDirectory()) {
// Change directory permissions before opening it, so mode 000 also works. Links
// are inspected with lstat and unlinked directly; never chmod or visit their target.
makeWritable(path.toFile());
try (DirectoryStream<Path> children = Files.newDirectoryStream(path)) {
for (Path child : children) delete(child, progress, removed);
}
}
Files.delete(path);
if (progress != null) progress.accept(++removed[0]);
}
private static void makeWritable(File dir) throws IOException {
if (!dir.setReadable(true, true) || !dir.setWritable(true, true) || !dir.setExecutable(true, true))
throw new IOException("Cannot make directory removable: " + dir);
}
static void carryHome(File old, File staging) throws IOException {
File home = new File(old, "root");
if (!home.isDirectory()) return;
makeWritable(old);
makeWritable(staging);
File target = new File(staging, "root");
delete(target, null);
if (!home.renameTo(target)) throw new IOException("Cannot carry root across the update");
}
}
@@ -25,22 +25,25 @@ class RuntimeInstallService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val removal = intent?.action == ACTION_REMOVE
val release = intent?.let(::releaseFrom)
startForeground(NOTIFICATION_ID, notification("Starting…", -1))
if (release == null) {
startForeground(NOTIFICATION_ID, notification(if (removal) "Removing Linux runtime" else "Starting…", -1))
if (release == null && !removal) {
stopSelf(startId)
return START_NOT_STICKY
}
Thread({
val manager = getSystemService(NotificationManager::class.java)
var shown = Int.MIN_VALUE
LinuxRuntimeInstaller.install(applicationContext, release) { stage, percent ->
// Every percent at most once: a notification per read would flood the system.
if (percent != shown) {
shown = percent
var shown = ""
val progress = LinuxRuntimeInstaller.ProgressListener { stage, percent ->
// Install percentages and throttled removal counts, including stage transitions.
if ("$stage:$percent" != shown) {
shown = "$stage:$percent"
manager?.notify(NOTIFICATION_ID, notification(stage, percent))
}
}
if (removal) LinuxRuntimeInstaller.attach(progress)
else LinuxRuntimeInstaller.install(applicationContext, release!!, progress)
@Suppress("DEPRECATION")
stopForeground(true)
stopSelf(startId)
@@ -50,9 +53,9 @@ class RuntimeInstallService : Service() {
private fun notification(stage: String, percent: Int): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Runtime install",
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Linux runtime",
NotificationManager.IMPORTANCE_LOW).apply {
description = "Shows while the Linux runtime downloads and installs"
description = "Shows while the Linux runtime is installed or removed"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
@@ -62,7 +65,7 @@ class RuntimeInstallService : Service() {
PendingIntent.FLAG_IMMUTABLE)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentTitle("Installing the Linux runtime")
.setContentTitle(if (LinuxRuntimeInstaller.isRemoving()) "Removing the Linux runtime" else "Installing the Linux runtime")
.setContentText(stage)
.setProgress(100, percent.coerceIn(0, 100), percent < 0)
.setContentIntent(open)
@@ -75,6 +78,7 @@ class RuntimeInstallService : Service() {
companion object {
private const val CHANNEL_ID = "runtime-install"
private const val NOTIFICATION_ID = 2
private const val ACTION_REMOVE = "com.droiddeck.launcher.REMOVE_RUNTIME"
private const val EXTRA_VERSION = "version"
private const val EXTRA_URL = "url"
private const val EXTRA_SHA256 = "sha256"
@@ -90,6 +94,11 @@ class RuntimeInstallService : Service() {
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun keepRemovalAlive(context: Context) {
val intent = Intent(context, RuntimeInstallService::class.java).setAction(ACTION_REMOVE)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
private fun releaseFrom(intent: Intent): LinuxRuntimeInstaller.Release? {
val version = intent.getStringExtra(EXTRA_VERSION) ?: return null
val url = intent.getStringExtra(EXTRA_URL) ?: return null
@@ -31,7 +31,7 @@ import java.io.RandomAccessFile
object UserApps {
private const val TAG = "UserApps"
const val GUEST_DIR = "/opt/droiddeck-apps"
const val SCRIPT_LAUNCHER = "/usr/local/bin/bannerlator-script-run"
const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run"
private const val FLATPAK_OVERRIDES = ".flatpak"
private const val ICON_SIZE = 256
private const val ELF_SCAN_LIMIT = 4000
@@ -53,6 +53,7 @@ object UserApps {
val key: String, val kind: Kind, val name: String, val icon: Any?, val detail: String?,
val program: String, val args: List<String>, val copied: Boolean = false,
val repo: String? = null, val version: String? = null,
val fex: String = LinuxFex.AUTO, val arch: String? = null,
)
class Release(val tag: String, val published: String, val asset: String, val url: String)
@@ -89,6 +90,7 @@ object UserApps {
"script:${dir.name}", Kind.SCRIPT, readName(dir) ?: dir.name, File(dir, "icon.png").takeIf { it.isFile },
FileUtils.readString(File(dir, "source"))?.trim(), SCRIPT_LAUNCHER, listOf("$GUEST_DIR/${dir.name}"),
copied = !java.nio.file.Files.isSymbolicLink(File(dir, "files").toPath()),
fex = LinuxFex.mode(dir),
)
}.orEmpty()
val images = AppImageManager.list(context).map { item ->
@@ -96,6 +98,7 @@ object UserApps {
App(
"appimage:${item.id}", Kind.APPIMAGE, item.name, item.icon, item.comment, AppImageManager.LAUNCHER, listOf(item.guestDir),
repo = FileUtils.readString(File(dir, GITHUB_REPO))?.trim(), version = FileUtils.readString(File(dir, GITHUB_TAG))?.trim(),
fex = LinuxFex.mode(dir), arch = FileUtils.readString(File(dir, "arch"))?.trim()?.takeIf { it.isNotEmpty() },
)
}
val flatpaks = FlatpakManager.installedApps(context).map { app ->
@@ -140,7 +143,7 @@ object UserApps {
* Renames [app] and sets its icon: [icon] a path or URL, "" for its default, null to keep it.
* The Linux desktop's menu entry follows.
*/
fun edit(context: Context, app: App, name: String, icon: String?): String? {
fun edit(context: Context, app: App, name: String, icon: String?, fex: String? = null): String? {
val id = app.key.substringAfter(':')
if (id.isEmpty() || '/' in id || id.startsWith(".")) return null
val dir = when (app.kind) {
@@ -155,6 +158,7 @@ object UserApps {
icon.isEmpty() -> if (app.kind == Kind.APPIMAGE) AppImageManager.restoreIcon(dir) else target.delete()
else -> withIcon(context, icon) { file -> if (file == null || !saveIcon(file, target)) return context.getString(R.string.user_apps_icon_failed) }
}
if (fex != null && app.kind != Kind.FLATPAK && !LinuxFex.setMode(dir, fex)) return context.getString(R.string.user_apps_failed)
when (app.kind) {
Kind.SCRIPT -> writeScriptEntry(context, dir)
Kind.APPIMAGE -> AppImageManager.writeMenuEntry(context, dir)
@@ -310,14 +314,18 @@ object UserApps {
return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo"
}
/** The ARM64 AppImage among a release's files: named for aarch64/arm64, else for no other CPU. */
/**
* The AppImage among a release's files: named for aarch64/arm64, else for no other CPU, else the
* x86_64 one, which runs through FEX.
*/
internal fun pickAsset(names: List<String>): String? {
val images = names.filter { it.endsWith(".appimage", ignoreCase = true) }
return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) }
}
/** The newest release of [repo] with an ARM64 AppImage, preferring full releases; else what went wrong. */
/** The newest release of [repo] with an AppImage this device runs, preferring full releases; else what went wrong. */
private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> {
val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15")
val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() }
@@ -14,7 +14,7 @@ import kotlin.math.abs
* The client reads `/sys/class/power_supply/BAT<n>/...` - a laptop's or a Deck's naming and files
* (`energy_now`, `power_now`, `time_to_empty_now`, a `uevent` of `POWER_SUPPLY_*` lines). Android
* names its supply `battery` and lays it out differently, and on many phones an app may not read
* it at all, so the client showed no battery. This writes a `BAT0` (and a `BAT1`, the Deck's name)
* it at all, so the client showed no battery. This writes one `BAT1` (the Deck's name)
* from Android's own battery API - the same on every device - into a directory the session binds
* over `/sys/class/power_supply`, refreshed every few seconds while the session runs.
*
@@ -31,6 +31,8 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
private var thread: Thread? = null
override fun start() {
// Older builds exported the same aggregate battery twice; MangoHud counts both supplies.
File(dir, "BAT0").deleteRecursively()
running = true
write()
thread = Thread({
@@ -39,7 +41,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
if (running) write()
}
}, "battery-sysfs").apply { isDaemon = true; start() }
Log.i(TAG, "battery: BAT0/BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
Log.i(TAG, "battery: BAT1 written to ${dir.path} every ${PERIOD_MS / 1000} s")
}
override fun stop() {
@@ -87,7 +89,7 @@ class BatteryComponent(val dir: File, private val vpowerDir: File) : SessionPart
pct <= 15 -> "Low"
else -> "Normal"
}
for (name in listOf("BAT0", "BAT1")) {
for (name in listOf("BAT1")) {
val bat = File(dir, name).apply { mkdirs() }
val attrs = linkedMapOf(
"type" to "Battery",
@@ -4,6 +4,8 @@ import com.droiddeck.launcher.core.Hashes
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.Downloader
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.gpu.GpuInfo
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.github.luben.zstd.ZstdInputStream
import com.github.luben.zstd.ZstdOutputStream
@@ -84,6 +86,7 @@ object ComponentsManager {
val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean,
/** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */
val reappliedAt: Long = 0,
val sync: EsyncPacks.ToolState? = null,
)
data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>)
data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long)
@@ -101,11 +104,25 @@ object ComponentsManager {
private fun stateFile(context: Context) = File(dataDir(context), "state.json")
private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json")
/**
* Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's
* Inside the Linux runtime, read by the Proton launch wrappers (steam-compatibility's
* bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use.
*/
private const val LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR)
private const val LAUNCH_DIR = "root/.local/share/droiddeck-components"
private const val LEGACY_LAUNCH_DIR = "root/.local/share/bannerlator-components"
private fun launchDir(context: Context): File {
migrateLaunchDir(context)
return File(root(context), LAUNCH_DIR)
}
fun migrateLaunchDir(context: Context) = synchronized(lock) {
val legacy = File(root(context), LEGACY_LAUNCH_DIR)
val dir = File(root(context), LAUNCH_DIR)
if (!legacy.isDirectory || dir.exists() || !legacy.renameTo(dir)) return@synchronized
val desired = File(dir, "desired.tsv")
val text = FileUtils.readString(desired) ?: return@synchronized
val staged = File(dir, "desired.tsv.staged")
if (!FileUtils.writeString(staged, text.replace("/$LEGACY_LAUNCH_DIR/", "/$LAUNCH_DIR/")) || !staged.renameTo(desired)) staged.delete()
}
fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" }
@@ -202,22 +219,24 @@ object ComponentsManager {
* Is anything running on this Proton right now - a game, or the client's own start-up runs,
* whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines.
*/
fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") }
/**
* Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun
* (Proton's own script stays up until the game exits); the client's start-up runs do not.
*/
fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") }
private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean {
val needle = proton.guestPath.trimEnd('/') + "/"
private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean {
val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath))
.map { it.trimEnd('/') + "/" }
val procs = File("/proc").listFiles() ?: return false
for (p in procs) {
if (!p.name.all(Char::isDigit)) continue
val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue
if (cmd.isEmpty()) continue
if (match(needle, String(cmd).replace('\u0000', ' '))) return true
val text = String(cmd).replace('\u0000', ' ')
if (needles.any { match(it, text) }) return true
}
return false
}
@@ -290,6 +309,7 @@ object ComponentsManager {
}
}
require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" }
if (comp == "fex") FEX_FILES.filter { it.endsWith(".so") && !File(staged, it).isFile }.forEach { File(dir, it).delete() }
COMP_DIR[comp]?.let { compDir ->
for (arch in PE_ARCHES) {
val target = File(dir, "$compDir/$arch")
@@ -392,6 +412,7 @@ object ComponentsManager {
if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) }
}
}
val syncPacks = EsyncPacks.status(root(context))
val views = protons(context).map { p ->
val comps = COMPONENTS.associateWith { comp ->
val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version"))
@@ -414,7 +435,7 @@ object ComponentsManager {
q?.optString("label"),
)
}
ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0)
ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath))
}
Snapshot(views, packages(context))
}
@@ -489,7 +510,7 @@ object ComponentsManager {
val wcp = File(packagesDir(context), safeName(file))
val info = packageInfo(wcp)
val state = loadState(context)
if (inUse(p)) {
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version))
saveState(context, state)
return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes."
@@ -515,7 +536,7 @@ object ComponentsManager {
val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp")
check(wcp.isFile) { "That original bundle is not stored" }
val state = loadState(context)
if (inUse(p)) {
if (inUse(context, p)) {
state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion"))
saveState(context, state)
return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes."
@@ -545,7 +566,7 @@ object ComponentsManager {
val all = protons(context)
for (pid in queued.keys().asSequence().toList()) {
val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue
if (inUse(p)) continue
if (inUse(context, p)) continue
val comps = queued.optJSONObject(pid) ?: continue
for (comp in comps.keys().asSequence().toList()) {
val q = comps.optJSONObject(comp) ?: continue
@@ -640,6 +661,107 @@ object ComponentsManager {
return Catalog(items, o.optLong("fetchedAt"))
}
/**
* A package Proton does not ship, required on the GPUs listed here. An empty [models] set
* means every model in [families]. A row the user already chose in Components is left alone;
* a Steam update that puts the Proton's own files back is replaced on the next session.
*/
internal data class ForcedPackage(
val families: Set<GpuInfo.Family>,
val comp: String,
val file: String,
val release: String,
val models: Set<Int> = emptySet(),
) {
fun matches(gpu: GpuInfo) = gpu.family in families && (models.isEmpty() || gpu.model in models)
}
internal val FORCED_PACKAGES = listOf(
// DXVK 3 refuses to start unless the device exposes storageBuffer8BitAccess. Turnip on
// Adreno 6xx does not. 2.7.1 is the last DXVK 2 package on the Dxvk-Linux tag.
ForcedPackage(setOf(GpuInfo.Family.A6XX), "dxvk", "dxvk-2.7.1-linux.wcp", "Dxvk-Linux"),
)
/**
* True when this Proton should receive the forced package. No active swap means the Proton's
* own copy. A record for a different Proton build means a Steam update put that copy back,
* whichever package was recorded. A record for this build is a Components choice and stays.
*/
internal fun forcedPackageWanted(activeFile: String?, protonVersionMatches: Boolean): Boolean =
activeFile.isNullOrEmpty() || !protonVersionMatches
/** The catalog row for this file and release, or null. */
internal fun catalogMatch(items: List<CatalogItem>, file: String, release: String): CatalogItem? =
items.firstOrNull { it.file == file && it.release == release }
/**
* Install every [FORCED_PACKAGES] row this GPU matches into each Proton that
* [forcedPackageWanted] selects. Returns a line for the log, or null when this GPU or these
* Protons need nothing. A failed fetch throws and the session logs it without stopping.
*/
fun ensureForcedPackages(context: Context): String? = ensureForcedPackages(
context, GpuInfo.detect(),
{ file, release -> catalogItem(context, file, release) },
{ download(context, it) {} },
{ id, file -> swap(context, id, file) },
)
internal fun ensureForcedPackages(
context: Context,
gpu: GpuInfo,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
apply: (protonId: String, file: String) -> String,
): String? {
val rules = FORCED_PACKAGES.filter { it.matches(gpu) }
if (rules.isEmpty()) return null
val installed = protons(context)
val lines = mutableListOf<String>()
for (rule in rules) {
val state = loadState(context)
val targets = installed.filter { p ->
val active = state.optJSONObject("active")?.optJSONObject(p.id)?.optJSONObject(rule.comp)
forcedPackageWanted(active?.optString("file"), active?.optString("protonVersion") == p.version)
}
if (targets.isEmpty()) continue
val missing = missingPackage(context, rule, lookup, fetch)
if (missing != null) {
lines.add(missing)
continue
}
lines.add(targets.joinToString("; ") { apply(it.id, rule.file) })
}
return lines.joinToString("; ").ifEmpty { null }
}
/** Null when [rule]'s package is stored and readable. Otherwise the log line, after a fetch. */
private fun missingPackage(
context: Context,
rule: ForcedPackage,
lookup: (file: String, release: String) -> CatalogItem?,
fetch: (CatalogItem) -> Unit,
): String? {
val wcp = File(packagesDir(context), safeName(rule.file))
if (wcp.isFile && runCatching { packageInfo(wcp) }.isSuccess) return null
wcp.delete()
val item = lookup(rule.file, rule.release)
?: return "${rule.file} is not in the Nightlies listing; ${LABEL.getValue(rule.comp)} stays on the Proton's copy"
fetch(item)
return null
}
/** The cached Nightlies row for this file, fetching the listing when the cache does not have it. */
internal fun catalogItem(context: Context, file: String, release: String): CatalogItem? =
resolveCatalogItem(file, release, { catalog(context, false).items }, { catalog(context, true).items })
/** [cached] when it has the row, otherwise [refresh]. [refresh] is not called on a hit. */
internal fun resolveCatalogItem(
file: String,
release: String,
cached: () -> List<CatalogItem>,
refresh: () -> List<CatalogItem>,
): CatalogItem? = catalogMatch(cached(), file, release) ?: catalogMatch(refresh(), file, release)
/** Downloads a catalog item into storage, verifying the release's sha256 and the package type. */
fun download(context: Context, item: CatalogItem, progress: (Int) -> Unit): Package {
require(item.url.startsWith("https://github.com/$NIGHTLIES/releases/download/")) { "Downloads come only from the Nightlies releases" }
@@ -0,0 +1,941 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.core.Hashes
import com.github.luben.zstd.ZstdInputStream
import org.apache.commons.compress.archivers.tar.TarArchiveEntry
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
import org.apache.commons.compress.archivers.tar.TarConstants
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.nio.file.Files
import java.security.KeyFactory
import java.security.PublicKey
import java.security.SecureRandom
import java.security.Signature
import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
object EsyncPacks {
private const val TAG = "DroidDeckSync"
const val REPO = "Droid-Deck/DroidDeck-Components"
const val INDEX_URL = "https://github.com/$REPO/releases/download/droiddeck-esync-index/index.json"
const val SIG_URL = "$INDEX_URL.sig"
const val ASSET_PREFIX = "https://github.com/$REPO/releases/download/droiddeck-esync-"
const val PUBLIC_KEY = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFyqZQAMh3g3nsEGLU+kBGVPBEkXrDv5F3kPtuQ+FV1DkTCGmYUQ1LS9wJtHGt0Ht4wToHGGEp9yzDibYZ4/zhg=="
const val NTDLL = "files/lib/wine/aarch64-unix/ntdll.so"
const val WINESERVER = "files/bin-arm64/wineserver"
const val GUEST_STORE = "/root/.local/share/droiddeck-esync"
private const val STORE = "root/.local/share/droiddeck-esync"
private const val PACK_JSON = "pack.json"
private const val COMPLETE = ".complete"
private const val IGNORED = ".ignored"
private const val DIST_JSON = ".droiddeck-esync.json"
private const val WANTED = "wanted.tsv"
private const val TOOLS = "tools.tsv"
private const val FLOOR = "floor"
private const val PACK_FAILURES = "packFailures"
private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack"
private const val INDEX_LIMIT = 4L shl 20
private const val SIG_LIMIT = 4096L
private const val ASSET_LIMIT = 256L shl 20
private const val PACK_JSON_LIMIT = 1L shl 20
private const val MAX_AGE_MS = 12L * 60 * 60 * 1000
private const val WANTED_AGE_MS = 30L * 60 * 1000
private const val FAILURE_BACKOFF_MS = 10L * 60 * 1000
private const val STALE_MS = 60L * 60 * 1000
private const val PRUNE_AGE_MS = 30L * 24 * 60 * 60 * 1000
private const val TIMEOUT_MS = 30_000
private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
private val SHA256 = Regex("[0-9a-f]{64}")
private val ASSET_PATH = Regex("[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*")
private val TEMP = Regex("\\..+\\.tmp-[0-9a-f]{16}")
private val RETIRED = Regex("\\..+\\.del-[0-9a-f]{16}")
private val BINARIES = listOf(NTDLL, WINESERVER)
private val EXPECTED = setOf(PACK_JSON, NTDLL, WINESERVER)
private val REAL_DIRS = setOf(".", "files", "files/bin-arm64", "files/lib", "files/lib/wine", "files/lib/wine/aarch64-unix")
private val DIRECTORIES = REAL_DIRS - "." + ""
private val STOCK_LINKS = setOf("dist.lock", "files/steampipe_fixups_mtime")
data class Asset(val url: String, val sha256: String, val size: Long)
data class Entry(
val id: String,
val flavor: String,
val rev: Int,
val version: String,
val versionLine: String,
val stock: Map<String, String>,
val exports: String,
val sourceMatch: Boolean,
val files: Map<String, String>,
val asset: Asset,
val revoked: Boolean,
)
data class Index(val schema: Int, val generated: Long, val packs: List<Entry>)
private class Signed(val bytes: ByteArray, val sig: ByteArray, val index: Index)
data class Wanted(
val ntdll: String,
val wineserver: String,
val version: String,
val exports: String,
val toolDir: String,
val firstSeen: Long,
val inUse: Rank? = null,
val epoch: Long = 0L,
) {
val key: String get() = "$ntdll:$wineserver"
}
data class Rank(val kind: Int, val sameVersion: Int, val rev: Int) : Comparable<Rank> {
override fun compareTo(other: Rank): Int = compareValuesBy(this, other, { it.kind }, { it.sameVersion }, { it.rev })
}
data class ToolState(
val toolDir: String,
val versionLine: String,
val ntdll: String,
val wineserver: String,
val state: String,
val packId: String?,
)
data class Status(val installed: Int, val wanted: Int, val tools: List<ToolState>) {
fun tool(guestPath: String): ToolState? {
val path = guestPath.trimEnd('/')
return tools.firstOrNull { it.toolDir.trimEnd('/') == path }
}
}
private val fetchLock = Any()
private val cacheLock = Any()
private val installLock = Any()
private val pending = AtomicBoolean(false)
private val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "droiddeck-esync-fetch").apply { isDaemon = true }
}
private val random = SecureRandom()
private val shippedKey: PublicKey by lazy { key(PUBLIC_KEY) }
internal var keyOverride: PublicKey? = null
private val publicKey: PublicKey get() = keyOverride ?: shippedKey
internal var fetcher: (String, Long, OutputStream, ((Long) -> Unit)?) -> Boolean = { url, limit, out, progress ->
httpFetch(url, limit, out, progress)
}
fun store(root: File): File = File(root, STORE)
private fun cacheDir(context: Context) = File(context.filesDir, "droiddeck-esync")
internal fun key(base64: String): PublicKey =
KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(base64)))
fun allowedUrl(url: String): Boolean =
url.startsWith(ASSET_PREFIX) && ASSET_PATH.matches(url.substring(ASSET_PREFIX.length))
fun parseIndex(bytes: ByteArray): Index {
val root = JSONObject(String(bytes, Charsets.UTF_8))
val schema = root.getInt("schema")
require(schema == 1) { "sync index schema $schema is not supported" }
val generated = root.getLong("generated")
val array = root.getJSONArray("packs")
val packs = ArrayList<Entry>()
val seen = HashSet<String>()
for (i in 0 until array.length()) {
val entry = try {
parseEntry(array.getJSONObject(i))
} catch (e: Exception) {
Log.w(TAG, "sync index entry $i skipped: ${e.message}")
continue
}
if (seen.add(entry.id)) packs.add(entry)
}
return Index(schema, generated, packs)
}
private fun parseEntry(o: JSONObject): Entry {
val id = o.getString("id")
require(ID.matches(id)) { "invalid id $id" }
val asset = o.getJSONObject("asset")
val url = asset.getString("url")
require(allowedUrl(url)) { "$id: asset $url is outside $ASSET_PREFIX" }
val sha256 = asset.getString("sha256").lowercase()
require(SHA256.matches(sha256)) { "$id: invalid asset sha256" }
val size = asset.getLong("size")
require(size in 1..ASSET_LIMIT) { "$id: invalid asset size $size" }
val flavor = o.getString("flavor")
require(flavor.isNotEmpty()) { "$id: no flavor" }
return Entry(
id, flavor, o.getInt("rev"), o.optString("version"), o.optString("version_line"),
hashes(o.getJSONObject("stock")), o.optString("exports").lowercase(), o.opt("source_match") == true,
hashes(o.getJSONObject("files")), Asset(url, sha256, size), o.optBoolean("revoked", false),
)
}
private fun hashes(o: JSONObject): Map<String, String> = BINARIES.associateWith { rel ->
o.getString(rel).lowercase().also { require(SHA256.matches(it)) { "invalid sha256 for $rel" } }
}
fun verify(indexBytes: ByteArray, sigBytes: ByteArray): Boolean = try {
verify(indexBytes, sigBytes, publicKey)
} catch (e: Exception) {
false
}
internal fun verify(indexBytes: ByteArray, sigBytes: ByteArray, key: PublicKey): Boolean = try {
Signature.getInstance("SHA256withECDSA").run {
initVerify(key)
update(indexBytes)
verify(sigBytes)
}
} catch (e: Exception) {
false
}
internal fun accept(indexBytes: ByteArray, sigBytes: ByteArray, cached: Index?, key: PublicKey = publicKey, floor: Long = 0L): Index? {
if (!verify(indexBytes, sigBytes, key)) {
Log.w(TAG, "sync index signature does not verify")
return null
}
val index = try {
parseIndex(indexBytes)
} catch (e: Exception) {
Log.w(TAG, "sync index unreadable: ${e.message}")
return null
}
val minimum = maxOf(cached?.generated ?: Long.MIN_VALUE, floor)
if (index.generated < minimum) {
Log.w(TAG, "sync index ${index.generated} is older than $minimum, one already accepted; kept the cached one")
return null
}
return index
}
internal fun kind(entry: Entry, row: Wanted): Int = when {
entry.stock[NTDLL] == row.ntdll && entry.stock[WINESERVER] == row.wineserver ->
if (entry.version != row.version && row.epoch > 0 && epochOf(entry.versionLine) > row.epoch) 0 else 2
entry.sourceMatch && entry.version.isNotEmpty() && entry.version == row.version &&
entry.exports.isNotEmpty() && entry.exports == row.exports -> 1
else -> 0
}
fun matches(entry: Entry, row: Wanted): Boolean = !entry.revoked && kind(entry, row) > 0
internal fun epochOf(versionLine: String): Long =
versionLine.trim().split(Regex("\\s+")).firstOrNull()?.toLongOrNull()?.takeIf { it > 0 } ?: 0L
internal fun withEpochs(wanted: List<Wanted>, tools: List<ToolState>): List<Wanted> = wanted.map { row ->
tools.firstOrNull {
it.toolDir == row.toolDir && it.ntdll.trim().lowercase() == row.ntdll && it.wineserver.trim().lowercase() == row.wineserver
}?.let { row.copy(epoch = epochOf(it.versionLine)) } ?: row
}
internal fun rank(entry: Entry, row: Wanted): Rank =
Rank(kind(entry, row), if (entry.version.isNotEmpty() && entry.version == row.version) 1 else 0, entry.rev)
fun best(entries: List<Entry>, row: Wanted): Entry? = entries.filter { matches(it, row) }
.maxWithOrNull(compareBy<Entry>({ rank(it, row) }, { it.id }))
internal fun plan(index: Index, wanted: List<Wanted>, installed: Set<String>): List<Entry> =
wanted.mapNotNull { row -> best(index.packs, row)?.takeIf { row.inUse == null || rank(it, row) > row.inUse } }
.distinctBy { it.id }.filter { it.id !in installed }
internal fun upgradeRows(store: File, tools: List<ToolState>): List<Wanted> = tools.mapNotNull { tool ->
val ntdll = tool.ntdll.trim().lowercase()
val wineserver = tool.wineserver.trim().lowercase()
if (tool.state != "pack" || !SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null
val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty()
val pack = tool.packId?.takeIf { ID.matches(it) }?.let { installedPack(store, it) }
val exports = if (pack != null && pack.opt("source_match") == true && token.isNotEmpty() && pack.opt("version") == token)
(pack.opt("exports") as? String).orEmpty() else ""
val epoch = epochOf(tool.versionLine)
val inUse = pack?.let { packRank(it, ntdll, wineserver, token, exports, epoch) }?.takeIf { it.kind > 0 }
Wanted(ntdll, wineserver, token, exports, tool.toolDir, 0L, inUse, epoch)
}
private fun packRank(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Rank =
Rank(covers(pack, ntdll, wineserver, token, exports, epoch), if (token.isNotEmpty() && pack.opt("version") == token) 1 else 0,
pack.optInt("rev"))
private fun installedPack(store: File, id: String): JSONObject? {
val dir = File(store, "packs/$id")
if (!File(dir, COMPLETE).isFile) return null
return readFile(File(dir, PACK_JSON), PACK_JSON_LIMIT)
?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() }
}
fun parseWanted(text: String): List<Wanted> = text.lineSequence().mapNotNull { line ->
val fields = line.trimEnd('\r').split('\t')
if (fields.size < 5) return@mapNotNull null
val ntdll = fields[0].trim().lowercase()
val wineserver = fields[1].trim().lowercase()
if (!SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null
Wanted(ntdll, wineserver, field(fields[2]), field(fields[3]).lowercase(), fields[4],
fields.getOrNull(5)?.trim()?.toLongOrNull() ?: 0L)
}.distinctBy { it.key }.toList()
private fun field(value: String): String = value.trim().takeIf { it != "-" }.orEmpty()
fun parseTools(text: String): List<ToolState> = text.lineSequence().mapNotNull { line ->
val fields = line.trimEnd('\r').split('\t')
if (fields.size < 6 || fields[0].isEmpty()) return@mapNotNull null
ToolState(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5].takeIf { it.isNotEmpty() && it != "-" })
}.toList()
fun status(root: File): Status = try {
val store = store(root)
if (!store.isDirectory) Status(0, 0, emptyList())
else {
val wanted = readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty()
val tools = readText(File(store, TOOLS))?.let { parseTools(it) }.orEmpty()
Status(installedIds(store).size, wanted.size, settled(store, tools, wanted))
}
} catch (t: Throwable) {
Log.w(TAG, "sync status", t)
Status(0, 0, emptyList())
}
internal fun settled(store: File, tools: List<ToolState>, wanted: List<Wanted>): List<ToolState> {
if (tools.none { it.state == "wanted" }) return tools
val packs = installedIds(store).sorted()
.filter { !File(store, "packs/$it/$IGNORED").exists() }
.mapNotNull { id -> installedPack(store, id)?.let { id to it } }
val rows = wanted.associateBy { it.key }
return tools.map { tool ->
if (tool.state != "wanted") return@map tool
val ntdll = tool.ntdll.trim().lowercase()
val wineserver = tool.wineserver.trim().lowercase()
val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty()
val exports = rows["$ntdll:$wineserver"]?.exports.orEmpty()
val epoch = epochOf(tool.versionLine)
val match = packs.map { (id, pack) -> Triple(id, pack, packRank(pack, ntdll, wineserver, token, exports, epoch)) }
.filter { it.third.kind > 0 }
.maxWithOrNull(compareBy({ it.third }, { it.first }))
if (match == null) tool else tool.copy(state = "pack", packId = match.first)
}
}
private fun covers(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Int {
val stock = pack.optJSONObject("stock")
val version = pack.opt("version") as? String
if (stock != null && stock.optString(NTDLL) == ntdll && stock.optString(WINESERVER) == wineserver) {
return if (version != token && epoch > 0 && epochOf(pack.optString("version_line")) > epoch) 0 else 2
}
val packExports = pack.opt("exports") as? String
return if (pack.opt("source_match") == true && token.isNotEmpty() && version == token &&
exports.isNotEmpty() && packExports == exports) 1 else 0
}
fun distPathsFor(root: File, stockGuestPath: String): List<String> = try {
val wanted = stockGuestPath.trimEnd('/')
if (wanted.isEmpty()) emptyList()
else File(store(root), "dist").listFiles().orEmpty()
.filter { !it.name.startsWith(".") && it.isDirectory }
.mapNotNull { dir ->
val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() }
?: return@mapNotNull null
val stock = record.optString("stock").trimEnd('/')
val tool = record.optString("tool").trimEnd('/')
if (stock == wanted || tool == wanted) "$GUEST_STORE/dist/${dir.name}" else null
}
.sorted()
} catch (t: Throwable) {
Log.w(TAG, "sync dists", t)
emptyList()
}
fun stageBundled(context: Context, root: File) {
try {
val store = store(root)
if (!store.isDirectory && !store.mkdirs()) {
Log.w(TAG, "could not create $store")
return
}
val bundled = bundledIndex(context)
val trusted = if (bundled == null) cachedIndex(context)
else storeIndex(context, bundled.bytes, bundled.sig, bundled.index, newerOnly = true)
if (trusted != null) removeRevoked(store, trusted)
if (bundled == null) return
if (trusted == null) {
Log.w(TAG, "the bundled sync index is older than one already accepted; bundled packs skipped")
return
}
val revoked = (bundled.index.packs + trusted.packs).filter { it.revoked }.map { it.id }.toSet()
val shipped = context.assets.list("droiddeck-esync/packs")?.toSet().orEmpty()
val missing = bundled.index.packs.filter {
it.id !in revoked && "${it.id}.tzst" in shipped && !File(store, "packs/${it.id}/$COMPLETE").isFile
}
if (missing.isEmpty()) return
sweep(context, store)
for (entry in missing) installBundled(context, store, entry)
} catch (t: Throwable) {
Log.w(TAG, "bundled sync packs", t)
}
}
private fun bundledIndex(context: Context): Signed? {
val bundle = context.assets.list("droiddeck-esync")?.toSet().orEmpty()
if ("index.json" !in bundle || "index.json.sig" !in bundle) return null
val bytes = context.assets.open("droiddeck-esync/index.json").use { readBounded(it, INDEX_LIMIT) }
val sig = context.assets.open("droiddeck-esync/index.json.sig").use { readBounded(it, SIG_LIMIT) }
if (!verify(bytes, sig)) {
Log.w(TAG, "the bundled sync index does not verify; bundled packs skipped")
return null
}
return Signed(bytes, sig, parseIndex(bytes))
}
private fun installBundled(context: Context, store: File, entry: Entry) {
val copy = try {
tempFile(context, entry.id)
} catch (e: IOException) {
Log.w(TAG, "bundled sync pack ${entry.id}", e)
return
}
try {
context.assets.open("droiddeck-esync/packs/${entry.id}.tzst").use { input ->
FileOutputStream(copy).use { output -> copyBounded(input, output, entry.asset.size) }
}
if (install(store, entry, copy)) {
Log.i(TAG, "sync pack ${entry.id} installed from the app")
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "installed"))
}
} catch (t: Throwable) {
Log.w(TAG, "bundled sync pack ${entry.id}", t)
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "failed", "reason" to t.message))
} finally {
copy.delete()
}
}
fun fetchInBackground(context: Context, root: File) {
val app = context.applicationContext ?: context
if (!pending.compareAndSet(false, true)) return
try {
executor.execute {
pending.set(false)
fetchWanted(app, root)
}
} catch (t: Throwable) {
pending.set(false)
Log.w(TAG, "could not schedule a sync fetch", t)
}
}
fun enabled(context: Context): Boolean = SessionPrefs.syncFallback(context) || try {
GameEnvironmentStore.read(context).let { config ->
(listOf(config.shared) + config.games.values).any { it["BL_SYNC_FALLBACK"] == "1" }
}
} catch (t: Throwable) {
false
}
fun fetchWanted(context: Context, root: File, onProgress: ((String, Int) -> Unit)? = null): Int = synchronized(fetchLock) {
try {
val store = store(root)
if (!store.isDirectory) return 0
cachedIndex(context)?.let { removeRevoked(store, it) }
if (!enabled(context)) return 0
sweep(context, store)
val toolsText = readText(File(store, TOOLS))
val tools = toolsText?.let { parseTools(it) }.orEmpty()
val wanted = withEpochs(readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty(), tools)
val index = refreshIndex(context, wanted) ?: return 0
removeRevoked(store, index)
val rows = wanted + upgradeRows(store, tools)
if (toolsText != null) prune(store, keep(store, index, rows, tools, bundledIds(context)), System.currentTimeMillis())
val planned = plan(index, rows, installedIds(store))
val state = loadState(context)
val previous = state.optJSONObject(PACK_FAILURES)
val failures = pruneFailures(previous, index)
val missing = planned.filter { !failedRecently(failures, it, System.currentTimeMillis()) }
var added = 0
for (entry in missing) {
if (download(context, store, entry, onProgress)) {
added++
failures.remove(entry.id)
} else {
failures.put(entry.id, failure(failures, entry, System.currentTimeMillis()))
}
}
val kept = failures.takeIf { it.length() > 0 }
if (kept?.toString() != previous?.toString()) {
if (kept != null) state.put(PACK_FAILURES, kept) else state.remove(PACK_FAILURES)
saveState(context, state)
}
if (planned.size > missing.size) Log.i(TAG, "sync: ${planned.size - missing.size} pack(s) wait after failed downloads")
if (missing.isNotEmpty()) Log.i(TAG, "sync: $added of ${missing.size} wanted pack(s) installed")
added
} catch (t: Throwable) {
Log.w(TAG, "fetching sync packs", t)
0
}
}
fun refreshIndex(context: Context, wanted: List<Wanted> = emptyList()): Index? = synchronized(fetchLock) {
val cached = cachedIndex(context)
val state = loadState(context)
val now = System.currentTimeMillis()
if (!due(cached, state, wanted, now)) return cached
val bytes = fetchBytes(INDEX_URL, INDEX_LIMIT)
val sig = if (bytes != null) fetchBytes(SIG_URL, SIG_LIMIT) else null
val fresh = if (bytes != null && sig != null) accept(bytes, sig, cached, floor = floor(context)) else null
if (bytes == null || sig == null || fresh == null) {
state.put("failedAt", now)
saveState(context, state)
return cached
}
val trusted = storeIndex(context, bytes, sig, fresh, newerOnly = false) ?: return cached
state.remove("failedAt")
state.put("checkedAt", now)
state.put("misses", misses(state.optJSONObject("misses"), trusted, wanted, now))
saveState(context, state)
Log.i(TAG, "sync index ${trusted.generated}: ${trusted.packs.size} pack(s)")
trusted
}
internal fun due(cached: Index?, state: JSONObject, wanted: List<Wanted>, now: Long): Boolean {
val failed = state.optLong("failedAt", 0L)
if (failed > 0 && now - failed in 0 until FAILURE_BACKOFF_MS) return false
val checked = state.optLong("checkedAt", 0L)
if (cached == null || checked <= 0 || now < checked || now - checked >= MAX_AGE_MS) return true
val misses = state.optJSONObject("misses")
return wanted.any { row ->
if (cached.packs.any { matches(it, row) }) return@any false
val miss = misses?.optJSONObject(row.key)
val count = miss?.optInt("n", 0) ?: 0
if (count <= 0) now - checked >= WANTED_AGE_MS
else now - (miss?.optLong("at", 0L) ?: 0L) >= backoff(count)
}
}
private fun backoff(misses: Int): Long = minOf(WANTED_AGE_MS shl minOf(misses - 1, 5), MAX_AGE_MS)
private fun packBackoff(failures: Int): Long = minOf(FAILURE_BACKOFF_MS shl minOf(failures - 1, 7), MAX_AGE_MS)
internal fun pruneFailures(previous: JSONObject?, index: Index): JSONObject {
val out = JSONObject()
if (previous == null) return out
val assets = index.packs.associate { it.id to it.asset.sha256 }
for (id in previous.keys()) {
val record = previous.optJSONObject(id) ?: continue
if (assets[id] == record.optString("sha")) out.put(id, record)
}
return out
}
internal fun failedRecently(failures: JSONObject?, entry: Entry, now: Long): Boolean {
val record = failures?.optJSONObject(entry.id) ?: return false
if (record.optString("sha") != entry.asset.sha256) return false
val count = record.optInt("n", 0)
return count > 0 && now - record.optLong("at", 0L) in 0 until packBackoff(count)
}
internal fun failure(failures: JSONObject, entry: Entry, now: Long): JSONObject {
val record = failures.optJSONObject(entry.id)?.takeIf { it.optString("sha") == entry.asset.sha256 }
return JSONObject().put("sha", entry.asset.sha256).put("n", (record?.optInt("n", 0) ?: 0) + 1).put("at", now)
}
private fun misses(previous: JSONObject?, index: Index, wanted: List<Wanted>, now: Long): JSONObject {
val out = JSONObject()
for (row in wanted) {
if (index.packs.any { matches(it, row) }) continue
val count = previous?.optJSONObject(row.key)?.optInt("n", 0) ?: 0
out.put(row.key, JSONObject().put("n", count + 1).put("at", now))
}
return out
}
private fun loadState(context: Context): JSONObject = synchronized(cacheLock) {
readFile(File(cacheDir(context), "state.json"), PACK_JSON_LIMIT)
?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() } ?: JSONObject()
}
private fun saveState(context: Context, state: JSONObject) = synchronized(cacheLock) {
try {
writeAtomic(File(cacheDir(context).apply { mkdirs() }, "state.json"), state.toString().toByteArray(Charsets.UTF_8))
} catch (e: IOException) {
Log.w(TAG, "sync state", e)
}
}
private fun cachedIndex(context: Context): Index? = synchronized(cacheLock) {
val dir = cacheDir(context)
val bytes = readFile(File(dir, "index.json"), INDEX_LIMIT) ?: return null
val sig = readFile(File(dir, "index.json.sig"), SIG_LIMIT) ?: return null
if (!verify(bytes, sig)) {
Log.w(TAG, "the cached sync index does not verify; ignoring it")
return null
}
runCatching { parseIndex(bytes) }.getOrNull()
}
private fun floor(context: Context): Long = synchronized(cacheLock) {
readFile(File(cacheDir(context), FLOOR), 64)?.toString(Charsets.UTF_8)?.trim()?.toLongOrNull() ?: 0L
}
private fun storeIndex(context: Context, bytes: ByteArray, sig: ByteArray, index: Index, newerOnly: Boolean): Index? = synchronized(cacheLock) {
val cached = cachedIndex(context)
val floor = floor(context)
if (index.generated < floor) return cached
if (cached != null && (cached.generated > index.generated || newerOnly && cached.generated >= index.generated)) return cached
try {
val dir = cacheDir(context).apply { mkdirs() }
if (index.generated > floor) writeAtomic(File(dir, FLOOR), index.generated.toString().toByteArray(Charsets.UTF_8))
writeAtomic(File(dir, "index.json.sig"), sig)
writeAtomic(File(dir, "index.json"), bytes)
} catch (e: IOException) {
Log.w(TAG, "could not cache the sync index", e)
}
index
}
private fun download(context: Context, store: File, entry: Entry, onProgress: ((String, Int) -> Unit)?): Boolean {
if (!allowedUrl(entry.asset.url)) {
Log.w(TAG, "sync pack ${entry.id}: ${entry.asset.url} is outside $ASSET_PREFIX")
return false
}
val part = try {
tempFile(context, entry.id)
} catch (e: IOException) {
Log.w(TAG, "sync pack ${entry.id}", e)
return false
}
try {
onProgress?.invoke(PROGRESS_LABEL, -1)
var reported = -1
val fetched = FileOutputStream(part).use { output ->
fetch(entry.asset.url, entry.asset.size, output) { done ->
val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100)
if (percent != reported) {
reported = percent
onProgress?.invoke(PROGRESS_LABEL, percent)
}
}
}
if (!fetched) {
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to "download"))
return false
}
if (install(store, entry, part)) {
Log.i(TAG, "sync pack ${entry.id} downloaded and installed")
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "installed"))
}
return true
} catch (t: Throwable) {
Log.w(TAG, "sync pack ${entry.id}", t)
SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to t.message))
return false
} finally {
part.delete()
}
}
internal fun install(store: File, entry: Entry, archive: File): Boolean {
require(archive.length() == entry.asset.size) { "${entry.id}: ${archive.length()} bytes, the index says ${entry.asset.size}" }
require(Hashes.sha256(archive) == entry.asset.sha256) { "${entry.id}: the archive does not match the index checksum" }
return TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use {
place(store, entry, it)
}
}
internal fun place(store: File, entry: Entry, tar: TarArchiveInputStream): Boolean {
val packs = File(store, "packs")
if (!packs.isDirectory && !packs.mkdirs()) throw IOException("could not create $packs")
val temp = File(packs, ".${entry.id}.tmp-${token()}")
try {
if (!temp.mkdir()) throw IOException("could not create $temp")
unpack(tar, temp)
val manifest = File(temp, PACK_JSON)
checkPack(JSONObject(manifest.readText()), entry)
for (rel in BINARIES) {
val file = File(temp, rel)
require(Hashes.sha256(file) == entry.files[rel]) { "${entry.id}: $rel does not match the index" }
if (!file.setReadable(true, false) || !file.setExecutable(true, false)) throw IOException("could not mark $rel executable")
}
manifest.setReadable(true, false)
if (!File(temp, COMPLETE).createNewFile()) throw IOException("could not mark ${entry.id} complete")
return commit(packs, temp, entry.id)
} finally {
if (temp.exists()) FileUtils.delete(temp)
}
}
private fun commit(packs: File, temp: File, id: String): Boolean = synchronized(installLock) {
val target = File(packs, id)
if (File(target, COMPLETE).isFile) return false
if (target.exists() || isLink(target)) retire(packs, target)
if (!temp.renameTo(target)) throw IOException("could not move sync pack $id into place")
true
}
internal fun checkPack(pack: JSONObject, entry: Entry) {
require(pack.opt("format") == 1) { "${entry.id}: pack.json format ${pack.opt("format")}" }
require(pack.opt("id") == entry.id) { "pack.json names ${pack.opt("id")}, the index ${entry.id}" }
require(pack.opt("flavor") == entry.flavor) { "${entry.id}: flavor differs from the index" }
require(pack.opt("rev") == entry.rev) { "${entry.id}: rev differs from the index" }
require(pack.opt("version") == entry.version) { "${entry.id}: version differs from the index" }
require(!pack.has("version_line") || pack.opt("version_line") is String) { "${entry.id}: version_line is not a string" }
require(strictHashes(pack.opt("stock")) == entry.stock) { "${entry.id}: stock hashes differ from the index" }
require(pack.opt("exports") == entry.exports) { "${entry.id}: exports differ from the index" }
require(pack.opt("source_match") == entry.sourceMatch) { "${entry.id}: source_match differs from the index" }
require(strictHashes(pack.opt("files")) == entry.files) { "${entry.id}: file hashes differ from the index" }
require(!pack.has("copy") || validCopy(pack.opt("copy"))) { "${entry.id}: invalid copy list" }
require(!truthy(pack.opt("revoked")) && !truthy(pack.opt("ignored"))) { "${entry.id}: pack.json is revoked" }
}
private fun strictHashes(value: Any?): Map<String, String>? {
if (value !is JSONObject) return null
return BINARIES.associateWith { rel -> (value.opt(rel) as? String)?.takeIf { SHA256.matches(it) } ?: return null }
}
private fun validCopy(value: Any?): Boolean =
value is JSONArray && (0 until value.length()).all { copyPath(value.opt(it)) }
internal fun copyPath(value: Any?): Boolean {
if (value !is String || value.isEmpty() || value.startsWith("/") || '\u0000' in value) return false
if (value.split('/').any { it.isEmpty() || it == "." || it == ".." }) return false
return value.substringBeforeLast('/', ".") in REAL_DIRS && value !in BINARIES && value !in REAL_DIRS &&
value !in STOCK_LINKS && value != DIST_JSON
}
private fun truthy(value: Any?): Boolean = when (value) {
null, JSONObject.NULL -> false
is Boolean -> value
is Number -> value.toDouble() != 0.0
is String -> value.isNotEmpty()
is JSONArray -> value.length() > 0
is JSONObject -> value.length() > 0
else -> true
}
internal fun unpack(tar: TarArchiveInputStream, dest: File) {
val seen = HashSet<String>()
while (true) {
val entry = tar.nextEntry ?: break
val rel = entryPath(entry) ?: continue
require(seen.add(rel)) { "$rel appears twice" }
val limit = if (rel == PACK_JSON) PACK_JSON_LIMIT else ASSET_LIMIT
require(entry.size in 0..limit) { "$rel is too large" }
val out = File(dest, rel)
out.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("could not create $it") }
FileOutputStream(out).use {
copyBounded(tar, it, limit)
it.fd.sync()
}
}
require(seen == EXPECTED) { "the pack lacks ${(EXPECTED - seen).joinToString()}" }
}
internal fun entryPath(entry: TarArchiveEntry): String? {
val name = entry.name
require(!name.startsWith("/")) { "absolute path $name" }
val parts = name.split('/').filter { it.isNotEmpty() }.let { if (it.firstOrNull() == ".") it.drop(1) else it }
require(parts.none { it == "." || it == ".." }) { "unsafe path $name" }
require(!entry.isSymbolicLink && !entry.isLink) { "link entry $name" }
require(!entry.isCharacterDevice && !entry.isBlockDevice && !entry.isFIFO) { "device entry $name" }
val rel = parts.joinToString("/")
if (entry.isDirectory) {
require(rel in DIRECTORIES) { "unexpected directory $name" }
return null
}
require(entry.linkFlag == TarConstants.LF_NORMAL || entry.linkFlag == TarConstants.LF_OLDNORM) { "unsupported entry type for $name" }
require(!entry.isSparse) { "sparse entry $name" }
require(rel in EXPECTED) { "unexpected entry $name" }
return rel
}
internal fun installedIds(store: File): Set<String> = File(store, "packs").listFiles().orEmpty()
.filter { ID.matches(it.name) && File(it, COMPLETE).isFile }
.map { it.name }
.toSet()
internal fun removeRevoked(store: File, index: Index): List<String> {
val revoked = index.packs.filter { it.revoked }.map { it.id }.toSet()
if (revoked.isEmpty()) return emptyList()
val packs = File(store, "packs")
val removed = ArrayList<String>()
synchronized(installLock) {
for (id in revoked) {
val dir = File(packs, id)
if (!dir.exists() && !isLink(dir)) continue
retire(packs, dir)
removed.add(id)
}
}
for (id in removed) {
Log.i(TAG, "sync pack $id was revoked; removed")
SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "revoked"))
}
return removed
}
private fun bundledIds(context: Context): Set<String> =
context.assets.list("droiddeck-esync/packs").orEmpty().filter { it.endsWith(".tzst") }.map { it.removeSuffix(".tzst") }.toSet()
internal fun keep(store: File, index: Index, rows: List<Wanted>, tools: List<ToolState>, bundled: Set<String>): Set<String> {
val ids = HashSet(bundled)
tools.mapNotNullTo(ids) { it.packId }
rows.mapNotNullTo(ids) { best(index.packs, it)?.id }
File(store, "dist").listFiles().orEmpty().filter { it.isDirectory }.forEach { dir ->
val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() }
(record?.opt("pack") as? String)?.let { ids.add(it) }
}
return ids
}
internal fun prune(store: File, keep: Set<String>, now: Long): List<String> {
val packs = File(store, "packs")
val removed = ArrayList<String>()
synchronized(installLock) {
for (id in installedIds(store).sorted()) {
val dir = File(packs, id)
if (id in keep || now - dir.lastModified() < PRUNE_AGE_MS) continue
retire(packs, dir)
removed.add(id)
}
}
for (id in removed) {
Log.i(TAG, "sync pack $id is no longer used; removed")
SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "pruned"))
}
return removed
}
private fun retire(packs: File, dir: File) {
val gone = File(packs, ".${dir.name}.del-${token()}")
if (dir.renameTo(gone)) FileUtils.delete(gone) else FileUtils.delete(dir)
}
private fun sweep(context: Context, store: File) {
val now = System.currentTimeMillis()
synchronized(installLock) {
File(store, "packs").listFiles()?.forEach { file ->
if (RETIRED.matches(file.name) || TEMP.matches(file.name) && now - file.lastModified() > STALE_MS) FileUtils.delete(file)
}
}
File(cacheDir(context), "tmp").listFiles()?.forEach { file ->
if (now - file.lastModified() > STALE_MS) FileUtils.delete(file)
}
}
private fun fetchBytes(url: String, limit: Long): ByteArray? {
val out = ByteArrayOutputStream()
return if (fetch(url, limit, out, null)) out.toByteArray() else null
}
private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean =
fetcher(url, limit, out, progress)
private fun httpFetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean {
var connection: HttpURLConnection? = null
try {
connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
instanceFollowRedirects = true
setRequestProperty("User-Agent", "DroidDeck-Android")
}
val code = connection.responseCode
if (code / 100 != 2) {
Log.w(TAG, "$url -> HTTP $code")
return false
}
if (connection.contentLengthLong > limit) {
Log.w(TAG, "$url is ${connection.contentLengthLong} bytes, more than $limit")
return false
}
connection.inputStream.use { input ->
val buffer = ByteArray(1 shl 16)
var total = 0L
while (true) {
val n = input.read(buffer)
if (n < 0) break
total += n
if (total > limit) {
Log.w(TAG, "$url sent more than $limit bytes")
return false
}
out.write(buffer, 0, n)
progress?.invoke(total)
}
}
return true
} catch (e: Exception) {
Log.w(TAG, "GET $url: ${e.message}")
return false
} finally {
connection?.disconnect()
}
}
private fun copyBounded(input: InputStream, out: OutputStream, limit: Long): Long {
val buffer = ByteArray(1 shl 16)
var total = 0L
while (true) {
val n = input.read(buffer)
if (n < 0) return total
total += n
if (total > limit) throw IOException("more than $limit bytes")
out.write(buffer, 0, n)
}
}
private fun readBounded(input: InputStream, limit: Long): ByteArray {
val out = ByteArrayOutputStream()
copyBounded(input, out, limit)
return out.toByteArray()
}
private fun readFile(file: File, limit: Long): ByteArray? = try {
if (!file.isFile || file.length() > limit) null else file.readBytes()
} catch (e: IOException) {
null
}
private fun readText(file: File): String? = readFile(file, INDEX_LIMIT)?.toString(Charsets.UTF_8)
private fun writeAtomic(file: File, bytes: ByteArray) {
val temp = File(file.parentFile, ".${file.name}.tmp-${token()}")
try {
temp.writeBytes(bytes)
if (!temp.renameTo(file)) throw IOException("could not replace $file")
} finally {
if (temp.exists()) temp.delete()
}
}
private fun tempFile(context: Context, id: String): File {
val dir = File(cacheDir(context), "tmp")
if (!dir.isDirectory && !dir.mkdirs()) throw IOException("could not create $dir")
return File(dir, "$id.${token()}.tzst")
}
private fun isLink(file: File): Boolean = try {
Files.isSymbolicLink(file.toPath())
} catch (e: Exception) {
false
}
private fun token(): String {
val bytes = ByteArray(8)
synchronized(random) { random.nextBytes(bytes) }
return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) }
}
}
@@ -3,6 +3,7 @@ package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
@@ -10,6 +11,8 @@ import java.io.File
object GameEnvironmentStore {
private const val SETTINGS = "game-environment.json"
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
/** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */
const val DXVK_CONFIG = "dxvkConfig"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
@@ -56,8 +59,16 @@ object GameEnvironmentStore {
@Synchronized
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context), SessionPrefs.forceSsbs(context)) + config.shared)
val json = encode(resolved)
// The Display page's texture filtering, as DXVK options the launcher appends to
// DXVK_CONFIG after the profiles - a user's own DXVK_CONFIG entry keeps its options.
val dxvk = TextureFiltering.dxvkOptions(
SessionPrefs.textureAnisotropy(context), SessionPrefs.textureLodBias(context),
TextureFiltering.autoLodBias(SessionState.upscaleRatio),
)
if (dxvk.isNotEmpty()) json.put(DXVK_CONFIG, dxvk)
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), json.toString())
}
private fun write(file: File, text: String) {
@@ -4,7 +4,7 @@ import java.io.File
/**
* Whether Deck mode's performance overlay is on screen. The client sets its level by rewriting
* mangoapp's config (bannerlator-session makes it, `/tmp/mangohud.*`), and `no_display` is the
* mangoapp's config (droiddeck-session makes it, `/tmp/mangohud.*`), and `no_display` is the
* level that hides it. The feeds whose reads cost something - the fan's tachometer busy-waits in
* the kernel, GPU memory walks every process's mappings - only run while it shows.
*/
@@ -0,0 +1,15 @@
package com.droiddeck.launcher.session
/** Platform-independent eligibility; a service existing alone does not mean it has a picture. */
object PipPolicy {
fun eligible(running: Boolean, frameSeen: Boolean, phase: SessionPhase, suspended: Boolean, automatic: Boolean): Boolean =
running && frameSeen && phase in setOf(SessionPhase.READY, SessionPhase.SUSPENDED) &&
(!automatic || !suspended)
fun aspect(width: Int, height: Int): Pair<Int, Int> = when {
width <= 0 || height <= 0 -> 16 to 9
width.toDouble() / height > 2.39 -> 239 to 100
height.toDouble() / width > 2.39 -> 100 to 239
else -> width to height
}
}
@@ -0,0 +1,86 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
object ProtonDefault {
private const val TAG = "ProtonDefault"
private const val DIR = "root/.local/share/droiddeck-compat"
data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean)
data class Request(val seq: Long, val valve: Boolean, val dir: String)
data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>)
enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE }
private fun dir(context: Context) = File(LinuxRuntime.rootDir(context), DIR)
internal fun parseState(text: String?): State {
val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return State(null, 0, false, null, emptyList())
val tools = o.optJSONArray("tools")
return State(
default = o.optString("default").ifEmpty { null },
applied = o.optLong("applied", 0),
live = o.optBoolean("live", false),
pending = o.optString("pending").ifEmpty { null },
tools = (0 until (tools?.length() ?: 0)).mapNotNull { i ->
tools?.optJSONObject(i)?.let { t ->
Tool(t.optString("name"), t.optString("display"), t.optString("dir"), t.optBoolean("valve"))
}?.takeIf { it.name.isNotEmpty() }
},
)
}
internal fun parseRequest(text: String?): Request? {
val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return null
val seq = o.optLong("seq", 0)
val dir = o.optString("dir")
return if (seq > 0 && dir.isNotEmpty()) Request(seq, o.optBoolean("valve"), dir) else null
}
internal fun chosen(state: State, request: Request?): Pair<Boolean, String>? {
if (request != null && request.seq > state.applied) return request.valve to request.dir
val tool = state.tools.firstOrNull { it.name == state.default } ?: return null
return tool.valve to tool.dir
}
internal fun runnable(state: State, proton: ComponentsManager.Proton): Boolean =
state.tools.isEmpty() || state.tools.any { it.valve == proton.valve && it.dir == proton.dir.name }
private fun read(file: File): String? = runCatching { file.readText() }.getOrNull()
fun state(context: Context): State = parseState(read(File(dir(context), "state.json")))
fun selectedId(context: Context, protons: List<ComponentsManager.Proton>): String? {
val d = dir(context)
val pick = chosen(parseState(read(File(d, "state.json"))), parseRequest(read(File(d, "request.json")))) ?: return null
return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id
}
internal fun nextSeq(now: Long, state: State, previous: Request?): Long =
maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1)
@Synchronized
fun request(context: Context, proton: ComponentsManager.Proton): Outcome {
val state = state(context)
if (!runnable(state, proton)) return Outcome.NOT_RUNNABLE
val d = dir(context)
val seq = nextSeq(System.currentTimeMillis(), state, parseRequest(read(File(d, "request.json"))))
val body = JSONObject().put("seq", seq).put("valve", proton.valve).put("dir", proton.dir.name).toString(1) + "\n"
val staged = File(d, "request.json.staged")
try {
d.mkdirs()
staged.writeText(body)
if (!staged.renameTo(File(d, "request.json"))) throw java.io.IOException("rename failed")
} catch (e: Exception) {
staged.delete()
Log.w(TAG, "could not write the Proton request", e)
throw e
}
val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name }
return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START
}
}
@@ -131,7 +131,7 @@ object ProtonExtras {
val guest = listOf(
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "PATH=/usr/local/bin:/usr/bin:/bin",
"LANG=C.UTF-8", "XDG_DATA_HOME=/root/.local/share",
"/usr/local/bin/bannerlator-proton-extra", "/root/.local/share/Steam", archive.absolutePath,
"/usr/local/bin/droiddeck-proton-extra", "/root/.local/share/Steam", archive.absolutePath,
)
val command = LinuxRuntime.command(context, null, runtimeDir, null, guest)
val process = ProcessBuilder(command)
@@ -155,6 +155,14 @@ object ProtonExtras {
else {
archive.delete()
unqueue(context, tool)
if (EsyncPacks.enabled(context)) {
onProgress("Fetching droiddeck-esync pack", -1)
try {
EsyncPacks.fetchWanted(context, root, onProgress)
} catch (t: Throwable) {
Log.w(TAG, "sync pack for ${tool.name}", t)
}
}
null
}
} catch (e: Exception) {
@@ -1,29 +1,62 @@
package com.droiddeck.launcher.session
import android.net.LocalServerSocket
import android.content.SharedPreferences
import android.os.Build
import android.os.CombinedVibration
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.InputDevice
import androidx.annotation.RequiresApi
import com.droiddeck.launcher.core.SessionPart
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.PadBridge
import java.io.DataInputStream
import kotlin.math.max
/**
* Rumble for the on-screen pad, on the phone itself.
* Rumble for the pad, on the motors in the player's hands.
*
* When a game plays a force-feedback effect on the fake pad, the fake evdev layer inside the guest
* connects to the abstract socket [NAME] and sends one packet: strong, weak, duration in ms and
* the pad slot, four little-endian 16-bit values. This listens for those and drives the device's
* vibrator with the stronger motor's strength for the effect's duration. Best effort both ways:
* a missing listener costs the guest nothing, and a bad packet is dropped.
* When a game plays a force-feedback effect on the fake pad (or Steam Input sends the Deck's
* rumble report), the fake evdev layer inside the guest connects to the abstract socket [NAME]
* and sends one packet: strong, weak, duration in ms and the pad slot, four little-endian 16-bit
* values. This listens for those and plays the effect on the physical controller that last drove
* the pad (PadBridge.activeControllerId) when Android exposes its motors - strong and weak
* separately where it has two - and otherwise on the device's own vibrator, with the stronger
* motor's strength: the on-screen pad, or a controller without rumble. Best effort both ways: a
* missing listener costs the guest nothing, and a bad packet is dropped.
*/
class RumbleComponent : SessionPart() {
@Volatile private var server: LocalServerSocket? = null
private var phone: Motors? = null
/** Where the last effect went, so the next one (or a stop) can end it there. */
private var playing: Motors? = null
private var controllerId = PadBridge.NO_CONTROLLER
private var controller: Motors? = null
private var preferences: SharedPreferences? = null
private var enabled = false
private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == null || key == "rumble") refreshEnabled()
}
/** The motors of the controller with this input device id, or null when it has none. Tests swap it. */
internal var controllerMotors: (Int) -> Motors? = ::lookupController
@Synchronized private fun refreshEnabled() {
enabled = app()?.let { ControllerPrefs.rumbleEnabled(it) } == true
if (!enabled) { playing?.cancel(); playing = null }
}
override fun start() {
val ctx = app() ?: return
val s = try { LocalServerSocket(NAME) } catch (e: Exception) { Log.w(TAG, "rumble: no listener ($e)"); return }
val vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator
else @Suppress("DEPRECATION") ctx.getSystemService(Vibrator::class.java)
phone = vibrator?.takeIf { it.hasVibrator() }?.let { PhoneMotors(it) }
preferences = ControllerPrefs.prefs(ctx).also { it.registerOnSharedPreferenceChangeListener(preferenceListener) }
refreshEnabled()
server = s
Thread({
while (server === s) {
@@ -31,8 +64,7 @@ class RumbleComponent : SessionPart() {
try {
val bytes = ByteArray(8)
DataInputStream(client.inputStream).readFully(bytes)
val strong = u16(bytes, 0); val weak = u16(bytes, 2); val ms = u16(bytes, 4)
buzz(max(strong, weak), ms)
buzz(u16(bytes, 0), u16(bytes, 2), u16(bytes, 4))
} catch (e: Exception) {
// A partial packet or a closed peer: nothing to play.
} finally {
@@ -43,33 +75,107 @@ class RumbleComponent : SessionPart() {
Log.i(TAG, "rumble: listening on @$NAME")
}
override fun stop() {
val s = server ?: return
@Synchronized override fun stop() {
preferences?.unregisterOnSharedPreferenceChangeListener(preferenceListener)
preferences = null
enabled = false
playing?.cancel()
playing = null
phone = null
controller = null
controllerId = PadBridge.NO_CONTROLLER
val s = server
server = null
try { s.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
try { s?.close() } catch (e: Exception) { /* the accept loop ends on the next wake */ }
}
private fun u16(b: ByteArray, at: Int): Int = (b[at].toInt() and 0xFF) or ((b[at + 1].toInt() and 0xFF) shl 8)
private fun buzz(strength: Int, ms: Int) {
val ctx = app() ?: return
val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) ctx.getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
else @Suppress("DEPRECATION") (ctx.getSystemService(Vibrator::class.java) ?: return)
if (!vibrator.hasVibrator()) return
if (strength == 0 || ms == 0) { vibrator.cancel(); return }
val amplitude = (strength * 255L / 65535L).toInt().coerceIn(1, 255)
val duration = ms.toLong().coerceIn(1L, 5000L)
try {
if (vibrator.hasAmplitudeControl()) vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
else vibrator.vibrate(VibrationEffect.createOneShot(duration, VibrationEffect.DEFAULT_AMPLITUDE))
} catch (e: Exception) {
Log.w(TAG, "rumble: $e")
@Synchronized private fun buzz(strong: Int, weak: Int, ms: Int) {
if (!enabled) return
val target = target()
if (target !== playing) {
playing?.cancel()
playing = target
target?.let { Log.i(TAG, "rumble: playing on ${it.name}") }
}
if (target == null) return
if ((strong == 0 && weak == 0) || ms == 0) { target.cancel(); return }
try {
target.play(strong, weak, ms.toLong().coerceIn(1L, 5000L))
} catch (e: Exception) {
Log.w(TAG, "rumble: ${target.name}: $e")
}
}
/** The active controller's motors when it has any, else the phone's. */
private fun target(): Motors? {
val id = PadBridge.activeControllerId()
if (id != controllerId) {
controllerId = id
controller = if (id == PadBridge.NO_CONTROLLER) null else try { controllerMotors(id) } catch (e: Exception) { null }
}
return controller ?: phone
}
/** One place an effect can play. Strengths are the evdev 0..65535 magnitudes. */
internal interface Motors {
val name: String
fun play(strong: Int, weak: Int, ms: Long)
fun cancel()
}
private class PhoneMotors(private val vibrator: Vibrator) : Motors {
override val name = "the device's vibrator"
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
/** Android 12+: each of the pad's motors on its own, the lower id being the strong one. */
@RequiresApi(31)
private class PadMotors(override val name: String, private val manager: VibratorManager, private val ids: IntArray) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) {
val combined = CombinedVibration.startParallel()
var any = false
fun add(id: Int, strength: Int) {
if (strength == 0) return
combined.addVibrator(id, oneShot(manager.getVibrator(id), strength, ms))
any = true
}
if (ids.size >= 2) { add(ids[0], strong); add(ids[1], weak) } else add(ids[0], max(strong, weak))
if (any) manager.vibrate(combined.combine()) else manager.cancel()
}
override fun cancel() = manager.cancel()
}
/** Before Android 12 a pad has one vibrator. */
private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors {
override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms))
override fun cancel() = vibrator.cancel()
}
companion object {
private const val TAG = "SessionService"
/** Must match the fake evdev layer (fakeinput_steam.cpp). */
const val NAME = "droiddeck-rumble"
private fun oneShot(vibrator: Vibrator, strength: Int, ms: Long): VibrationEffect {
val amplitude = if (vibrator.hasAmplitudeControl()) (strength * 255L / 65535L).toInt().coerceIn(1, 255)
else VibrationEffect.DEFAULT_AMPLITUDE
return VibrationEffect.createOneShot(ms, amplitude)
}
private fun lookupController(id: Int): Motors? {
val device = InputDevice.getDevice(id) ?: return null
val name = "\"${device.name}\""
if (Build.VERSION.SDK_INT >= 31) {
val manager = device.vibratorManager
val ids = manager.vibratorIds.sortedArray()
return if (ids.isEmpty()) null else PadMotors(name, manager, ids)
}
@Suppress("DEPRECATION")
val vibrator = device.vibrator
return if (vibrator.hasVibrator()) LegacyPadMotors(name, vibrator) else null
}
}
}
@@ -13,6 +13,8 @@ object SecondaryLibrary {
"4185400" to "SteamLinuxRuntime_4-arm64",
"4427310" to "Proton Experimental (ARM64)",
"4628740" to "Proton 11.0 (ARM64)",
"1628350" to "SteamLinuxRuntime_sniper",
"3127680" to "FEX-Emu",
)
fun privateRoot(files: File, library: File): File {
@@ -32,7 +34,7 @@ object SecondaryLibrary {
val id = Regex("\"appid\"\\s+\"(\\d+)\"").find(text)?.groupValues?.get(1)
val dir = Regex("\"installdir\"\\s+\"([^\"]+)\"").find(text)?.groupValues?.get(1)
if (id != null && dir != null && File(dir).name == dir &&
(dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime"))) tools[id] = dir
(dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime") || id in bootstrapTools)) tools[id] = dir
}
val overrides = ArrayList<Pair<File, String>>()
for (name in listOf("compatdata", "shadercache")) {
@@ -34,6 +34,16 @@ object SessionArtifacts {
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/**
* Written by a session's own ending, and only when every file in the folder, subfolders
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
* the app wrote), went through the redactor without a failure. It lists each of those files
* with its size and modification time, under the redactor's rules version: a share copies a
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
* a file added or changed since, a hidden one, or every file of a folder without it.
*/
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
/** Everything the end of a session gathers, into [dir]. Safe to call for a dead session. */
fun collect(context: Context, dir: File, reason: String) {
try {
@@ -44,11 +54,11 @@ object SessionArtifacts {
}
}
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
copySteamLogs(context, dir)
val record = Record(dir)
copySteamLogs(context, dir, record)
// A session the system killed leaves its trace here and nowhere else.
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
scrubFolder(dir)
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
markScrubbed(scrubTree(record))
SessionEvents.record("session.artifacts_collected", mapOf("reason" to reason), dir)
File(dir, COMPLETE_MARKER).writeText("collected: $reason at ${now()}\n")
} catch (e: Exception) {
@@ -71,8 +81,7 @@ object SessionArtifacts {
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
scrubFolder(dir)
File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it) }
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
@@ -84,29 +93,121 @@ object SessionArtifacts {
* through the redactor before the folder can be shared. A file is rewritten only if a line
* changed. steam/ was scrubbed on the way in.
*/
private fun scrubFolder(dir: File) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") }?.forEach { f ->
/** A file's size and modification time as they stood right after it was scrubbed. */
private class Scrubbed(val length: Long, val modified: Long)
/** Files of a folder as they stood right after scrubbing, filled by [scrubTree] and [copySteamLogs]. */
private class Record(val dir: File) {
val files = HashMap<File, Scrubbed>()
var failed = false
}
private fun scrubFolder(dir: File, record: Record) {
dir.listFiles { f -> f.isFile && !f.name.startsWith(".") && f !in record.files }?.forEach { f ->
if (!LogRedactor.isText(f)) return@forEach
try {
val tmp = File(dir, ".${f.name}.scrub")
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
if (tmp.length() != f.length() || tmp.readBytes().contentEquals(f.readBytes()).not()) tmp.renameTo(f) else tmp.delete()
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
}
val done = scrubFile(dir, f)
if (done == null) record.failed = true else record.files[f] = done
}
}
/** Steam's logs: redacted into steam/, never copied verbatim. */
private fun copySteamLogs(context: Context, dir: File) {
/**
* Puts [f] through the redactor in place. Returns its size and modification time as it then
* stands, or null if it could not, and [f] may still hold the original. A writer still
* appending to [f] (the app's own log capture) changes its size afterwards, and a share then
* scrubs it again.
*/
private fun scrubFile(dir: File, f: File): Scrubbed? {
val tmp = File(dir, ".${f.name}.scrub")
return try {
val before = Scrubbed(f.length(), f.lastModified())
tmp.bufferedWriter().use { w -> LogRedactor.scrubTo(f, w) }
val scrubbed = tmp.readBytes()
if (scrubbed.contentEquals(f.readBytes())) {
tmp.delete()
// Unchanged by the redactor, and by anything else while it read.
before.takeIf { f.length() == it.length && f.lastModified() == it.modified }
} else {
// Shared storage can refuse a rename; then the scrubbed bytes are written over the
// original instead, so it never stays behind unscrubbed.
if (!tmp.renameTo(f)) { f.writeBytes(scrubbed); tmp.delete() }
Scrubbed(scrubbed.size.toLong(), f.lastModified()).takeIf { f.length() == it.length }
}
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${f.name}", e)
tmp.delete()
null
}
}
/**
* Records [record]'s folder as scrubbed - every file went through without a failure - or
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
*/
private fun markScrubbed(record: Record) {
val dir = record.dir
val tree = File(dir, SCRUBBED_TREE_MARKER)
if (record.failed) {
tree.delete()
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
return
}
try {
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
lines.append(s.length).append('\t').append(s.modified).append('\t')
.append(f.relativeTo(dir).path).append('\n')
}
tree.writeText(lines.toString())
} catch (e: Exception) {
tree.delete()
}
}
/**
* The files of [dir] that its own ending scrubbed and that are unchanged since (same size and
* modification time), by path relative to [dir]. Empty when the folder has no such record, or
* one made under other redactor rules.
*/
fun scrubbedFiles(dir: File): Set<String> {
val lines = try { File(dir, SCRUBBED_TREE_MARKER).takeIf { it.isFile }?.readLines() } catch (e: Exception) { null }
?: return emptySet()
if (lines.firstOrNull() != "rules ${LogRedactor.RULES_VERSION}") return emptySet()
return lines.drop(1).mapNotNull { line ->
val parts = line.split('\t', limit = 3)
if (parts.size != 3) return@mapNotNull null
val f = File(dir, parts[2])
parts[2].takeIf { f.isFile && f.length().toString() == parts[0] && f.lastModified().toString() == parts[1] }
}.toSet()
}
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
@androidx.annotation.VisibleForTesting
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
/**
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
* copied files there verbatim, and a Steam log the app did not copy over again (gone from the
* runtime, or past its size limit) stayed as the script left it. Files [copySteamLogs] already
* redacted on the way in are in [record] and left alone.
*/
private fun scrubTree(record: Record): Record {
record.dir.walkTopDown().filter { it.isDirectory }.forEach { scrubFolder(it, record) }
return record
}
/** Steam's logs: redacted into steam/, never copied verbatim. What it wrote goes in [record]. */
private fun copySteamLogs(context: Context, dir: File, record: Record) {
val logs = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/logs")
if (!logs.isDirectory) return
val out = File(dir, "steam").apply { mkdirs() }
logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src ->
try {
File(out, src.name).bufferedWriter().use { w ->
val dst = File(out, src.name)
dst.bufferedWriter().use { w ->
src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() }
}
record.files[dst] = Scrubbed(dst.length(), dst.lastModified())
} catch (e: Exception) {
Log.w(TAG, "could not scrub ${src.name}", e)
}
@@ -143,12 +244,11 @@ object SessionArtifacts {
"crash.log holds Android's crash buffer as of the next app start - if this\n" +
"session died of a crash, the entry is in there unless the device rebooted.\n"
)
if (newest) {
copySteamLogs(context, dir)
}
val record = Record(dir)
if (newest) copySteamLogs(context, dir, record)
SessionLogCapture.dumpCrashBuffer(File(dir, "crash.log"))
SessionEvents.record("session.artifacts_recovered", mapOf("newest" to newest), dir)
scrubFolder(dir)
markScrubbed(scrubTree(record))
File(dir, COMPLETE_MARKER).writeText("collected: late, at next app start, ${now()}\n")
Log.i(TAG, "finished the abandoned session folder $dir")
} catch (e: Exception) {
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more