diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8eb47b0a1..8fd6411fd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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-, 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" diff --git a/.github/workflows/pr-release-publish.yml b/.github/workflows/pr-release-publish.yml index 1759674c1..3ae1ab307 100644 --- a/.github/workflows/pr-release-publish.yml +++ b/.github/workflows/pr-release-publish.yml @@ -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" diff --git a/.gitignore b/.gitignore index 54cad4e34..965928cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 4195f0be8..fac983903 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/assets/steam-startup/droiddeck-startup.webm b/app/src/main/assets/steam-startup/droiddeck-startup.webm index ee181257b..7e2fa4587 100644 Binary files a/app/src/main/assets/steam-startup/droiddeck-startup.webm and b/app/src/main/assets/steam-startup/droiddeck-startup.webm differ diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 6a562ffd9..26fd98c04 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -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 = { diff --git a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt index e76d02d7b..f0c0feb3f 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/FexPreset.kt @@ -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) + class Preset(val id: String, val label: Int, val detail: Int, val env: List) 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 = 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 = byId(id).env } diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt new file mode 100644 index 000000000..11975edd3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -0,0 +1,38 @@ +package com.droiddeck.launcher.core + +object GameEnvironment { + data class Config( + val shared: Map = emptyMap(), + val games: Map> = emptyMap(), + ) { + fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty() + + fun withEntries(scope: String, entries: Map): 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 = linkedMapOf( + "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 = + defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty() +} diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt new file mode 100644 index 000000000..0904c5b2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -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 = 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(",") + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index 17b659734..e14ac7ccc 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -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 } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt new file mode 100644 index 000000000..4210d07e6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -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 = 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) = 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 + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index ede2b4fd5..be31867ac 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -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", diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 1f3f1c69e..465b870e6 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -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). */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index 87d690743..a071e2686 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -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, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index bbbb78892..ee5ffd4df 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -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() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt new file mode 100644 index 000000000..c871ac847 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GameEnvironmentEditor.kt @@ -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(null) } + var games by remember { mutableStateOf(emptyList>()) } + 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?>(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()) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt index 5f13007b2..8ed0a25a4 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -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.", diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index a9efc01c9..70581f5b0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -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") { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c46d83f86..a0519dce5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,4 +19,68 @@ Resume Stop session Frame generation + Game environment + Shared defaults and per-game overrides for games using Proton. Applies on the next game launch. + Edit variables + All Proton games + Game profile + Steam app ID or non-Steam compatibility ID + Open profile + Game %1$s + Add variable + Variable name + Value + Save + Cancel + Close + Remove + Restore inherited settings + Reset this profile + Unset for this profile + No variables set. Driver and Proton defaults apply. + Inherited + Custom variable + D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game. + Could not load or apply the settings. Check available storage and try again. + Use a valid variable name and a value without NUL characters (up to 8192 characters). + The standard Linux Proton runtime does not use this variable; it may require a custom component. + Loading… + Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value. + D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection. + D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection. + Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching. + Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage. + VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it. + Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage. + Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing. + Set to 0 to disable Proton’s gamepad-navigation helper for this game. + DXVK overlay for D3D9–11. fps displays frame rate; unset hides it. + DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play. + D3D12 frame-rate limit. 0 disables the limit. + Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game. + FEX preset + Applies on the next game launch. + Games + FEX defaults + Uses FEX defaults. + Stability + Safest, slowest settings. + Compatibility + Full TSO with multiblock. + Intermediate + Balanced TSO with reduced x87 precision. + Performance + All TSO disabled; multiblock. + Performance + TSO + Performance settings with main TSO enabled. + Extreme + Disables self-modifying-code checks. JIT games and DRM may break. + Extreme + TSO + Extreme settings with main TSO enabled. + Extreme-gn + Performance with TSC and metadata hints. Try before Extreme. + Denuvo + Performance with full code checks and the hypervisor bit hidden. + Custom value + Select value diff --git a/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt new file mode 100644 index 000000000..320f57333 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/core/GameEnvironmentTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt new file mode 100644 index 000000000..1064ab2b8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt @@ -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() + 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>): 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() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt new file mode 100644 index 000000000..1fb648e73 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/GameEnvironmentStoreTest.kt @@ -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)) + } +} diff --git a/artwork/boot-animation-source/README.md b/artwork/boot-animation-source/README.md index e1915c39a..4caa7aa72 100644 --- a/artwork/boot-animation-source/README.md +++ b/artwork/boot-animation-source/README.md @@ -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 diff --git a/artwork/boot-animation-source/render.py b/artwork/boot-animation-source/render.py index 9c55b9b10..ee85ea06a 100644 --- a/artwork/boot-animation-source/render.py +++ b/artwork/boot-animation-source/render.py @@ -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"): diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md new file mode 100644 index 000000000..5b9244a66 --- /dev/null +++ b/docs/development/game-environment.md @@ -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. diff --git a/tools/build_local.sh b/tools/build_local.sh index a7a0d4d67..ec3028de3 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -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}' diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 6820a9dce..6e1d17595 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -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. diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env b/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env new file mode 100755 index 000000000..ff9e87449 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env @@ -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()) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat index 4abca5b52..7040f2dde 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat @@ -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" { diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py new file mode 100644 index 000000000..8c37d1123 --- /dev/null +++ b/tools/tests/test_game_environment.py @@ -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()