Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 633ef0814a perf: cache the Steam client's repeated path lookups to cut startup from 31s to 6s 2026-09-28 23:04:42 -04:00
Kurt Himebauch 7c422577b6 fix: let the Steam client shut down cleanly when a session stops (#68) 2026-09-28 22:12:19 -04:00
Kurt Himebauch 1cc1ee994d feat: pair wireless debugging from a notification for the child-process gate (#67)
* feat: pair wireless debugging from a notification for the child-process gate

* fix: pair over loopback, handle Android 12 and 14 child-process routes, use D logo for notifications

* fix: read the adb tls port directly before falling back to mdns

* fix: end pairing on the same notification so progress never lingers
2026-09-28 21:37:10 -04:00
MaxsTechReviewandMaxsTechReview d8d375cb9a Steam Owned Games Importing (#62)
* Add ownership-aware Steam game folder imports

* Fix Steam title lookup and installed game manifests

---------

Co-authored-by: MaxsTechReview <[email protected]>
2026-09-28 18:13:08 -04:00
Kurt Himebauch 221644e8be Merge pull request #61 from magarto/guest-hostname-setting 2026-09-28 17:07:59 -04:00
magartus 448feb30bc Let the user choose the guest host name
0.2.0 hardcodes the proot utsname nodename to "DroidDeck". Steam sends
it as the machine name, so an install updated from an earlier build
(which reported Android's "localhost") shows up in Steam under a new
name, with the same machine-id and login. /etc/hostname cannot fix it
from inside: proot's -k override wins.

Add a "Host name" field (Performance > Session identity), stored in
SessionPrefs and passed to guestUtsname(). Only a valid RFC 1123 label
is saved; blank or invalid falls back to the old default, "DroidDeck",
so nothing changes for anyone who leaves it alone. The value is also
listed in device.txt.
2026-09-28 22:58:54 +02:00
MaxsTechReviewandMaxsTechReview 240f1194c0 Fix/variables (#60)
* Add editable per-game environment settings below FEX presets

* Add typed preset variable pickers and default D3D12 capability levels

---------

Co-authored-by: MaxsTechReview <[email protected]>
2026-09-28 15:17:57 -04:00
Kurt Himebauch 7b74eebce2 Merge pull request #59 from Droid-Deck/ci/discord-build-announce
ci: publish each signed main build to DroidDeck-CI and post it to Discord
2026-09-28 14:54:29 -04:00
MaxsTechReview 30741302fb Merge pull request #53 from maxjivi05/fix/adaptive-joysticks
Hide idle adaptive sticks and activate them at touch positions
2026-09-28 14:48:26 -04:00
MaxsTechReview 645f7d0bf1 Merge branch 'main' into fix/adaptive-joysticks 2026-09-28 14:48:14 -04:00
Kurt Himebauch 3c577fd9dd Merge pull request #57 from Droid-Deck/fix/desktop-hardware-cursor
Desktop: hand labwc's cursor to the app, so full-screen games scan out
2026-09-28 14:40:02 -04:00
MaxsTechReview 659ff4817f Expand adaptive joystick activation area by fifty percent 2026-09-28 14:39:55 -04:00
Kurt Himebauch 626adb62af Merge branch 'main' into fix/desktop-hardware-cursor 2026-09-28 14:39:52 -04:00
Kurt Himebauch 71cb100a40 Merge pull request #58 from Droid-Deck/tools/local-release-signing
build_local: sign with DroidDeck's key when a local .signing.env has it
2026-09-28 14:39:22 -04:00
xXJSONDeruloXx 68002f3fa7 build_local: sign with DroidDeck's key when a local .signing.env has it
A local build was signed with the testkey alone, which a release refuses
as an update ("signatures do not match"): testing on a device with a CI
build meant uninstalling it and losing its data first.

With an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
RELEASE_KEY_ALIAS) in the checkout, or in the main checkout for a
worktree, build_local.sh finishes with tools/release/sign-apk.sh as CI's
main builds do, and the apk installs over a release. Without the file
nothing changes. DROIDDECK_SIGNING_ENV points at the file explicitly.
2026-09-28 14:33:15 -04:00
xXJSONDeruloXx dae7592788 Desktop: hand labwc's cursor to the app, so full-screen games scan out
The desktop forced a software cursor (WLR_NO_HARDWARE_CURSORS=1) because
labwc's cursor never showed when handed to the app's compositor. With a
software cursor wlroots never scans out: every frame of a full-screen game
was composited by labwc first ("Direct scan-out disabled by software
cursor", 2284 times in one Adreno 825 report).

Why the cursor never showed: wlroots' Wayland backend attaches and commits
its cursor surface before wl_pointer.set_cursor gives it the cursor role,
and sets it again with nothing new committed each time the pointer enters.
The compositor released a role-less surface's buffer on commit, so the
cursor surface had no image by the time it became the cursor.

- A role-less surface's last buffer (cursor-sized only) is held until the
  next commit; set_cursor publishes it, and a repeat set_cursor of the
  surface already shown unhides its last image.
- A dma-buf cursor is read back after the client's implicit render fence,
  as the zero-copy path waits for it.
- The desktop no longer forces a software cursor.

Device-tested on the AYN Thor (Adreno 740, 1920x1080): labwc's own cursor
is drawn, with its shapes (I-beam over a terminal); wlroots logs "Direct
scan-out enabled" for a full-screen glxgears. At the same 60 fps on screen
the GPU is 3.0 % busy against 4.6 % with the software cursor, both at
550 MHz.
2026-09-28 14:33:08 -04:00
MaxsTechReview e10f5b4430 Limit adaptive stick activation near saved positions 2026-09-28 14:21:34 -04:00
MaxsTechReview 3e32b75a63 Merge branch 'main' into fix/adaptive-joysticks 2026-09-28 12:53:44 -04:00
MaxsTechReview 9530e7b394 Hide idle adaptive sticks and activate them at touch positions 2026-09-28 10:54:24 -04:00
47 changed files with 2742 additions and 228 deletions

No files matched your search

+3
View File
@@ -307,6 +307,9 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Steam import and game environment tests
run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
+3
View File
@@ -29,3 +29,6 @@ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
droiddeck-release-key/
password.txt
__pycache__/
# Local signing with the release key (tools/build_local.sh); holds a password.
.signing.env
+1
View File
@@ -176,4 +176,5 @@ dependencies {
implementation 'com.termux.termux-app:terminal-view:0.118.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}
+3
View File
@@ -130,6 +130,9 @@
<service
android:name=".runtime.RuntimeInstallService"
android:exported="false" />
<service
android:name=".core.WirelessAdbPairingService"
android:exported="false" />
<!-- Hands a zipped session folder to the share sheet; nothing else is exposed. -->
<provider
android:name="androidx.core.content.FileProvider"
+95 -15
View File
@@ -30,6 +30,7 @@
#include <time.h>
#include <stdarg.h>
#include <errno.h>
#include <poll.h>
#include <sys/timerfd.h>
#include <sys/syscall.h>
#include <sys/sysmacros.h>
@@ -247,6 +248,10 @@ struct surface {
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
enum surface_role role;
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -475,6 +480,7 @@ static int g_ntouches;
#define CURSOR_MAX_PX (256 * 256)
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
static struct surface *g_cursor_surface; /* compositor thread only */
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
@@ -482,6 +488,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
static int g_cursor_hidden = 1; /* the client asked for no pointer */
static int g_cursor_serial; /* bumped on every change; the app polls it */
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
static void cursor_publish_shown(void) {
pthread_mutex_lock(&g_cursor_lock);
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
pthread_mutex_unlock(&g_cursor_lock);
}
static void cursor_publish_hidden(void) {
pthread_mutex_lock(&g_cursor_lock);
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
@@ -931,6 +945,68 @@ static void surface_set_input(struct wl_client *c, struct wl_resource *r,
static void send_toplevel_configure(struct surface *s);
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
* renderer) is read back once the client's render into it is done - its implicit fence, as the
* zero-copy path waits for it (ahb_swapchain_present). */
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (!db->img) return;
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
int r;
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
return;
}
g_cursor_shown = s;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
}
/* Only what could be a pointer image is held; anything larger goes straight back. */
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
struct dmabuf_buffer *db = get_dmabuf(buffer);
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
int64_t px = db ? (int64_t)db->width * db->height
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
s->idle_buffer = buffer;
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
}
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
static struct wl_resource *surface_take_idle(struct surface *s) {
struct wl_resource *buffer = s->idle_buffer;
if (!buffer) return NULL;
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
return buffer;
}
static void surface_drop_idle(struct surface *s) {
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) wl_buffer_send_release(buffer);
}
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
struct surface *s = wl_resource_get_user_data(r);
struct surface *child;
@@ -970,28 +1046,19 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
s->pending_buffer = NULL;
s->pending_attach = 0;
surface_drop_idle(s); /* replaced, whatever the surface is now */
if (s->role == ROLE_CURSOR) {
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
* rather than composited, so it survives the zero-copy and HDR layer paths. */
if (!buffer) {
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
} else if (shm) {
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
if (!db->img && !db->import_failed) {
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
db->stride[0], db->offset[0]);
if (!db->img) db->import_failed = 1;
}
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
} else {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
}
if (buffer) wl_buffer_send_release(buffer);
} else if (s->role == ROLE_NONE) {
/* Role-less surface: never drawn. */
if (buffer) wl_buffer_send_release(buffer);
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
if (buffer) surface_hold_idle(s, buffer);
} else if (db) {
take_dmabuf(s, db, buffer);
} else if (shm) {
@@ -1063,6 +1130,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
touch_cancel_surface(s);
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
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;
@@ -1139,6 +1208,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
wl_list_init(&s->child_link);
wl_list_init(&s->toplevel_link);
wl_list_init(&s->pending_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
wl_list_insert(&g_surfaces, &s->link);
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
}
@@ -2143,6 +2213,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
g_cursor_surface = s;
g_cursor_hx = hx;
g_cursor_hy = hy;
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
* committed each time the pointer re-enters. */
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) {
cursor_publish_buffer(s, buffer);
wl_buffer_send_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
static const struct wl_pointer_interface pointer_impl = {
@@ -45,6 +45,7 @@ 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.WirelessAdbPairingService
import com.droiddeck.launcher.ui.CoreRow
import com.droiddeck.launcher.ui.PerformancePage
import com.droiddeck.launcher.ui.ModeSettingsPage
@@ -124,6 +125,7 @@ class MainActivity : ComponentActivity() {
private var zinkLazy by mutableStateOf(false)
private var noXalia by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf<String?>(null)
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
private var showPhantomGate by mutableStateOf(false)
@@ -430,6 +432,12 @@ class MainActivity : ComponentActivity() {
android.widget.Toast.makeText(this, "ADB command copied", android.widget.Toast.LENGTH_SHORT).show()
},
onDismissPhantomGate = { showPhantomGate = false },
onStartWirelessAdbPairing = { WirelessAdbPairingService.start(this) },
onOpenNotificationSettings = {
startActivity(Intent(android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -864,7 +872,7 @@ 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, steamDeckMode = steamDeckMode, noXalia = noXalia,
prootNoSeccomp = prootNoSeccomp, phantomWarning = phantomWarning,
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
@@ -873,6 +881,7 @@ class MainActivity : ComponentActivity() {
onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on); steamDeckMode = on },
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
SessionPrefs.setClientCpus(this, CpuCores.format(clientCores))
@@ -965,6 +974,7 @@ class MainActivity : ComponentActivity() {
zinkLazy = SessionPrefs.zinkLazy(this)
noXalia = SessionPrefs.noXalia(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
}
@@ -988,14 +998,14 @@ class MainActivity : ComponentActivity() {
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
Library.SteamGame(
g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
hero = art.hero ?: art.header, gameFiles = g.folder,
protonPrefix = Library.protonPrefix(this, g.appId),
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
)
}
} else emptyList()
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
ui.post { steamGames = games; emulatorList = emus }
ui.post { steamGames = games.distinctBy { it.gameId }; emulatorList = emus }
// 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 })) {
@@ -1037,15 +1047,18 @@ class MainActivity : ComponentActivity() {
}
private fun refreshPhantomStatus() {
phantomProcessStatus = PhantomProcessLimit.read(contentResolver)
phantomProcessStatus = PhantomProcessLimit.read(this)
phantomWarning = if (PhantomProcessLimit.blocksSteam(phantomProcessStatus)) {
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.ADB_COMMAND}"
"${PhantomProcessLimit.title(phantomProcessStatus)}. ${PhantomProcessLimit.instructions(phantomProcessStatus)}\n\n${PhantomProcessLimit.adbCommand()}"
} else null
}
private fun openDeveloperOptions(displayId: Int?) {
val highlight = if (PhantomProcessLimit.hasDeveloperToggle() &&
WirelessAdbPairingService.stage.value == WirelessAdbPairingService.Stage.Idle) null else "toggle_adb_wireless"
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.apply { if (highlight != null) putExtra(":settings:fragment_args_key", highlight) }
try {
if (displayId == null) startActivity(intent)
else startActivity(intent, ActivityOptions.makeBasic().setLaunchDisplayId(displayId).toBundle())
@@ -147,6 +147,7 @@ object DeviceReport {
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
k("Skip xalia", SessionPrefs.noXalia(context))
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch games to fill", SessionPrefs.forceFullscreen(context))
k("Client audio", if (SessionPrefs.clientDirectAudio(context)) "DirectAudio" else "classic")
@@ -161,7 +162,7 @@ object DeviceReport {
})
h("Android process limits")
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context.contentResolver)))
k("Phantom proc monitor", PhantomProcessLimit.reportValue(PhantomProcessLimit.read(context)))
h("Device switch files in Download")
for (name in listOf("droiddeck-env", "droiddeck-tu-debug", "droiddeck-driver",
@@ -1,42 +1,33 @@
package com.droiddeck.launcher.core
/**
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
* session's environment; the games under FEX inherit them from the client. The default is FEX's
* own defaults - no variables at all - which is what every session has run on so far.
*
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
*/
import com.droiddeck.launcher.R
object FexPreset {
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
)
val all: List<Preset> = listOf(
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
)
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
/** Game-launch variables in KEY=VALUE form. */
fun env(id: String): List<String> = byId(id).env
}
@@ -0,0 +1,38 @@
package com.droiddeck.launcher.core
object GameEnvironment {
data class Config(
val shared: Map<String, String?> = emptyMap(),
val games: Map<String, Map<String, String?>> = emptyMap(),
) {
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
fun withEntries(scope: String, entries: Map<String, String?>): Config =
if (scope.isEmpty()) copy(shared = entries.toMap())
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
}
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",
"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",
)
fun validName(name: String) = namePattern.matches(name)
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
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?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
).apply {
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
}
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
}
@@ -0,0 +1,43 @@
package com.droiddeck.launcher.core
import com.droiddeck.launcher.R
object GameEnvironmentOptions {
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
val all = listOf(
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
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("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),
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
)
fun find(name: String): Option? = all.firstOrNull { it.name == name }
fun toggle(value: String, choice: String): String {
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
if (!selected.remove(choice)) selected.add(choice)
return selected.joinToString(",")
}
}
@@ -1,6 +1,6 @@
package com.droiddeck.launcher.core
import android.content.ContentResolver
import android.content.Context
import android.os.Build
import android.provider.Settings
@@ -14,17 +14,53 @@ enum class PhantomProcessStatus {
}
object PhantomProcessLimit {
const val ADB_COMMAND = "adb shell settings put global settings_enable_monitor_phantom_procs false"
const val SHELL_COMMAND = "settings put global settings_enable_monitor_phantom_procs false"
private const val SETTING = "settings_enable_monitor_phantom_procs"
fun adbCommand(enabled: Boolean): String =
"adb shell settings put global $SETTING ${if (enabled) "true" else "false"}"
private const val MAX_PHANTOM = "2147483647"
private const val PREFS = "phantom-process-limit"
private const val PREF_ANDROID_12_OFF = "android12-off"
fun read(resolver: ContentResolver, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
fun usesDeviceConfig(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk == Build.VERSION_CODES.S
fun shellCommands(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): List<String> = when {
usesDeviceConfig(sdk) && enabled -> listOf(
"device_config delete activity_manager max_phantom_processes",
"device_config set_sync_disabled_for_tests none",
)
usesDeviceConfig(sdk) -> listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes $MAX_PHANTOM",
)
else -> listOf("settings put global $SETTING ${if (enabled) "true" else "false"}")
}
fun verifyCommand(sdk: Int = Build.VERSION.SDK_INT): String =
if (usesDeviceConfig(sdk)) "device_config get activity_manager max_phantom_processes"
else "settings get global $SETTING"
fun verified(output: String, enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): Boolean {
val value = output.trim()
return if (usesDeviceConfig(sdk)) (value == MAX_PHANTOM) != enabled
else value == if (enabled) "true" else "false"
}
fun adbCommand(enabled: Boolean, sdk: Int = Build.VERSION.SDK_INT): String =
shellCommands(enabled, sdk).joinToString(" && ") { "adb shell $it" }
fun adbCommand(): String = adbCommand(false)
fun rememberAndroid12(context: Context, off: Boolean) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(PREF_ANDROID_12_OFF, off).apply()
}
fun read(context: Context, sdk: Int = Build.VERSION.SDK_INT): PhantomProcessStatus {
if (sdk < Build.VERSION_CODES.S) return PhantomProcessStatus.NOT_APPLICABLE
if (usesDeviceConfig(sdk)) {
val off = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(PREF_ANDROID_12_OFF, false)
return if (off) PhantomProcessStatus.DISABLED else PhantomProcessStatus.UNREADABLE
}
val global = try {
Settings.Global.getString(resolver, SETTING)
Settings.Global.getString(context.contentResolver, SETTING)
} catch (_: Exception) {
return PhantomProcessStatus.UNREADABLE
}
@@ -57,6 +93,8 @@ object PhantomProcessLimit {
null
}
fun hasDeveloperToggle(sdk: Int = Build.VERSION.SDK_INT): Boolean = sdk >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
fun blocksSteam(status: PhantomProcessStatus): Boolean =
status != PhantomProcessStatus.NOT_APPLICABLE && status != PhantomProcessStatus.DISABLED
@@ -69,20 +107,20 @@ object PhantomProcessLimit {
}
fun instructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.UNSET -> "This setting is unset, so the ROM default applies and may enforce the limit. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not read the setting. Set “Restrict child processes” to off in Developer options. If you cannot find that option, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence() + " If that is not possible, connect this device to a computer with ADB and run the command below. Return here and check again."
PhantomProcessStatus.DISABLED -> "Steam sessions can start."
PhantomProcessStatus.NOT_APPLICABLE -> "This Android version does not use this limit."
}
fun gateInstructions(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.ENABLED -> "Turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.UNSET -> "The ROM default may enforce this limit. Explicitly turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.UNREADABLE -> "DroidDeck could not check this setting. Turn off “Restrict child processes” in Developer options."
PhantomProcessStatus.ENABLED, PhantomProcessStatus.UNSET, PhantomProcessStatus.UNREADABLE -> fixSentence()
else -> instructions(status)
}
fun fixSentence(sdk: Int = Build.VERSION.SDK_INT): String =
if (hasDeveloperToggle(sdk)) "Turn on “Disable child process restrictions” in Developer options, and keep Developer options on afterwards."
else "This Android version has no switch for it. Use Wireless debugging to change it from this device."
fun reportValue(status: PhantomProcessStatus): String = when (status) {
PhantomProcessStatus.DISABLED -> "disabled (good - the OS will not kill the session's children)"
PhantomProcessStatus.ENABLED -> "ENABLED (the OS may kill the session with no log; turn off Restrict child processes)"
@@ -6,7 +6,9 @@ import android.net.nsd.NsdServiceInfo
import com.flyfishxu.kadb.Kadb
import com.flyfishxu.kadb.cert.KadbCert
import java.io.File
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -19,6 +21,7 @@ object WirelessAdbFix {
private const val KEY_FILE = "wireless-adb-key.pem"
private const val HOST_FILE = "wireless-adb-host.txt"
private const val CONNECTION_FILE = "wireless-adb-connection.txt"
const val LOOPBACK = "127.0.0.1"
private data class SavedConnection(val host: String, val port: Int?)
@@ -76,9 +79,27 @@ object WirelessAdbFix {
}
/** Finds this paired device's separate TLS connection port from Android's ADB mDNS record. */
fun isLocalAddress(address: InetAddress): Boolean = runCatching {
if (address.isLoopbackAddress) return true
NetworkInterface.getNetworkInterfaces()?.toList().orEmpty().any { network ->
network.inetAddresses.toList().any { it.address.contentEquals(address.address) }
}
}.getOrDefault(address is Inet4Address)
fun localConnectPort(context: Context): Int? =
tlsPortProperty()
?: findConnectPort(context, LOOPBACK)
private fun tlsPortProperty(): Int? = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
.invoke(null, "service.adb.tls.port") as? String
}.getOrNull()?.trim()?.toIntOrNull()?.takeIf { it in 1..65535 }
fun findConnectPort(context: Context, pairedHost: String, timeoutSeconds: Long = 12): Int? {
val manager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
val expectedAddress = runCatching { InetAddress.getByName(pairedHost).address }.getOrNull() ?: return null
val paired = runCatching { InetAddress.getByName(pairedHost) }.getOrNull() ?: return null
val matches: (InetAddress) -> Boolean =
if (paired.isLoopbackAddress) ::isLocalAddress else { address -> address.address.contentEquals(paired.address) }
val resolvedPort = AtomicInteger(-1)
val finished = AtomicBoolean(false)
val latch = CountDownLatch(1)
@@ -101,8 +122,8 @@ object WirelessAdbFix {
}
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host?.address
if (address != null && address.contentEquals(expectedAddress) && finished.compareAndSet(false, true)) {
val address = serviceInfo.host
if (address != null && matches(address) && finished.compareAndSet(false, true)) {
resolvedPort.set(serviceInfo.port)
latch.countDown()
} else {
@@ -148,14 +169,16 @@ object WirelessAdbFix {
fun setChildProcessLimit(context: Context, host: String, port: Int, enabled: Boolean) {
loadOrCreateIdentity(context.applicationContext)
Kadb.create(host, port).use { adb ->
val value = if (enabled) "true" else "false"
val change = adb.shell("settings put global settings_enable_monitor_phantom_procs $value")
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
val result = adb.shell("settings get global settings_enable_monitor_phantom_procs")
check(result.exitCode == 0 && result.output.trim() == value) {
PhantomProcessLimit.shellCommands(enabled).forEach { command ->
val change = adb.shell(command)
check(change.exitCode == 0) { change.allOutput.ifBlank { "ADB command failed (${change.exitCode})" } }
}
val result = adb.shell(PhantomProcessLimit.verifyCommand())
check(result.exitCode == 0 && PhantomProcessLimit.verified(result.output, enabled)) {
"Android did not confirm the child-process limit was changed: ${result.allOutput.trim()}"
}
}
if (PhantomProcessLimit.usesDeviceConfig()) PhantomProcessLimit.rememberAndroid12(context, !enabled)
saveConnection(context, host, port)
}
@@ -0,0 +1,270 @@
package com.droiddeck.launcher.core
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.RemoteInput
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class WirelessAdbPairingService : Service() {
sealed interface Stage {
data object Idle : Stage
data object Waiting : Stage
data class CodeNeeded(val error: String? = null) : Stage
data class Working(val step: String) : Stage
data object Done : Stage
data class Failed(val error: String) : Stage
}
private val main = Handler(Looper.getMainLooper())
private var nsd: NsdManager? = null
private var discovery: NsdManager.DiscoveryListener? = null
private var pairingHost: String? = null
private var pairingPort: Int? = null
@Volatile private var working = false
private val timeout = Runnable { finish(Stage.Failed("Timed out waiting for the pairing pop-up. Try again from DroidDeck.")) }
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_CODE -> {
val code = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_CODE)
?.filter(Char::isDigit)?.toString().orEmpty()
onCode(code)
}
ACTION_CANCEL -> finish(Stage.Idle)
else -> begin()
}
return START_NOT_STICKY
}
private fun begin() {
pairingHost = null
pairingPort = null
working = false
publish(Stage.Waiting)
startForeground(NOTIFICATION_ID, notification(Stage.Waiting))
startDiscovery()
main.removeCallbacks(timeout)
main.postDelayed(timeout, TIMEOUT_MS)
}
private fun startDiscovery() {
stopDiscovery()
val manager = getSystemService(Context.NSD_SERVICE) as NsdManager
val listener = object : NsdManager.DiscoveryListener {
override fun onDiscoveryStarted(serviceType: String) = Unit
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType?.contains(PAIRING_TYPE) != true) return
runCatching { manager.resolveService(serviceInfo, resolveListener()) }
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) = Unit
override fun onDiscoveryStopped(serviceType: String) = Unit
override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
main.post { finish(Stage.Failed("Could not look for the pairing pop-up ($errorCode). Check that Wi-Fi is on.")) }
}
override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit
}
runCatching { manager.discoverServices("$PAIRING_TYPE.", NsdManager.PROTOCOL_DNS_SD, listener) }
.onSuccess { nsd = manager; discovery = listener }
.onFailure { finish(Stage.Failed("Could not look for the pairing pop-up: ${it.localizedMessage}")) }
}
private fun resolveListener() = object : NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) = Unit
override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
val address = serviceInfo.host ?: return
if (!WirelessAdbFix.isLocalAddress(address)) return
main.post {
if (working || current.value == Stage.Done) return@post
pairingHost = address.hostAddress
pairingPort = serviceInfo.port
if (current.value !is Stage.CodeNeeded) show(Stage.CodeNeeded())
}
}
}
private fun onCode(code: String) {
val host = pairingHost
val port = pairingPort
if (working) return
if (host == null || port == null) {
show(Stage.Waiting)
return
}
if (code.length != 6) {
show(Stage.CodeNeeded("Enter all 6 digits of the code"))
return
}
working = true
show(Stage.Working("Pairing…"))
Thread({
val paired = runCatching { kotlinx.coroutines.runBlocking { WirelessAdbFix.pair(this@WirelessAdbPairingService, WirelessAdbFix.LOOPBACK, port, code) } }
if (paired.isFailure) {
main.post {
working = false
pairingPort = null
show(Stage.CodeNeeded("That code didn't work. Open the pairing pop-up again and enter its new code."))
}
return@Thread
}
main.post {
stopDiscovery()
show(Stage.Working("Paired. Connecting…"))
}
val result = runCatching {
val connectPort = WirelessAdbFix.localConnectPort(this)
?: error("Paired, but the Wireless debugging port was not found. Keep Wireless debugging on and try again.")
main.post { show(Stage.Working("Applying the setting…")) }
WirelessAdbFix.setChildProcessLimit(this, WirelessAdbFix.LOOPBACK, connectPort, false)
}
main.post {
working = false
finish(result.exceptionOrNull()?.let { Stage.Failed(it.localizedMessage ?: "Wireless debugging command failed") } ?: Stage.Done)
}
}, "wireless-adb-pairing").start()
}
private fun show(stage: Stage) {
publish(stage)
getSystemService(NotificationManager::class.java)?.notify(NOTIFICATION_ID, notification(stage))
}
private fun finish(stage: Stage) {
main.removeCallbacks(timeout)
stopDiscovery()
publish(stage)
val manager = getSystemService(NotificationManager::class.java)
if (stage is Stage.Done || stage is Stage.Failed) {
manager?.notify(NOTIFICATION_ID, notification(stage))
stopForeground(STOP_FOREGROUND_DETACH)
} else {
stopForeground(STOP_FOREGROUND_REMOVE)
manager?.cancel(NOTIFICATION_ID)
}
stopSelf()
}
private fun stopDiscovery() {
val listener = discovery ?: return
runCatching { nsd?.stopServiceDiscovery(listener) }
discovery = null
}
override fun onDestroy() {
main.removeCallbacks(timeout)
stopDiscovery()
if (current.value.let { it is Stage.Waiting || it is Stage.CodeNeeded || it is Stage.Working }) publish(Stage.Idle)
super.onDestroy()
}
private fun notification(stage: Stage): Notification {
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Wireless debugging setup",
NotificationManager.IMPORTANCE_HIGH).apply {
description = "Asks for the Wireless debugging pairing code while Settings is open"
setShowBadge(false)
setSound(null, null)
enableVibration(false)
})
val open = PendingIntent.getActivity(this, 0,
Intent(this, MainActivity::class.java).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
PendingIntent.FLAG_IMMUTABLE)
val cancel = PendingIntent.getService(this, 1,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL), PendingIntent.FLAG_IMMUTABLE)
val builder = Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_session)
.setContentIntent(open)
.setShowWhen(false)
.setCategory(Notification.CATEGORY_STATUS)
when (stage) {
Stage.Waiting, Stage.Idle -> builder
.setContentTitle("Pair Wireless debugging")
.setContentText("Turn on Wireless debugging, then tap “Pair device with pairing code”.")
.setStyle(Notification.BigTextStyle().bigText(
"Turn on Wireless debugging, then tap “Pair device with pairing code”. Stay in Settings: the code goes in this notification."))
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
is Stage.CodeNeeded -> {
val reply = PendingIntent.getService(this, 2,
Intent(this, WirelessAdbPairingService::class.java).setAction(ACTION_CODE),
if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
else PendingIntent.FLAG_UPDATE_CURRENT)
val input = RemoteInput.Builder(KEY_CODE).setLabel("6-digit pairing code").build()
val text = stage.error ?: "Tap Enter code and type the 6 digits shown in Settings."
builder
.setContentTitle(if (stage.error == null) "Enter the Wi-Fi pairing code" else "Try the code again")
.setContentText(text)
.setStyle(Notification.BigTextStyle().bigText(text))
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Enter code", reply).addRemoteInput(input).build())
.addAction(Notification.Action.Builder(null, "Cancel", cancel).build())
}
is Stage.Working -> builder
.setContentTitle("Setting up Steam")
.setContentText(stage.step)
.setProgress(0, 0, true)
.setOngoing(true)
.setOnlyAlertOnce(true)
Stage.Done -> builder
.setContentTitle("Steam is ready")
.setContentText("Tap to go back to DroidDeck. You can turn Wireless debugging off now.")
.setStyle(Notification.BigTextStyle().bigText("Tap to go back to DroidDeck. You can turn Wireless debugging off now."))
.setAutoCancel(true)
is Stage.Failed -> builder
.setContentTitle("Wireless debugging setup stopped")
.setContentText(stage.error)
.setStyle(Notification.BigTextStyle().bigText(stage.error))
.setAutoCancel(true)
}
return builder.build()
}
companion object {
private const val CHANNEL_ID = "wireless-adb-pairing"
private const val NOTIFICATION_ID = 3
private const val PAIRING_TYPE = "_adb-tls-pairing._tcp"
private const val ACTION_CODE = "com.droiddeck.launcher.action.WIRELESS_ADB_CODE"
private const val ACTION_CANCEL = "com.droiddeck.launcher.action.WIRELESS_ADB_CANCEL"
private const val KEY_CODE = "code"
private const val TIMEOUT_MS = 5 * 60_000L
private val current = MutableStateFlow<Stage>(Stage.Idle)
val stage: StateFlow<Stage> = current
private fun publish(stage: Stage) { current.value = stage }
fun start(context: Context) {
context.getSystemService(NotificationManager::class.java)?.cancel(NOTIFICATION_ID)
publish(Stage.Waiting)
val intent = Intent(context, WirelessAdbPairingService::class.java)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun cancel(context: Context) {
if (current.value.let { it is Stage.Done || it is Stage.Failed || it is Stage.Idle }) {
publish(Stage.Idle)
return
}
context.startService(Intent(context, WirelessAdbPairingService::class.java).setAction(ACTION_CANCEL))
}
fun notificationsEnabled(context: Context): Boolean =
context.getSystemService(NotificationManager::class.java)?.areNotificationsEnabled() != false
}
}
@@ -1,24 +1,15 @@
package com.droiddeck.launcher.frontend
import android.content.Context
import android.os.Environment
import android.util.Log
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs
import java.io.File
import java.util.zip.CRC32
/**
* The user's own Windows games, added to the Steam client's library.
*
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
* is the .exe in it that looks most like the game - the one named after the folder, else the
* largest, never an installer, redistributable or crash reporter - unless the user picked one
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
* with the appid the client itself would derive, so launching from the rail and from the
* client's own library are the same thing. Any number of Games folders, from anywhere on the
* device: each is bound into the session under /root/Games on its own.
*/
/** Windows game folders shared with the Steam session. */
object AddedGames {
private const val TAG = "AddedGames"
@@ -29,6 +20,7 @@ object AddedGames {
/** What steam://rungameid/ takes for a shortcut. */
val gameId: Long,
val candidates: List<File>,
val steamAppId: Int? = null,
) {
fun folderName(): String = folder.name
}
@@ -96,12 +88,15 @@ object AddedGames {
fun scan(context: Context): List<Game> {
val out = ArrayList<Game>()
for (root in roots(context)) {
val dir = root.host
val folders = roots(context).map { it.host } + listOfNotNull(
GameStorage.effective(context)?.let { File(it.path) },
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
)
for (dir in folders.distinctBy { it.absolutePath }) {
if (!dir.isDirectory) { Log.w(TAG, "$dir is not a folder; skipped"); continue }
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) scanGame(context, folder, out)
}
return out
return out.distinctBy { it.folder.canonicalPath }
}
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
@@ -115,10 +110,23 @@ object AddedGames {
val name = folder.name
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
val appId = crc or 0x80000000L
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
val steamId = steamRoute(steamRoot, appId)
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
}
}
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
val users = File(root, "config/loginusers.vdf").readText()
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
?: return@runCatching null
val account = recent.groupValues[1].toLong() - 76561197960265728L
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
.optInt(appId.toString()).takeIf { it > 0 }
}.getOrNull()
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
fun writeListing(context: Context, games: List<Game>): File {
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
@@ -126,6 +134,7 @@ object AddedGames {
games.forEachIndexed { i, g ->
if (i > 0) json.append(',')
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
// The art, as the session sees it: the app's cache is bound at its own path, a file in
// the game's folder at the folder's guest path.
@@ -145,5 +154,5 @@ object AddedGames {
return guestPath(context, file)
}
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
private fun quote(s: String): String = JSONObject.quote(s)
}
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
fun contains(x: Float, y: Float, r: Float): Boolean {
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
val dx = x - cx
val dy = y - cy
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
val hit = r * 1.25f
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
val hit = r * 1.25f + padding
return dx * dx + dy * dy <= hit * hit
}
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
override fun onDraw(canvas: Canvas) {
for (control in controls) {
if (!isVisible(control)) continue
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
val held = control.pressedBy != -1
val radius = control.radius
if (control.stick >= 0) {
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
val x = event.getX(index)
val y = event.getY(index)
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
if (control.pressedBy != -1) return true
control.pressedBy = event.getPointerId(index)
if (control.stick >= 0) {
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
val adaptive = settings.adaptiveSticks
control.ax = if (adaptive) event.getX(index) else control.cx
control.ay = if (adaptive) event.getY(index) else control.cy
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
for (control in controls) {
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
if (control.pressedBy != -1) changed = true
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
control.pressedBy = -1
control.clicked = false
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
}
private fun controlAt(x: Float, y: Float): Control? =
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
controls.firstOrNull {
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 (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 {
val dx = x - it.cx
val dy = y - it.cy
val reach = it.radius * 1.4f * sqrt(1.5f)
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
}.minByOrNull {
val dx = x - it.cx
val dy = y - it.cy
dx * dx + dy * dy
}
}
private fun isVisible(control: Control): Boolean = when {
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
controls.forEach {
it.pressedBy = -1
it.clicked = false
it.lastUp = 0L
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
it.kx = 0f; it.ky = 0f
}
@@ -6,6 +6,7 @@ import android.system.ErrnoException;
import android.system.Os;
import android.system.StructStat;
import android.system.StructUtsname;
import com.droiddeck.launcher.session.SessionPrefs;
import java.io.File;
@@ -33,7 +34,6 @@ public final class LinuxRuntime {
/** Shortcut extra naming which of the modes above a Linux entry launches. */
public static final String EXTRA_LINUX_MODE = "linux_mode";
private static final String KGSL_DEVICE = "/dev/kgsl-3d0";
private static final String GUEST_HOSTNAME = "DroidDeck";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
@@ -119,7 +119,7 @@ public final class LinuxRuntime {
cmd.add(prootBinary(context).getPath());
cmd.add("--kill-on-exit");
// Preserve the host kernel identity while giving the guest the app's branded host name.
cmd.add("--kernel-release=" + guestUtsname());
cmd.add("--kernel-release=" + guestUtsname(SessionPrefs.guestHostname(context)));
// Android's app seccomp policy traps the whole set*id family. Xwayland's Popen() calls
// setgid()/setuid() before it execs xkbcomp and _exit(127)s when they fail, so without
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
@@ -250,9 +250,9 @@ public final class LinuxRuntime {
}
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
private static String guestUtsname() {
private static String guestUtsname(String hostname) {
StructUtsname host = Os.uname();
return "\\" + host.sysname + "\\" + GUEST_HOSTNAME + "\\" + host.release
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
}
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.AtomicFile
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import java.io.File
object GameEnvironmentStore {
private const val SETTINGS = "game-environment.json"
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
@Synchronized
fun read(context: Context): GameEnvironment.Config {
val file = AtomicFile(File(context.filesDir, SETTINGS))
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
}
fun decode(json: JSONObject): GameEnvironment.Config {
require(json.getInt("version") == 1)
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
value?.keys()?.forEach { name ->
require(GameEnvironment.validName(name))
val entry = value.get(name)
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
put(name, if (entry == JSONObject.NULL) null else entry as String)
}
}
val games = json.optJSONObject("games")
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
games?.keys()?.forEach { id ->
require(id.isNotEmpty() && GameEnvironment.validScope(id))
put(id, entries(games.getJSONObject(id)))
}
})
}
fun encode(config: GameEnvironment.Config): JSONObject {
fun entries(values: Map<String, String?>) = JSONObject().apply {
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
}
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
config.games.forEach { (id, values) -> put(id, entries(values)) }
})
}
@Synchronized
fun save(context: Context, config: GameEnvironment.Config) {
val json = encode(config)
decode(json)
write(File(context.filesDir, SETTINGS), json.toString())
publish(context, config)
}
@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())
}
private fun write(file: File, text: String) {
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
val atomic = AtomicFile(file)
val output = atomic.startWrite()
try {
output.write(text.toByteArray(Charsets.UTF_8))
atomic.finishWrite(output)
} catch (error: Exception) {
atomic.failWrite(output)
throw error
}
}
}
@@ -24,7 +24,9 @@ object SessionFiles {
* rename, so a session that still has one mapped keeps the file it opened.
*/
fun stage(context: Context, root: File) {
GameEnvironmentStore.publish(context)
val files = arrayOf(
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
"libblsession.so" to "usr/local/lib/libblsession.so",
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
@@ -38,6 +40,7 @@ object SessionFiles {
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
"usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games",
"usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults",
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
@@ -211,6 +211,22 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootNoSeccomp", on).apply()
}
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
fun guestHostname(context: Context): String =
validGuestHostname(prefs(context).getString("guestHostname", null)) ?: DEFAULT_GUEST_HOSTNAME
fun setGuestHostname(context: Context, name: String) {
val valid = validGuestHostname(name)
prefs(context).edit().apply { if (valid == null) remove("guestHostname") else putString("guestHostname", valid) }.apply()
}
fun validGuestHostname(name: String?): String? {
val trimmed = name?.trim() ?: return null
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
}
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
@@ -298,6 +314,8 @@ object SessionPrefs {
fun setFexPreset(context: Context, id: String) {
prefs(context).edit().putString("fexPreset", id).apply()
runCatching { GameEnvironmentStore.publish(context) }
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
}
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
@@ -309,6 +309,7 @@ class SessionService : Service() {
val added = com.droiddeck.launcher.frontend.AddedGames.scan(this)
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
guest.add("BL_ADDED_GAMES=" + listing.path)
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
}
// Where the guest leaves a request for another session (the desktop's Steam launchers).
@@ -528,15 +529,6 @@ class SessionService : Service() {
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
// whose seccomp answers its syscalls normally there is no reason to take it away.
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
// here, before the script, so every game process inherits them from the client. The
// default preset sets nothing, which is what every session ran on before.
if (steamHere) {
val preset = SessionPrefs.fexPreset(this)
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
vars.forEach { guest.add(it) }
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
}
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
@@ -744,6 +736,24 @@ class SessionService : Service() {
return try { Pair(fields[1].toInt(), fields[19].toLong()) } catch (e: NumberFormatException) { null }
}
private fun askSteamToExit(prootPid: Int) {
if (!File("/proc/$prootPid").exists()) return
val request = File(LinuxRuntime.sessionRoot(this), "steam-stop")
try {
request.writeText("")
} catch (e: Exception) {
Log.w(TAG, "could not ask the Steam client to exit", e)
return
}
val started = System.currentTimeMillis()
if (waitForExit(prootPid, STEAM_PICKUP_MS) || request.exists()) {
request.delete()
return
}
val exited = waitForExit(prootPid, STEAM_EXIT_MS)
Log.i(TAG, "Steam client ${if (exited) "exited" else "did not exit"} after ${System.currentTimeMillis() - started} ms")
}
private fun waitForExit(pid: Int, timeoutMs: Long): Boolean {
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
@@ -961,9 +971,11 @@ class SessionService : Service() {
}
components.clear()
FakeInputWriter.releaseAllRingSlots()
val steamClientMayRun = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP
val finishAfterTeardown: () -> Unit = {
if (prootPid > 1 || auxiliary.isNotEmpty()) {
Thread({
if (prootPid > 1 && steamClientMayRun) askSteamToExit(prootPid)
auxiliary.forEach { (pid, started) -> teardown(pid, started) }
if (prootPid > 1) teardown(prootPid)
killGuestLeftovers()
@@ -1122,6 +1134,8 @@ class SessionService : Service() {
"steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so")
/** How long proot gets to run its own cleanup before it is killed outright. */
private const val GRACE_MS = 1200L
private const val STEAM_PICKUP_MS = 1500L
private const val STEAM_EXIT_MS = 10_000L
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centers where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SecondaryButton("Edit") { a.onEditLayout() }
@@ -157,6 +157,8 @@ class FrontEndActions(
val onSetPhantomProcessLimit: (Boolean, (String?) -> Unit) -> Unit = { _, done -> done("Wireless debugging is unavailable") },
val onCopyPhantomCommand: (Boolean) -> Unit = {},
val onDismissPhantomGate: () -> Unit = {},
val onStartWirelessAdbPairing: () -> Unit = {},
val onOpenNotificationSettings: () -> Unit = {},
val controller: ControllerActions? = null,
)
@@ -469,7 +471,13 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
status = s.phantomProcessStatus,
onDismiss = a.onDismissPhantomGate,
onOpenDeveloperOptions = requestDeveloperOptions,
onSetUpWirelessAdb = { requestWirelessAdbFix(false) },
onFixWithWirelessDebugging = {
a.onStartWirelessAdbPairing()
requestDeveloperOptions()
},
onEnterAddressManually = { requestWirelessAdbFix(false) },
onCopyCommand = { a.onCopyPhantomCommand(false) },
onOpenNotificationSettings = a.onOpenNotificationSettings,
)
}
}
@@ -0,0 +1,234 @@
package com.droiddeck.launcher.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.GameEnvironmentOptions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import com.droiddeck.launcher.frontend.Library
import com.droiddeck.launcher.session.GameEnvironmentStore
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
var open by remember { mutableStateOf(false) }
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) {
Text(stringResource(R.string.game_env_edit))
}
}
if (open) GameEnvironmentEditor { open = false }
}
@Composable
private fun GameEnvironmentEditor(onClose: () -> Unit) {
val context = LocalContext.current
val coroutine = rememberCoroutineScope()
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
var scope by remember { mutableStateOf("") }
var appId by remember { mutableStateOf("") }
var scopeMenu by remember { mutableStateOf(false) }
var addMenu by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
val preset = SessionPrefs.fexPreset(context)
LaunchedEffect(Unit) {
runCatching {
withContext(Dispatchers.IO) {
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
}
}.onSuccess { (settings, installed) -> config = settings; games = installed }
.onFailure { error = true }
}
fun save(next: GameEnvironment.Config) {
busy = true
coroutine.launch {
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
result.onSuccess { config = next; error = false }.onFailure { error = true }
busy = false
}
}
AlertDialog(
onDismissRequest = { if (!busy) onClose() },
title = { Text(stringResource(R.string.game_env_title)) },
confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } },
text = {
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.game_env_hint))
Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall)
if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error)
if (config == null && !error) Text(stringResource(R.string.game_env_loading))
val current = config
if (current != null) {
val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared)
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium)
Box {
TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) }
DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false })
val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false })
}
}
OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy,
label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth())
TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) {
Text(stringResource(R.string.game_env_open))
}
Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall)
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty))
for ((name, value) in entries) {
HorizontalDivider()
Text(name, style = MaterialTheme.typography.titleSmall)
Text(value ?: stringResource(R.string.game_env_unset))
if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall)
Row {
TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) }
if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) {
Text(stringResource(R.string.game_env_remove))
}
}
if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) {
Text(stringResource(R.string.game_env_restore))
}
}
Box {
TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) }
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false })
for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false })
}
}
TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) {
Text(stringResource(R.string.game_env_reset))
}
}
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
}
},
)
editing?.let { (name, value) ->
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
config?.let {
val entries = it.entries(scope).toMutableMap()
if (name.isNotEmpty() && name != key) entries[name] = null
entries[key] = content
save(it.withEntries(scope, entries))
}
editing = null
}
}
}
@Composable
private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
var name by remember { mutableStateOf(initialName) }
var value by remember { mutableStateOf(initialValue) }
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.game_env_edit)) },
confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
VariableNamePicker(name) { selected ->
name = selected
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
}
VariableValueEditor(name, value) { value = it }
GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let {
Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall)
}
if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused))
if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun VariableNamePicker(name: String, onChange: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium)
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(if (custom) stringResource(R.string.game_env_custom) else name)
}
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = {
custom = true; onChange(""); expanded = false
})
for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) {
DropdownMenuItem(text = { Text(option.name) }, onClick = {
custom = false; onChange(option.name); expanded = false
})
}
}
}
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
}
@Composable
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
val option = GameEnvironmentOptions.find(name)
var expanded by remember(name) { mutableStateOf(false) }
var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) }
when {
option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row(
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(stringResource(R.string.game_env_value))
Switch(checked = value == option.choices.last(), onCheckedChange = {
onChange(if (it) option.choices.last() else option.choices.first())
})
}
option != null && option.choices.isNotEmpty() -> {
Box {
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) })
}
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false })
for (choice in option.choices) {
val selected = choice in value.split(',')
DropdownMenuItem(text = { Text(choice) }, leadingIcon = {
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null)
}, onClick = {
custom = false
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) {
onChange(GameEnvironmentOptions.toggle(value, choice))
} else {
onChange(choice); expanded = false
}
})
}
}
}
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
}
else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text),
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
}
}
@@ -16,6 +16,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
@@ -347,13 +349,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
for (dir in s.addedGamesDirs) {
val n = s.addedGames.count { it.folderPath.startsWith("$dir/") }
ActionRow(
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". Forget: the games leave the client's library at the next session start; nothing on disk is touched.",
dir.substringAfterLast('/').ifEmpty { dir }, dir + " · " + (if (n == 0) "no game folders with a .exe found" else "$n game${if (n == 1) "" else "s"}") + ". " + stringResource(R.string.added_games_forget_hint),
"Forget", onClick = { a.onForgetAddedGamesDir(dir) },
)
}
ActionRow(
if (s.addedGamesDirs.isEmpty()) "Games folder" else "Another games folder",
"Your own Windows games, one subfolder each, anywhere: internal storage, the SD card, a USB drive. As many folders as you like. Each game goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start.",
stringResource(R.string.added_games_import_hint),
"Add…", onClick = a.onPickAddedGamesDir,
)
ToggleRow(
@@ -368,12 +370,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
)
}
if (steam && s.fexPreset != null) SettingsGroup("Games") {
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(
host, "fex", "FEX preset", "Applies on next game launch.",
FexPreset.all.map { it.id to it.label }, s.fexPreset,
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
)
GameEnvironmentRow()
if (s.forceFullscreen != null) ToggleRow(
host, "fill", "Stretch games to fill the screen",
"Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.",
@@ -400,7 +403,7 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
val open = host.open == "storage"
SettingsRow(
"Second library",
"Adds a library location in Steam. Applies next session.",
stringResource(R.string.second_library_import_hint),
highlighted = open,
) {
androidx.compose.foundation.layout.Box {
@@ -7,15 +7,22 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
class CoreRow(val core: Int, val label: String)
@@ -32,6 +39,7 @@ fun PerformancePage(
steamDeckMode: Boolean,
noXalia: Boolean,
prootNoSeccomp: Boolean,
guestHostname: String,
phantomWarning: String?,
onClientOverride: (Boolean) -> Unit,
onTuSysmem: (Boolean) -> Unit,
@@ -41,6 +49,7 @@ fun PerformancePage(
onSteamDeckMode: (Boolean) -> Unit,
onNoXalia: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
onGameCore: (Int, Boolean) -> Unit,
onDismiss: () -> Unit,
@@ -50,7 +59,7 @@ fun PerformancePage(
val coreItems = cores.map { it.core to it.label }
SettingsPage(
host, title = "Performance",
lede = "CPU cores and session fixes. Applies next session.",
lede = "CPU cores, session fixes and the host name. Applies next session.",
onBack = onDismiss,
) {
SettingsGroup("Steam client cores") {
@@ -110,6 +119,25 @@ fun PerformancePage(
prootNoSeccomp, onChange = onProotNoSeccomp,
)
}
SettingsGroup("Session identity") {
var draft by remember(guestHostname) { mutableStateOf(guestHostname) }
val valid = SessionPrefs.validGuestHostname(draft) != null
SettingsRow(
"Host name",
if (valid || draft.isBlank()) "What the session and Steam report as this machine's name. Blank restores ${SessionPrefs.DEFAULT_GUEST_HOSTNAME}."
else "Letters, digits and inner hyphens only, up to 63 characters. Not saved until it is valid.",
) {
OutlinedTextField(
draft, { v ->
draft = v.take(63)
if (draft.isBlank() || SessionPrefs.validGuestHostname(draft) != null) onGuestHostname(draft)
},
singleLine = true, isError = !valid && draft.isNotBlank(),
placeholder = { Text(SessionPrefs.DEFAULT_GUEST_HOSTNAME) },
modifier = Modifier.width(220.dp),
)
}
}
if (phantomWarning != null) {
Spacer(Modifier.height(16.dp))
Column(
@@ -1,64 +1,96 @@
package com.droiddeck.launcher.ui
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.core.PhantomProcessLimit
import com.droiddeck.launcher.core.PhantomProcessStatus
import com.droiddeck.launcher.core.WirelessAdbPairingService
import com.droiddeck.launcher.core.WirelessAdbPairingService.Stage
@Composable
fun PhantomProcessGatePage(
status: PhantomProcessStatus,
onDismiss: () -> Unit,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
onOpenNotificationSettings: () -> Unit,
) {
BackHandler(onBack = onDismiss)
val context = LocalContext.current
val stage by WirelessAdbPairingService.stage.collectAsState()
val environment by produceState(GateEnvironment.read(context)) {
while (true) {
value = GateEnvironment.read(context)
kotlinx.coroutines.delay(2_000)
}
}
val hasToggle = PhantomProcessLimit.hasDeveloperToggle()
BoxWithConstraints(Modifier.fillMaxWidth()) {
val compact = maxWidth < 620.dp && maxHeight < 500.dp
SettingsPage(
host = rememberMenuHost(),
title = if (compact) "Steam paused" else "Steam cannot start yet",
title = "One Android setting to change",
eyebrow = "Steam",
lede = if (compact) compactStatus(status) else "${PhantomProcessLimit.title(status)} · checking again every two seconds",
lede = if (compact) null else "Android stops apps that start lots of background processes, and Steam starts dozens. " +
"Until this is changed, games close with no error.",
onBack = onDismiss,
// Scrolls, so the larger text never pushes the ADB command off a short screen.
scrollContent = true,
compactLayout = compact,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val wide = maxWidth >= 620.dp
Column(
modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
when {
compact -> CompactGateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
wide -> Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.weight(1f)) {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
}
Column(Modifier.weight(1f)) { AdbFallback() }
}
else -> {
GateInstructions(status, onOpenDeveloperOptions, onSetUpWirelessAdb)
AdbFallback()
}
val fix: @Composable ColumnScope.() -> Unit = {
FixGroup(hasToggle, stage, environment, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging,
onOpenNotificationSettings, onCancel = { WirelessAdbPairingService.cancel(context) })
}
val side: @Composable ColumnScope.() -> Unit = {
StatusGroup(status, compact)
OtherWays(hasToggle, compact, onOpenDeveloperOptions, onFixWithWirelessDebugging, onEnterAddressManually, onCopyCommand)
}
if (wide) {
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.widthIn(max = 900.dp).fillMaxWidth()) {
Column(Modifier.weight(1.2f), content = fix)
Column(Modifier.weight(1f), content = side)
}
} else {
Column(Modifier.widthIn(max = 600.dp).fillMaxWidth()) {
fix()
side()
}
}
}
@@ -66,97 +98,168 @@ fun PhantomProcessGatePage(
}
}
private fun compactStatus(status: PhantomProcessStatus): String {
val state = when (status) {
PhantomProcessStatus.ENABLED -> "Child-process limit is on"
PhantomProcessStatus.UNSET -> "Child-process limit is unset"
PhantomProcessStatus.UNREADABLE -> "Child-process limit is unreadable"
else -> PhantomProcessLimit.title(status)
private data class GateEnvironment(val onWifi: Boolean, val notifications: Boolean) {
companion object {
fun read(context: Context): GateEnvironment {
val connectivity = context.getSystemService(ConnectivityManager::class.java)
val wifi = runCatching {
connectivity?.getNetworkCapabilities(connectivity.activeNetwork)
?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
}.getOrDefault(true)
return GateEnvironment(wifi, WirelessAdbPairingService.notificationsEnabled(context))
}
}
return "$state · retrying every 2 sec"
}
@Composable
private fun CompactGateInstructions(
status: PhantomProcessStatus,
private fun FixGroup(
hasToggle: Boolean,
stage: Stage,
environment: GateEnvironment,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onOpenNotificationSettings: () -> Unit,
onCancel: () -> Unit,
) {
SettingsGroup("1 · Developer options", compact = true) {
val pairing = stage is Stage.Waiting || stage is Stage.CodeNeeded || stage is Stage.Working
SettingsGroup(if (hasToggle) "Developer options" else "Fix it for me", compact = compact) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
PhantomProcessLimit.gateInstructions(status),
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
PrimaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", compact = true, onClick = onSetUpWirelessAdb)
when {
pairing -> PairingProgress(stage, onCancel)
hasToggle -> {
Body("Turn on “Disable child process restrictions”, then come back. Keep Developer options on afterwards: turning them off turns the switch back off.")
PrimaryButton("Open Developer options", compact = compact, onClick = onOpenDeveloperOptions)
}
else -> {
Body("This Android version has no switch for it, so DroidDeck changes it over Wireless debugging on this device. About a minute, no computer needed.")
(stage as? Stage.Failed)?.let { Body(it.error, error = true) }
PrimaryButton(
if (stage is Stage.Failed) "Try again" else "Fix it for me",
compact = compact,
enabled = environment.onWifi && environment.notifications,
onClick = onFixWithWirelessDebugging,
)
}
}
if (!hasToggle && !pairing) {
if (!environment.onWifi) {
Body("Wireless debugging needs Wi-Fi. Connect to any network first; it doesn't need internet.", warn = true)
}
if (!environment.notifications) {
Body("The pairing code is entered in a notification. Allow notifications for DroidDeck, or use Other ways.", warn = true)
SecondaryButton("Allow notifications", compact = compact, onClick = onOpenNotificationSettings)
}
}
}
}
SettingsGroup("3 · Computer ADB last resort", compact = true) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(
"If the setting is missing and Wireless debugging is unavailable, run this from a computer:",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun PairingProgress(stage: Stage, onCancel: () -> Unit) {
val steps = listOf(
"Open Settings → Developer options → Wireless debugging and turn it on",
"Tap “Pair device with pairing code”",
"Type the code into the DroidDeck notification",
)
val active = when (stage) {
Stage.Waiting -> 1
is Stage.CodeNeeded -> 2
else -> 3
}
steps.forEachIndexed { index, step ->
val done = index < active
val current = index == active
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (done) "✓" else "${index + 1}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (done) LocalPalette.current.good else MaterialTheme.colorScheme.onSurfaceVariant)
Text(step, style = MaterialTheme.typography.bodySmall,
color = if (current) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
when (stage) {
Stage.Waiting -> "Waiting for the pairing pop-up…"
is Stage.CodeNeeded -> stage.error ?: "Pop-up found. Enter its code in the notification."
is Stage.Working -> stage.step
else -> ""
},
style = MaterialTheme.typography.bodySmall,
color = if (stage is Stage.CodeNeeded && stage.error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
SecondaryButton("Cancel", compact = true, onClick = onCancel)
}
}
@Composable
private fun StatusGroup(status: PhantomProcessStatus, compact: Boolean) {
SettingsGroup("Status", compact = compact) {
Column(Modifier.fillMaxWidth().padding(if (compact) 10.dp else 14.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(PhantomProcessLimit.title(status), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground)
Body("Checking every 2 seconds. This page closes when it's off.")
}
}
}
@Composable
private fun GateInstructions(
status: PhantomProcessStatus,
private fun OtherWays(
hasToggle: Boolean,
compact: Boolean,
onOpenDeveloperOptions: () -> Unit,
onSetUpWirelessAdb: () -> Unit,
onFixWithWirelessDebugging: () -> Unit,
onEnterAddressManually: () -> Unit,
onCopyCommand: () -> Unit,
) {
SettingsGroup("1 · Developer options first") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
var open by rememberSaveable { mutableStateOf(false) }
SettingsGroup("Other ways", compact = compact) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { open = !open }.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Body(if (hasToggle) "Wireless debugging, manual address, computer ADB" else "Manual address, computer ADB, Developer options", modifier = Modifier.weight(1f))
Text(if (open) "▴" else "▾", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (open) Column(
modifier = Modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (hasToggle) {
SecondaryButton("Use Wireless debugging instead", compact = true, onClick = onFixWithWirelessDebugging)
} else {
Body("Some ROMs add the switch anyway. Look for “child process” in Developer options.")
SecondaryButton("Open Developer options", compact = true, onClick = onOpenDeveloperOptions)
}
Body("If the notification doesn't work, enter the pop-up's address and code yourself.")
SecondaryButton("Enter address manually", compact = true, onClick = onEnterAddressManually)
Body("From a computer with ADB:")
Text(
PhantomProcessLimit.gateInstructions(status),
PhantomProcessLimit.adbCommand(),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
PrimaryButton("Open Developer options", onClick = onOpenDeveloperOptions)
SecondaryButton("Use Wireless debugging", onClick = onSetUpWirelessAdb)
SecondaryButton("Copy command", compact = true, onClick = onCopyCommand)
}
}
}
@Composable
private fun AdbFallback() {
SettingsGroup("3 · Computer ADB last resort") {
Column(
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Use this only if Developer options has no setting and Wireless debugging is unavailable. Connect this device to a computer with ADB and run:",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
)
Text(
PhantomProcessLimit.ADB_COMMAND,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
private fun Body(text: String, modifier: Modifier = Modifier, error: Boolean = false, warn: Boolean = false) {
Text(
text,
modifier = modifier,
style = MaterialTheme.typography.bodySmall,
color = when {
error -> MaterialTheme.colorScheme.error
warn -> androidx.compose.ui.graphics.Color(0xFFFFB86B)
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
@Composable
@@ -89,6 +89,8 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
@@ -405,9 +407,12 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
ChoiceRow(host, "shape", "Screen ratio", null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
FexPreset.all.map { it.id to it.label }, a.fexPreset,
}
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
}
SettingsGroup("Support") {
SettingsRow("Session logs", "Send this session's logs with a bug report") {
@@ -1,11 +1,8 @@
<!--
Status-bar icon: Android renders it as a single-colour silhouette, so this is the launcher
controller reduced to one path, with the d-pad and the buttons punched out (evenOdd) rather
than drawn in a second colour that would never survive.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M40,36 H68 C79,36 87,44.5 87,55.5 v4 c0,6.6 -5.4,12 -12,12 c-4.5,0 -8.6,-2.5 -10.6,-6.5 L62,60 H46 l-2.4,5 C41.6,69 37.5,71.5 33,71.5 c-6.6,0 -12,-5.4 -12,-12 v-4 C21,44.5 29,36 40,36 Z M33,47.5 h4.5 v-4.5 h4.5 v4.5 h4.5 v4.5 h-4.5 v4.5 h-4.5 v-4.5 h-4.5 z M71,43 a2.5,2.5 0 1,0 0.01,0 z M77,47.5 a2.5,2.5 0 1,0 0.01,0 z M65,47.5 a2.5,2.5 0 1,0 0.01,0 z M71,52 a2.5,2.5 0 1,0 0.01,0 z M54,44 a6,6 0 1,0 0.01,0 z" />
android:viewportWidth="239.92" android:viewportHeight="239.92">
<group android:translateX="55.98" android:translateY="8.1">
<path android:fillColor="#FFFFFFFF" android:fillType="evenOdd"
android:pathData="M-31.98,0 H63.97 C125.81,0 175.93,50.08 175.93,111.86 S125.81,223.72 63.97,223.72 H-31.98 Z M-7.83,111.86 a71.81,71.81 0 1,0 143.62,0 a71.81,71.81 0 1,0 -143.62,0 Z M8,111.86 a55.98,55.98 0 1,0 111.96,0 a55.98,55.98 0 1,0 -111.96,0 Z M56.055,0 h15.83 v40.49 h-15.83 Z M56.055,183.23 h15.83 v40.49 h-15.83 Z" />
</group>
</vector>
+67
View File
@@ -19,4 +19,71 @@
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</string>
<string name="added_games_import_hint">Choose Windows games with one subfolder per game. Steam checks ownership while signed in. Confirmed games use their Steam library entries after restarting Steam; other games stay as non-Steam shortcuts. Steam may verify or download files for imported games.</string>
<string name="second_library_import_hint">Adds a Steam library and scans Windows game folders here and in steamapps/common. After the ownership check and a Steam restart, owned games use Steam entries; other games use non-Steam shortcuts.</string>
<string name="added_games_forget_hint">Forget removes non-Steam shortcuts next session. Manage Steam installations in Steam. Files stay on disk.</string>
<string name="game_env_title">Game environment</string>
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
<string name="game_env_edit">Edit variables</string>
<string name="game_env_shared">All Proton games</string>
<string name="game_env_scope">Game profile</string>
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
<string name="game_env_open">Open profile</string>
<string name="game_env_profile">Game %1$s</string>
<string name="game_env_add">Add variable</string>
<string name="game_env_name">Variable name</string>
<string name="game_env_value">Value</string>
<string name="game_env_save">Save</string>
<string name="game_env_cancel">Cancel</string>
<string name="game_env_close">Close</string>
<string name="game_env_remove">Remove</string>
<string name="game_env_restore">Restore inherited settings</string>
<string name="game_env_reset">Reset this profile</string>
<string name="game_env_unset">Unset for this profile</string>
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
<string name="game_env_inherited">Inherited</string>
<string name="game_env_custom">Custom variable</string>
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
<string name="game_env_loading">Loading…</string>
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
<string name="game_env_cache_size">Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage.</string>
<string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string>
<string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string>
<string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string>
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
<string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string>
<string name="fex_preset_title">FEX preset</string>
<string name="fex_next_launch">Applies on the next game launch.</string>
<string name="game_settings_title">Games</string>
<string name="fex_default_label">FEX defaults</string>
<string name="fex_default_detail">Uses FEX defaults.</string>
<string name="fex_stability_label">Stability</string>
<string name="fex_stability_detail">Safest, slowest settings.</string>
<string name="fex_compatibility_label">Compatibility</string>
<string name="fex_compatibility_detail">Full TSO with multiblock.</string>
<string name="fex_intermediate_label">Intermediate</string>
<string name="fex_intermediate_detail">Balanced TSO with reduced x87 precision.</string>
<string name="fex_performance_label">Performance</string>
<string name="fex_performance_detail">All TSO disabled; multiblock.</string>
<string name="fex_performance_tso_label">Performance + TSO</string>
<string name="fex_performance_tso_detail">Performance settings with main TSO enabled.</string>
<string name="fex_extreme_label">Extreme</string>
<string name="fex_extreme_detail">Disables self-modifying-code checks. JIT games and DRM may break.</string>
<string name="fex_extreme_tso_label">Extreme + TSO</string>
<string name="fex_extreme_tso_detail">Extreme settings with main TSO enabled.</string>
<string name="fex_extreme_gn_label">Extreme-gn</string>
<string name="fex_extreme_gn_detail">Performance with TSC and metadata hints. Try before Extreme.</string>
<string name="fex_denuvo_label">Denuvo</string>
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
<string name="game_env_custom_value">Custom value</string>
<string name="game_env_select_value">Select value</string>
</resources>
@@ -0,0 +1,75 @@
package com.droiddeck.launcher.core
import org.junit.Assert.*
import org.junit.Test
class GameEnvironmentTest {
@Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() {
val values = GameEnvironment.defaults("")
assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"])
assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"])
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
}
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
val config = GameEnvironment.Config(
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
games = mapOf("42" to mapOf("FEX_MULTIBLOCK" to "1", "VKD3D_FEATURE_LEVEL" to null)),
)
val game = GameEnvironment.effective(config, "COMPATIBILITY", "42")
assertEquals("1", game["FEX_MULTIBLOCK"])
assertTrue(game.containsKey("VKD3D_FEATURE_LEVEL"))
assertNull(game["VKD3D_FEATURE_LEVEL"])
val other = GameEnvironment.effective(config, "COMPATIBILITY", "43")
assertEquals("0", other["FEX_MULTIBLOCK"])
assertEquals("12_2", other["VKD3D_FEATURE_LEVEL"])
}
@Test fun graphicsDefaultsRespectExistingEditsAndRemovals() {
val config = GameEnvironment.Config(shared = mapOf("VKD3D_FEATURE_LEVEL" to "12_0"))
.withEntries("42", mapOf("VKD3D_SHADER_MODEL" to null))
val game = GameEnvironment.effective(config, "", "42")
assertEquals("12_0", game["VKD3D_FEATURE_LEVEL"])
assertTrue(game.containsKey("VKD3D_SHADER_MODEL"))
assertNull(game["VKD3D_SHADER_MODEL"])
val restored = GameEnvironment.effective(config.withEntries("42", emptyMap()), "", "42")
assertEquals("6_9", restored["VKD3D_SHADER_MODEL"])
}
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
assertEquals("", GameEnvironmentOptions.toggle("fps", "fps"))
assertEquals("fps", GameEnvironmentOptions.toggle("", "fps"))
}
@Test fun switchingPresetDropsOldPresetOnlyVariables() {
val config = GameEnvironment.Config()
assertEquals("none", GameEnvironment.effective(config, "EXTREME", "")["FEX_SMCCHECKS"])
assertFalse(GameEnvironment.effective(config, "COMPATIBILITY", "").containsKey("FEX_SMCCHECKS"))
assertFalse(GameEnvironment.effective(config, "", "").containsKey("FEX_MULTIBLOCK"))
}
@Test fun resettingOneProfilePreservesOtherProfiles() {
val config = GameEnvironment.Config(shared = mapOf("CUSTOM" to "shared"))
.withEntries("42", mapOf("CUSTOM" to "game"))
.withEntries("43", mapOf("CUSTOM" to "other"))
.withEntries("42", emptyMap())
assertEquals("shared", GameEnvironment.effective(config, "", "42")["CUSTOM"])
assertEquals("other", GameEnvironment.effective(config, "", "43")["CUSTOM"])
assertFalse(config.games.containsKey("42"))
}
@Test fun validationPreservesLiteralValuesAndRejectsInvalidKeys() {
assertTrue(GameEnvironment.validName("mesa_glthread"))
assertFalse(GameEnvironment.validName("X=Y"))
assertFalse(GameEnvironment.validName("1VAR"))
assertFalse(GameEnvironment.validName("VAR\n"))
assertTrue(GameEnvironment.validValue("a=b 'quote' $(touch file) ; $"))
assertTrue(GameEnvironment.validValue(""))
assertFalse(GameEnvironment.validValue("bad\u0000value"))
assertFalse(GameEnvironment.validValue("x".repeat(8193)))
assertTrue(GameEnvironment.validScope("4294967295"))
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
}
}
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PhantomProcessLimitTest {
@@ -26,4 +28,33 @@ class PhantomProcessLimitTest {
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status(null, null))
assertEquals(PhantomProcessStatus.UNSET, PhantomProcessLimit.status("", ""))
}
@Test
fun android12UsesDeviceConfig() {
assertEquals(
listOf(
"device_config set_sync_disabled_for_tests persistent",
"device_config put activity_manager max_phantom_processes 2147483647",
),
PhantomProcessLimit.shellCommands(enabled = false, sdk = 31),
)
assertTrue(PhantomProcessLimit.verified("2147483647\n", enabled = false, sdk = 31))
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 31))
}
@Test
fun android12LAndLaterUseTheFlag() {
assertEquals(
listOf("settings put global settings_enable_monitor_phantom_procs false"),
PhantomProcessLimit.shellCommands(enabled = false, sdk = 33),
)
assertTrue(PhantomProcessLimit.verified("false", enabled = false, sdk = 34))
assertFalse(PhantomProcessLimit.verified("null", enabled = false, sdk = 33))
}
@Test
fun developerToggleStartsAtAndroid14() {
assertFalse(PhantomProcessLimit.hasDeveloperToggle(33))
assertTrue(PhantomProcessLimit.hasDeveloperToggle(34))
}
}
@@ -0,0 +1,191 @@
package com.droiddeck.launcher.input
import kotlin.math.sqrt
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.MotionEvent
import android.view.View
import android.widget.FrameLayout
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE, qualifiers = "mdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class OnScreenControlsTest {
private lateinit var context: Context
private lateinit var view: OnScreenControls
private var time = 1000L
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit()
ControllerPrefs.setLayout(context, 1200, 800, mapOf("ls" to (0.45f to 0.5f), "rs" to (0.55f to 0.5f)))
view = OnScreenControls(context, null)
view.layout(0, 0, 1200, 800)
}
@Test fun adaptiveStickHidesUntilTouchedAndReturnsToNeutralOnRelease() {
val left = control("ls")
assertEquals(0, pixel(value(left, "cx"), value(left, "cy")))
assertTrue(touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f)))
assertEquals(7, field(left, "pressedBy"))
assertEquals(540f, value(left, "ax"), 0f)
assertEquals(400f, value(left, "ay"), 0f)
assertNotEquals(0, pixel(540f, 400f))
touch(MotionEvent.ACTION_MOVE, 7 to (580f to 420f))
assertTrue(value(left, "kx") > 0f)
assertTrue(value(left, "ky") > 0f)
touch(MotionEvent.ACTION_UP, 7 to (580f to 420f))
assertEquals(-1, field(left, "pressedBy"))
assertEquals(0f, value(left, "kx"), 0f)
assertEquals(0f, value(left, "ky"), 0f)
assertEquals(0, pixel(540f, 400f))
}
@Test fun simultaneousSticksKeepTheirPointersAndReleaseIndependently() {
val left = control("ls")
val right = control("rs")
touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f))
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (540f to 400f), 9 to (660f to 400f))
assertEquals(7, field(left, "pressedBy"))
assertEquals(9, field(right, "pressedBy"))
touch(MotionEvent.ACTION_POINTER_DOWN or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
assertEquals(7, field(left, "pressedBy"))
touch(MotionEvent.ACTION_POINTER_UP or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
touch(MotionEvent.ACTION_MOVE, 7 to (700f to 400f), 9 to (500f to 400f))
assertTrue(value(left, "kx") > 0f)
assertTrue(value(right, "kx") < 0f)
touch(MotionEvent.ACTION_POINTER_UP, 7 to (700f to 400f), 9 to (500f to 400f))
assertEquals(-1, field(left, "pressedBy"))
assertEquals(9, field(right, "pressedBy"))
touch(MotionEvent.ACTION_CANCEL, 9 to (500f to 400f))
for (stick in listOf(left, right)) {
assertEquals(-1, field(stick, "pressedBy"))
assertEquals(0f, value(stick, "kx"), 0f)
}
}
@Test fun cancelledTouchDoesNotBecomeAStickClick() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_CANCEL, 1 to (540f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(false, field(control("ls"), "clicked"))
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
assertEquals(true, field(control("ls"), "clicked"))
}
@Test fun buttonsTakePriorityOverAdaptiveRegions() {
val button = control("a")
touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy")))
assertEquals(3, field(button, "pressedBy"))
assertEquals(-1, field(control("rs"), "pressedBy"))
}
@Test fun fixedSticksAndEditorRemainVisibleAtSavedPositions() {
ControllerPrefs.setAdaptiveSticks(context, false)
view.reload()
val left = control("ls")
val x = value(left, "cx")
val y = value(left, "cy")
assertNotEquals(0, pixel(x, y))
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
touch(MotionEvent.ACTION_DOWN, 1 to (x + 10f to y))
assertEquals(x, value(left, "ax"), 0f)
assertEquals(10f, value(left, "kx"), 0f)
ControllerPrefs.setAdaptiveSticks(context, true)
view = OnScreenControls(context, null, editing = true)
view.layout(0, 0, 1200, 800)
assertNotEquals(0, pixel(value(control("ls"), "cx"), value(control("ls"), "cy")))
}
@Test fun buttonsOnlyAndReloadReleaseAdaptiveSticks() {
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
touch(MotionEvent.ACTION_MOVE, 1 to (570f to 400f))
view.reload()
assertEquals(-1, field(control("ls"), "pressedBy"))
assertEquals(0f, value(control("ls"), "kx"), 0f)
view.setButtonsOnly(true)
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
}
@Test fun adaptiveActivationIsLimitedToExpandedSavedCircle() {
val left = control("ls")
val x = value(left, "cx")
val y = value(left, "cy")
val reach = value(left, "radius") * 1.4f * sqrt(1.5f)
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach - 1f)))
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach + 1f)))
assertEquals(y - reach + 1f, value(left, "ay"), 0.001f)
touch(MotionEvent.ACTION_UP, 1 to (x to y - reach + 1f))
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x - reach to y - reach)))
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
}
@Test fun touchesOutsideActivationAreasStayWithTrackpad() {
val received = mutableListOf<Int>()
val trackpad = View(context).apply {
setOnTouchListener { _, event -> received.add(event.actionMasked); true }
}
val root = FrameLayout(context).apply {
addView(trackpad, FrameLayout.LayoutParams(1200, 800))
addView(view, FrameLayout.LayoutParams(1200, 800))
measure(View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY))
layout(0, 0, 1200, 800)
}
for ((action, position) in listOf(
MotionEvent.ACTION_DOWN to (600f to 200f),
MotionEvent.ACTION_MOVE to (540f to 400f),
MotionEvent.ACTION_UP to (540f to 400f),
)) {
val event = MotionEvent.obtain(1000L, time++, action, position.first, position.second, 0)
try { assertTrue(root.dispatchTouchEvent(event)) } finally { event.recycle() }
}
assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), received)
assertEquals(-1, field(control("ls"), "pressedBy"))
}
@Test fun adaptiveActivationLeavesFivePixelsAroundButtonHitTargets() {
for (id in listOf("a", "rb")) {
val button = control(id)
val radius = value(button, "radius")
val edge = value(button, "cy") - radius * if (id == "rb") 1.05f else 1.25f
val x = value(button, "cx")
val left = control("ls")
left.javaClass.getDeclaredField("cx").apply { isAccessible = true }.setFloat(left, x)
left.javaClass.getDeclaredField("cy").apply { isAccessible = true }.setFloat(left, edge - 10f)
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 4f)))
assertEquals(-1, field(left, "pressedBy"))
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 6f)))
assertEquals(1, field(left, "pressedBy"))
touch(MotionEvent.ACTION_UP, 1 to (x to edge - 6f))
}
}
private fun control(id: String): Any = (field(view, "controls") as List<*>).first { field(it!!, "id") == id }!!
private fun field(owner: Any, name: String): Any = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner)!!
private fun value(owner: Any, name: String) = field(owner, name) as Float
private fun pixel(x: Float, y: Float): Int {
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
view.draw(Canvas(bitmap))
val color = bitmap.getPixel(x.toInt(), y.toInt())
bitmap.recycle()
return color
}
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray()
val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray()
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
return try { view.onTouchEvent(event) } finally { event.recycle() }
}
}
@@ -0,0 +1,64 @@
package com.droiddeck.launcher.session
import android.content.Context
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.io.File
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class GameEnvironmentStoreTest {
private lateinit var context: Context
private lateinit var guest: File
@Before fun setUp() {
context = RuntimeEnvironment.getApplication()
guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json")
File(context.filesDir, "game-environment.json").delete()
}
@Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() {
val config = GameEnvironment.Config(
shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""),
games = mapOf("42" to mapOf("LITERAL" to null, "VKD3D_SHADER_MODEL" to "6_9")),
)
GameEnvironmentStore.save(context, config)
assertEquals(config, GameEnvironmentStore.read(context))
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
assertEquals("false", published.shared["MESA_SHADER_CACHE_DISABLE"])
assertEquals("12_2", published.shared["VKD3D_FEATURE_LEVEL"])
assertEquals("6_9", published.shared["VKD3D_SHADER_MODEL"])
assertEquals(config.games, published.games)
assertEquals(config.shared["LITERAL"], published.shared["LITERAL"])
assertEquals("", published.shared["EMPTY"])
}
@Test fun changingFexPresetPublishesForNextGameLaunch() {
GameEnvironmentStore.save(context, GameEnvironment.Config())
SessionPrefs.setFexPreset(context, "EXTREME")
assertEquals("none", JSONObject(guest.readText()).getJSONObject("shared").getString("FEX_SMCCHECKS"))
SessionPrefs.setFexPreset(context, "")
assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS"))
}
@Test fun invalidDataCannotReplaceSavedConfiguration() {
val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok"))
GameEnvironmentStore.save(context, original)
assertTrue(runCatching { GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BAD=NAME" to "x"))) }.isFailure)
assertEquals(original, GameEnvironmentStore.read(context))
}
@Test fun customComponentVariablesRemainEditable() {
val config = GameEnvironment.Config(shared = mapOf("DXVK_ASYNC" to "1"))
GameEnvironmentStore.save(context, config)
assertEquals(config, GameEnvironmentStore.read(context))
}
}
@@ -0,0 +1,30 @@
package com.droiddeck.launcher.session
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class GuestHostnameTest {
@Test fun validLabelsAreKept() {
assertEquals("localhost", SessionPrefs.validGuestHostname("localhost"))
assertEquals("Fold8-Steam", SessionPrefs.validGuestHostname("Fold8-Steam"))
assertEquals("a", SessionPrefs.validGuestHostname("a"))
assertEquals("x".repeat(63), SessionPrefs.validGuestHostname("x".repeat(63)))
}
@Test fun surroundingSpaceIsTrimmed() {
assertEquals("localhost", SessionPrefs.validGuestHostname(" localhost \n"))
}
@Test fun anythingElseIsRejected() {
assertNull(SessionPrefs.validGuestHostname(null))
assertNull(SessionPrefs.validGuestHostname(""))
assertNull(SessionPrefs.validGuestHostname(" "))
assertNull(SessionPrefs.validGuestHostname("-leading"))
assertNull(SessionPrefs.validGuestHostname("trailing-"))
assertNull(SessionPrefs.validGuestHostname("two words"))
assertNull(SessionPrefs.validGuestHostname("dotted.name"))
assertNull(SessionPrefs.validGuestHostname("back\\slash"))
assertNull(SessionPrefs.validGuestHostname("x".repeat(64)))
}
}
+81
View File
@@ -0,0 +1,81 @@
# Steam game environment
Open **Games → Game environment**, directly below **FEX preset**, in Steam
settings or the in-session drawer. Choose all Proton games, an installed game, or enter a Steam app ID. For a
non-Steam shortcut, use its numeric `compatdata` directory ID (unsigned 32-bit).
Changes apply on the next game launch, including when Steam is already running.
A running game must be restarted. Native Linux games and Steam itself are outside
this editor's scope.
The order is inherited process environment, selected FEX preset and built-in
settings, shared edits, then game-specific edits. Editor entries therefore win
over matching Steam launch-option variables. **Remove** explicitly unsets a
variable. **Restore inherited settings** removes the override. **Reset this
profile** clears only that profile's overrides. Values are literal strings, with
no shell expansion; quotes are only needed when the consuming program expects them.
## Defaults and available suggestions
The initial configuration enables Mesa shader caching with
`MESA_SHADER_CACHE_DISABLE=false`, sets `VKD3D_FEATURE_LEVEL=12_2` and
`VKD3D_SHADER_MODEL=6_9`, and preserves the selected FEX preset. Existing edits
and explicit removals take precedence over these defaults. FEX's own default
remains the default preset. Shader caching in VKD3D/DXVK, synchronization, ray
tracing and diagnostic logging otherwise retain the runtime's defaults.
The variable-name picker offers 24 predefined variables and a Custom entry. Like
WinNative, known variables use toggles, value dropdowns, multi-select lists, or
numeric/text fields. Feature-level and shader-model dropdowns also accept custom
values. Custom variables have editable names and literal values.
| Variable | Use |
| --- | --- |
| `VKD3D_FEATURE_LEVEL` | D3D12 capability override; defaults to `12_2`. |
| `VKD3D_SHADER_MODEL` | Shader-model override; defaults to `6_9`. |
| `VKD3D_CONFIG` | Per-game workarounds such as `nodxr`; not a universal performance preset. |
| `MESA_SHADER_CACHE_MAX_SIZE` | Storage budget for Mesa's shader cache, for example `1G`. |
| `mesa_glthread` | OpenGL threading; test with the affected game. |
| `DXVK_CONFIG` | DXVK configuration options; recent builds accept `dxvk.maxFrameRate = 60`. |
| `VKD3D_FRAME_RATE` | D3D12 frame-rate limit. |
| `DXVK_HUD`, `PROTON_LOG` | Optional diagnostics; leave unset for ordinary play. |
| `PROTON_USE_WINED3D` | D3D9–11 OpenGL fallback for compatibility testing. |
| `PROTON_USE_XALIA` | Proton's gamepad-navigation helper toggle. |
Forcing `12_2` or `6_9` changes reported capabilities; it cannot implement a missing
Vulkan feature. Remove either entry to use automatic detection for that capability.
Other picker suggestions are editable starting values and are enabled only when
added and saved. Older or
custom components can support a different set of options. Android Wine wrapper,
ALSA-server, Box64 and patched async-DXVK options are identified in the editor as
requiring a different/custom component; custom entries remain allowed. The old
`DXVK_FRAME_RATE` environment variable was removed from current DXVK, so it is
not offered as a suggestion. Prefer the session frame limiter for ordinary play.
## Launch path and references
WinNative's Linux session filters Android-only options, merges user variables
over the FEX preset, and passes the result to `env -i` before starting Steam. Its
Windows/Wine graphics helper also maps the selected feature level to
`VKD3D_FEATURE_LEVEL`. Reviewed source: [WinNative Linux session](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/runtime/display/XServerDisplayActivity.java),
[variable editor](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/shared/ui/widget/EnvVarsView.java),
[graphics configuration](https://github.com/maxjivi05/WinNative/blob/c9fbbb342f2689c852046804f4f5c9afa45b5dcb/app/src/main/feature/settings/drivers/DXVKConfigUtils.java).
DroidDeck instead publishes an atomic JSON snapshot at
`/root/.config/droiddeck/game-environment.json`. Both its Valve ARM64 Proton
wrapper and adopted third-party Proton wrappers execute `bannerlator-game-env`,
which reads that snapshot for each real game launch and uses `execvpe` to start
Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed
configuration falls back to the inherited environment without evaluating its
contents. Signed non-Steam prefix IDs are normalized to unsigned IDs.
Upstream references: [VKD3D capability parsing](https://github.com/HansKristian-Work/vkd3d-proton/blob/master/libs/vkd3d/device.c),
[VKD3D options](https://github.com/HansKristian-Work/vkd3d-proton#environment-variables),
[Proton runtime options](https://github.com/ValveSoftware/Proton/tree/proton_11.0#runtime-config-options),
[Mesa variables](https://docs.mesa3d.org/envvars.html),
[DXVK variables](https://github.com/doitsujin/dxvk#environment-variables),
[DXVK frame-limiter changes](https://github.com/doitsujin/dxvk/releases).
Validation: `./gradlew testDebugUnitTest` and
`python3 -m unittest discover -s tools/tests -p test_game_environment.py`.
Actual game compatibility depends on the installed Proton, VKD3D and Vulkan driver;
these checks do not establish that every device supports feature level 12_2 or SM 6_9.
+62
View File
@@ -0,0 +1,62 @@
# Steam game imports
Second Library still registers the selected Steam library. It now also scans Windows
executables in its immediate game subfolders and `steamapps/common`, using the same
importer as Added Games. Existing Steam manifests are preserved.
`bannerlator-steam-games watch` runs beside the client. It identifies a game by an
existing manifest's install directory, a `steam_appid.txt` beside the game or selected
executable, or a unique normalized title from Steam's store search with an explicit
country. Matching also accepts the terminal Windows Edition and INTERGRADE suffixes;
multiple matches still remain unresolved. Artwork's fuzzy matching is deliberately not
used for identity. Ambiguous or unresolved titles remain non-Steam games.
Ownership comes from the running native ARM64 client's `BIsSubscribedApp`, through
versioned SteamClient020 / SteamUser021 / STEAMAPPS_INTERFACE_VERSION008 interfaces. The
helper gets the account from that connection, not from a public profile. No API key,
password, profile visibility change, or CEF debugging port is needed. Each probe runs in
a bounded subprocess so library failures cannot crash the importer or client.
The resulting snapshot is stored under that account's `userdata/<id>/config` and
refreshed every 30 seconds while logged in. Title lookups retry every five minutes and
when the source changes, so an early network failure does not end the watcher. Changed
status and probe failures are written to the session log. Startup and post-exit import
use the last successful snapshot for the selected account. A newly identified title
therefore starts as a shortcut and becomes a Steam entry after Steam exits and starts
again. An unavailable client or offline first launch falls back to shortcuts; existing
snapshots remain usable offline. Steam still enforces the current license at launch.
For a confirmed game without a manifest, the importer links the source folder into the
internal Steam library and writes an installed manifest with `Universe=1` and
`StateFlags=4`. Steam ignores manifests without the universe field. This registers
existing files; it does not assert a verified build ID or depot manifest. Steam can
still require verification, updates or redistributables. Steam updates and uninstall
operate on the linked game files, so imported folders must be writable. Existing Steam
manifests and directories are preserved. Only the exact minimal manifests generated by
the earlier importer are migrated, with the matching source symlink still present. If
registration fails, the game remains a shortcut. Once Steam takes over an install,
manage it in Steam; forgetting an Added Games folder only removes its shortcuts.
The account's `.droiddeck-routes.json` maps shortcut IDs to real Steam app IDs so the
Android launcher uses the same title and Proton prefix as Steam. Other accounts do not
inherit those routes. Managed shortcuts are replaced; user-created shortcuts and
unreadable shortcut files are preserved. Both routes use the existing ARM64 Proton
registration.
## Validation
Run `python3 -m unittest discover -s tools/tests -p 'test_*.py'` and Android unit tests.
Tests cover identity and edition matching, network recovery, account isolation,
owned/unowned/unknown routing, legacy manifest migration, manifest preservation,
filesystem collisions, shortcut preservation, and the versioned IPC call/cleanup
contract. On 2026-09-28, the ARM64 Steam client (1790545198) confirmed ownership and
installed status through ISteamApps for Final Fantasy VII Rebirth (2909400), Final
Fantasy VII Remake Intergrade (1462040), and Final Fantasy XV (637650), with all three
install paths resolving to the original second-library folders. The old managed
shortcuts were removed. This verifies library registration, not complete game launches.
Unowned fallback and account isolation are covered by automated tests.
References: [Steam Apps
API](https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp), [Valve's
versioned interfaces](https://github.com/ValveSoftware/Proton/tree/proton_11.0/lsteamcli
ent/steamworks_sdk_154).
+23
View File
@@ -309,6 +309,29 @@ grep -qi 'CN=Android, OU=Android, O=Android' <<<"${signature_output}" || {
exit 1
}
# With DroidDeck's own key at hand, sign as CI's main builds are (tools/release/sign-apk.sh), so
# the apk installs over a release instead of being refused as a different signer. The key's
# location and password live in an untracked .signing.env (RELEASE_KEYSTORE, RELEASE_STORE_PASSWORD,
# RELEASE_KEY_ALIAS), looked for in this checkout and then in the main one, which worktrees share.
signing_env=${DROIDDECK_SIGNING_ENV:-}
if [[ -z "${signing_env}" ]]; then
for candidate in "${repo_root}/.signing.env" \
"$(dirname "$(git -C "${repo_root}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo .)")/.signing.env"; do
[[ -f "${candidate}" ]] && { signing_env=${candidate}; break; }
done
fi
if [[ -n "${signing_env}" ]]; then
echo "Signing with DroidDeck's key (${signing_env})"
(
set -a
# shellcheck disable=SC1090
. "${signing_env}"
set +a
BUILD_TOOLS="${build_tools}" "${repo_root}/tools/release/sign-apk.sh" "${apk}" standard "${apk}.release"
)
mv "${apk}.release" "${apk}"
fi
printf 'APK: %s\n' "${apk}"
printf 'SHA-256: '
shasum -a 256 "${apk}" | awk '{print $1}'
+4 -4
View File
@@ -55,10 +55,10 @@ if [ -n "${BL_DEBUG_DIR:-}" ]; then
echo "== droiddeck-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') renderer=$WLR_RENDERER"
fi
export WLR_BACKENDS=wayland
# The app's compositor draws no wl_pointer cursor surfaces, so a cursor wlroots hands to its
# parent ("hardware" cursor on the wayland backend) never shows - on the GPU renderers the whole
# cursor was missing. Draw it into the desktop's own frames instead.
export WLR_NO_HARDWARE_CURSORS=${WLR_NO_HARDWARE_CURSORS:-1}
# The cursor goes to the app's compositor as a wl_pointer cursor surface ("hardware" cursor on the
# wayland backend), which the app draws as its pointer. Drawn into the desktop's own frames instead
# (WLR_NO_HARDWARE_CURSORS=1), it cost every frame a full-screen composite: a software cursor
# rules out direct scan-out, so a full-screen game was never handed to the app as it is.
mkdir -p "$HOME/.config/labwc" "$HOME/.config/lxqt"
# Our defaults for labwc and the panel, refreshed every session. What a user changes in the
# panel's own settings lands in panel.conf and is kept; only rc.xml and autostart are ours.
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/python3
import json
import os
import re
import sys
from pathlib import Path
def game_id(env):
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
if not prefix:
return None
value = prefix.rsplit("/", 1)[-1]
if not re.fullmatch(r"-?[0-9]+", value):
return None
number = int(value)
if not -(2**31) <= number < 2**32 or number == 0:
return None
return str(number % 2**32)
def apply_config(env, config, app_id):
if config.get("version") != 1:
raise ValueError("unsupported environment configuration")
result = env.copy()
entries = dict(config.get("shared", {}))
entries.update(config.get("games", {}).get(app_id, {}))
for name, value in entries.items():
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
raise ValueError("invalid environment name")
if value is None:
result.pop(name, None)
elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
result[name] = value
else:
raise ValueError("invalid environment value")
return result
def main():
command = sys.argv[1:]
if not command:
return 2
env = os.environ.copy()
app_id = game_id(env)
if len(command) > 1 and command[1] == "waitforexitandrun" and app_id:
path = Path.home() / ".config/droiddeck/game-environment.json"
try:
with path.open(encoding="utf-8") as source:
env = apply_config(env, json.load(source), app_id)
except FileNotFoundError:
pass
except (OSError, ValueError, TypeError, AttributeError):
print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr)
os.execvpe(command[0], command, env)
if __name__ == "__main__":
sys.exit(main())
@@ -539,10 +539,31 @@ BUSCONF
# again on the branch it just wrote.
steam_channel=${BL_STEAM_CHANNEL:-publicbeta}
steam_beta=$steam_root/package/beta
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" &
games_watcher=$!
fi
echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*"
# An update that cannot install asks for a restart (rc=42) every time; the count is capped so a
# session can never again loop in silence, which reads as a menu opening and closing by itself.
steam_updates=0
rm -f "$HOME/.bl-stop-requested"
if [ -n "${BL_LAUNCH_DIR:-}" ] && [ -d "$BL_LAUNCH_DIR" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
(
while :; do
if [ -e "$BL_LAUNCH_DIR/steam-stop" ]; then
rm -f "$BL_LAUNCH_DIR/steam-stop"
: > "$HOME/.bl-stop-requested"
echo "== steam: the app asked for a clean exit"
"$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true
exit 0
fi
sleep 0.5
done
) &
stop_watcher=$!
fi
while :; do
rm -f "$HOME/.bl-restart-once"
if [ -f "$steam_beta" ] && [ "$(cat "$steam_beta" 2>/dev/null)" != "$steam_channel" ]; then
@@ -556,6 +577,13 @@ BUSCONF
rc=$?
echo "== steam exited rc=$rc"
collect_steam_logs
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
fi
if [ -f "$HOME/.bl-stop-requested" ]; then
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
break
fi
# The session asked for this exit (the ARM64 Proton landed): start again, without the URL
# arguments, which were acted on. Not counted against the update cap.
if [ -f "$HOME/.bl-restart-once" ]; then
@@ -572,6 +600,14 @@ BUSCONF
# The URL arguments were acted on the first time; a restart must not repeat them.
set --
done
if [ -n "${stop_watcher:-}" ]; then
kill "$stop_watcher" 2>/dev/null || true
wait "$stop_watcher" 2>/dev/null || true
fi
if [ -n "${games_watcher:-}" ]; then
kill "$games_watcher" 2>/dev/null || true
wait "$games_watcher" 2>/dev/null || true
fi
if [ -n "${BL_DESKTOP:-}" ]; then
# Started from the desktop (bannerlator-steam-launch): the desktop's own programs share this
# script's parent, so reap_orphans - right under gamescope, where everything is the client's -
@@ -233,7 +233,7 @@ for name in %s; do
bl_directaudio "$depot"
bl_game_cpus
bl_idtech3 "$@"
exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
fi
done
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
@@ -282,7 +282,7 @@ bl_game_cpus
bl_idtech3 "$@"
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
# started from its own tree.
exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
"""
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
{
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Resolve added games against the signed-in Steam client's licenses."""
import ctypes as C
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import time
import unicodedata
import urllib.parse
import urllib.request
STATE = ".droiddeck-games.json"
BASE_ID = 76561197960265728
def read_json(path, default):
try:
return json.loads(Path(path).read_text())
except (OSError, ValueError):
return default
def save_json(path, value):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(value))
os.replace(temp, path)
def title_key(name):
return "".join(c for c in unicodedata.normalize("NFKC", name.replace("™", "").replace("®", "")).casefold() if c.isalnum())
def edition_key(name):
name = re.sub(r"(?i)\s+(?:windows edition|intergrade)\s*$", "", name)
return title_key(name)
def source_stamp(game):
try:
exe = Path(game["exe"])
stat = exe.stat()
hints = []
for parent in sorted({Path(game["folder"]), exe.parent}):
hint = parent / "steam_appid.txt"
hints.append(hint.read_text().strip() if hint.is_file() else "")
return [game["name"], str(exe), stat.st_size, stat.st_mtime_ns, hints]
except (OSError, ValueError):
return None
def identify(game):
folder = Path(game["folder"])
steamapps = folder.parent.parent
if folder.parent.name == "common" and steamapps.name == "steamapps":
matches = []
for manifest in steamapps.glob("appmanifest_*.acf"):
try:
text = manifest.read_text()
install = re.search(r'"installdir"\s*"([^"\\]*)"', text, re.I)
if install and install[1] == folder.name:
matches.append(int(manifest.stem.removeprefix("appmanifest_")))
except (OSError, ValueError):
pass
if matches:
return matches[0] if len(matches) == 1 else None
ids = set()
for parent in {folder, Path(game["exe"]).parent}:
try:
appid = int((parent / "steam_appid.txt").read_text().strip())
if 0 < appid < 0x80000000:
ids.add(appid)
except (OSError, ValueError):
pass
if ids:
return next(iter(ids)) if len(ids) == 1 else None
url = "https://store.steampowered.com/api/storesearch/?l=english&cc=US&term=" + urllib.parse.quote(game["name"])
try:
with urllib.request.urlopen(url, timeout=5) as response:
items = json.loads(response.read(1024 * 1024)).get("items", [])
for key in (title_key, edition_key):
ids = {int(item["id"]) for item in items
if key(item.get("name", "")) == key(game["name"]) and key(game["name"])}
if ids:
return next(iter(ids)) if len(ids) == 1 else None
return None
except (OSError, ValueError, KeyError):
return None
def method(obj, slot, result, *args):
if not obj:
raise RuntimeError("Steam interface unavailable")
table = C.cast(obj, C.POINTER(C.POINTER(C.c_void_p))).contents
return C.CFUNCTYPE(result, C.c_void_p, *args)(table[slot])
def probe(steam_root, ids):
# Slots belong to Valve's SteamClient020, SteamUser021 and STEAMAPPS_INTERFACE_VERSION008.
library = C.CDLL(str(Path(steam_root) / "steamrtarm64/steamclient.so"))
library.CreateInterface.argtypes = [C.c_char_p, C.c_void_p]
library.CreateInterface.restype = C.c_void_p
client = library.CreateInterface(b"SteamClient020", None)
pipe = method(client, 0, C.c_int)(client)
if not pipe:
raise RuntimeError("Steam pipe unavailable")
user = 0
try:
user = method(client, 2, C.c_int, C.c_int)(client, pipe)
if not user:
raise RuntimeError("No signed-in Steam user")
get_interface = lambda slot, version: method(client, slot, C.c_void_p, C.c_int, C.c_int, C.c_char_p)(client, user, pipe, version)
account = get_interface(5, b"SteamUser021")
if not method(account, 1, C.c_bool)(account):
raise RuntimeError("Steam is offline")
steamid = method(account, 2, C.c_uint64)(account)
apps = get_interface(15, b"STEAMAPPS_INTERFACE_VERSION008")
subscribed = method(apps, 6, C.c_bool, C.c_uint32)
return {"account": str(steamid - BASE_ID), "checked": time.time(),
"owned": {str(appid): bool(subscribed(apps, appid)) for appid in ids}}
finally:
if user:
method(client, 4, None, C.c_int, C.c_int)(client, pipe, user)
method(client, 1, C.c_bool, C.c_int)(client, pipe)
def watch(steam_root, listing):
games = read_json(listing, [])
if os.environ.get("BL_STEAM_OFFLINE") == "1" or not games:
return
candidates, sources = {}, {}
next_lookup = 0
last_status = None
while True:
now = time.monotonic()
for game in games:
folder = game["folder"]
stamp = source_stamp(game)
if now >= next_lookup or stamp != sources.get(folder):
sources[folder] = stamp
appid = identify(game)
if appid:
candidates[folder] = appid
else:
candidates.pop(folder, None)
if now >= next_lookup:
next_lookup = now + 300
status = "waiting for a matching Steam title"
if candidates:
try:
result = subprocess.run([sys.executable, __file__, "probe", steam_root,
*map(str, sorted(set(candidates.values())))],
capture_output=True, text=True, timeout=15)
if result.returncode != 0:
status = "ownership probe failed: " + (result.stderr.strip()[-300:] or str(result.returncode))
else:
line = next(line for line in result.stdout.splitlines() if line.startswith("DROIDDECK_OWNERSHIP="))
snapshot = json.loads(line.removeprefix("DROIDDECK_OWNERSHIP="))
if not snapshot["account"].isdigit() or int(snapshot["account"]) <= 0:
raise ValueError("invalid Steam account")
snapshot["candidates"] = candidates
snapshot["sources"] = sources
save_json(Path(steam_root) / "userdata" / snapshot["account"] / "config" / STATE, snapshot)
status = "matched %d/%d games; %d owned" % (len(candidates), len(games), sum(snapshot["owned"].values()))
except (OSError, ValueError, KeyError, StopIteration, subprocess.TimeoutExpired) as error:
status = "ownership check unavailable: " + str(error)
if status != last_status:
print("bannerlator-steam-games: " + status, flush=True)
last_status = status
time.sleep(30)
def seed_install(steam_root, game, appid):
steamapps = Path(steam_root) / "steamapps"
common = steamapps / "common"
name = f"DroidDeck-{appid}"
target = common / name
source = Path(game["folder"])
title = game["name"].replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ").replace("\r", " ")
content = ('"AppState"\n{\n' + f' "appid" "{appid}"\n "Universe" "1"\n "name" "{title}"\n'
+ f' "StateFlags" "4"\n "installdir" "{name}"\n' + '}\n')
legacy = content.replace(' "Universe" "1"\n', '')
manifest = steamapps / f"appmanifest_{appid}.acf"
for root in [Path(steam_root), Path("/mnt/bannerlator-sd")]:
existing = root / "steamapps" / manifest.name
if existing.is_file():
if (existing == manifest and source.is_dir() and target.is_symlink()
and os.readlink(target) == str(source)
and existing.read_text() in (legacy, legacy.replace('"StateFlags" "4"', '"StateFlags" "2"'))):
temp = manifest.with_suffix(".droiddeck")
temp.write_text(content)
os.replace(temp, manifest)
return True
if not source.is_dir():
return False
common.mkdir(parents=True, exist_ok=True)
if os.path.lexists(target):
if not target.is_symlink() or os.readlink(target) != str(source):
return False
else:
target.symlink_to(source, target_is_directory=True)
try:
with manifest.open("x") as output:
output.write(content)
except FileExistsError:
pass
return True
def route(steam_root, acct, games):
snapshot = read_json(Path(acct) / "config" / STATE, {})
if not isinstance(snapshot, dict):
snapshot = {}
owned = snapshot.get("owned", {}) if snapshot.get("account") == Path(acct).name else {}
candidates = snapshot.get("candidates", {})
sources = snapshot.get("sources", {})
if not all(isinstance(value, dict) for value in (owned, candidates, sources)):
owned, candidates, sources = {}, {}, {}
shortcuts, routes = [], {}
for game in games:
appid = candidates.get(game.get("folder"))
stamp = source_stamp(game)
confirmed = (isinstance(appid, int) and 0 < appid < 0x80000000
and owned.get(str(appid)) is True and stamp is not None
and sources.get(game.get("folder")) == stamp)
if confirmed:
try:
if seed_install(steam_root, game, appid):
routes[str(game["appid"])] = appid
continue
except OSError as error:
print(f"bannerlator-steam-games: import unavailable ({error})")
shortcuts.append(game)
save_json(Path(acct) / "config/.droiddeck-routes.json", routes)
return shortcuts, routes
if __name__ == "__main__":
if len(sys.argv) < 4:
sys.exit(2)
try:
if sys.argv[1] == "probe":
print("DROIDDECK_OWNERSHIP=" + json.dumps(probe(sys.argv[2], [int(value) for value in sys.argv[3:]])), flush=True)
elif sys.argv[1] == "watch":
watch(sys.argv[2], sys.argv[3])
else:
sys.exit(2)
except (OSError, RuntimeError, ValueError) as error:
print(str(error), file=sys.stderr)
sys.exit(1)
@@ -1,15 +1,7 @@
#!/usr/bin/env python3
"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's
library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts.
"""Import added games while Steam is closed, preserving user-created shortcuts.
bannerlator-steam-shortcuts <steam_root> <added-games.json>
The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths.
The client keeps non-Steam games in userdata/<account>/config/shortcuts.vdf, a binary VDF it
reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote
carry the tag "droiddeck-app" and are replaced wholesale; anything the user added inside the
client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with
the top bit set), so the launch URL the app uses matches what the client shows.
Usage: bannerlator-steam-shortcuts <steam_root> <added-games.json>
"""
import importlib.machinery
import importlib.util
@@ -207,8 +199,15 @@ def main():
try:
with open(path, "rb") as f:
existing = parse(f.read()).get("shortcuts", {})
except (ValueError, IndexError) as e:
print("bannerlator-steam-shortcuts: could not read the existing file (%s); starting over" % e)
except (ValueError, IndexError, struct.error) as e:
print("bannerlator-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e)
return 1
helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bannerlator-steam-games")
loader = importlib.machinery.SourceFileLoader("steam_games", helper)
spec = importlib.util.spec_from_loader(loader.name, loader)
routing = importlib.util.module_from_spec(spec)
loader.exec_module(routing)
games, routes = routing.route(steam_root, acct, games)
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")]
entries = {str(i): v for i, v in enumerate(kept + ours)}
@@ -226,8 +225,8 @@ def main():
compat = importlib.util.module_from_spec(spec)
spec.loader.exec_module(compat)
cfg = os.path.join(steam_root, "config", "config.vdf")
if os.path.isfile(cfg) and ours:
compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")])
if os.path.isfile(cfg) and (ours or routes):
compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())])
print("bannerlator-steam-shortcuts: mapped them to %s" % compat.TOOL)
except Exception as e: # noqa: BLE001 - the games still show; the client falls back to the default tool
print("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e)
+4 -1
View File
@@ -131,6 +131,9 @@ static struct pad *pad_for_syslink(const char *path) {
static void *next_symbol(const char *name) { return dlsym(RTLD_NEXT, name); }
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *))
__attribute__((visibility("hidden")));
/* Wine reaches libudev from its HID bus driver, which is the only caller these pads are for. */
static int caller_is_winebus(void *caller) {
Dl_info info;
@@ -152,7 +155,7 @@ char *realpath(const char *path, char *resolved) {
}
if (real_realpath == NULL) real_realpath = next_symbol("realpath");
if (real_realpath == NULL) return NULL;
return real_realpath(path, resolved);
return bl_cached_realpath(path, resolved, real_realpath);
}
void *udev_device_new_from_syspath(void *udev, const char *syspath) {
+189
View File
@@ -0,0 +1,189 @@
/*
* Path lookups the Steam client repeats, answered from a short-lived cache.
*
* While it registers its compatibility tools after logon, the client resolves the same few
* directories - its local tools under compatibilitytools.d and its own platform directory - with
* realpath() and access() thousands of times a second. Each realpath() is one readlinkat() per
* path component, and under proot every one of those is a trapped syscall with a round trip
* through the tracer. Measured on an AYN Thor, that loop was 23 of the client's 31 seconds of
* startup, with proot busy for 16 of them.
*
* Only the client itself uses the cache, never a game or a helper. Only successful answers are
* kept, and only for a moment: a path that stops existing is noticed within PATH_CACHE_TTL_NS, and
* one that starts existing is never hidden, since failures always go to the filesystem.
* BL_NO_PATH_CACHE=1 turns it off.
*/
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <pthread.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define PATH_CACHE_SLOTS 256
#define PATH_CACHE_KEY_MAX 512
#define PATH_CACHE_TTL_NS 2000000000LL
enum { KIND_REALPATH, KIND_ACCESS };
struct entry {
long long stamp;
unsigned hash;
int kind;
int mode;
int flags;
char key[PATH_CACHE_KEY_MAX];
char *value;
};
static struct entry table[PATH_CACHE_SLOTS];
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static long long now_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC_COARSE, &t);
return (long long)t.tv_sec * 1000000000LL + t.tv_nsec;
}
static int enabled(void) {
static int cached = -1;
const char *off;
if (cached >= 0) return cached;
off = getenv("BL_NO_PATH_CACHE");
cached = strcmp(program_invocation_short_name, "steam") == 0 &&
strstr(program_invocation_name, "steamrtarm64") != NULL &&
!(off != NULL && off[0] == '1');
return cached;
}
static unsigned hash_of(const char *s, int kind, int mode, int flags) {
unsigned h = 2166136261u ^ (unsigned)(kind * 31 + mode * 7 + flags);
while (*s) h = (h ^ (unsigned char)*s++) * 16777619u;
return h;
}
static int cacheable(const char *path) {
return path != NULL && path[0] == '/' && strlen(path) < PATH_CACHE_KEY_MAX && enabled();
}
static struct entry *find(const char *path, int kind, int mode, int flags, unsigned h) {
struct entry *e = &table[h % PATH_CACHE_SLOTS];
if (e->stamp == 0 || e->hash != h || e->kind != kind || e->mode != mode || e->flags != flags) return NULL;
if (now_ns() - e->stamp > PATH_CACHE_TTL_NS || strcmp(e->key, path) != 0) return NULL;
return e;
}
static void store(const char *path, int kind, int mode, int flags, unsigned h, const char *value) {
struct entry *e = &table[h % PATH_CACHE_SLOTS];
char *copy = value != NULL ? strdup(value) : NULL;
if (value != NULL && copy == NULL) return;
free(e->value);
e->value = copy;
e->hash = h;
e->kind = kind;
e->mode = mode;
e->flags = flags;
memcpy(e->key, path, strlen(path) + 1);
e->stamp = now_ns();
}
__attribute__((visibility("hidden")))
char *bl_cached_realpath(const char *path, char *resolved, char *(*real)(const char *, char *)) {
char out[PATH_MAX];
unsigned h;
struct entry *e;
if (!cacheable(path)) return real(path, resolved);
h = hash_of(path, KIND_REALPATH, 0, 0);
pthread_mutex_lock(&lock);
e = find(path, KIND_REALPATH, 0, 0, h);
if (e != NULL && e->value != NULL) {
size_t n = strlen(e->value) + 1;
char *result = resolved != NULL ? resolved : malloc(n);
if (result != NULL) memcpy(result, e->value, n);
pthread_mutex_unlock(&lock);
return result;
}
pthread_mutex_unlock(&lock);
if (real(path, out) == NULL) return NULL;
pthread_mutex_lock(&lock);
store(path, KIND_REALPATH, 0, 0, h, out);
pthread_mutex_unlock(&lock);
if (resolved != NULL) {
memcpy(resolved, out, strlen(out) + 1);
return resolved;
}
return strdup(out);
}
static int cached_access(const char *path, int mode, int flags, int (*check)(const char *, int, int)) {
unsigned h;
int hit;
int result;
if (!cacheable(path)) return check(path, mode, flags);
h = hash_of(path, KIND_ACCESS, mode, flags);
pthread_mutex_lock(&lock);
hit = find(path, KIND_ACCESS, mode, flags, h) != NULL;
pthread_mutex_unlock(&lock);
if (hit) return 0;
result = check(path, mode, flags);
if (result == 0) {
pthread_mutex_lock(&lock);
store(path, KIND_ACCESS, mode, flags, h, NULL);
pthread_mutex_unlock(&lock);
}
return result;
}
static int (*next_faccessat(void))(int, const char *, int, int) {
static int (*real)(int, const char *, int, int);
if (real == NULL) real = (int (*)(int, const char *, int, int))dlsym(RTLD_NEXT, "faccessat");
return real;
}
static int check_cwd(const char *path, int mode, int flags) {
int (*real)(int, const char *, int, int) = next_faccessat();
if (real == NULL) {
errno = ENOSYS;
return -1;
}
return real(AT_FDCWD, path, mode, flags);
}
int access(const char *path, int mode) {
return cached_access(path, mode, 0, check_cwd);
}
int euidaccess(const char *path, int mode) {
return cached_access(path, mode, AT_EACCESS, check_cwd);
}
int eaccess(const char *path, int mode) {
return cached_access(path, mode, AT_EACCESS, check_cwd);
}
int faccessat(int dirfd, const char *path, int mode, int flags) {
int (*real)(int, const char *, int, int);
if (dirfd == AT_FDCWD) {
return cached_access(path, mode, flags, check_cwd);
}
real = next_faccessat();
if (real == NULL) {
errno = ENOSYS;
return -1;
}
return real(dirfd, path, mode, flags);
}
+92
View File
@@ -0,0 +1,92 @@
import json
import os
from pathlib import Path
import runpy
import subprocess
import sys
import tempfile
import unittest
BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin"
MODULE = runpy.run_path(str(BIN / "bannerlator-game-env"))
COMPAT = runpy.run_path(str(BIN / "bannerlator-steam-compat"))
class GameEnvironmentTest(unittest.TestCase):
def test_profile_precedence_and_unset(self):
env = {"KEEP": "inherited", "REMOVE": "inherited", "CUSTOM": "launch option"}
config = {"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"REMOVE": None, "CUSTOM": "game", "EMPTY": ""}}}
self.assertEqual(MODULE["apply_config"](env, config, "42"), {"KEEP": "inherited", "CUSTOM": "game", "EMPTY": ""})
self.assertEqual(env["REMOVE"], "inherited")
self.assertEqual(MODULE["apply_config"](env, config, "43")["CUSTOM"], "shared")
def test_invalid_configuration_is_atomic(self):
env = {"ORIGINAL": "unchanged"}
for entries in ({"A": "ok", "BAD=KEY": "x"}, {"A": "bad\0value"}, {"A": 1}):
with self.assertRaises(ValueError):
MODULE["apply_config"](env, {"version": 1, "shared": entries}, "42")
self.assertEqual(env, {"ORIGINAL": "unchanged"})
def test_game_ids_and_probes(self):
for prefix in ("", "/compatdata/0", "/compatdata/0-123", "/compatdata/nope", "/compatdata/4294967296"):
self.assertIsNone(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": prefix}))
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/42/"}), "42")
self.assertEqual(MODULE["game_id"]({"STEAM_COMPAT_DATA_PATH": "/a/compatdata/-1"}), "4294967295")
def test_launch_reads_updates_preserves_argv_and_does_not_execute_values(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
config = home / ".config/droiddeck/game-environment.json"
config.parent.mkdir(parents=True)
probe = home / "fake-proton"
probe.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ.get('CUSTOM'), sys.argv[1:]]))\n")
probe.chmod(0o755)
env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"}
for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"):
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}}))
result = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True)
self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]])
self.assertFalse((home / "injected").exists())
for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")):
output = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True)
self.assertEqual(json.loads(output)[0], "original")
config.write_text("{broken")
result = subprocess.run([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True)
self.assertEqual(json.loads(result.stdout)[0], "original")
def test_generated_valve_and_third_party_launchers_apply_configuration(self):
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
steam = home / "Steam"
tools = steam / "compatibilitytools.d"
depot = steam / "steamapps/common" / COMPAT["SOURCES"][0]
(depot / "files/bin-arm64").mkdir(parents=True)
extra = tools / "custom-proton"
extra.mkdir(parents=True)
for base in (depot, extra):
proton = base / "proton"
proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n")
proton.chmod(0o755)
(extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }')
COMPAT["build_tool"](str(tools / COMPAT["TOOL"]))
COMPAT["adopt_extras"](str(tools))
config = home / ".config/droiddeck/game-environment.json"
config.parent.mkdir(parents=True)
config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}}))
wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"])
for wrapper in wrappers:
wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/bannerlator-game-env", str(BIN / "bannerlator-game-env")))
result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"],
env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)},
text=True, capture_output=True)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]])
def test_both_proton_wrappers_call_environment_launcher(self):
for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"):
subprocess.run(["bash", "-n"], input=script, text=True, check=True)
self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env', script)
if __name__ == "__main__":
unittest.main()
+231
View File
@@ -0,0 +1,231 @@
import importlib.machinery
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
def load(name):
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
spec = importlib.util.spec_from_loader(name, loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
imports = load('bannerlator-steam-games')
shortcuts = load('bannerlator-steam-shortcuts')
class ImportsTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.steam = self.root / 'Steam'
self.acct = self.steam / 'userdata/123'
self.folder = self.root / 'Games/Example'
self.folder.mkdir(parents=True)
self.exe = self.folder / 'Example.exe'
self.exe.write_bytes(b'game')
self.game = dict(name='Example', exe=str(self.exe), folder=str(self.folder),
dir=str(self.folder), appid=0x87654321)
def snapshot(self, owned=True, account='123'):
imports.save_json(self.acct / 'config' / imports.STATE,
dict(account=account, owned={'42': owned},
candidates={str(self.folder): 42},
sources={str(self.folder): imports.source_stamp(self.game)}))
def test_owned_uses_real_id_and_registers_installed_files(self):
self.snapshot()
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual([], games)
self.assertEqual({str(self.game['appid']): 42}, routes)
manifest = self.steam / 'steamapps/appmanifest_42.acf'
self.assertIn('"StateFlags" "4"', manifest.read_text())
self.assertIn('"Universe" "1"', manifest.read_text())
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
self.assertEqual(b'game', self.exe.read_bytes())
def test_migrates_only_unchanged_legacy_import_manifests(self):
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
path = self.steam / 'steamapps/appmanifest_42.acf'
current = path.read_text()
legacy = current.replace(' "Universe" "1"\n', '').replace('"StateFlags" "4"', '"StateFlags" "2"')
path.write_text(legacy)
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(current, path.read_text())
changed = legacy.replace('"StateFlags" "2"', '"StateFlags" "6"')
path.write_text(changed)
imports.route(self.steam, self.acct, [self.game])
self.assertEqual(changed, path.read_text())
def test_unresolved_titles_are_retried_after_network_recovers(self):
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
from unittest.mock import Mock
result = Mock(returncode=0, stdout='DROIDDECK_OWNERSHIP=' + json.dumps(dict(account='123', owned={'42': True})))
with patch.object(imports, 'identify', side_effect=[None, 42]) as identify, \
patch.object(imports.time, 'monotonic', side_effect=[0, 301]), \
patch.object(imports.time, 'sleep', side_effect=[None, RuntimeError('stop')]), \
patch.object(imports.subprocess, 'run', return_value=result), \
patch('builtins.print'):
with self.assertRaisesRegex(RuntimeError, 'stop'):
imports.watch(self.steam, listing)
self.assertEqual(2, identify.call_count)
snapshot = imports.read_json(self.acct / 'config' / imports.STATE, {})
self.assertTrue(snapshot['owned']['42'])
def test_unowned_unknown_and_different_account_stay_shortcuts(self):
for owned, account in [(False, '123'), (True, '456')]:
self.snapshot(owned, account)
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
(self.acct / 'config' / imports.STATE).unlink()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assertFalse((self.steam / 'steamapps').exists())
def test_existing_manifest_is_never_overwritten(self):
manifest = self.steam / 'steamapps/appmanifest_42.acf'
manifest.parent.mkdir(parents=True)
manifest.write_text('Steam owns this file')
self.snapshot()
imports.route(self.steam, self.acct, [self.game])
self.assertEqual('Steam owns this file', manifest.read_text())
def test_import_collision_falls_back_without_touching_files(self):
occupied = self.steam / 'steamapps/common/DroidDeck-42'
occupied.mkdir(parents=True)
self.snapshot()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
self.assertTrue(occupied.is_dir())
self.assertFalse(occupied.is_symlink())
def test_removed_folder_does_not_import(self):
self.snapshot()
self.exe.unlink()
self.folder.rmdir()
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_replaced_game_does_not_inherit_cached_ownership(self):
self.snapshot()
self.exe.write_bytes(b'a different game')
self.assertEqual(([self.game], {}), imports.route(self.steam, self.acct, [self.game]))
def test_duplicate_owned_folders_share_one_steam_entry(self):
self.snapshot()
games, routes = imports.route(self.steam, self.acct, [self.game, self.game])
self.assertEqual([], games)
self.assertEqual(1, len(routes))
self.assertEqual(1, len(list((self.steam / 'steamapps').glob('*.acf'))))
def test_explicit_appid_does_not_need_network(self):
(self.folder / 'steam_appid.txt').write_text('42\n')
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(self.game))
def test_manifest_identity_uses_install_directory(self):
folder = self.root / 'second/steamapps/common/Custom Name'
folder.mkdir(parents=True)
(folder.parent.parent / 'appmanifest_42.acf').write_text('"AppState" { "installdir" "Custom Name" }')
game = dict(self.game, folder=str(folder), exe=str(folder / 'game.exe'))
with patch.object(imports.urllib.request, 'urlopen', side_effect=AssertionError):
self.assertEqual(42, imports.identify(game))
def test_store_requires_unique_exact_title(self):
cases = [([{'id': 42, 'name': 'Example Deluxe'}], None),
([{'id': 42, 'name': 'Example'}, {'id': 43, 'name': 'Example'}], None),
([{'id': 42, 'name': 'EXAMPLE'}], 42)]
for items, expected in cases:
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': items}).encode()
self.assertEqual(expected, imports.identify(self.game))
def test_store_country_and_edition_suffixes(self):
for folder, store in [('FINAL FANTASY VII REMAKE', 'FINAL FANTASY VII REMAKE INTERGRADE'),
('FINAL FANTASY XV', 'FINAL FANTASY XV WINDOWS EDITION')]:
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [{'id': 42, 'name': store}]}).encode()
self.assertEqual(42, imports.identify(dict(self.game, name=folder)))
self.assertIn('cc=US&', request.call_args.args[0])
def test_edition_matching_rejects_ambiguous_titles_and_soundtracks(self):
with patch.object(imports.urllib.request, 'urlopen') as request:
request.return_value.__enter__.return_value.read.return_value = json.dumps({'items': [
{'id': 42, 'name': 'Example WINDOWS EDITION'},
{'id': 43, 'name': 'Example INTERGRADE'},
{'id': 44, 'name': 'Example Soundtrack'},
]}).encode()
self.assertIsNone(imports.identify(self.game))
def test_corrupt_shortcuts_are_preserved(self):
path = self.acct / 'config/shortcuts.vdf'
path.parent.mkdir(parents=True)
original = b'corrupt'
path.write_bytes(original)
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
run = subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True)
self.assertEqual(1, run.returncode)
self.assertEqual(original, path.read_bytes())
def test_user_shortcuts_survive_owned_promotion(self):
self.snapshot()
path = self.acct / 'config/shortcuts.vdf'
manual = dict(shortcuts.shortcut(self.game), tags={'0': 'Personal'}, AppName='Manual')
path.write_bytes(shortcuts.write({'shortcuts': {'0': manual, '1': shortcuts.shortcut(self.game)}}))
listing = self.root / 'games.json'
listing.write_text(json.dumps([self.game]))
subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True)
result = shortcuts.parse(path.read_bytes())['shortcuts']
self.assertEqual([manual], list(result.values()))
self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text()))
class SteamProbeTest(unittest.TestCase):
def test_probe_reads_account_and_licenses_and_releases_handles(self):
import ctypes as C
from unittest.mock import Mock
objects, callbacks, calls = [], [], []
def interface(slots):
table = (C.c_void_p * 24)()
for slot, result, args, callback in slots:
fn = C.CFUNCTYPE(result, C.c_void_p, *args)(callback)
callbacks.append(fn)
table[slot] = C.cast(fn, C.c_void_p).value
obj = C.pointer(C.cast(table, C.POINTER(C.c_void_p)))
objects.extend([table, obj])
return C.cast(obj, C.c_void_p).value
user = interface([
(1, C.c_bool, [], lambda _: True),
(2, C.c_uint64, [], lambda _: imports.BASE_ID + 123),
])
apps = interface([(6, C.c_bool, [C.c_uint32], lambda _, appid: appid == 42)])
client = interface([
(0, C.c_int, [], lambda _: 7),
(1, C.c_bool, [C.c_int], lambda _, pipe: calls.append(('pipe', pipe)) or True),
(2, C.c_int, [C.c_int], lambda _, pipe: 9),
(4, None, [C.c_int, C.c_int], lambda _, pipe, handle: calls.append(('user', pipe, handle))),
(5, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: user if version == b'SteamUser021' else 0),
(15, C.c_void_p, [C.c_int, C.c_int, C.c_char_p], lambda _, handle, pipe, version: apps if version == b'STEAMAPPS_INTERFACE_VERSION008' else 0),
])
library = Mock()
library.CreateInterface.return_value = client
with patch.object(imports.C, 'CDLL', return_value=library):
result = imports.probe('/Steam', [42, 43])
self.assertEqual('123', result['account'])
self.assertEqual({'42': True, '43': False}, result['owned'])
self.assertEqual([('user', 7, 9), ('pipe', 7)], calls)
library.CreateInterface.assert_called_once_with(b'SteamClient020', None)
if __name__ == '__main__':
unittest.main()