mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Merge branch 'main' into fix/steamdeck-performance-overlay
This commit is contained in:
30 files changed
+1335
-90
No files matched your search
@@ -34,12 +34,18 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: No private key in the tree
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -301,6 +307,9 @@ jobs:
|
||||
set -o pipefail
|
||||
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
|
||||
|
||||
- name: Game environment launch tests
|
||||
run: python3 -m unittest discover -s tools/tests -p test_game_environment.py
|
||||
|
||||
- name: Unit tests
|
||||
run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION"
|
||||
|
||||
@@ -427,5 +436,94 @@ jobs:
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
environment: signing-main
|
||||
variants: '["standard"]'
|
||||
variants: ${{ needs.build.outputs.variants }}
|
||||
secrets: inherit
|
||||
|
||||
# Each signed main build as a prerelease of the public Droid-Deck/DroidDeck-CI (main-<sha>, the
|
||||
# last KEEP kept), where anyone can download it without a GitHub account, then a post in the
|
||||
# Discord's builds channel linking to it. The release is made with a token of the org's
|
||||
# "DroidDeck CI Publisher" app (installed on DroidDeck-CI only, Contents: write): its ID in the
|
||||
# DROIDDECK_CI_APP_ID variable, its private key in the DROIDDECK_CI_APP_KEY secret.
|
||||
# DISCORD_BUILDS_WEBHOOK is the channel's webhook URL.
|
||||
announce:
|
||||
needs: [build, sign]
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CI_REPO: Droid-Deck/DroidDeck-CI
|
||||
KEEP: 10
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: droiddeck-signed-*
|
||||
merge-multiple: true
|
||||
path: signed
|
||||
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag=main-${GITHUB_SHA::7}
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
||||
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
||||
# Only the newest KEEP main builds stay; PR releases (pr-N) are left alone.
|
||||
gh release list -R "$CI_REPO" -L 200 --json tagName,createdAt \
|
||||
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.createdAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
||||
while IFS= read -r old; do gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag; done
|
||||
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
|
||||
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
|
||||
echo "release=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "apk=$GITHUB_SERVER_URL/$CI_REPO/releases/download/$tag/$apk" >> "$GITHUB_OUTPUT"
|
||||
echo "Published $url"
|
||||
|
||||
- name: Post to Discord
|
||||
env:
|
||||
WEBHOOK: ${{ secrets.DISCORD_BUILDS_WEBHOOK }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE: ${{ steps.publish.outputs.release }}
|
||||
APK: ${{ steps.publish.outputs.apk }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$WEBHOOK" ]; then
|
||||
echo "::notice::No DISCORD_BUILDS_WEBHOOK secret; nothing posted"
|
||||
exit 0
|
||||
fi
|
||||
commit=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA")
|
||||
subject=$(jq -r '.commit.message | split("\n")[0]' <<<"$commit")
|
||||
# A merge commit's subject is "Merge pull request #N from ..."; its body's first line is
|
||||
# the PR's title, which says more.
|
||||
title=$(jq -r '.commit.message | split("\n") | map(select(length > 0)) | .[1] // ""' <<<"$commit")
|
||||
pr=$(sed -n 's/^Merge pull request #\([0-9]*\).*/\1/p' <<<"$subject")
|
||||
if [ -n "$pr" ]; then
|
||||
what="[#$pr]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$pr): ${title:-$subject}"
|
||||
else
|
||||
what="[\`${GITHUB_SHA::7}\`]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA): $subject"
|
||||
fi
|
||||
get=$(printf 'Download: [standard APK](<%s>) · [all variants](<%s>)' "$APK" "$RELEASE")
|
||||
content=$(printf '**New signed build** `main-%s`\n%s\n%s' "${GITHUB_SHA::7}" "$what" "$get")
|
||||
jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
|
||||
curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
|
||||
echo "Posted: $content"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
Binary file not shown.
@@ -30,6 +30,7 @@
|
||||
#include <time.h>
|
||||
#include <stdarg.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <sys/timerfd.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/sysmacros.h>
|
||||
@@ -249,6 +250,10 @@ struct surface {
|
||||
int releases_pending; /* FPS limiter: buffers of this surface still to be released */
|
||||
|
||||
enum surface_role role;
|
||||
/* A role-less surface's last buffer, held unreleased: it may become the pointer's image
|
||||
* (wl_pointer.set_cursor after the commit, which is wlroots' order). */
|
||||
struct wl_resource *idle_buffer;
|
||||
struct wl_listener idle_buffer_destroy;
|
||||
struct wl_resource *xdg_surface, *xdg_toplevel;
|
||||
struct wl_resource *viewport;
|
||||
|
||||
@@ -477,6 +482,7 @@ static int g_ntouches;
|
||||
#define CURSOR_MAX_PX (256 * 256)
|
||||
static pthread_mutex_t g_cursor_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static struct surface *g_cursor_surface; /* compositor thread only */
|
||||
static struct surface *g_cursor_shown; /* compositor thread: whose image g_cursor_px holds */
|
||||
static int g_cursor_hx, g_cursor_hy; /* hotspot, surface-local */
|
||||
static uint32_t g_cursor_px[CURSOR_MAX_PX]; /* guarded by g_cursor_lock: ARGB8888 snapshot */
|
||||
static uint32_t g_cursor_rb[CURSOR_MAX_PX]; /* compositor thread: GPU readback staging */
|
||||
@@ -484,6 +490,14 @@ static int g_cursor_w, g_cursor_h; /* 0 = nothing to draw */
|
||||
static int g_cursor_hidden = 1; /* the client asked for no pointer */
|
||||
static int g_cursor_serial; /* bumped on every change; the app polls it */
|
||||
|
||||
/* The last image again, after a hide: the same surface set as the pointer once more with nothing
|
||||
* newly committed to it (wlroots re-sets its cursor surface each time the pointer enters). */
|
||||
static void cursor_publish_shown(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (g_cursor_w > 0) { g_cursor_hidden = 0; g_cursor_serial++; } /* serial: the hotspot may be new */
|
||||
pthread_mutex_unlock(&g_cursor_lock);
|
||||
}
|
||||
|
||||
static void cursor_publish_hidden(void) {
|
||||
pthread_mutex_lock(&g_cursor_lock);
|
||||
if (!g_cursor_hidden) { g_cursor_hidden = 1; g_cursor_serial++; }
|
||||
@@ -954,6 +968,68 @@ static void surface_set_input(struct wl_client *c, struct wl_resource *r,
|
||||
|
||||
static void send_toplevel_configure(struct surface *s);
|
||||
|
||||
/* The pointer image from a cursor surface's buffer: wl_shm is copied, a dma-buf (labwc on a GPU
|
||||
* renderer) is read back once the client's render into it is done - its implicit fence, as the
|
||||
* zero-copy path waits for it (ahb_swapchain_present). */
|
||||
static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
|
||||
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (!db->img) return;
|
||||
struct pollfd p = {.fd = db->fd[0], .events = POLLIN};
|
||||
int r;
|
||||
do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR);
|
||||
if (vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) != 0) return;
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
g_cursor_shown = s;
|
||||
}
|
||||
|
||||
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
|
||||
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
}
|
||||
|
||||
/* Only what could be a pointer image is held; anything larger goes straight back. */
|
||||
static void surface_hold_idle(struct surface *s, struct wl_resource *buffer) {
|
||||
struct dmabuf_buffer *db = get_dmabuf(buffer);
|
||||
struct wl_shm_buffer *shm = db ? NULL : wl_shm_buffer_get(buffer);
|
||||
int64_t px = db ? (int64_t)db->width * db->height
|
||||
: shm ? (int64_t)wl_shm_buffer_get_width(shm) * wl_shm_buffer_get_height(shm) : 0;
|
||||
if (px <= 0 || px > CURSOR_MAX_PX) { wl_buffer_send_release(buffer); return; }
|
||||
s->idle_buffer = buffer;
|
||||
s->idle_buffer_destroy.notify = on_idle_buffer_destroyed;
|
||||
wl_resource_add_destroy_listener(buffer, &s->idle_buffer_destroy);
|
||||
}
|
||||
|
||||
/* Lets go of the held buffer; release = give it back to the client (not while it is going away). */
|
||||
static struct wl_resource *surface_take_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = s->idle_buffer;
|
||||
if (!buffer) return NULL;
|
||||
wl_list_remove(&s->idle_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
s->idle_buffer = NULL;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static void surface_drop_idle(struct surface *s) {
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
}
|
||||
|
||||
static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
struct surface *child;
|
||||
@@ -997,28 +1073,19 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
s->pending_buffer = NULL;
|
||||
s->pending_attach = 0;
|
||||
|
||||
surface_drop_idle(s); /* replaced, whatever the surface is now */
|
||||
if (s->role == ROLE_CURSOR) {
|
||||
/* The client's pointer image, copied out for the app's overlay (see cursor_publish_*)
|
||||
* rather than composited, so it survives the zero-copy and HDR layer paths. */
|
||||
if (!buffer) {
|
||||
cursor_publish_hidden(); /* wlroots clears its cursor surface to hide the pointer */
|
||||
} else if (shm) {
|
||||
cursor_publish_shm(shm, g_cursor_hx, g_cursor_hy);
|
||||
} else if (db && db->n_planes >= 1 && db->width > 0 && db->height > 0 &&
|
||||
(int64_t)db->width * db->height <= CURSOR_MAX_PX) {
|
||||
if (!db->img && !db->import_failed) {
|
||||
db->img = vkp_image_from_dmabuf(db->fd[0], db->format, db->modifier, db->width, db->height,
|
||||
db->stride[0], db->offset[0]);
|
||||
if (!db->img) db->import_failed = 1;
|
||||
}
|
||||
if (db->img && vkp_image_readback(db->img, g_cursor_rb, CURSOR_MAX_PX) == 0)
|
||||
cursor_publish_pixels((const uint8_t *)g_cursor_rb, db->width, db->height,
|
||||
(size_t)db->width * 4, g_cursor_hx, g_cursor_hy);
|
||||
} else {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
}
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
} else if (s->role == ROLE_NONE) {
|
||||
/* Role-less surface: never drawn. */
|
||||
if (buffer) wl_buffer_send_release(buffer);
|
||||
/* Role-less surface: never drawn, but its buffer is kept for a set_cursor to come. */
|
||||
if (buffer) surface_hold_idle(s, buffer);
|
||||
} else if (db) {
|
||||
take_dmabuf(s, db, buffer);
|
||||
} else if (shm) {
|
||||
@@ -1090,6 +1157,8 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
for (int i = 0; i < g_nkbs; i++) if (g_kbs[i].focus == r) g_kbs[i].focus = NULL;
|
||||
touch_cancel_surface(s);
|
||||
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
|
||||
if (g_cursor_shown == s) g_cursor_shown = NULL;
|
||||
surface_take_idle(s);
|
||||
if (g_grab == s) g_grab = NULL;
|
||||
if (g_key_target == s) g_key_target = NULL;
|
||||
if (g_ime_click == s) g_ime_click = NULL;
|
||||
@@ -1171,6 +1240,7 @@ static void compositor_create_surface(struct wl_client *c, struct wl_resource *r
|
||||
wl_list_init(&s->child_link);
|
||||
wl_list_init(&s->toplevel_link);
|
||||
wl_list_init(&s->pending_buffer_destroy.link);
|
||||
wl_list_init(&s->idle_buffer_destroy.link);
|
||||
wl_list_insert(&g_surfaces, &s->link);
|
||||
wl_resource_set_implementation(s->resource, &surface_impl, s, surface_resource_destroy);
|
||||
}
|
||||
@@ -2188,6 +2258,16 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
|
||||
g_cursor_surface = s;
|
||||
g_cursor_hx = hx;
|
||||
g_cursor_hy = hy;
|
||||
/* Its content may already be committed: wlroots' Wayland backend attaches and commits the
|
||||
* cursor surface first and sets it as the pointer after, and sets it again with nothing new
|
||||
* committed each time the pointer re-enters. */
|
||||
struct wl_resource *buffer = surface_take_idle(s);
|
||||
if (buffer) {
|
||||
cursor_publish_buffer(s, buffer);
|
||||
wl_buffer_send_release(buffer);
|
||||
} else if (g_cursor_shown == s) {
|
||||
cursor_publish_shown();
|
||||
}
|
||||
}
|
||||
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static const struct wl_pointer_interface pointer_impl = {
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
/**
|
||||
* FEXCore presets for the x86 games the Steam client launches, Bannerlator's table carried over
|
||||
* so a preset means the same thing in both apps. Each is a set of FEX_* variables put into the
|
||||
* session's environment; the games under FEX inherit them from the client. The default is FEX's
|
||||
* own defaults - no variables at all - which is what every session has run on so far.
|
||||
*
|
||||
* TSO = x86's total store ordering emulated on ARM: safest, slowest. MULTIBLOCK compiles more per
|
||||
* block. X87REDUCEDPRECISION trades x87 exactness for speed. SMCCHECKS=none is the risky one -
|
||||
* anything that writes its own code at runtime (JIT, .NET, DRM) can misbehave. DENUVO keeps the
|
||||
* checks at full and hides the hypervisor bit, which its anti-tamper looks for.
|
||||
*/
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object FexPreset {
|
||||
class Preset(val id: String, val label: String, val detail: String, val env: List<String>)
|
||||
class Preset(val id: String, val label: Int, val detail: Int, val env: List<String>)
|
||||
|
||||
private fun tso(main: Int, vector: Int, memcpy: Int, halfBarrier: Int) = listOf(
|
||||
"FEX_TSOENABLED=$main", "FEX_VECTORTSOENABLED=$vector", "FEX_MEMCPYSETTSOENABLED=$memcpy", "FEX_HALFBARRIERTSOENABLED=$halfBarrier",
|
||||
)
|
||||
|
||||
val all: List<Preset> = listOf(
|
||||
Preset("", "FEX defaults", "Uses FEX defaults.", emptyList()),
|
||||
Preset("STABILITY", "Stability", "Safest, slowest settings.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", "Compatibility", "Full TSO with multiblock.", tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", "Intermediate", "Balanced TSO with reduced x87 precision.", tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", "Performance", "All TSO disabled; multiblock.", tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", "Performance + TSO", "Performance settings with main TSO enabled.", tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", "Extreme", "Disables self-modifying-code checks. JIT games and DRM may break.",
|
||||
Preset("", R.string.fex_default_label, R.string.fex_default_detail, emptyList()),
|
||||
Preset("STABILITY", R.string.fex_stability_label, R.string.fex_stability_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=0")),
|
||||
Preset("COMPATIBILITY", R.string.fex_compatibility_label, R.string.fex_compatibility_detail, tso(1, 1, 1, 1) + listOf("FEX_X87REDUCEDPRECISION=0", "FEX_MULTIBLOCK=1")),
|
||||
Preset("INTERMEDIATE", R.string.fex_intermediate_label, R.string.fex_intermediate_detail, tso(1, 0, 0, 1) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE", R.string.fex_performance_label, R.string.fex_performance_detail, tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("PERFORMANCE_TSO", R.string.fex_performance_tso_label, R.string.fex_performance_tso_detail, tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1")),
|
||||
Preset("EXTREME", R.string.fex_extreme_label, R.string.fex_extreme_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_TSO", "Extreme + TSO", "Extreme settings with main TSO enabled.",
|
||||
Preset("EXTREME_TSO", R.string.fex_extreme_tso_label, R.string.fex_extreme_tso_detail,
|
||||
tso(1, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=none", "FEX_DISABLEL2CACHE=1", "FEX_DYNAMICL1CACHE=1", "FEX_DYNAMICL1CACHEINCREASECOUNTHEURISTIC=250", "FEX_DYNAMICL1CACHEDECREASECOUNTHEURISTIC=50")),
|
||||
Preset("EXTREME_GN", "Extreme-gn", "Performance with TSC and metadata hints. Try before Extreme.",
|
||||
Preset("EXTREME_GN", R.string.fex_extreme_gn_label, R.string.fex_extreme_gn_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMALLTSCSCALE=1", "FEX_VOLATILEMETADATA=1")),
|
||||
Preset("DENUVO", "Denuvo", "Performance with full code checks and the hypervisor bit hidden.",
|
||||
Preset("DENUVO", R.string.fex_denuvo_label, R.string.fex_denuvo_detail,
|
||||
tso(0, 0, 0, 0) + listOf("FEX_X87REDUCEDPRECISION=1", "FEX_MULTIBLOCK=1", "FEX_SMCCHECKS=full", "FEX_HIDEHYPERVISORBIT=1")),
|
||||
)
|
||||
|
||||
fun byId(id: String): Preset = all.firstOrNull { it.id == id } ?: all[0]
|
||||
|
||||
/** The variables for the session's environment, in KEY=VALUE form; empty for the default. */
|
||||
/** Game-launch variables in KEY=VALUE form. */
|
||||
fun env(id: String): List<String> = byId(id).env
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
object GameEnvironment {
|
||||
data class Config(
|
||||
val shared: Map<String, String?> = emptyMap(),
|
||||
val games: Map<String, Map<String, String?>> = emptyMap(),
|
||||
) {
|
||||
fun entries(scope: String) = if (scope.isEmpty()) shared else games[scope].orEmpty()
|
||||
|
||||
fun withEntries(scope: String, entries: Map<String, String?>): Config =
|
||||
if (scope.isEmpty()) copy(shared = entries.toMap())
|
||||
else copy(games = if (entries.isEmpty()) games - scope else games + (scope to entries.toMap()))
|
||||
}
|
||||
|
||||
private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val unused = setOf(
|
||||
"WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER",
|
||||
"WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED",
|
||||
"ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE",
|
||||
"DXVK_ASYNC", "DXVK_GPLASYNCCACHE",
|
||||
)
|
||||
|
||||
fun validName(name: String) = namePattern.matches(name)
|
||||
fun supported(name: String) = name !in unused && !name.startsWith("BOX64_") && !name.startsWith("BOX86_")
|
||||
fun validValue(value: String) = '\u0000' !in value && value.length <= 8192
|
||||
fun validScope(scope: String) = scope.isEmpty() || (Regex("[1-9][0-9]*").matches(scope) && scope.toLongOrNull()?.let { it in 1..4294967295L } == true)
|
||||
|
||||
fun defaults(preset: String): Map<String, String?> = linkedMapOf<String, String?>(
|
||||
"MESA_SHADER_CACHE_DISABLE" to "false",
|
||||
"VKD3D_FEATURE_LEVEL" to "12_2",
|
||||
"VKD3D_SHADER_MODEL" to "6_9",
|
||||
).apply {
|
||||
FexPreset.env(preset).forEach { put(it.substringBefore('='), it.substringAfter('=')) }
|
||||
}
|
||||
|
||||
fun effective(config: Config, preset: String, scope: String): Map<String, String?> =
|
||||
defaults(preset) + config.shared + if (scope.isEmpty()) emptyMap() else config.games[scope].orEmpty()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import com.droiddeck.launcher.R
|
||||
|
||||
object GameEnvironmentOptions {
|
||||
enum class Type { TOGGLE, CHOICE, MULTIPLE, NUMBER, TEXT }
|
||||
data class Option(val name: String, val value: String, val detail: Int, val type: Type, val choices: List<String> = emptyList())
|
||||
|
||||
val all = listOf(
|
||||
Option("MESA_SHADER_CACHE_DISABLE", "false", R.string.game_env_cache, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("VKD3D_FEATURE_LEVEL", "12_2", R.string.game_env_feature, Type.CHOICE, listOf("12_2", "12_1", "12_0", "11_1", "11_0")),
|
||||
Option("VKD3D_SHADER_MODEL", "6_9", R.string.game_env_shader, Type.CHOICE, listOf("6_9", "6_8", "6_7", "6_6", "6_5", "6_4", "6_3", "6_2", "6_1", "6_0", "5_1")),
|
||||
Option("VKD3D_CONFIG", "nodxr", R.string.game_env_vkd3d_config, Type.MULTIPLE, listOf("nodxr", "single_queue", "no_upload_hvv")),
|
||||
Option("MESA_SHADER_CACHE_MAX_SIZE", "1G", R.string.game_env_cache_size, Type.CHOICE, listOf("512M", "1G", "2G", "4G")),
|
||||
Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")),
|
||||
Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT),
|
||||
Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER),
|
||||
Option("mesa_glthread", "true", R.string.game_env_glthread, Type.TOGGLE, listOf("false", "true")),
|
||||
Option("ZINK_DESCRIPTORS", "auto", 0, Type.CHOICE, listOf("auto", "lazy", "cached", "notemplates")),
|
||||
Option("ZINK_DEBUG", "nir", 0, Type.MULTIPLE, listOf("nir", "spirv", "tgsi", "validation", "sync", "compact", "noreorder")),
|
||||
Option("SteamDeck", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1", 0, Type.MULTIPLE, listOf("enable_tp_ubwc_flag_hint=1", "storage_8bit=1")),
|
||||
Option("TU_DEBUG", "forcecb", 0, Type.MULTIPLE, listOf("forcecb", "nocb", "startup", "deck_emu", "nir", "nobin", "sysmem", "gmem", "forcebin", "layout", "noubwc", "nomultipos", "nolrz", "nolrzfc", "perf", "perfc", "flushall", "syncdraw", "push_consts_per_stage", "rast_order", "unaligned_store", "log_skip_gmem_ops", "dynamic", "bos", "3d_load", "fdm", "noconform", "rd")),
|
||||
Option("IR3_SHADER_DEBUG", "nouboopt", 0, Type.MULTIPLE, listOf("nouboopt", "nopreamble", "noearlypreamble")),
|
||||
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
|
||||
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
|
||||
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
|
||||
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
|
||||
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
|
||||
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
|
||||
)
|
||||
|
||||
fun find(name: String): Option? = all.firstOrNull { it.name == name }
|
||||
|
||||
fun toggle(value: String, choice: String): String {
|
||||
val selected = value.split(',').filter { it.isNotEmpty() }.toMutableSet()
|
||||
if (!selected.remove(choice)) selected.add(choice)
|
||||
return selected.joinToString(",")
|
||||
}
|
||||
}
|
||||
@@ -45,11 +45,11 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
val halfW get() = if (wide) radius * SHOULDER_WIDTH else radius
|
||||
val halfH get() = if (wide) radius * SHOULDER_HEIGHT else radius
|
||||
|
||||
fun contains(x: Float, y: Float, r: Float): Boolean {
|
||||
fun contains(x: Float, y: Float, r: Float, padding: Float = 0f): Boolean {
|
||||
val dx = x - cx
|
||||
val dy = y - cy
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f && abs(dy) <= halfH + r * 0.25f
|
||||
val hit = r * 1.25f
|
||||
if (wide) return abs(dx) <= halfW + r * 0.25f + padding && abs(dy) <= halfH + r * 0.25f + padding
|
||||
val hit = r * 1.25f + padding
|
||||
return dx * dx + dy * dy <= hit * hit
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
for (control in controls) {
|
||||
if (!isVisible(control)) continue
|
||||
if (control.stick >= 0 && settings.adaptiveSticks && !editing && control.pressedBy == -1) continue
|
||||
val held = control.pressedBy != -1
|
||||
val radius = control.radius
|
||||
if (control.stick >= 0) {
|
||||
@@ -426,10 +427,13 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
val index = event.actionIndex
|
||||
val control = controlAt(event.getX(index), event.getY(index)) ?: return false
|
||||
val x = event.getX(index)
|
||||
val y = event.getY(index)
|
||||
val control = controlAt(x, y) ?: adaptiveStickAt(x, y) ?: return false
|
||||
if (control.pressedBy != -1) return true
|
||||
control.pressedBy = event.getPointerId(index)
|
||||
if (control.stick >= 0) {
|
||||
control.clicked = settings.stickClick && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
control.clicked = settings.stickClick && control.lastUp > 0L && event.eventTime - control.lastUp < DOUBLE_TAP_MS
|
||||
val adaptive = settings.adaptiveSticks
|
||||
control.ax = if (adaptive) event.getX(index) else control.cx
|
||||
control.ay = if (adaptive) event.getY(index) else control.cy
|
||||
@@ -468,7 +472,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
for (control in controls) {
|
||||
if (control.pressedBy == pointer || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
||||
if (control.pressedBy != -1) changed = true
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked) 0L else event.eventTime
|
||||
if (control.stick >= 0 && control.pressedBy != -1) control.lastUp = if (control.clicked || event.actionMasked == MotionEvent.ACTION_CANCEL) 0L else event.eventTime
|
||||
control.pressedBy = -1
|
||||
control.clicked = false
|
||||
if (control.stick >= 0 && (control.kx != 0f || control.ky != 0f)) control.dirty = true
|
||||
@@ -505,7 +509,25 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
}
|
||||
|
||||
private fun controlAt(x: Float, y: Float): Control? =
|
||||
controls.firstOrNull { isVisible(it) && it.contains(x, y, it.radius) }
|
||||
controls.firstOrNull {
|
||||
isVisible(it) && (editing || !settings.adaptiveSticks || it.stick < 0) && it.contains(x, y, it.radius)
|
||||
}
|
||||
|
||||
private fun adaptiveStickAt(x: Float, y: Float): Control? {
|
||||
if (!settings.adaptiveSticks || editing || buttonsOnly) return null
|
||||
if (x < safe.left || x >= width - safe.right || y < safe.top || y >= height - safe.bottom) return null
|
||||
if (controls.any { it.stick < 0 && isVisible(it) && it.contains(x, y, it.radius, 5f) }) return null
|
||||
return controls.filter {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
val reach = it.radius * 1.4f * sqrt(1.5f)
|
||||
it.stick >= 0 && isVisible(it) && it.pressedBy == -1 && dx * dx + dy * dy <= reach * reach
|
||||
}.minByOrNull {
|
||||
val dx = x - it.cx
|
||||
val dy = y - it.cy
|
||||
dx * dx + dy * dy
|
||||
}
|
||||
}
|
||||
|
||||
private fun isVisible(control: Control): Boolean = when {
|
||||
buttonsOnly && !editing -> control.id == "guide" || control.id == "qam"
|
||||
@@ -574,6 +596,7 @@ class OnScreenControls(context: Context, private val pad: PadBridge?, private va
|
||||
controls.forEach {
|
||||
it.pressedBy = -1
|
||||
it.clicked = false
|
||||
it.lastUp = 0L
|
||||
if (it.stick >= 0 && (it.kx != 0f || it.ky != 0f)) it.dirty = true
|
||||
it.kx = 0f; it.ky = 0f
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object GameEnvironmentStore {
|
||||
private const val SETTINGS = "game-environment.json"
|
||||
private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json"
|
||||
|
||||
@Synchronized
|
||||
fun read(context: Context): GameEnvironment.Config {
|
||||
val file = AtomicFile(File(context.filesDir, SETTINGS))
|
||||
val bytes = try { file.readFully() } catch (_: java.io.FileNotFoundException) { return GameEnvironment.Config() }
|
||||
return decode(JSONObject(bytes.toString(Charsets.UTF_8)))
|
||||
}
|
||||
|
||||
fun decode(json: JSONObject): GameEnvironment.Config {
|
||||
require(json.getInt("version") == 1)
|
||||
fun entries(value: JSONObject?): Map<String, String?> = buildMap {
|
||||
value?.keys()?.forEach { name ->
|
||||
require(GameEnvironment.validName(name))
|
||||
val entry = value.get(name)
|
||||
require(entry == JSONObject.NULL || entry is String && GameEnvironment.validValue(entry))
|
||||
put(name, if (entry == JSONObject.NULL) null else entry as String)
|
||||
}
|
||||
}
|
||||
val games = json.optJSONObject("games")
|
||||
return GameEnvironment.Config(entries(json.optJSONObject("shared")), buildMap {
|
||||
games?.keys()?.forEach { id ->
|
||||
require(id.isNotEmpty() && GameEnvironment.validScope(id))
|
||||
put(id, entries(games.getJSONObject(id)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun encode(config: GameEnvironment.Config): JSONObject {
|
||||
fun entries(values: Map<String, String?>) = JSONObject().apply {
|
||||
values.forEach { (key, value) -> put(key, value ?: JSONObject.NULL) }
|
||||
}
|
||||
return JSONObject().put("version", 1).put("shared", entries(config.shared)).put("games", JSONObject().apply {
|
||||
config.games.forEach { (id, values) -> put(id, entries(values)) }
|
||||
})
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun save(context: Context, config: GameEnvironment.Config) {
|
||||
val json = encode(config)
|
||||
decode(json)
|
||||
write(File(context.filesDir, SETTINGS), json.toString())
|
||||
publish(context, config)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publish(context: Context, config: GameEnvironment.Config = read(context)) {
|
||||
val resolved = config.copy(shared = GameEnvironment.defaults(SessionPrefs.fexPreset(context)) + config.shared)
|
||||
write(File(LinuxRuntime.rootDir(context), GUEST_FILE), encode(resolved).toString())
|
||||
}
|
||||
|
||||
private fun write(file: File, text: String) {
|
||||
check(file.parentFile!!.isDirectory || file.parentFile!!.mkdirs())
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
try {
|
||||
output.write(text.toByteArray(Charsets.UTF_8))
|
||||
atomic.finishWrite(output)
|
||||
} catch (error: Exception) {
|
||||
atomic.failWrite(output)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ object SessionFiles {
|
||||
* rename, so a session that still has one mapped keeps the file it opened.
|
||||
*/
|
||||
fun stage(context: Context, root: File) {
|
||||
GameEnvironmentStore.publish(context)
|
||||
val files = arrayOf(
|
||||
"usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env",
|
||||
"libblsession.so" to "usr/local/lib/libblsession.so",
|
||||
"libfakeinput.so" to "usr/local/lib/libfakeinput.so",
|
||||
"usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session",
|
||||
|
||||
@@ -298,6 +298,8 @@ object SessionPrefs {
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
runCatching { GameEnvironmentStore.publish(context) }
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
|
||||
|
||||
@@ -528,15 +528,6 @@ class SessionService : Service() {
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
|
||||
// whose seccomp answers its syscalls normally there is no reason to take it away.
|
||||
if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0")
|
||||
// The FEXCore preset for the x86 games the client launches: its FEX_* variables go in
|
||||
// here, before the script, so every game process inherits them from the client. The
|
||||
// default preset sets nothing, which is what every session ran on before.
|
||||
if (steamHere) {
|
||||
val preset = SessionPrefs.fexPreset(this)
|
||||
val vars = com.droiddeck.launcher.core.FexPreset.env(preset)
|
||||
vars.forEach { guest.add(it) }
|
||||
if (vars.isNotEmpty()) Log.i(TAG, "fex preset $preset: ${vars.joinToString(" ")}")
|
||||
}
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
|
||||
@@ -59,7 +59,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre
|
||||
ChoiceRow(host, "controller-opacity", "Opacity", null, ControllerPrefs.opacities.map { it to "$it%" }, c.opacity, onPick = a.onOpacity)
|
||||
ChoiceRow(host, "controller-size", "Button size", "100% keeps the standard size", ControllerPrefs.sizes.map { it to "$it%" }, c.size, onPick = a.onSize)
|
||||
ToggleRow(host, "controller-stick-click", "Stick click", "Double-tap a stick and hold for L3 or R3", c.stickClick, onChange = a.onStickClick)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "A stick centers where your thumb lands and returns home when you let go", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
ToggleRow(host, "controller-adaptive", "Adaptive sticks", "Sticks appear when touched near their saved positions and hide when released", c.adaptiveSticks, onChange = a.onAdaptiveSticks)
|
||||
SettingsRow("Layout", if (c.customLayout) "Custom positions saved" else "Placed for this screen's size and your grip") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SecondaryButton("Edit") { a.onEditLayout() }
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.core.GameEnvironmentOptions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.session.GameEnvironmentStore
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun GameEnvironmentRow(modifier: Modifier = Modifier) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
SettingsRow(stringResource(R.string.game_env_title), stringResource(R.string.game_env_hint)) {
|
||||
TextButton(onClick = { open = true }, modifier = modifier.controllerConfirm { open = true }) {
|
||||
Text(stringResource(R.string.game_env_edit))
|
||||
}
|
||||
}
|
||||
if (open) GameEnvironmentEditor { open = false }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GameEnvironmentEditor(onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val coroutine = rememberCoroutineScope()
|
||||
var config by remember { mutableStateOf<GameEnvironment.Config?>(null) }
|
||||
var games by remember { mutableStateOf(emptyList<Pair<String, String>>()) }
|
||||
var scope by remember { mutableStateOf("") }
|
||||
var appId by remember { mutableStateOf("") }
|
||||
var scopeMenu by remember { mutableStateOf(false) }
|
||||
var addMenu by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
val preset = SessionPrefs.fexPreset(context)
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
GameEnvironmentStore.read(context) to Library.steamGames(context).map { it.appId.toString() to it.name }
|
||||
}
|
||||
}.onSuccess { (settings, installed) -> config = settings; games = installed }
|
||||
.onFailure { error = true }
|
||||
}
|
||||
fun save(next: GameEnvironment.Config) {
|
||||
busy = true
|
||||
coroutine.launch {
|
||||
val result = runCatching { withContext(Dispatchers.IO) { GameEnvironmentStore.save(context, next) } }
|
||||
result.onSuccess { config = next; error = false }.onFailure { error = true }
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!busy) onClose() },
|
||||
title = { Text(stringResource(R.string.game_env_title)) },
|
||||
confirmButton = { TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.game_env_close)) } },
|
||||
text = {
|
||||
Column(Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stringResource(R.string.game_env_hint))
|
||||
Text(stringResource(R.string.game_env_defaults), style = MaterialTheme.typography.bodySmall)
|
||||
if (error) Text(stringResource(R.string.game_env_error), color = MaterialTheme.colorScheme.error)
|
||||
if (config == null && !error) Text(stringResource(R.string.game_env_loading))
|
||||
val current = config
|
||||
if (current != null) {
|
||||
val profileName = if (scope.isEmpty()) stringResource(R.string.game_env_shared)
|
||||
else games.firstOrNull { it.first == scope }?.second ?: stringResource(R.string.game_env_profile, scope)
|
||||
Text(stringResource(R.string.game_env_scope), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
TextButton(onClick = { scopeMenu = true }, enabled = !busy) { Text(profileName) }
|
||||
DropdownMenu(expanded = scopeMenu, onDismissRequest = { scopeMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_shared)) }, onClick = { scope = ""; scopeMenu = false })
|
||||
val profiles = (games + current.games.keys.filter { id -> games.none { it.first == id } }.map { it to it }).distinctBy { it.first }
|
||||
for ((id, name) in profiles) DropdownMenuItem(text = { Text(name) }, onClick = { scope = id; scopeMenu = false })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = appId, onValueChange = { appId = it }, singleLine = true, enabled = !busy,
|
||||
label = { Text(stringResource(R.string.game_env_app_id)) }, modifier = Modifier.fillMaxWidth())
|
||||
TextButton(onClick = { scope = appId.toLong().toString(); appId = "" }, enabled = !busy && appId.isNotEmpty() && GameEnvironment.validScope(appId)) {
|
||||
Text(stringResource(R.string.game_env_open))
|
||||
}
|
||||
Text(stringResource(R.string.game_env_overrides), style = MaterialTheme.typography.bodySmall)
|
||||
val entries = GameEnvironment.effective(current, preset, scope).toSortedMap()
|
||||
if (entries.isEmpty()) Text(stringResource(R.string.game_env_empty))
|
||||
for ((name, value) in entries) {
|
||||
HorizontalDivider()
|
||||
Text(name, style = MaterialTheme.typography.titleSmall)
|
||||
Text(value ?: stringResource(R.string.game_env_unset))
|
||||
if (!current.entries(scope).containsKey(name)) Text(stringResource(R.string.game_env_inherited), style = MaterialTheme.typography.labelSmall)
|
||||
Row {
|
||||
TextButton(onClick = { editing = name to (value ?: "") }, enabled = !busy) { Text(stringResource(R.string.game_env_edit)) }
|
||||
if (value != null) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) + (name to null))) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_remove))
|
||||
}
|
||||
}
|
||||
if (current.entries(scope).containsKey(name)) TextButton(onClick = { save(current.withEntries(scope, current.entries(scope) - name)) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.game_env_restore))
|
||||
}
|
||||
}
|
||||
Box {
|
||||
TextButton(onClick = { addMenu = true }, enabled = !busy) { Text(stringResource(R.string.game_env_add)) }
|
||||
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = { editing = "" to ""; addMenu = false })
|
||||
for (option in GameEnvironmentOptions.all) DropdownMenuItem(text = { Text(option.name) }, onClick = { editing = option.name to option.value; addMenu = false })
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { save(current.withEntries(scope, emptyMap())) }, enabled = !busy && current.entries(scope).isNotEmpty()) {
|
||||
Text(stringResource(R.string.game_env_reset))
|
||||
}
|
||||
}
|
||||
if (busy) LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
)
|
||||
editing?.let { (name, value) ->
|
||||
VariableDialog(name, value, onDismiss = { editing = null }) { key, content ->
|
||||
config?.let {
|
||||
val entries = it.entries(scope).toMutableMap()
|
||||
if (name.isNotEmpty() && name != key) entries[name] = null
|
||||
entries[key] = content
|
||||
save(it.withEntries(scope, entries))
|
||||
}
|
||||
editing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableDialog(initialName: String, initialValue: String, onDismiss: () -> Unit, onSave: (String, String) -> Unit) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var value by remember { mutableStateOf(initialValue) }
|
||||
val valid = GameEnvironment.validName(name) && GameEnvironment.validValue(value)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.game_env_edit)) },
|
||||
confirmButton = { TextButton(onClick = { onSave(name, value) }, enabled = valid) { Text(stringResource(R.string.game_env_save)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.game_env_cancel)) } },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
VariableNamePicker(name) { selected ->
|
||||
name = selected
|
||||
value = GameEnvironmentOptions.find(selected)?.value.orEmpty()
|
||||
}
|
||||
VariableValueEditor(name, value) { value = it }
|
||||
GameEnvironmentOptions.find(name)?.takeIf { it.detail != 0 }?.let {
|
||||
Text(stringResource(it.detail), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
if (!GameEnvironment.supported(name)) Text(stringResource(R.string.game_env_unused))
|
||||
if (!valid && name.isNotEmpty()) Text(stringResource(R.string.game_env_invalid), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableNamePicker(name: String, onChange: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var custom by remember { mutableStateOf(name.isEmpty() || GameEnvironmentOptions.find(name) == null) }
|
||||
Text(stringResource(R.string.game_env_name), style = MaterialTheme.typography.labelMedium)
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom) else name)
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom)) }, onClick = {
|
||||
custom = true; onChange(""); expanded = false
|
||||
})
|
||||
for (option in GameEnvironmentOptions.all.sortedBy { it.name.uppercase(java.util.Locale.ROOT) }) {
|
||||
DropdownMenuItem(text = { Text(option.name) }, onClick = {
|
||||
custom = false; onChange(option.name); expanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = name, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_name)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VariableValueEditor(name: String, value: String, onChange: (String) -> Unit) {
|
||||
val option = GameEnvironmentOptions.find(name)
|
||||
var expanded by remember(name) { mutableStateOf(false) }
|
||||
var custom by remember(name) { mutableStateOf(option == null || value !in option.choices && option.type != GameEnvironmentOptions.Type.MULTIPLE) }
|
||||
when {
|
||||
option?.type == GameEnvironmentOptions.Type.TOGGLE -> Row(
|
||||
Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringResource(R.string.game_env_value))
|
||||
Switch(checked = value == option.choices.last(), onCheckedChange = {
|
||||
onChange(if (it) option.choices.last() else option.choices.first())
|
||||
})
|
||||
}
|
||||
option != null && option.choices.isNotEmpty() -> {
|
||||
Box {
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (custom) stringResource(R.string.game_env_custom_value) else value.ifEmpty { stringResource(R.string.game_env_select_value) })
|
||||
}
|
||||
DropdownMenu(expanded, onDismissRequest = { expanded = false }, modifier = Modifier.heightIn(max = 280.dp)) {
|
||||
DropdownMenuItem(text = { Text(stringResource(R.string.game_env_custom_value)) }, onClick = { custom = true; expanded = false })
|
||||
for (choice in option.choices) {
|
||||
val selected = choice in value.split(',')
|
||||
DropdownMenuItem(text = { Text(choice) }, leadingIcon = {
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) Checkbox(checked = selected, onCheckedChange = null)
|
||||
}, onClick = {
|
||||
custom = false
|
||||
if (option.type == GameEnvironmentOptions.Type.MULTIPLE) {
|
||||
onChange(GameEnvironmentOptions.toggle(value, choice))
|
||||
} else {
|
||||
onChange(choice); expanded = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (custom) OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
else -> OutlinedTextField(value = value, onValueChange = onChange, singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = if (option?.type == GameEnvironmentOptions.Type.NUMBER) KeyboardType.Number else KeyboardType.Text),
|
||||
label = { Text(stringResource(R.string.game_env_value)) }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.runtime.DeckyManager
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -376,12 +378,13 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderPath) else a.onAddedGameExe(g.folderPath, path) },
|
||||
)
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup("Games") {
|
||||
if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(
|
||||
host, "fex", "FEX preset", "Applies on next game launch.",
|
||||
FexPreset.all.map { it.id to it.label }, s.fexPreset,
|
||||
note = FexPreset.byId(s.fexPreset).detail, onPick = a.onFexPreset,
|
||||
host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset,
|
||||
note = stringResource(FexPreset.byId(s.fexPreset).detail), onPick = a.onFexPreset,
|
||||
)
|
||||
GameEnvironmentRow()
|
||||
if (s.forceFullscreen != null) ToggleRow(
|
||||
host, "fill", "Stretch games to fill the screen",
|
||||
"Keeps games that resize their own window (FlatOut) full screen. Turn it off if a game shows up small in a corner (Quake 3). Applies next session.",
|
||||
|
||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.core.FexPreset
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -405,9 +407,12 @@ fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageCha
|
||||
ChoiceRow(host, "shape", "Screen ratio", null,
|
||||
SessionPrefs.shapeChoices, a.shapeMode,
|
||||
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
|
||||
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
|
||||
FexPreset.all.map { it.id to it.label }, a.fexPreset,
|
||||
}
|
||||
if (a.steam) SettingsGroup(stringResource(R.string.game_settings_title)) {
|
||||
ChoiceRow(host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch),
|
||||
FexPreset.all.map { it.id to stringResource(it.label) }, a.fexPreset,
|
||||
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
|
||||
GameEnvironmentRow(modifier = focus.track(page, "game-env"))
|
||||
}
|
||||
SettingsGroup("Support") {
|
||||
SettingsRow("Session logs", "Send this session's logs with a bug report") {
|
||||
|
||||
@@ -19,4 +19,68 @@
|
||||
<string name="resume_session">Resume</string>
|
||||
<string name="stop_session">Stop session</string>
|
||||
<string name="frame_gen_title">Frame generation</string>
|
||||
<string name="game_env_title">Game environment</string>
|
||||
<string name="game_env_hint">Shared defaults and per-game overrides for games using Proton. Applies on the next game launch.</string>
|
||||
<string name="game_env_edit">Edit variables</string>
|
||||
<string name="game_env_shared">All Proton games</string>
|
||||
<string name="game_env_scope">Game profile</string>
|
||||
<string name="game_env_app_id">Steam app ID or non-Steam compatibility ID</string>
|
||||
<string name="game_env_open">Open profile</string>
|
||||
<string name="game_env_profile">Game %1$s</string>
|
||||
<string name="game_env_add">Add variable</string>
|
||||
<string name="game_env_name">Variable name</string>
|
||||
<string name="game_env_value">Value</string>
|
||||
<string name="game_env_save">Save</string>
|
||||
<string name="game_env_cancel">Cancel</string>
|
||||
<string name="game_env_close">Close</string>
|
||||
<string name="game_env_remove">Remove</string>
|
||||
<string name="game_env_restore">Restore inherited settings</string>
|
||||
<string name="game_env_reset">Reset this profile</string>
|
||||
<string name="game_env_unset">Unset for this profile</string>
|
||||
<string name="game_env_empty">No variables set. Driver and Proton defaults apply.</string>
|
||||
<string name="game_env_inherited">Inherited</string>
|
||||
<string name="game_env_custom">Custom variable</string>
|
||||
<string name="game_env_defaults">D3D12 feature level defaults to 12_2 and shader model to 6_9. Both can be changed or removed for each game.</string>
|
||||
<string name="game_env_error">Could not load or apply the settings. Check available storage and try again.</string>
|
||||
<string name="game_env_invalid">Use a valid variable name and a value without NUL characters (up to 8192 characters).</string>
|
||||
<string name="game_env_unused">The standard Linux Proton runtime does not use this variable; it may require a custom component.</string>
|
||||
<string name="game_env_loading">Loading…</string>
|
||||
<string name="game_env_overrides">Settings in this profile override shared settings and matching Steam launch-option variables. Remove unsets a variable; restore uses its inherited value.</string>
|
||||
<string name="game_env_feature">D3D12 feature-level override. 12_2 reports DirectX 12 Ultimate capabilities; unsupported features can fail. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_shader">D3D12 shader-model override. 6_9 requires a sufficiently recent VKD3D and matching GPU features. Leave unset for automatic detection.</string>
|
||||
<string name="game_env_cache">Keep Mesa shader caching enabled to reduce repeated compilation. false enables caching.</string>
|
||||
<string name="game_env_cache_size">Maximum Mesa shader-cache size, such as 1G. Larger caches use more storage.</string>
|
||||
<string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string>
|
||||
<string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string>
|
||||
<string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string>
|
||||
<string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string>
|
||||
<string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string>
|
||||
<string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string>
|
||||
<string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string>
|
||||
<string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string>
|
||||
<string name="fex_preset_title">FEX preset</string>
|
||||
<string name="fex_next_launch">Applies on the next game launch.</string>
|
||||
<string name="game_settings_title">Games</string>
|
||||
<string name="fex_default_label">FEX defaults</string>
|
||||
<string name="fex_default_detail">Uses FEX defaults.</string>
|
||||
<string name="fex_stability_label">Stability</string>
|
||||
<string name="fex_stability_detail">Safest, slowest settings.</string>
|
||||
<string name="fex_compatibility_label">Compatibility</string>
|
||||
<string name="fex_compatibility_detail">Full TSO with multiblock.</string>
|
||||
<string name="fex_intermediate_label">Intermediate</string>
|
||||
<string name="fex_intermediate_detail">Balanced TSO with reduced x87 precision.</string>
|
||||
<string name="fex_performance_label">Performance</string>
|
||||
<string name="fex_performance_detail">All TSO disabled; multiblock.</string>
|
||||
<string name="fex_performance_tso_label">Performance + TSO</string>
|
||||
<string name="fex_performance_tso_detail">Performance settings with main TSO enabled.</string>
|
||||
<string name="fex_extreme_label">Extreme</string>
|
||||
<string name="fex_extreme_detail">Disables self-modifying-code checks. JIT games and DRM may break.</string>
|
||||
<string name="fex_extreme_tso_label">Extreme + TSO</string>
|
||||
<string name="fex_extreme_tso_detail">Extreme settings with main TSO enabled.</string>
|
||||
<string name="fex_extreme_gn_label">Extreme-gn</string>
|
||||
<string name="fex_extreme_gn_detail">Performance with TSC and metadata hints. Try before Extreme.</string>
|
||||
<string name="fex_denuvo_label">Denuvo</string>
|
||||
<string name="fex_denuvo_detail">Performance with full code checks and the hypervisor bit hidden.</string>
|
||||
<string name="game_env_custom_value">Custom value</string>
|
||||
<string name="game_env_select_value">Select value</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.droiddeck.launcher.core
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class GameEnvironmentTest {
|
||||
@Test fun defaultsIncludeRequestedGraphicsLevelsAndKeepCaching() {
|
||||
val values = GameEnvironment.defaults("")
|
||||
assertEquals("false", values["MESA_SHADER_CACHE_DISABLE"])
|
||||
assertEquals("12_2", values["VKD3D_FEATURE_LEVEL"])
|
||||
assertEquals("6_9", values["VKD3D_SHADER_MODEL"])
|
||||
}
|
||||
|
||||
@Test fun gameOverridesSharedAndPresetWithoutLeakingToOtherGames() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("FEX_MULTIBLOCK" to "0", "VKD3D_FEATURE_LEVEL" to "12_2"),
|
||||
games = mapOf("42" to mapOf("FEX_MULTIBLOCK" to "1", "VKD3D_FEATURE_LEVEL" to null)),
|
||||
)
|
||||
val game = GameEnvironment.effective(config, "COMPATIBILITY", "42")
|
||||
assertEquals("1", game["FEX_MULTIBLOCK"])
|
||||
assertTrue(game.containsKey("VKD3D_FEATURE_LEVEL"))
|
||||
assertNull(game["VKD3D_FEATURE_LEVEL"])
|
||||
val other = GameEnvironment.effective(config, "COMPATIBILITY", "43")
|
||||
assertEquals("0", other["FEX_MULTIBLOCK"])
|
||||
assertEquals("12_2", other["VKD3D_FEATURE_LEVEL"])
|
||||
}
|
||||
|
||||
@Test fun graphicsDefaultsRespectExistingEditsAndRemovals() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("VKD3D_FEATURE_LEVEL" to "12_0"))
|
||||
.withEntries("42", mapOf("VKD3D_SHADER_MODEL" to null))
|
||||
val game = GameEnvironment.effective(config, "", "42")
|
||||
assertEquals("12_0", game["VKD3D_FEATURE_LEVEL"])
|
||||
assertTrue(game.containsKey("VKD3D_SHADER_MODEL"))
|
||||
assertNull(game["VKD3D_SHADER_MODEL"])
|
||||
val restored = GameEnvironment.effective(config.withEntries("42", emptyMap()), "", "42")
|
||||
assertEquals("6_9", restored["VKD3D_SHADER_MODEL"])
|
||||
}
|
||||
|
||||
@Test fun multiSelectionPreservesCustomTokensAndRemovesToggledValues() {
|
||||
assertEquals("fps,custom_token,frametimes", GameEnvironmentOptions.toggle("fps,custom_token", "frametimes"))
|
||||
assertEquals("custom_token", GameEnvironmentOptions.toggle("fps,custom_token", "fps"))
|
||||
assertEquals("", GameEnvironmentOptions.toggle("fps", "fps"))
|
||||
assertEquals("fps", GameEnvironmentOptions.toggle("", "fps"))
|
||||
}
|
||||
|
||||
@Test fun switchingPresetDropsOldPresetOnlyVariables() {
|
||||
val config = GameEnvironment.Config()
|
||||
assertEquals("none", GameEnvironment.effective(config, "EXTREME", "")["FEX_SMCCHECKS"])
|
||||
assertFalse(GameEnvironment.effective(config, "COMPATIBILITY", "").containsKey("FEX_SMCCHECKS"))
|
||||
assertFalse(GameEnvironment.effective(config, "", "").containsKey("FEX_MULTIBLOCK"))
|
||||
}
|
||||
|
||||
@Test fun resettingOneProfilePreservesOtherProfiles() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("CUSTOM" to "shared"))
|
||||
.withEntries("42", mapOf("CUSTOM" to "game"))
|
||||
.withEntries("43", mapOf("CUSTOM" to "other"))
|
||||
.withEntries("42", emptyMap())
|
||||
assertEquals("shared", GameEnvironment.effective(config, "", "42")["CUSTOM"])
|
||||
assertEquals("other", GameEnvironment.effective(config, "", "43")["CUSTOM"])
|
||||
assertFalse(config.games.containsKey("42"))
|
||||
}
|
||||
|
||||
@Test fun validationPreservesLiteralValuesAndRejectsInvalidKeys() {
|
||||
assertTrue(GameEnvironment.validName("mesa_glthread"))
|
||||
assertFalse(GameEnvironment.validName("X=Y"))
|
||||
assertFalse(GameEnvironment.validName("1VAR"))
|
||||
assertFalse(GameEnvironment.validName("VAR\n"))
|
||||
assertTrue(GameEnvironment.validValue("a=b 'quote' $(touch file) ; $"))
|
||||
assertTrue(GameEnvironment.validValue(""))
|
||||
assertFalse(GameEnvironment.validValue("bad\u0000value"))
|
||||
assertFalse(GameEnvironment.validValue("x".repeat(8193)))
|
||||
assertTrue(GameEnvironment.validScope("4294967295"))
|
||||
for (scope in listOf("0", "-1", "01", "+1", "4294967296", "../42")) assertFalse(GameEnvironment.validScope(scope))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import kotlin.math.sqrt
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE, qualifiers = "mdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class OnScreenControlsTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var view: OnScreenControls
|
||||
private var time = 1000L
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
context.getSharedPreferences("controller", Context.MODE_PRIVATE).edit().clear().commit()
|
||||
ControllerPrefs.setLayout(context, 1200, 800, mapOf("ls" to (0.45f to 0.5f), "rs" to (0.55f to 0.5f)))
|
||||
view = OnScreenControls(context, null)
|
||||
view.layout(0, 0, 1200, 800)
|
||||
}
|
||||
|
||||
@Test fun adaptiveStickHidesUntilTouchedAndReturnsToNeutralOnRelease() {
|
||||
val left = control("ls")
|
||||
assertEquals(0, pixel(value(left, "cx"), value(left, "cy")))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f)))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
assertEquals(540f, value(left, "ax"), 0f)
|
||||
assertEquals(400f, value(left, "ay"), 0f)
|
||||
assertNotEquals(0, pixel(540f, 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 7 to (580f to 420f))
|
||||
assertTrue(value(left, "kx") > 0f)
|
||||
assertTrue(value(left, "ky") > 0f)
|
||||
touch(MotionEvent.ACTION_UP, 7 to (580f to 420f))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertEquals(0f, value(left, "kx"), 0f)
|
||||
assertEquals(0f, value(left, "ky"), 0f)
|
||||
assertEquals(0, pixel(540f, 400f))
|
||||
}
|
||||
|
||||
@Test fun simultaneousSticksKeepTheirPointersAndReleaseIndependently() {
|
||||
val left = control("ls")
|
||||
val right = control("rs")
|
||||
touch(MotionEvent.ACTION_DOWN, 7 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl 8), 7 to (540f to 400f), 9 to (660f to 400f))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
assertEquals(9, field(right, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_POINTER_DOWN or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
|
||||
assertEquals(7, field(left, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_POINTER_UP or (2 shl 8), 7 to (540f to 400f), 9 to (660f to 400f), 11 to (500f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 7 to (700f to 400f), 9 to (500f to 400f))
|
||||
assertTrue(value(left, "kx") > 0f)
|
||||
assertTrue(value(right, "kx") < 0f)
|
||||
touch(MotionEvent.ACTION_POINTER_UP, 7 to (700f to 400f), 9 to (500f to 400f))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertEquals(9, field(right, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_CANCEL, 9 to (500f to 400f))
|
||||
for (stick in listOf(left, right)) {
|
||||
assertEquals(-1, field(stick, "pressedBy"))
|
||||
assertEquals(0f, value(stick, "kx"), 0f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun cancelledTouchDoesNotBecomeAStickClick() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_CANCEL, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(false, field(control("ls"), "clicked"))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
assertEquals(true, field(control("ls"), "clicked"))
|
||||
}
|
||||
|
||||
@Test fun buttonsTakePriorityOverAdaptiveRegions() {
|
||||
val button = control("a")
|
||||
touch(MotionEvent.ACTION_DOWN, 3 to (value(button, "cx") to value(button, "cy")))
|
||||
assertEquals(3, field(button, "pressedBy"))
|
||||
assertEquals(-1, field(control("rs"), "pressedBy"))
|
||||
}
|
||||
|
||||
@Test fun fixedSticksAndEditorRemainVisibleAtSavedPositions() {
|
||||
ControllerPrefs.setAdaptiveSticks(context, false)
|
||||
view.reload()
|
||||
val left = control("ls")
|
||||
val x = value(left, "cx")
|
||||
val y = value(left, "cy")
|
||||
assertNotEquals(0, pixel(x, y))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (x + 10f to y))
|
||||
assertEquals(x, value(left, "ax"), 0f)
|
||||
assertEquals(10f, value(left, "kx"), 0f)
|
||||
ControllerPrefs.setAdaptiveSticks(context, true)
|
||||
view = OnScreenControls(context, null, editing = true)
|
||||
view.layout(0, 0, 1200, 800)
|
||||
assertNotEquals(0, pixel(value(control("ls"), "cx"), value(control("ls"), "cy")))
|
||||
}
|
||||
|
||||
@Test fun buttonsOnlyAndReloadReleaseAdaptiveSticks() {
|
||||
touch(MotionEvent.ACTION_DOWN, 1 to (540f to 400f))
|
||||
touch(MotionEvent.ACTION_MOVE, 1 to (570f to 400f))
|
||||
view.reload()
|
||||
assertEquals(-1, field(control("ls"), "pressedBy"))
|
||||
assertEquals(0f, value(control("ls"), "kx"), 0f)
|
||||
view.setButtonsOnly(true)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
}
|
||||
|
||||
@Test fun adaptiveActivationIsLimitedToExpandedSavedCircle() {
|
||||
val left = control("ls")
|
||||
val x = value(left, "cx")
|
||||
val y = value(left, "cy")
|
||||
val reach = value(left, "radius") * 1.4f * sqrt(1.5f)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach - 1f)))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to y - reach + 1f)))
|
||||
assertEquals(y - reach + 1f, value(left, "ay"), 0.001f)
|
||||
touch(MotionEvent.ACTION_UP, 1 to (x to y - reach + 1f))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x - reach to y - reach)))
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (600f to 200f)))
|
||||
}
|
||||
|
||||
@Test fun touchesOutsideActivationAreasStayWithTrackpad() {
|
||||
val received = mutableListOf<Int>()
|
||||
val trackpad = View(context).apply {
|
||||
setOnTouchListener { _, event -> received.add(event.actionMasked); true }
|
||||
}
|
||||
val root = FrameLayout(context).apply {
|
||||
addView(trackpad, FrameLayout.LayoutParams(1200, 800))
|
||||
addView(view, FrameLayout.LayoutParams(1200, 800))
|
||||
measure(View.MeasureSpec.makeMeasureSpec(1200, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(800, View.MeasureSpec.EXACTLY))
|
||||
layout(0, 0, 1200, 800)
|
||||
}
|
||||
for ((action, position) in listOf(
|
||||
MotionEvent.ACTION_DOWN to (600f to 200f),
|
||||
MotionEvent.ACTION_MOVE to (540f to 400f),
|
||||
MotionEvent.ACTION_UP to (540f to 400f),
|
||||
)) {
|
||||
val event = MotionEvent.obtain(1000L, time++, action, position.first, position.second, 0)
|
||||
try { assertTrue(root.dispatchTouchEvent(event)) } finally { event.recycle() }
|
||||
}
|
||||
assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_UP), received)
|
||||
assertEquals(-1, field(control("ls"), "pressedBy"))
|
||||
}
|
||||
|
||||
@Test fun adaptiveActivationLeavesFivePixelsAroundButtonHitTargets() {
|
||||
for (id in listOf("a", "rb")) {
|
||||
val button = control(id)
|
||||
val radius = value(button, "radius")
|
||||
val edge = value(button, "cy") - radius * if (id == "rb") 1.05f else 1.25f
|
||||
val x = value(button, "cx")
|
||||
val left = control("ls")
|
||||
left.javaClass.getDeclaredField("cx").apply { isAccessible = true }.setFloat(left, x)
|
||||
left.javaClass.getDeclaredField("cy").apply { isAccessible = true }.setFloat(left, edge - 10f)
|
||||
assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 4f)))
|
||||
assertEquals(-1, field(left, "pressedBy"))
|
||||
assertTrue(touch(MotionEvent.ACTION_DOWN, 1 to (x to edge - 6f)))
|
||||
assertEquals(1, field(left, "pressedBy"))
|
||||
touch(MotionEvent.ACTION_UP, 1 to (x to edge - 6f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun control(id: String): Any = (field(view, "controls") as List<*>).first { field(it!!, "id") == id }!!
|
||||
private fun field(owner: Any, name: String): Any = owner.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(owner)!!
|
||||
private fun value(owner: Any, name: String) = field(owner, name) as Float
|
||||
|
||||
private fun pixel(x: Float, y: Float): Int {
|
||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
view.draw(Canvas(bitmap))
|
||||
val color = bitmap.getPixel(x.toInt(), y.toInt())
|
||||
bitmap.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
private fun touch(action: Int, vararg pointers: Pair<Int, Pair<Float, Float>>): Boolean {
|
||||
val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray()
|
||||
val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray()
|
||||
val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
return try { view.onTouchEvent(event) } finally { event.recycle() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.core.GameEnvironment
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.io.File
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class GameEnvironmentStoreTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var guest: File
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
guest = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/game-environment.json")
|
||||
File(context.filesDir, "game-environment.json").delete()
|
||||
}
|
||||
|
||||
@Test fun savedSettingsAndPublishedSettingsPreserveUnsetsAndLiteralValues() {
|
||||
val config = GameEnvironment.Config(
|
||||
shared = mapOf("LITERAL" to "a=b 'quoted' $(echo test)", "EMPTY" to ""),
|
||||
games = mapOf("42" to mapOf("LITERAL" to null, "VKD3D_SHADER_MODEL" to "6_9")),
|
||||
)
|
||||
GameEnvironmentStore.save(context, config)
|
||||
assertEquals(config, GameEnvironmentStore.read(context))
|
||||
val published = GameEnvironmentStore.decode(JSONObject(guest.readText()))
|
||||
assertEquals("false", published.shared["MESA_SHADER_CACHE_DISABLE"])
|
||||
assertEquals("12_2", published.shared["VKD3D_FEATURE_LEVEL"])
|
||||
assertEquals("6_9", published.shared["VKD3D_SHADER_MODEL"])
|
||||
assertEquals(config.games, published.games)
|
||||
assertEquals(config.shared["LITERAL"], published.shared["LITERAL"])
|
||||
assertEquals("", published.shared["EMPTY"])
|
||||
}
|
||||
|
||||
@Test fun changingFexPresetPublishesForNextGameLaunch() {
|
||||
GameEnvironmentStore.save(context, GameEnvironment.Config())
|
||||
SessionPrefs.setFexPreset(context, "EXTREME")
|
||||
assertEquals("none", JSONObject(guest.readText()).getJSONObject("shared").getString("FEX_SMCCHECKS"))
|
||||
SessionPrefs.setFexPreset(context, "")
|
||||
assertFalse(JSONObject(guest.readText()).getJSONObject("shared").has("FEX_SMCCHECKS"))
|
||||
}
|
||||
|
||||
@Test fun invalidDataCannotReplaceSavedConfiguration() {
|
||||
val original = GameEnvironment.Config(shared = mapOf("CUSTOM" to "ok"))
|
||||
GameEnvironmentStore.save(context, original)
|
||||
assertTrue(runCatching { GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BAD=NAME" to "x"))) }.isFailure)
|
||||
assertEquals(original, GameEnvironmentStore.read(context))
|
||||
}
|
||||
|
||||
@Test fun customComponentVariablesRemainEditable() {
|
||||
val config = GameEnvironment.Config(shared = mapOf("DXVK_ASYNC" to "1"))
|
||||
GameEnvironmentStore.save(context, config)
|
||||
assertEquals(config, GameEnvironmentStore.read(context))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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.
|
||||
@@ -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}'
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/python3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def game_id(env):
|
||||
prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/")
|
||||
if not prefix:
|
||||
return None
|
||||
value = prefix.rsplit("/", 1)[-1]
|
||||
if not re.fullmatch(r"-?[0-9]+", value):
|
||||
return None
|
||||
number = int(value)
|
||||
if not -(2**31) <= number < 2**32 or number == 0:
|
||||
return None
|
||||
return str(number % 2**32)
|
||||
|
||||
|
||||
def apply_config(env, config, app_id):
|
||||
if config.get("version") != 1:
|
||||
raise ValueError("unsupported environment configuration")
|
||||
result = env.copy()
|
||||
entries = dict(config.get("shared", {}))
|
||||
entries.update(config.get("games", {}).get(app_id, {}))
|
||||
for name, value in entries.items():
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
||||
raise ValueError("invalid environment name")
|
||||
if value is None:
|
||||
result.pop(name, None)
|
||||
elif isinstance(value, str) and "\0" not in value and len(value) <= 8192:
|
||||
result[name] = value
|
||||
else:
|
||||
raise ValueError("invalid environment value")
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
command = sys.argv[1:]
|
||||
if not command:
|
||||
return 2
|
||||
env = os.environ.copy()
|
||||
app_id = game_id(env)
|
||||
if len(command) > 1 and command[1] == "waitforexitandrun" and app_id:
|
||||
path = Path.home() / ".config/droiddeck/game-environment.json"
|
||||
try:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
env = apply_config(env, json.load(source), app_id)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr)
|
||||
os.execvpe(command[0], command, env)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -233,7 +233,7 @@ for name in %s; do
|
||||
bl_directaudio "$depot"
|
||||
bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
exec ${BL_TASKSET:-} "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
fi
|
||||
done
|
||||
echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2
|
||||
@@ -282,7 +282,7 @@ bl_game_cpus
|
||||
bl_idtech3 "$@"
|
||||
# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be
|
||||
# started from its own tree.
|
||||
exec ${BL_TASKSET:-} "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}"
|
||||
"""
|
||||
EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools"
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user