Merge branch 'main' into fix/steamdeck-performance-overlay

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-28 16:36:33 -04:00
commit 9d891d4db1
30 files changed
+1335 -90

No files matched your search

+99 -1
View File
@@ -34,12 +34,18 @@ jobs:
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
outputs:
variants: ${{ steps.variants.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: The packages a signed build ships as
id: variants
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
- name: No private key in the tree
run: |
set -euo pipefail
@@ -301,6 +307,9 @@ jobs:
set -o pipefail
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
- name: Game environment launch tests
run: python3 -m unittest discover -s tools/tests -p test_game_environment.py
- name: Unit tests
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
@@ -427,5 +436,94 @@ jobs:
uses: ./.github/workflows/sign.yml
with:
environment: signing-main
variants: '["standard"]'
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
# Discord's builds channel linking to it. The release is made with a token of the org's
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
announce:
needs: [build, sign]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
env:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
steps:
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-signed-*
merge-multiple: true
path: signed
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
id: publish
env:
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
run: |
set -euo pipefail
tag=main-${GITHUB_SHA::7}
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
rm signed/SHA256SUMS-*.txt
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
SHA-256:
\`\`\`
$(cat signed/SHA256SUMS.txt)
\`\`\`"
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
# Only the newest KEEP main builds stay; PR releases (pr-N) are left alone.
gh release list -R "$CI_REPO" -L 200 --json tagName,createdAt \
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.createdAt) | reverse | .['"$KEEP"':][] | .tagName' |
while IFS= read -r old; do gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag; done
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
echo "release=$url" >> "$GITHUB_OUTPUT"
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
echo "Published $url"
- name: Post to Discord
env:
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
GH_TOKEN: ${{ github.token }}
RELEASE: ${{ steps.publish.outputs.release }}
APK: ${{ steps.publish.outputs.apk }}
run: |
set -euo pipefail
if [ -z "$WEBHOOK" ]; then
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
exit 0
fi
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
# the PR's title, which says more.
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
if [ -n "$pr" ]; then
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
else
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
fi
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
echo "Posted: $content"
+10 -6
View File
@@ -120,18 +120,22 @@ jobs:
with:
name: droiddeck-pr-signed-standard
path: signed
- name: Publish to DroidDeck-CI when configured
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
- uses: actions/create-github-app-token@v2
id: ci-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-CI
- name: Publish to DroidDeck-CI
env:
GH_TOKEN: ${{ secrets.DROIDDECK_CI_TOKEN }}
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
PR_NUMBER: ${{ inputs.pr_number }}
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
LABEL: ${{ needs.authorize.outputs.label }}
run: |
set -euo pipefail
if [[ -z "$GH_TOKEN" ]]; then
echo '::warning::No CI token; download the signed APK from this run'
exit 0
fi
repo=Droid-Deck/DroidDeck-CI
tag="pr-$PR_NUMBER"
apk="DroidDeck-$LABEL.apk"
+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'
}
+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>
@@ -249,6 +250,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;
@@ -477,6 +482,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 */
@@ -484,6 +490,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++; }
@@ -954,6 +968,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;
@@ -997,28 +1073,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) {
@@ -1090,6 +1157,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;
@@ -1171,6 +1240,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);
}
@@ -2188,6 +2258,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 = {
@@ -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(",")
}
}
@@ -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
}
@@ -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",
@@ -298,6 +298,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). */
@@ -528,15 +528,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,
@@ -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() }
@@ -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())
}
}
@@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.runtime.DeckyManager
import com.droiddeck.launcher.session.SessionPrefs
@@ -376,12 +378,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.",
@@ -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") {
+64
View File
@@ -19,4 +19,68 @@
<string name="resume_session">Resume</string>
<string name="stop_session">Stop session</string>
<string name="frame_gen_title">Frame generation</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))
}
}
@@ -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))
}
}
+11 -8
View File
@@ -1,13 +1,17 @@
# DroidDeck boot animation
This source accompanies the single Steam startup movie bundled by the app:
`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1920×1080, pitch black background, VP9 video
`app/src/main/assets/steam-startup/droiddeck-startup.webm` (1280×720 at 30 fps, pitch black background, VP8 video
with Opus audio). `droiddeck-boot.html` is the editable animation; the scripts and WAV below
reproduce the selected render. No alternate MP4 or WebM exports are kept in the repository.
render the animation at the bundled playback profile. No alternate MP4 or WebM exports are kept in the repository.
From the repository root, render the movie with:
python3 artwork/boot-animation-source/render.py 1920 1080 60 app/src/main/assets/steam-startup/droiddeck-startup.webm dark
python3 artwork/boot-animation-source/render.py 1280 720 30 app/src/main/assets/steam-startup/droiddeck-startup.webm dark
The bundled profile reduces decoded pixels per second by 78% compared with 1080p/60.
VP8 also lowers software decode cost while Steam is starting. Keep the bundled movie at
720p/30; higher-resolution exports are intended for sharing.
The app stages that file into Steam's `config/uioverrides/movies` folder before each session. If
Steam still has its built-in startup movie selected, DroidDeck sets this movie as the device's
@@ -27,13 +31,12 @@ it down or step frame by frame (arrow keys).
## Re-render the video
pip install playwright && playwright install chromium # ffmpeg with libvpx-vp9 also required
python3 render.py 1280 800 60 droiddeck-boot-1280x800.webm
python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.webm
pip install playwright && playwright install chromium # ffmpeg with libvpx also required
python3 render.py 1280 720 30 droiddeck-boot-1280x720.webm
python3 render.py 1920 1080 60 droiddeck-boot-1920x1080.mp4 # .mp4 = H.264 for sharing
python3 render.py 1280 800 60 droiddeck-boot-dark-1280x800.webm dark # dark version
python3 render.py 1280 720 30 droiddeck-boot-dark-1280x720.webm dark # dark version
Each frame gets real motion blur (180° shutter, adaptive sub-frames). Output is VP9 for .webm or H.264 for .mp4, both BT.709,
Each frame gets real motion blur (180° shutter, adaptive sub-frames). Output is VP8 for .webm or H.264 for .mp4, both BT.709,
so the orb stays #1A9FFF after compression.
## Sound
+8 -8
View File
@@ -2,11 +2,11 @@
"""Render the DroidDeck boot animation to .webm (Steam) or .mp4 (H.264).
Usage: python3 render.py [WIDTH] [HEIGHT] [FPS] [OUT] [light|dark]
python3 render.py 1280 800 60 droiddeck-boot.webm
python3 render.py 1280 720 30 droiddeck-boot.webm
python3 render.py 1920 1080 60 droiddeck-boot.mp4
python3 render.py 1280 800 60 droiddeck-boot-dark.webm dark
python3 render.py 1280 720 30 droiddeck-boot-dark.webm dark
Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx-vp9.
Needs: pip install playwright && playwright install chromium, plus ffmpeg with libvpx.
The animation is drawn by droiddeck-boot.html (same folder); each frame gets real
motion blur (180-degree shutter, up to 48 sub-frames when things move fast).
"""
@@ -15,8 +15,8 @@ from pathlib import Path
from playwright.sync_api import sync_playwright
W = int(sys.argv[1]) if len(sys.argv) > 1 else 1280
H = int(sys.argv[2]) if len(sys.argv) > 2 else 800
FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 60
H = int(sys.argv[2]) if len(sys.argv) > 2 else 720
FPS = int(sys.argv[3]) if len(sys.argv) > 3 else 30
OUT = sys.argv[4] if len(sys.argv) > 4 else f"droiddeck-boot-{W}x{H}.webm"
THEME = sys.argv[5] if len(sys.argv) > 5 else "light" # light | dark
ONLY = [int(x) for x in os.environ.get("FRAMES", "").split(",") if x] # debug: render some frames only
@@ -54,9 +54,9 @@ cmd += ["-vf", "scale=out_color_matrix=bt709:out_range=tv:flags=accurate_rnd+ful
if OUT.lower().endswith(".mp4"): # H.264 for sharing, previews and anything that won't play webm
cmd += ["-c:v", "libx264", "-preset", "slow", "-crf", "15", "-profile:v", "high", "-tune", "animation",
"-g", str(FPS * 2), "-movflags", "+faststart"]
else: # VP9 webm for Steam's startup movie
cmd += ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "18", "-deadline", "good", "-cpu-used", "2",
"-row-mt", "1", "-g", str(FPS * 2)]
else: # VP8 keeps software decoding inexpensive during Steam startup
cmd += ["-c:v", "libvpx", "-b:v", "2M", "-crf", "10", "-deadline", "good", "-cpu-used", "2",
"-g", str(FPS * 2)]
cmd += COLOR + ([] if SOUND.exists() else ["-an"]) + [OUT]
subprocess.run(cmd, check=True)
if not os.environ.get("FRAMEDIR"):
+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.
+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())
@@ -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"
{
+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()