diff --git a/.github/workflows/authorship.yml b/.github/workflows/authorship.yml
index 97c6637a8..ebbcae644 100644
--- a/.github/workflows/authorship.yml
+++ b/.github/workflows/authorship.yml
@@ -23,7 +23,7 @@ permissions:
jobs:
validate:
- name: Build APK
+ name: Validate source and build result
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ebfd545ca..146e7fe3b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,26 +16,29 @@ on:
- 'LICENSE'
- '.gitignore'
workflow_dispatch:
+ inputs:
+ rebuild_preloads:
+ description: Rebuild preloads instead of reusing the approved binary snapshot
+ type: boolean
+ default: false
permissions:
contents: read
+ actions: read
pull-requests: write
concurrency:
group: build-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-env:
- PRELOAD_CACHE_VERSION: v6
-
jobs:
- xbox:
- name: Xbox ARM64 backend
+ source:
+ name: Pin source tree
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
- runs-on: ubuntu-24.04-arm
- timeout-minutes: 30
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
outputs:
- base-sha: ${{ steps.merge.outputs.base-sha }}
+ base-sha: ${{ steps.merge.outputs.base-sha || steps.trusted.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
@@ -53,9 +56,46 @@ jobs:
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit origin/main
- - name: Record backend source tree
+ - name: Resolve trusted manifest base
+ id: trusted
+ if: github.event_name != 'pull_request'
+ run: |
+ set -euo pipefail
+ git fetch --no-tags origin main
+ echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
+ - name: Record pinned source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
+
+ xbox:
+ name: Xbox ARM64 backend
+ needs: source
+ if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Merge the pinned APK base
+ if: github.event_name == 'pull_request'
+ env:
+ BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
+ run: |
+ set -euo pipefail
+ git fetch --no-tags origin "$BUILD_BASE_SHA"
+ git config user.name "ci"
+ git config user.email "ci@users.noreply.github.com"
+ git merge --no-ff --no-edit FETCH_HEAD
+ - name: Verify pinned source tree
+ env:
+ SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
+ run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
+ - name: Plan component identities
+ id: plan
+ run: python3 tools/components/prepare.py plan
- uses: actions/cache@v4
id: xbox-cache
with:
@@ -63,14 +103,10 @@ jobs:
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
- key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
+ key: xbox-v2-${{ steps.plan.outputs.xbox }}
- name: Build Xbox backend
if: steps.xbox-cache.outputs.cache-hit != 'true'
- run: |
- rustup toolchain install 1.99.0 --profile minimal
- sudo apt-get update -qq
- sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
- bash tools/xbox/build.sh
+ run: python3 tools/components/prepare.py xbox
- uses: actions/upload-artifact@v4
with:
name: xbox-arm64
@@ -85,9 +121,158 @@ jobs:
if-no-files-found: error
retention-days: 14
+ components:
+ name: Prepare verified components
+ needs: source
+ if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 40
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Merge the pinned APK base
+ if: github.event_name == 'pull_request'
+ env:
+ BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
+ run: |
+ set -euo pipefail
+ git fetch --no-tags origin "$BUILD_BASE_SHA"
+ git config user.name "ci"
+ git config user.email "ci@users.noreply.github.com"
+ git merge --no-ff --no-edit FETCH_HEAD
+ - name: Verify pinned source tree
+ env:
+ SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
+ run: test "$(git rev-parse 'HEAD^{tree}')" = "$SOURCE_TREE"
+ - name: Resolve the pinned Android NDK
+ run: bash tools/ci/resolve-ndk.sh
+
+ - name: Plan component identities
+ id: plan
+ run: python3 tools/components/prepare.py plan
+ - name: Cache remaining Linux runtime assets
+ id: preloads
+ uses: actions/cache@v4
+ with:
+ path: |
+ app/src/main/assets/linuxfs
+ !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
+ !app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
+ !app/src/main/assets/linuxfs/i386/libblsession.so
+ !app/src/main/assets/linuxfs/i386/libfakeinput.so
+ !app/src/main/assets/linuxfs/i386/libfaultreport.so
+ !app/src/main/assets/linuxfs/libblaudit.so
+ !app/src/main/assets/linuxfs/libblfastpath.so
+ !app/src/main/assets/linuxfs/libblsession.so
+ !app/src/main/assets/linuxfs/libfakeinput.so
+ !app/src/main/assets/linuxfs/libssbs.so
+ !app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard
+ !app/src/main/assets/linuxfs/x86_64/libblsession.so
+ !app/src/main/assets/linuxfs/x86_64/libfakeinput.so
+ !app/src/main/assets/linuxfs/x86_64/libfaultreport.so
+ !app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so
+ !app/src/main/assets/linuxfs/x86_64/libwebkitinput.so
+ key: runtime-v9-${{ steps.plan.outputs.runtime }}
+
+ - name: Cache PulseAudio 13.0 source
+ id: pa-src
+ uses: actions/cache@v4
+ with:
+ path: pa13
+ key: pulseaudio-src-${{ steps.plan.outputs.pulseaudio-source }}
+ - name: Cache proot
+ id: proot
+ uses: actions/cache@v4
+ with:
+ path: |
+ app/src/main/jniLibs/arm64-v8a/libproot.so
+ app/src/main/jniLibs/arm64-v8a/libproot-loader.so
+ key: proot-v2-${{ steps.plan.outputs.proot }}
+ - name: Cache GOG Galaxy companion
+ id: comet
+ uses: actions/cache@v4
+ with:
+ path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet
+ key: comet-v2-${{ steps.plan.outputs.comet }}
+
+ - name: Restore verified preload snapshots
+ id: component-preloads
+ env:
+ GH_TOKEN: ${{ github.token }}
+ FORCE_PRELOAD_REBUILD: ${{ inputs.rebuild_preloads || 'false' }}
+ COMPONENT_TRUSTED_BASE: ${{ needs.source.outputs.base-sha }}
+ run: python3 tools/components/prepare.py preload-snapshot
+
+ - name: Prepare required Linux build tools once
+ env:
+ COMPONENTS_TO_BUILD: >-
+ ${{ steps.preloads.outputs.cache-hit != 'true' && 'runtime' || '' }}
+ ${{ steps.component-preloads.outputs.arm64 != 'true' && 'arm64-preloads' || '' }}
+ ${{ steps.comet.outputs.cache-hit != 'true' && 'comet' || '' }}
+ run: python3 tools/components/prepare.py tools
+
+ - name: Build changed ARM64 preloads
+ if: steps.component-preloads.outputs.arm64 != 'true'
+ run: python3 tools/components/prepare.py arm64-preloads
+
+ - name: Prepare remaining Linux runtime assets
+ if: steps.preloads.outputs.cache-hit != 'true'
+ run: python3 tools/components/prepare.py runtime
+
+ - name: Fetch the Debian image the preloads build in
+ if: steps.component-preloads.outputs.x86 != 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 tools/components/prepare.py debian-image
+
+ - name: Build the x86 preloads FEX guests load (glibc 2.31)
+ if: steps.component-preloads.outputs.x86 != 'true'
+ run: python3 tools/components/prepare.py x86-preloads
+
+ - name: Bundle the droiddeck-esync packs
+ run: python3 tools/components/prepare.py esync
+
+ - name: Fetch PulseAudio 13.0 source
+ if: steps.pa-src.outputs.cache-hit != 'true'
+ run: python3 tools/components/prepare.py pulseaudio-source
+
+ - name: Fetch DirectAudio (tools/directaudio/release.env)
+ run: python3 tools/components/prepare.py directaudio
+
+ - name: Build the AAudio sink into the audio bundle
+ run: python3 tools/components/prepare.py audio
+
+ - name: Build proot
+ if: steps.proot.outputs.cache-hit != 'true'
+ run: python3 tools/components/prepare.py proot
+
+ - name: Check the preloads
+ run: |
+ set -euo pipefail
+ bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
+
+ - name: Build GOG Galaxy companion
+ if: steps.comet.outputs.cache-hit != 'true'
+ run: python3 tools/components/prepare.py comet
+
+ - name: Prepare graphics driver variants
+ run: python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
+ - name: Record and pack prepared components
+ run: python3 tools/components/prepare.py pack
+ - uses: actions/upload-artifact@v4
+ with:
+ name: prepared-components
+ path: app/build/prepared-components.tar.gz
+ if-no-files-found: error
+ compression-level: 0
+ retention-days: 14
+
build:
- needs: xbox
- name: Compile and test APK
+ needs: [source, components, xbox]
+ name: Compile APK and Android tests
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -150,7 +335,7 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
- BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
+ BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
@@ -208,7 +393,7 @@ jobs:
- name: Verify backend and APK source match
env:
- XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
+ XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
@@ -218,13 +403,11 @@ jobs:
- uses: gradle/actions/setup-gradle@v4
with:
+ cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
cache-cleanup: on-success
- - name: Resolve the runner's NDK
- run: |
- NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
- VERSION=$(sed -n 's/^Pkg.Revision *= *//p' "$NDK/source.properties")
- echo "NDK_VERSION=$VERSION" >> "$GITHUB_ENV"
+ - name: Resolve the pinned Android NDK
+ run: bash tools/ci/resolve-ndk.sh
- name: Set the APK build label
id: build-label
@@ -250,264 +433,6 @@ jobs:
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- - name: Cache the preloads
- id: preloads
- uses: actions/cache@v4
- with:
- path: app/src/main/assets/linuxfs
- key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
-
- - name: Build libfakeinput.so and libblsession.so (glibc aarch64)
- if: steps.preloads.outputs.cache-hit != 'true'
- run: |
- set -euo pipefail
- sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
- -o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
- sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan1 mesa-vulkan-drivers >/dev/null
- D=app/src/main/assets/linuxfs
- mkdir -p "$D"
- aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
- -pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
- -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
- aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
- aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
- -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
- aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
- aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
- -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
- aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
- aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
- -fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
- aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
- aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
- -o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
- aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
- mkdir -p "$D/usr/local/bin"
- aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
- aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
- VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
- tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
- for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
- install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
- done
- for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
- [ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
- done
- if [ -f tools/gamescope/release.env ]; then
- . tools/gamescope/release.env
- curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
- echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
- tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
- test -f "$D/usr/local/bin/gamescope"
- rm gamescope.tzst
- fi
- . tools/linuxfs/uruntime.env
- curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
- echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
- install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
- install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
- rm uruntime
- M="$D/usr/local/lib/mangoapp"
- mkdir -p "$M" mango-pkgs
- grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
- [ -n "$url" ] || continue
- curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
- echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
- tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
- rm mango-pkgs/pkg.tar.zst
- done
- install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
- for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
- cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
- done
- aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
- -o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
- aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
- install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
- rm -rf mango-pkgs
- ls -l "$M"
- mkdir -p pw-pkgs
- grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
- [ -n "$url" ] || continue
- curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
- echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
- tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
- rm pw-pkgs/pkg.tar.zst
- done
- tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
- rm -rf pw-pkgs
- ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
- T="$D/usr/local/lib/droiddeck-msitools"
- mkdir -p "$T" msi-pkgs
- grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
- [ -n "$url" ] || continue
- curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
- echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
- tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
- rm msi-pkgs/pkg.tar.xz
- done
- install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
- install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
- install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
- install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
- for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
- cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
- done
- for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
- install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
- done
- rm -rf msi-pkgs
- ls -l "$T"
- install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
- install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
- install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
- install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
- install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
- ls -lR "$D"
-
- - name: Fetch the Debian image the preloads build in
- if: steps.preloads.outputs.cache-hit != 'true'
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- . tools/linuxfs/debian-image.env
- image=public.ecr.aws/docker/library/debian:bullseye
- if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
- && { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
- zstd -dc /tmp/debian-image.tar.zst | docker load
- rm -f /tmp/debian-image.tar.zst
- else
- echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
- for attempt in 1 2 3 4 5; do
- docker pull "$image" && break
- [ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
- echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
- sleep $((attempt * 30))
- done
- fi
- docker image ls "$image"
-
- - name: Build the x86 preloads FEX guests load (glibc 2.31)
- if: steps.preloads.outputs.cache-hit != 'true'
- run: |
- set -euo pipefail
- docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
- set -euo pipefail
- install_compilers() {
- apt-get -o Acquire::Check-Valid-Until=false update -qq &&
- DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
- }
- printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
- if ! install_compilers; then
- printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
- install_compilers
- fi
- tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
- '
- sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
- ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
-
- - name: Bundle the droiddeck-esync packs
- run: |
- set -euo pipefail
- if [ -f tools/droiddeck-esync/release.env ]; then
- . tools/droiddeck-esync/release.env
- release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
- curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
- echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
- mkdir -p app/src/main/assets/droiddeck-esync
- tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
- rm sync-bundle.tzst
- curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
- revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
- for id in $revoked; do
- if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
- echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
- rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
- fi
- done
- rm sync-index.json
- test -f app/src/main/assets/droiddeck-esync/index.json
- test -f app/src/main/assets/droiddeck-esync/index.json.sig
- ls -lR app/src/main/assets/droiddeck-esync
- fi
-
- - name: Cache PulseAudio 13.0 source
- id: pa-src
- uses: actions/cache@v4
- with:
- path: pa13
- key: pulseaudio-src-13.0
- - name: Fetch PulseAudio 13.0 source
- if: steps.pa-src.outputs.cache-hit != 'true'
- run: |
- set -euo pipefail
- curl -fsSL -o pa.tar.gz https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz
- mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
- - name: Fetch DirectAudio (tools/directaudio/release.env)
- run: tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
- - name: Build the AAudio sink into the audio bundle
- run: |
- set -euo pipefail
- export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
- tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
- rm -rf pa-bundle && mkdir -p pa-bundle
- tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
- test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
- install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
- install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
- (cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
- mkdir -p app/build/prepared-assets
- mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
- tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
- rm -rf pa-bundle sink-out
- - name: Cache proot
- id: proot
- uses: actions/cache@v4
- with:
- path: |
- app/src/main/jniLibs/arm64-v8a/libproot.so
- app/src/main/jniLibs/arm64-v8a/libproot-loader.so
- key: proot-${{ env.NDK_VERSION }}-${{ hashFiles('tools/proot/**') }}
- - name: Build proot
- if: steps.proot.outputs.cache-hit != 'true'
- run: |
- set -euo pipefail
- export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
- tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
-
- - name: Check the preloads
- run: |
- set -euo pipefail
- bash tools/linuxfs/check-preloads.sh app/src/main/assets/linuxfs
-
- - name: Check Quest tracking IPC
- run: |
- set -euo pipefail
- NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
- VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
- mkdir -p build/vr/include
- cp -R "$VK_INCLUDE/vulkan" build/vr/include/
- if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
- g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
- /tmp/test-xr-tracking
- g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
- /tmp/test-xr-touch
- g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
- /tmp/test-pad-controller-config
- g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
- /tmp/test-xr-vsync
- g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
- /tmp/test-xr-frames
- cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
- cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
- g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
- -Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
- tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
- -ldl -o /tmp/test-presenter-shutdown
- timeout 10 /tmp/test-presenter-shutdown
-
- name: Rust toolchain for the store engines (aarch64-linux-android)
id: rust
run: |
@@ -525,9 +450,10 @@ jobs:
~/.cargo/git/db
~/.cargo/bin/cargo-ndk
app/src/main/rust/stores/target
- key: stores-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}
+ key: stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-${{ hashFiles('app/src/main/rust/stores/src/**', 'app/src/main/rust/stores/Cargo.toml', 'app/src/main/rust/stores/build.rs', 'app/src/main/rust/stores/.cargo/**', 'app/build.gradle') }}
restore-keys: |
- stores-${{ steps.rust.outputs.rustc }}-
+ stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-${{ hashFiles('app/src/main/rust/stores/Cargo.lock') }}-
+ stores-v2-${{ runner.os }}-${{ runner.arch }}-${{ env.NDK_VERSION }}-${{ steps.rust.outputs.rustc }}-
- name: cargo-ndk
run: |
@@ -545,18 +471,26 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
+ - uses: actions/download-artifact@v4
+ with:
+ name: prepared-components
+ path: app/build
+ - name: Verify and unpack prepared components
+ env:
+ SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
+ run: python3 tools/components/prepare.py unpack --tree "$SOURCE_TREE"
+
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
- - name: Build GOG Galaxy companion
- if: steps.preloads.outputs.cache-hit != 'true'
- run: |
- rustup toolchain install 1.90.0 --profile minimal
- rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
- RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
-
+ - name: Cache checksum-pinned pack inputs
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/droiddeck-pack-inputs
+ key: pack-inputs-v1-${{ hashFiles('tools/packs/build.sh', 'tools/packs/fetch-verified.sh', 'tools/packs/tracking/release.env') }}
+ restore-keys: pack-inputs-v1-
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
@@ -567,7 +501,6 @@ jobs:
SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
- python3 tools/quest/build-driver.py "$ANDROID_HOME/ndk/$NDK_VERSION"
python3 tools/release/runtime_inputs.py record
./gradlew assembleRelease --console=plain -PndkVersion="$NDK_VERSION" 2>&1 | tee /tmp/build.log
python3 tools/release/check_session_assets.py app/build/outputs/apk/release/app-release.apk
@@ -598,16 +531,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- - name: Install Python test dependencies
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y -qq python3-yaml gcc-aarch64-linux-gnu qemu-user
-
- - name: Steam import and game environment tests
- run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
-
- name: Unit tests
id: unit-tests
+ env:
+ DROIDDECK_BUILD_LABEL: ${{ steps.build-label.outputs.label }}
+ SGDB_API_KEY: ${{ secrets.SGDB_API_KEY }}
run: |
set -o pipefail
mkdir -p app/build
@@ -683,86 +611,84 @@ jobs:
TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }}
APK_OUTCOME: ${{ steps.apk-upload.outcome }}
SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
+ run: bash tools/release/report-build.sh
+
+ python-tests:
+ name: Python and native regression tests
+ needs: source
+ if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Merge the pinned APK base
+ if: github.event_name == 'pull_request'
+ env:
+ BUILD_BASE_SHA: ${{ needs.source.outputs.base-sha }}
run: |
- set -uo pipefail
- MARK=""
- {
- echo "$MARK"
- echo "## Build of this pull request"
- echo
- [ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
- echo
- case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
- success:success:success)
- echo "### Build passed"
- echo
- echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
- echo
- if [ "$SAME_REPO" = true ]; then
- echo "A release-signed tester build that installs over Stable or Preview is being published now and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
- else
- echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
- fi
- echo
- echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
- ;;
- success:success:*)
- echo "### APK publication incomplete"
- echo
- echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
- ;;
- success:failure:*)
- echo "### ERROR: Unit tests failed"
- echo
- echo "The APK compiled, but unit tests failed. No installable artifact was published."
- echo
- if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
- echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
- else
- echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
- fi
- ;;
- success:*)
- echo "### Build incomplete"
- echo
- echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
- ;;
- failure:*)
- echo "### ERROR: Build failed"
- echo
- echo "The compiler's errors ([full log]($RUN_URL)):"
- echo
- echo '```'
- { grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
- | sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
- | grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
- echo '```'
- echo
- echo "What went wrong (Gradle)
"
- echo
- echo '```'
- awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
- echo '```'
- echo " "
- ;;
- skipped:*|cancelled:*)
- echo "### Not built"
- echo
- echo "The build did not run (see above, or [the run]($RUN_URL))."
- ;;
- esac
- echo
- } > /tmp/pr-report.md
- cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
- ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.body | startswith(\"$MARK\")) | .id" | head -1 || true)
- if [ -n "$ID" ]; then
- gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
- else
- gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
- fi
+ set -euo pipefail
+ git fetch --no-tags origin "$BUILD_BASE_SHA"
+ git config user.name "ci"
+ git config user.email "ci@users.noreply.github.com"
+ git merge --no-ff --no-edit FETCH_HEAD
+ - name: Verify backend and test source match
+ env:
+ XBOX_SOURCE_TREE: ${{ needs.source.outputs.tree-sha }}
+ run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
+ - name: Install Python test dependencies
+ timeout-minutes: 7
+ run: bash tools/ci/install-ubuntu-packages.sh python3-yaml gcc-aarch64-linux-gnu libc6-dev-arm64-cross qemu-user libvulkan-dev mesa-vulkan-drivers
+ - name: Resolve the pinned Android NDK
+ run: bash tools/ci/resolve-ndk.sh
+
+ - name: Check Quest tracking IPC
+ run: |
+ set -euo pipefail
+ NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
+ VK_INCLUDE=$(ls -d "$NDK"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1)
+ mkdir -p build/vr/include
+ cp -R "$VK_INCLUDE/vulkan" build/vr/include/
+ if [ -d "$VK_INCLUDE/vk_video" ]; then cp -R "$VK_INCLUDE/vk_video" build/vr/include/; fi
+ g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_tracking.cpp -o /tmp/test-xr-tracking
+ /tmp/test-xr-tracking
+ g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
+ /tmp/test-xr-touch
+ g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
+ /tmp/test-pad-controller-config
+ g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
+ /tmp/test-xr-vsync
+ g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
+ /tmp/test-xr-frames
+ cc -Itools/vr/third_party/wayland -c tools/vr/src/wayland_client_stub.c -o /tmp/test-wayland-stub.o
+ cc -Itools/vr/third_party/wayland -c app/src/main/cpp/waylandcomp/generated/xdg-shell-protocol.c -o /tmp/test-xdg-shell.o
+ g++ -std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers -pthread -Ibuild/vr/include \
+ -Itools/vr/third_party/wayland -Iapp/src/main/cpp/waylandcomp/generated \
+ tools/vr/tests/test_presenter_shutdown.cpp tools/vr/src/presenter.cpp /tmp/test-wayland-stub.o /tmp/test-xdg-shell.o \
+ -ldl -o /tmp/test-presenter-shutdown
+ timeout 10 /tmp/test-presenter-shutdown
+
+ - name: Steam import and game environment tests
+ run: python3 -m unittest discover -s tools/tests -p 'test_*.py'
+
+ checks:
+ name: Compile and test APK
+ needs: [build, python-tests]
+ if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ - name: Require compilation and every test job
+ env:
+ BUILD_RESULT: ${{ needs.build.result }}
+ PYTHON_RESULT: ${{ needs.python-tests.result }}
+ run: test "$BUILD_RESULT" = success && test "$PYTHON_RESULT" = success
sign:
- needs: build
+ needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -774,7 +700,7 @@ jobs:
secrets: inherit
packs:
- needs: build
+ needs: [build, checks]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
@@ -794,6 +720,10 @@ jobs:
CI_REPO: Droid-Deck/DroidDeck-CI
KEEP: 10
steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.sha }}
+ persist-credentials: false
- uses: actions/download-artifact@v4
with:
pattern: droiddeck-signed-*
@@ -811,72 +741,13 @@ jobs:
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
- run: |
- set -euo pipefail
- pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
- --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
- if [ -n "$pr" ]; then
- number=$(jq -r .number <<<"$pr")
- title=$(jq -r .title <<<"$pr")
- merged=$(jq -r '.merged_at[:10]' <<<"$pr")
- summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
- awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
- {
- echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
- if [ -n "$summary" ]; then
- echo
- echo "$summary"
- fi
- } > notes-head.md
- else
- subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
- echo "**Pushed to main** without a PR - $subject" > notes-head.md
- fi
- cat notes-head.md
+ run: bash tools/release/describe-build.sh
- 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="$(cat notes-head.md)
-
- Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
- Commit: $GITHUB_SHA
- VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
- 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/*
- gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
- --jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
- while IFS= read -r old; do
- if [ "$old" = "$tag" ]; then
- continue
- fi
- if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
- if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
- echo "::error::Could not delete old release $old"
- exit 1
- fi
- fi
- done
- published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
- jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
- 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"
+ run: bash tools/release/publish-ci.sh
- name: Post the Preview build to Discord
env:
@@ -884,26 +755,8 @@ jobs:
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")
- 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 Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\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"
+ run: bash tools/release/announce-preview.sh
+
catalog:
needs: announce
diff --git a/README.md b/README.md
index fb86fa653..7d9081593 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,16 @@ DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big

+
+
+
+
+
+
+
+
+
+

> Note: DroidDeck does not have a stand-alone website. Do not click on any download links from websites claiming to be the DroidDeck team.
@@ -47,7 +57,7 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
-GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
+GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc//mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. The proot fast path picks the guest name of a host file bound under several names in the order [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later) sorts its bindings, so links and `/proc/self/fd` read back as they do through proot. The session's [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) opens its sockets with the per-socket access settings documented in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions through its `access.rules`. Wine write watches use the userfaultfd support in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/ntdll/unix/virtual.c` (LGPL-2.1-or-later): libblsession gives it the arm64 syscall number and answers its `PAGEMAP_SCAN` from `/proc/self/pagemap` where Android refuses the ioctl. Wine on arm64 sets a thread's full context by raising `SIGUSR2` from `syscall_dispatcher_return_slowpath` in [Wine](https://gitlab.winehq.org/wine/wine)'s `dlls/ntdll/unix/signal_arm64.c` (LGPL-2.1-or-later); libblsession runs that handler in place on a frame laid out as the [Linux](https://github.com/torvalds/linux) kernel's `arch/arm64/kernel/signal.c` builds it and returns through `rt_sigreturn`, so proot does not stop the thread for it. Wine's process list reads each process's memory counters from `/proc//status` in `fill_vm_counters` ([Wine](https://gitlab.winehq.org/wine/wine) `dlls/ntdll/unix/process.c`, LGPL-2.1-or-later), so libblsession gives those reads the kernel's file instead of a copy with `TracerPid` cleared. droiddeck-ntsync is checked against the object, wait and error semantics documented for the [Linux](https://github.com/torvalds/linux) ntsync driver in `Documentation/userspace-api/ntsync.rst` (GPL-2.0). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
diff --git a/app/build.gradle b/app/build.gradle
index e81b92711..1b1eb5ae7 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -80,7 +80,7 @@ def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getO
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
def releaseSigner = rootProject.file('keystore/release-signer.sha256').text.trim()
-def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
+def commitTime = (gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit]) ?: '0').with { it.isLong() ? it : '0' }
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
@@ -220,6 +220,10 @@ android {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
+ if (ciBuild) {
+ outputs.upToDateWhen { false }
+ outputs.doNotCacheIf('CI executes the test suite on every run') { true }
+ }
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
def logTarget = providers.systemProperty('robolectric.logging').orNull
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fcd9b2e11..286a9ee8a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -34,6 +34,7 @@
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="false"
+ android:allowAudioPlaybackCapture="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt
index 33585818b..f8eb31291 100644
--- a/app/src/main/cpp/waylandcomp/CMakeLists.txt
+++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt
@@ -8,6 +8,7 @@ add_library(droiddeckwayland SHARED
src/sc_layer.c
src/ahb_swapchain.c
src/waylandcomp_jni.c
+ src/gpu_clock_pin.c
src/droiddeck_ext.c
src/wl_dmabuf.c
src/wl_clipboard.c
diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c
index 95a8bf83a..1be637a7b 100644
--- a/app/src/main/cpp/waylandcomp/src/compositor.c
+++ b/app/src/main/cpp/waylandcomp/src/compositor.c
@@ -808,10 +808,14 @@ static uint32_t nominal_refresh_ns(void) {
}
static int64_t presented_ns(int64_t render_ns) {
- if (!g_tick_vsync_ns || render_ns - g_last_vsync_ns > (int64_t)nominal_refresh_ns() ||
- render_ns < g_tick_vsync_ns)
+ int64_t tick = g_tick_vsync_ns;
+ int64_t refresh = (int64_t)nominal_refresh_ns();
+ if (!tick || render_ns - g_last_vsync_ns > refresh)
return render_ns;
- return g_tick_vsync_ns;
+ int64_t delta = render_ns - tick;
+ if (delta > refresh || -delta > refresh)
+ return render_ns;
+ return tick;
}
static void feedback_present_all(struct wl_list *list, int64_t t) {
diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
index 94bb581a0..9de2824d7 100644
--- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
+++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c
@@ -238,7 +238,7 @@ static int ensure_ring(uint32_t w, uint32_t h, VkFormat fmt, uint32_t n) {
}
g_ring_w = w; g_ring_h = h; g_ring_fmt = fmt;
LOGD("framegen: generation ring %ux%u x%u", w, h, n);
- return 1;
+ return 2;
}
@@ -350,6 +350,7 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
atomic_load(&g_model), atomic_load(&g_preset));
}
+ const int size_changed = g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt;
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
refuse_format(fmt, kind, w, h, "build its chain");
@@ -361,17 +362,25 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
}
return 0;
}
- if (g_built_w != (uint32_t)w || g_built_h != (uint32_t)h || g_built_fmt != fmt) {
+ if (size_changed) {
g_built_w = (uint32_t)w; g_built_h = (uint32_t)h; g_built_fmt = fmt;
g_generating_logged = 0;
}
uint32_t want = (uint32_t)(mult - 1);
- if (!ensure_ring((uint32_t)w, (uint32_t)h, fmt, want)) {
+ const int ring = ensure_ring((uint32_t)w, (uint32_t)h, fmt, want);
+ if (!ring) {
if (fmt != VK_FORMAT_R8G8B8A8_UNORM) { refuse_format(fmt, kind, w, h, "make its generation ring"); return 0; }
FGLOG("no memory for the generation ring (%dx%d x%u); frame generation stays off", w, h, want);
mark_failed(kind);
return 0;
}
+ /* The ring and the engine's images are new on this frame. Dispatching into them now
+ * null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
+ * Show this frame as it is; generation starts once the size has settled. */
+ if (size_changed || ring == 2) {
+ FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
+ return 0;
+ }
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
* generations would otherwise read as a slower game and justify even more generations. */
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
new file mode 100644
index 000000000..0f8e1f61b
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c
@@ -0,0 +1,170 @@
+#include "gpu_clock_pin.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../adrenotools/src/hook/kgsl.h"
+
+static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
+static pthread_t worker;
+static bool joinable;
+static atomic_bool running;
+
+struct pin {
+ int fd;
+ unsigned int context;
+ unsigned int memory;
+ unsigned int timestamp;
+ unsigned long gpuaddr;
+ uint32_t nop;
+ void *commands;
+ size_t size;
+};
+
+static struct pin pin = { .fd = -1 };
+
+static void release_pin(void) {
+ if (pin.context) {
+ struct kgsl_drawctxt_destroy destroy = { .drawctxt_id = pin.context };
+ ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &destroy);
+ }
+ if (pin.commands) munmap(pin.commands, pin.size);
+ if (pin.memory) {
+ struct kgsl_gpumem_free_id free_memory = { .id = pin.memory };
+ ioctl(pin.fd, IOCTL_KGSL_GPUMEM_FREE_ID, &free_memory);
+ }
+ if (pin.fd >= 0) close(pin.fd);
+ pin = (struct pin) { .fd = -1 };
+}
+
+static bool submit_pin(void) {
+ struct kgsl_command_object object = {
+ .gpuaddr = pin.gpuaddr,
+ .size = 2 * sizeof(uint32_t),
+ .flags = KGSL_CMDLIST_IB,
+ .id = pin.memory,
+ };
+ struct kgsl_gpu_command command = {
+ .flags = KGSL_CMDBATCH_PWR_CONSTRAINT,
+ .cmdlist = (uintptr_t)&object,
+ .cmdsize = sizeof(object),
+ .numcmds = 1,
+ .context_id = pin.context,
+ };
+ if (ioctl(pin.fd, IOCTL_KGSL_GPU_COMMAND, &command) < 0) return false;
+ pin.timestamp = command.timestamp;
+ return true;
+}
+
+static bool prepare_pin(void) {
+ pin.fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
+ if (pin.fd < 0) return false;
+ struct kgsl_devinfo info = {0};
+ struct kgsl_device_getproperty device_info = {
+ .type = KGSL_PROP_DEVICE_INFO, .value = &info, .sizebytes = sizeof(info),
+ };
+ if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &device_info) < 0) return false;
+ unsigned int core = info.chip_id >> 24;
+ if (!core) core = info.gpu_id / 100;
+ if (core < 3) { errno = ENODEV; return false; }
+ pin.nop = core >= 5 ? 0x70100001 : 0xc0001000;
+ struct kgsl_drawctxt_create context = {
+ .flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE | KGSL_CONTEXT_PWR_CONSTRAINT,
+ };
+ if (ioctl(pin.fd, IOCTL_KGSL_DRAWCTXT_CREATE, &context) < 0) return false;
+ pin.context = context.drawctxt_id;
+ struct kgsl_device_constraint_pwrlevel level = { .level = KGSL_CONSTRAINT_PWR_MAX };
+ struct kgsl_device_constraint constraint = {
+ .type = KGSL_CONSTRAINT_PWRLEVEL,
+ .context_id = pin.context,
+ .data = &level,
+ .size = sizeof(level),
+ };
+ struct kgsl_device_getproperty property = {
+ .type = KGSL_PROP_PWR_CONSTRAINT,
+ .value = &constraint,
+ .sizebytes = sizeof(constraint),
+ };
+ if (ioctl(pin.fd, IOCTL_KGSL_SETPROPERTY, &property) < 0) return false;
+ struct kgsl_gpumem_alloc_id memory = { .size = 4096 };
+ if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &memory) < 0) return false;
+ pin.memory = memory.id;
+ pin.gpuaddr = memory.gpuaddr;
+ pin.size = memory.mmapsize;
+ void *commands = mmap(NULL, pin.size, PROT_READ | PROT_WRITE, MAP_SHARED, pin.fd,
+ (off_t)pin.memory << 12);
+ if (commands == MAP_FAILED) return false;
+ pin.commands = commands;
+ // A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
+ uint32_t *words = commands;
+ words[0] = pin.nop;
+ words[1] = 0;
+ struct kgsl_gpumem_sync_cache sync = { .id = pin.memory, .op = KGSL_GPUMEM_CACHE_CLEAN };
+ if (ioctl(pin.fd, IOCTL_KGSL_GPUMEM_SYNC_CACHE, &sync) < 0) return false;
+ return submit_pin();
+}
+
+static void *hold_pin(void *unused) {
+ (void)unused;
+ while (atomic_load(&running)) {
+ struct kgsl_device_waittimestamp_ctxtid wait = {
+ .context_id = pin.context,
+ .timestamp = pin.timestamp,
+ .timeout = 100,
+ };
+ if (ioctl(pin.fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait) < 0) {
+ if (errno == EINTR || errno == ETIMEDOUT) continue;
+ break;
+ }
+ // Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
+ usleep(25000);
+ if (atomic_load(&running) && !submit_pin()) break;
+ }
+ if (atomic_exchange(&running, false))
+ __android_log_print(ANDROID_LOG_WARN, "GpuClockPin", "constraint stopped: %s", strerror(errno));
+ release_pin();
+ return NULL;
+}
+
+bool gpu_clock_pin_set(bool on) {
+ pthread_mutex_lock(&lock);
+ if (joinable) {
+ if (on && atomic_load(&running)) {
+ pthread_mutex_unlock(&lock);
+ return true;
+ }
+ atomic_store(&running, false);
+ pthread_join(worker, NULL);
+ joinable = false;
+ }
+ bool ok = true;
+ if (on) {
+ ok = prepare_pin();
+ if (ok) {
+ atomic_store(&running, true);
+ int error = pthread_create(&worker, NULL, hold_pin, NULL);
+ if (error) {
+ atomic_store(&running, false);
+ errno = error;
+ ok = false;
+ } else {
+ joinable = true;
+ }
+ }
+ if (!ok) {
+ int error = errno;
+ release_pin();
+ errno = error;
+ }
+ }
+ pthread_mutex_unlock(&lock);
+ return ok;
+}
diff --git a/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h
new file mode 100644
index 000000000..5fe17d102
--- /dev/null
+++ b/app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h
@@ -0,0 +1,5 @@
+#pragma once
+
+#include
+
+bool gpu_clock_pin_set(bool on);
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index 96d0b415d..d7f1c9903 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -1626,6 +1626,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
if (fx) {
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
+ /* Frame generation samples every output pixel. Upscaling first made it interpolate
+ * the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
+ * Grade at the game's size instead; the screen blit still scales to the panel. */
+ if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
vkp_effects_set_formats(scene_fmt, scene_fmt);
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
result_layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
index 6a407088b..bc349cdf6 100644
--- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
@@ -16,6 +16,7 @@
#include "effects_chain.h"
#include "cardboard.h"
#include "droiddeck_color.h"
+#include "gpu_clock_pin.h"
extern int droiddeck_wayland_run(void);
extern void droiddeck_wayland_set_scale_mode(int mode, int alignment);
@@ -634,8 +635,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIE
extern void adrenotools_set_turbo(bool turbo);
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
- (void)env; (void)clazz;
- adrenotools_set_turbo(on == JNI_TRUE);
+ (void)clazz;
+ if (!gpu_clock_pin_set(on == JNI_TRUE)) {
+ int saved_errno = errno;
+ jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException");
+ if (error) (*env)->ThrowNew(env, error, strerror(saved_errno));
+ }
+ // Clear a legacy global pin when recovering its marker after an app upgrade or crash.
+ if (on != JNI_TRUE) adrenotools_set_turbo(false);
}
JNIEXPORT jint JNICALL
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index 3fc438377..aa26d5fca 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -28,6 +28,7 @@ class App : Application() {
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
+ com.droiddeck.launcher.session.SessionPrefs.applyNtsyncDefault(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
index 7ccbba7f5..344aa7452 100644
--- a/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
+++ b/app/src/main/java/com/droiddeck/launcher/DriverMenus.kt
@@ -55,6 +55,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
private set
private var runtimePending by mutableStateOf(false)
private var sessionRunning by mutableStateOf(false)
+ var tpUbwcMode: String by mutableStateOf(SessionPrefs.TP_UBWC_AUTO)
+ private set
val uiMode: String get() = when (mode) {
SessionPrefs.GPU_DRIVERS_AUTO -> GPU_MODE_AUTO
@@ -73,8 +75,16 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
mode = uiMode, display = displaySlot(), runtime = runtimeSlot(),
canRestart = !sessionRunning,
checkedAt = TurnipReleases.cached(activity)?.checkedAt ?: 0L,
+ tpUbwcMode = tpUbwcMode.takeIf { gpu.model == 740 },
+ tpUbwcRestartRequired = com.droiddeck.launcher.gpu.TpUbwcHint.restartRequired(activity),
)
+ fun chooseTpUbwcMode(mode: String) {
+ if (gpu.model != 740) return
+ SessionPrefs.setTpUbwcMode(activity, mode)
+ tpUbwcMode = mode
+ }
+
private fun slotSource(chosenDefault: Boolean, defaultLabel: Int): String = activity.getString(when {
uiMode == GPU_MODE_AUTO -> R.string.gpu_src_auto
uiMode == GPU_MODE_PAIR && pairRows.any { it.active } -> R.string.gpu_src_pair
@@ -124,6 +134,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
var androidSelected by mutableStateOf("")
fun refreshDrivers() {
+ tpUbwcMode = SessionPrefs.tpUbwcMode(activity)
val lm = LinuxVulkanDriverManager(activity)
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
val bundles = DriverBundle.all(activity)
diff --git a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
index 1081cb894..d648f44e7 100644
--- a/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/FrontEndActivity.kt
@@ -120,6 +120,7 @@ class FrontEndActivity : ComponentActivity() {
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var returnAfterGame by mutableStateOf(true)
+ private var shaderPrecache by mutableStateOf(true)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
@@ -154,6 +155,7 @@ class FrontEndActivity : ComponentActivity() {
private var gpuClockPin by mutableStateOf(false)
private var prootNoSeccomp by mutableStateOf(false)
private var prootFastPath by mutableStateOf(true)
+ private var cursorComposite by mutableStateOf(false)
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
private var phantomWarning by mutableStateOf(null)
private var phantomProcessStatus by mutableStateOf(PhantomProcessStatus.NOT_APPLICABLE)
@@ -161,7 +163,7 @@ class FrontEndActivity : ComponentActivity() {
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
- private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
+ private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
@@ -1509,6 +1511,7 @@ class FrontEndActivity : ComponentActivity() {
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
onMode = { m -> drivers.chooseMode(m) },
onRestart = { drivers.restartApp() },
+ onTpUbwcMode = { drivers.chooseTpUbwcMode(it) },
),
protons = com.droiddeck.launcher.ui.ProtonsState(
rows = protons.protonRows, busyId = protons.protonBusyId, stage = protons.protonStage, percent = protons.protonPercent,
@@ -1634,6 +1637,7 @@ class FrontEndActivity : ComponentActivity() {
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
+ shaderPrecache = if (mode == SessionService.MODE_STEAM) shaderPrecache else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1720,9 +1724,15 @@ class FrontEndActivity : ComponentActivity() {
syncFallback = SessionPrefs.syncFallback(this)
},
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
- onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
+ onSteamClientArch = { id ->
+ SessionPrefs.setSteamClientArch(this, id)
+ steamClientArch = id
+ shaderPrecache = SessionPrefs.shaderPrecache(this)
+ refresh()
+ },
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
+ onShaderPrecache = { on -> SessionPrefs.setShaderPrecache(this, on); shaderPrecache = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
@@ -1763,7 +1773,8 @@ class FrontEndActivity : ComponentActivity() {
fsyncFirst = fsyncFirst,
gamescopeRealtime = gamescopeRealtime,
gpuClockPin = gpuClockPin,
- prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning,
+ prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, cursorComposite = cursorComposite,
+ guestHostname = guestHostname, phantomWarning = phantomWarning,
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
@@ -1777,6 +1788,7 @@ class FrontEndActivity : ComponentActivity() {
onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on },
onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on },
onProotFastPath = { on -> SessionPrefs.setProotFastPath(this, on); prootFastPath = on },
+ onCursorComposite = { on -> SessionPrefs.setCursorComposite(this, on); cursorComposite = on },
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
onClientCore = { core, on ->
clientCores = if (on) clientCores + core else clientCores - core
@@ -1892,6 +1904,7 @@ class FrontEndActivity : ComponentActivity() {
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
+ shaderPrecache = SessionPrefs.shaderPrecache(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
@@ -1958,6 +1971,7 @@ class FrontEndActivity : ComponentActivity() {
gpuClockPin = SessionPrefs.gpuClockPin(this)
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
prootFastPath = SessionPrefs.prootFastPath(this)
+ cursorComposite = SessionPrefs.cursorComposite(this)
guestHostname = SessionPrefs.guestHostname(this)
refreshPhantomStatus()
}
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 6de07a00d..4f159908b 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -329,6 +329,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var glassesSettings by mutableStateOf(Glasses.settings.value)
private var glassesScreen: GlassesScreen? = null
private var glassesOutputActive by mutableStateOf(false)
+ private var pausedFrame by mutableStateOf(null)
+ private var pausedFrameWanted = false
private var trackingRecovery by mutableStateOf(null)
private var glasses3dForVr = false
private var ended = false
@@ -1013,9 +1015,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
}
com.droiddeck.launcher.ui.AchievementNotificationOverlay()
- if (SessionState.suspended) SessionPausedOverlay(
+ // Auto and Native resume on their own as the session comes back; only a failed resume needs the button.
+ val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE
+ SessionPausedOverlay(
+ suspended = SessionState.suspended && (!autoResume || SessionState.resumeFailed),
+ engaged = SessionState.resumeRequested || SessionState.resuming,
+ failures = SessionState.resumeFailures,
+ frame = pausedFrame,
title = pausedTitle(),
onResume = { SessionService.resume(this@SessionActivity) },
+ onFrameNeeded = ::requestPausedFrame,
+ onHidden = {
+ pausedFrame = null
+ pausedFrameWanted = false
+ },
)
if (leaving) com.droiddeck.launcher.ui.FloodSpread(
com.droiddeck.launcher.ui.LocalPalette.current.signal,
@@ -1693,7 +1706,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "compositor.start driver=$driverId")
CompositorHost.startOrAttach(
- holder.surface, runtimeDir.path,
+ this, holder.surface, runtimeDir.path,
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(),
)
@@ -2860,6 +2873,50 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
+ // The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
+ private fun capturePausedFrame() {
+ if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
+ if (SessionState.suspended && pausedFrame != null) return
+ if (suspendPolicy == SessionPrefs.SUSPEND_NEVER || !::surfaceView.isInitialized) return
+ if (surfaceView.holder.surface?.isValid != true) return
+ val width = surfaceView.width
+ val height = surfaceView.height
+ if (width <= 0 || height <= 0) return
+ val scale = minOf(1f, PAUSED_FRAME_MAX_EDGE / maxOf(width, height).toFloat())
+ val bitmap = try {
+ android.graphics.Bitmap.createBitmap(
+ maxOf(1, (width * scale).toInt()), maxOf(1, (height * scale).toInt()), android.graphics.Bitmap.Config.ARGB_8888,
+ )
+ } catch (_: OutOfMemoryError) {
+ return
+ }
+ val surfaceAt = IntArray(2).also { surfaceView.getLocationInWindow(it) }
+ val overlayAt = IntArray(2).also { sessionOverlay.getLocationInWindow(it) }
+ val left = surfaceAt[0] - overlayAt[0]
+ val top = surfaceAt[1] - overlayAt[1]
+ val bounds = android.graphics.Rect(left, top, left + width, top + height)
+ try {
+ android.view.PixelCopy.request(surfaceView, bitmap, { result ->
+ if (result == android.view.PixelCopy.SUCCESS && !isDestroyed) {
+ pausedFrame = com.droiddeck.launcher.ui.PausedFrame(bitmap.asImageBitmap(), bounds)
+ pausedFrameWanted = false
+ } else {
+ bitmap.recycle()
+ }
+ }, uiHandler)
+ } catch (_: IllegalArgumentException) {
+ bitmap.recycle()
+ }
+ }
+
+ // Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the
+ // compositor shows the frozen frame again once its output is back, so copy it from there.
+ private fun requestPausedFrame() {
+ if (pausedFrame != null) return
+ pausedFrameWanted = true
+ if (resumed && surfaceW > 0) capturePausedFrame()
+ }
+
private fun pipBounds(): android.graphics.Rect {
val rect = android.graphics.Rect()
if (!::surfaceView.isInitialized || !surfaceView.getGlobalVisibleRect(rect)) return rect
@@ -2990,6 +3047,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
applyFollowOrientation(resources.configuration)
if (runtimeRemovalBlocked) return
+ if (!SessionState.suspended) pausedFrame = null
com.droiddeck.launcher.ui.Motion.refresh(this)
refreshHomeApp()
if (intent?.action == SessionService.ACTION_RESUME) {
@@ -3015,6 +3073,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
+ capturePausedFrame()
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
@@ -3253,6 +3312,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE
Log.i(TAG, "compositor output attached: " +
if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone")
+ if (pausedFrameWanted) uiHandler.postDelayed({
+ if (pausedFrameWanted && pausedFrame == null && resumed) capturePausedFrame()
+ }, PAUSED_FRAME_REDRAW_MS)
}
private fun restorePhoneOutput(screen: GlassesScreen) {
@@ -3512,6 +3574,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
+ private const val PAUSED_FRAME_MAX_EDGE = 1920f
+ private const val PAUSED_FRAME_REDRAW_MS = 150L
private const val TAG = "SessionActivity"
private var startedScreens = 0
private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need"
diff --git a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt b/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt
deleted file mode 100644
index adaa106a0..000000000
--- a/app/src/main/java/com/droiddeck/launcher/core/CpuAllocationDefaults.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package com.droiddeck.launcher.core
-
-import com.droiddeck.launcher.gpu.GpuInfo
-import com.droiddeck.launcher.gpu.SocNames
-
-object CpuAllocationDefaults {
- data class Allocation(val client: String, val game: String)
-
- val device: Allocation? by lazy {
- forDevice(SocNames.model(), GpuInfo.systemProperty("ro.board.platform"),
- CpuCores.all.associateWith(CpuCores::maxGhz))
- }
-
- fun forDevice(model: String, platform: String, frequencies: Map): Allocation? {
- val chip = model.trim().uppercase().substringBefore('-')
- val gen2 = chip in setOf("SM8550", "QCS8550") ||
- (chip.isEmpty() && platform.trim().equals("kalama", ignoreCase = true))
- if (!gen2 || frequencies.size != 8 || frequencies.keys.any { it < 0 } ||
- frequencies.values.any { it == null || !it.isFinite() || it <= 0.0 }) return null
- val clusters = frequencies.entries.groupBy { it.value }.toSortedMap(compareBy { it ?: 0.0 }).values.toList()
- if (clusters.map { it.size } != listOf(3, 4, 1)) return null
- return Allocation(
- client = CpuCores.format(clusters[2].map { it.key }.toSet()),
- game = CpuCores.format(clusters[1].map { it.key }.toSet()),
- )
- }
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
index 903851a5a..1e59fb577 100644
--- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
+++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
@@ -200,7 +200,12 @@ object DeviceReport {
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
- if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
+ if (gpu.model == 740) {
+ val hint = com.droiddeck.launcher.gpu.TpUbwcHint
+ k("TP UBWC hint", hint.forThisDevice(context) ?: "driver default")
+ k("TP UBWC mode", hint.activeMode ?: SessionPrefs.TP_UBWC_AUTO)
+ if (hint.restartRequired(context)) k("TP UBWC pending", "${SessionPrefs.tpUbwcMode(context)} (restart required)")
+ }
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
index 27cb9dbce..1425a78e7 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerActivity.kt
@@ -29,7 +29,7 @@ class FileManagerActivity : ComponentActivity() {
setContent {
DroidDeckTheme(appScale = com.droiddeck.launcher.core.AppUiPrefs.scale(this)) {
Surface(modifier = Modifier.fillMaxSize().systemBarsPadding(), color = MaterialTheme.colorScheme.background) {
- FileManagerScreen(initialDir = initialDir)
+ FileManagerScreen(initialDir = initialDir, onExit = { finish() })
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
index 0843057f7..627761c2d 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileManagerScreen.kt
@@ -28,6 +28,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ExitToApp
+import androidx.compose.material.icons.automirrored.filled.KeyboardReturn
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.CreateNewFolder
@@ -90,12 +92,18 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
+private enum class FileManagerNavigation { EXIT, RETURN_TO_START, BACK }
+
+internal fun canNavigateUp(currentDir: File, currentRoot: File): Boolean =
+ currentDir != currentRoot && currentDir.parentFile?.exists() == true
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileManagerScreen(
@@ -105,6 +113,7 @@ fun FileManagerScreen(
initialDir: File? = null,
pickerTitle: String? = null,
onPick: ((File) -> Unit)? = null,
+ onExit: (() -> Unit)? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -118,12 +127,14 @@ fun FileManagerScreen(
val pickPrefs = remember { context.getSharedPreferences("file_manager", android.content.Context.MODE_PRIVATE) }
val browsePrefs = pickPrefs
- val rootDir = remember {
+ val rootDir = remember(initialDir) {
initialDir?.takeIf { it.isDirectory } ?: File("/storage/emulated/0")
}
-
+ val launchDir = remember(initialDir, pickMode) {
+ initialDir?.takeIf { !pickMode }
+ }
var currentDir by remember { mutableStateOf(rootDir) }
- var currentRoot by remember {
+ val initialRoot = remember(rootDir) {
val abs = rootDir.absolutePath
val internal = "/storage/emulated/0"
val vol = when {
@@ -139,8 +150,9 @@ fun FileManagerScreen(
?: rootDir
}
}
- mutableStateOf(vol)
+ vol
}
+ var currentRoot by remember { mutableStateOf(initialRoot) }
var entries by remember { mutableStateOf(listOf()) }
var selectedEntry by remember { mutableStateOf(null) }
var showMenuFor by remember { mutableStateOf(null) }
@@ -151,8 +163,10 @@ fun FileManagerScreen(
var pendingConflict by remember { mutableStateOf(null) }
var conflictChoice by remember { mutableStateOf(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
- var operationJob by remember { mutableStateOf(null) }
- var cancelledJob by remember { mutableStateOf(null) }
+ var operationJob by remember { mutableStateOf(null) }
+ var cancelledJob by remember { mutableStateOf(null) }
+ var pendingNavigation by remember { mutableStateOf(null) }
+ var cancellingNavigation by remember { mutableStateOf(false) }
var pendingBulkDelete by remember { mutableStateOf>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
@@ -178,9 +192,10 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
- var listingJob by remember { mutableStateOf(null) }
+ var listingJob by remember { mutableStateOf(null) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
+ if (cancellingNavigation) return
currentDir = dir
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
listingJob?.cancel()
@@ -205,25 +220,90 @@ fun FileManagerScreen(
}
fun openDrive(dir: File) {
+ if (cancellingNavigation) return
currentRoot = dir
loadDirectory(dir)
}
+ fun returnToStart() {
+ if (cancellingNavigation) return
+ val dir = launchDir ?: return
+ if (!dir.isDirectory) {
+ Toast.makeText(context, R.string.fm_start_unavailable, Toast.LENGTH_SHORT).show()
+ return
+ }
+ showFavorites = false
+ showSearch = false
+ searchQuery = ""
+ currentRoot = initialRoot
+ loadDirectory(dir)
+ }
+
LaunchedEffect(Unit) { loadDirectory(rootDir) }
- BackHandler(enabled = showFavorites || currentDir != currentRoot) {
- if (showFavorites) {
- showFavorites = false
- return@BackHandler
+ val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
+ fun isOperationActive() = isOperationRunning || operationJob?.isCompleted == false || cancelledJob?.isCompleted == false
+ val operationActive = isOperationActive()
+
+ fun navigate(action: FileManagerNavigation) {
+ if (cancellingNavigation) return
+ when (action) {
+ FileManagerNavigation.EXIT -> onExit?.invoke()
+ FileManagerNavigation.RETURN_TO_START -> returnToStart()
+ FileManagerNavigation.BACK -> {
+ if (showFavorites) showFavorites = false
+ else if (canNavigateUp(currentDir, currentRoot)) currentDir.parentFile?.let { loadDirectory(it) }
+ else onExit?.invoke()
+ }
}
- val parent = currentDir.parentFile
- if (parent != null && parent.exists()) loadDirectory(parent)
}
- val canGoUp = currentDir != currentRoot && currentDir.parentFile?.exists() == true
- fun goUp() {
- val parent = currentDir.parentFile
- if (currentDir != currentRoot && parent != null && parent.exists()) loadDirectory(parent)
+ fun requestNavigation(action: FileManagerNavigation) {
+ if (cancellingNavigation) return
+ if (isOperationActive()) pendingNavigation = action else navigate(action)
+ }
+
+ BackHandler(enabled = operationActive || cancellingNavigation || showFavorites || currentDir != currentRoot) {
+ requestNavigation(FileManagerNavigation.BACK)
+ }
+
+ if (pendingNavigation != null) {
+ OutlinedAlertDialog(
+ onDismissRequest = { pendingNavigation = null },
+ title = { Text(stringResource(R.string.fm_cancel_operation_title)) },
+ text = { Text(stringResource(R.string.fm_cancel_operation_confirmation)) },
+ confirmButton = {
+ TextButton(onClick = {
+ val action = pendingNavigation ?: return@TextButton
+ pendingNavigation = null
+ val job = operationJob?.takeUnless(Job::isCompleted)
+ ?: cancelledJob?.takeUnless(Job::isCompleted)
+ if (job == null) {
+ navigate(action)
+ } else {
+ cancellingNavigation = true
+ cancelledJob = job
+ job.cancel()
+ scope.launch {
+ job.join()
+ if (operationJob === job) operationJob = null
+ if (cancelledJob === job) cancelledJob = null
+ pendingConflict = null
+ isOperationRunning = false
+ operationDeterminate = false
+ cancellingNavigation = false
+ if (action != FileManagerNavigation.EXIT) loadDirectory(currentDir, resetScroll = false)
+ navigate(action)
+ }
+ }
+ }) { Text(stringResource(R.string.fm_accept)) }
+ },
+ dismissButton = {
+ TextButton(onClick = { pendingNavigation = null }) {
+ Text(stringResource(R.string.fm_decline))
+ }
+ },
+ )
}
fun uniqueDestination(dir: File, name: String): File {
@@ -241,11 +321,14 @@ fun FileManagerScreen(
}
fun performDelete(file: File) {
- scope.launch {
+ if (isOperationActive()) return
+ operationJob = scope.launch {
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_deleting)
- val ok = withContext(Dispatchers.IO) { FileOps.delete(file) }
+ val job = coroutineContext[Job]
+ val ok = withContext(Dispatchers.IO) { FileOps.delete(file) { job?.isActive == false } }
isOperationRunning = false
+ if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_delete_failed, Toast.LENGTH_SHORT).show()
}
@@ -260,7 +343,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
- if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
+ if (sources.isEmpty() || isOperationActive()) return
val dstDir = currentDir
val cut = isCutOperation
@@ -269,7 +352,7 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
- val job = coroutineContext[kotlinx.coroutines.Job]
+ val job = coroutineContext[Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -322,7 +405,7 @@ fun FileManagerScreen(
isOperationRunning = false
operationDeterminate = false
- operationJob = null
+ if (operationJob === job) operationJob = null
clipboardFiles = emptyList()
isCutOperation = false
selectionMode = false
@@ -340,32 +423,38 @@ fun FileManagerScreen(
}
fun performRename(file: File, newName: String) {
+ if (isOperationActive()) return
val target = File(file.parentFile, newName)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, newName), Toast.LENGTH_SHORT).show()
return
}
- scope.launch {
+ operationJob = scope.launch {
+ val job = coroutineContext[Job]
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_renaming)
val ok = withContext(Dispatchers.IO) { file.renameTo(target) }
isOperationRunning = false
+ if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_rename_failed, Toast.LENGTH_SHORT).show()
}
}
fun createFolder(parent: File, name: String) {
+ if (isOperationActive()) return
val target = File(parent, name)
if (target.exists()) {
Toast.makeText(context, context.getString(R.string.fm_already_exists, name), Toast.LENGTH_SHORT).show()
return
}
- scope.launch {
+ operationJob = scope.launch {
+ val job = coroutineContext[Job]
isOperationRunning = true
operationLabel = context.getString(R.string.fm_op_creating_folder)
val ok = withContext(Dispatchers.IO) { target.mkdirs() }
isOperationRunning = false
+ if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (!ok) Toast.makeText(context, R.string.fm_create_folder_failed, Toast.LENGTH_SHORT).show()
}
@@ -481,18 +570,20 @@ fun FileManagerScreen(
},
confirmButton = {
TextButton(onClick = {
+ if (isOperationActive()) return@TextButton
pendingBulkDelete = emptyList()
selectionMode = false
selectedPaths = emptySet()
operationJob = scope.launch {
isOperationRunning = true
+ val job = coroutineContext[Job]
var failed = 0
victims.forEachIndexed { i, f ->
operationLabel = context.getString(R.string.fm_op_deleting_batch, i + 1, victims.size, f.name)
- if (!withContext(Dispatchers.IO) { FileOps.delete(f) }) failed++
+ if (!withContext(Dispatchers.IO) { FileOps.delete(f) { job?.isActive == false } }) failed++
}
isOperationRunning = false
- operationJob = null
+ if (operationJob === job) operationJob = null
loadDirectory(currentDir, resetScroll = false)
if (failed > 0) {
Toast.makeText(context, context.resources.getQuantityString(R.plurals.fm_delete_some_failed, failed, failed), Toast.LENGTH_SHORT).show()
@@ -582,7 +673,7 @@ fun FileManagerScreen(
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
- IconButton(onClick = { goUp() }, enabled = canGoUp) {
+ IconButton(onClick = { requestNavigation(FileManagerNavigation.BACK) }, enabled = canGoUp && !cancellingNavigation) {
Icon(Icons.Filled.ArrowBack, stringResource(R.string.fm_back), tint = MaterialTheme.colorScheme.primary)
}
@@ -672,6 +763,13 @@ fun FileManagerScreen(
)
}
+ if (launchDir != null) {
+ IconButton(onClick = { requestNavigation(FileManagerNavigation.RETURN_TO_START) }, enabled = !cancellingNavigation) {
+ Icon(Icons.AutoMirrored.Filled.KeyboardReturn, stringResource(R.string.fm_return_to_start), tint = MaterialTheme.colorScheme.primary)
+ }
+ if (LocalConfiguration.current.screenWidthDp >= 700) Spacer(Modifier.width(56.dp))
+ }
+
if (!showFavorites) {
if (!pickMode) {
OutlinedButton(
@@ -747,6 +845,11 @@ fun FileManagerScreen(
Icon(Icons.Filled.StarBorder, stringResource(R.string.fm_show_favorites), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
+ if (!pickMode && onExit != null) {
+ IconButton(onClick = { requestNavigation(FileManagerNavigation.EXIT) }, enabled = !cancellingNavigation) {
+ Icon(Icons.AutoMirrored.Filled.ExitToApp, stringResource(R.string.fm_exit), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
}
if (showSearch && !showFavorites) {
@@ -986,7 +1089,7 @@ fun FileManagerScreen(
contentPadding = PaddingValues(8.dp),
) {
if (canGoUp) item(key = "..", span = { GridItemSpan(maxLineSpan) }) {
- ParentFolderRow(compact = compactRows, onTap = { goUp() })
+ ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.BACK) })
}
items(shownEntries, key = { it.absolutePath }) { file ->
val isFav = remember(file.absolutePath, favTick) {
@@ -1052,7 +1155,7 @@ fun FileManagerScreen(
} else
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (canGoUp) item(key = "..") {
- ParentFolderRow(compact = compactRows, onTap = { goUp() })
+ ParentFolderRow(compact = compactRows, onTap = { requestNavigation(FileManagerNavigation.BACK) })
}
if (entries.isEmpty()) {
item {
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
index 29df5ff6d..05d467ef5 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FileOps.kt
@@ -9,6 +9,7 @@ import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Files
import java.util.Locale
+import java.util.concurrent.CancellationException
object FileOps {
private const val TAG = "FileOps"
@@ -24,24 +25,27 @@ object FileOps {
fun isSymlink(file: File): Boolean = try { Files.isSymbolicLink(file.toPath()) } catch (e: Exception) { false }
- fun delete(target: File?): Boolean {
+ fun delete(target: File?, isCancelled: () -> Boolean = { false }): Boolean {
+ if (isCancelled()) throw CancellationException("Delete cancelled: $target")
if (target == null) return false
if (target.isDirectory && !isSymlink(target)) {
- if (!clear(target)) return false
+ if (!clear(target, isCancelled)) return false
}
+ if (isCancelled()) throw CancellationException("Delete cancelled: $target")
return target.delete()
}
- fun clear(dir: File): Boolean {
+ fun clear(dir: File, isCancelled: () -> Boolean = { false }): Boolean {
val children = dir.listFiles() ?: return true
var ok = true
- for (child in children) if (!delete(child)) ok = false
+ for (child in children) if (!delete(child, isCancelled)) ok = false
return ok
}
- fun totalSize(f: File?): Long {
+ fun totalSize(f: File?, isCancelled: () -> Boolean = { false }): Long {
+ if (isCancelled()) throw CancellationException("Size scan cancelled: $f")
if (f == null || !f.exists() || isSymlink(f)) return 0
- if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it) } ?: 0
+ if (f.isDirectory) return f.listFiles()?.sumOf { totalSize(it, isCancelled) } ?: 0
return f.length()
}
@@ -53,12 +57,12 @@ object FileOps {
return true
}
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
- if (isCancelled()) return false
+ // Once the destination is committed, finish deleting the source.
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
- val total = maxOf(totalSize(src), 1L)
+ val total = maxOf(totalSize(src, isCancelled), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
}
@@ -71,15 +75,23 @@ object FileOps {
return false
}
if (src.isDirectory) {
- if (!dst.exists() && !dst.mkdirs()) return false
- var allOk = true
- src.list()?.forEach { name ->
- if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
- Log.e(TAG, "Failed to copy: ${File(src, name)}")
- allOk = false
+ val created = if (dst.exists()) false else {
+ if (!dst.mkdirs()) return false
+ true
+ }
+ try {
+ var allOk = true
+ src.list()?.forEach { name ->
+ if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
+ Log.e(TAG, "Failed to copy: ${File(src, name)}")
+ allOk = false
+ }
}
+ return allOk
+ } catch (e: CancellationException) {
+ if (created) delete(dst)
+ throw e
}
- return allOk
}
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
@@ -118,6 +130,7 @@ object FileOps {
val size = input.size()
var position = 0L
while (position < size) {
+ if (isCancelled()) throw CancellationException("Copy cancelled: $src")
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
if (transferred <= 0) break
position += transferred
@@ -125,12 +138,19 @@ object FileOps {
progress?.onProgress(done[0], total)
}
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
+ if (size > 0 && isCancelled()) throw CancellationException("Copy cancelled: $src")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
- if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
- Files.setPosixFilePermissions(copied, permissions)
+ if (permissions != null) {
+ try {
+ if (Files.getPosixFilePermissions(copied) != permissions) {
+ Files.setPosixFilePermissions(copied, permissions)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to restore destination permissions: $target", e)
+ }
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
diff --git a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
index 6fd89fa99..498ec504e 100644
--- a/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/files/FilePickerActivity.kt
@@ -50,6 +50,7 @@ class FilePickerActivity : ComponentActivity() {
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SELECTED_FILE, file.absolutePath))
finish()
},
+ onExit = { finish() },
)
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
index 5de835f6e..2a83d57e5 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGames.kt
@@ -274,7 +274,7 @@ object AddedGames {
class FolderResult(val added: List, val already: Int)
- fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
+ fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult {
val app = context.applicationContext
// Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
@@ -283,7 +283,7 @@ object AddedGames {
var already = 0
val fresh = ArrayList()
val added = ArrayList()
- for (sub in dir.listFiles { f -> f.isDirectory && !f.name.startsWith(".") }?.sortedBy { it.name.lowercase() }.orEmpty()) {
+ for (sub in dir.listFiles { f -> f.isDirectory && (showHidden || !f.name.startsWith(".")) }?.sortedBy { it.name.lowercase() }.orEmpty()) {
val path = canonical(sub)
if (listed.any { it == path || inside(path, it) || inside(it, path) }) { already++; continue }
onChecking(sub.name)
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
index 1ccee78e2..893d569a6 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/TpUbwcHint.kt
@@ -1,6 +1,8 @@
package com.droiddeck.launcher.gpu
+import android.content.Context
import android.util.Log
+import com.droiddeck.launcher.session.SessionPrefs
object TpUbwcHint {
const val ENV = "FD_DEV_FEATURES"
@@ -8,31 +10,43 @@ object TpUbwcHint {
@Volatile private var resolved = false
@Volatile private var cached: String? = null
+ @Volatile var activeMode: String? = null
+ private set
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
- // match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
+ // match the system driver. A mismatch corrupts Android UI.
// Samsung's One UI firmware sets it whatever blob version it reports.
- internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
+ internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false,
+ mode: String = SessionPrefs.TP_UBWC_AUTO): String? {
if (model != 740) return null
+ if (mode == SessionPrefs.TP_UBWC_ON) return "enable_tp_ubwc_flag_hint=1"
+ if (mode == SessionPrefs.TP_UBWC_OFF) return "enable_tp_ubwc_flag_hint=0"
if (samsung) return "enable_tp_ubwc_flag_hint=1"
- val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
- return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
+ val parts = systemDriverVersion?.split('.') ?: return null
+ val blob = parts.getOrNull(1)?.toIntOrNull() ?: return null
+ val patch = parts.getOrNull(2)?.toIntOrNull() ?: 0
+ val on = blob >= 700 || (blob == 676 && patch >= 76)
+ return "enable_tp_ubwc_flag_hint=" + if (on) "1" else "0"
}
@Synchronized
- fun forThisDevice(): String? {
+ fun forThisDevice(context: Context): String? {
if (resolved) return cached
+ val mode = SessionPrefs.tpUbwcMode(context)
val gpu = GpuInfo.detect()
val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
- cached = features(model, version, gpu.oneUi8Gen2)
+ cached = features(model, version, gpu.oneUi8Gen2, mode)
+ activeMode = mode
resolved = true
- Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
+ Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}, mode $mode: ${cached ?: "driver default"}")
return cached
}
- fun applyToProcess() {
- val value = forThisDevice() ?: return
+ fun restartRequired(context: Context): Boolean = resolved && activeMode != SessionPrefs.tpUbwcMode(context)
+
+ fun applyToProcess(context: Context) {
+ val value = forThisDevice(context) ?: return
runCatching { android.system.Os.setenv(ENV, value, true) }
.onFailure { Log.w(TAG, "setenv $ENV failed: ${it.javaClass.simpleName}") }
}
diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt
index ac87bd0aa..45f07471b 100644
--- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt
@@ -301,15 +301,18 @@ class LinuxNetworkLinkComponent(
}
}
- private fun seededMac(): String {
- val id = Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) ?: "bannerlator"
- val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
- val b0 = (bytes[0].toInt() and 0xfc) or 0x02
- return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
- .joinToString(":") { "%02x".format(it and 0xff) }
- }
+ private fun seededMac(): String =
+ seededMac { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) }
companion object {
+ internal fun seededMac(readId: () -> String?): String {
+ val id = runCatching(readId).getOrNull() ?: "bannerlator"
+ val bytes = java.security.MessageDigest.getInstance("SHA-1").digest(id.toByteArray())
+ val b0 = (bytes[0].toInt() and 0xfc) or 0x02
+ return listOf(b0, bytes[1].toInt(), bytes[2].toInt(), bytes[3].toInt(), bytes[4].toInt(), bytes[5].toInt())
+ .joinToString(":") { "%02x".format(it and 0xff) }
+ }
+
private const val TAG = "LinuxNetworkLink"
private const val LINK_FILE = "etc/droiddeck-net"
private const val LINK_INDEX = 2
diff --git a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
index d1aae9d72..776a9f7a0 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt
@@ -721,13 +721,19 @@ object ComponentsManager {
class StoredOriginal(val protonId: String, val protonName: String?, val comp: String, val protonVersion: String, val size: Long, val kept: Boolean)
class Stored(val packages: List, val originals: List)
- private fun packagesInUse(state: JSONObject): Set {
+ internal fun liveSelection(key: String, entry: JSONObject, proton: Proton?): Boolean =
+ proton != null && (key == "queued" || entry.optString("protonVersion") == proton.version)
+
+ private fun packagesInUse(state: JSONObject, installed: Map): Set {
val names = HashSet()
for (key in listOf("active", "queued")) {
val byProton = state.optJSONObject(key) ?: continue
for (pid in byProton.keys()) {
val comps = byProton.optJSONObject(pid) ?: continue
- for (comp in comps.keys()) comps.optJSONObject(comp)?.optString("file")?.takeIf { it.isNotEmpty() }?.let(names::add)
+ for (comp in comps.keys()) {
+ val entry = comps.optJSONObject(comp) ?: continue
+ if (liveSelection(key, entry, installed[pid])) entry.optString("file").takeIf { it.isNotEmpty() }?.let(names::add)
+ }
}
}
val profiles = state.optJSONObject("profiles")
@@ -747,8 +753,8 @@ object ComponentsManager {
fun stored(context: Context): Stored = synchronized(lock) {
val state = loadState(context)
- val used = packagesInUse(state)
- val installed = protons(context).associateBy { it.id }
+ val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
+ val used = packagesInUse(state, installed)
val originals = originalsDir(context).listFiles()?.filter { it.isDirectory }?.flatMap { proton ->
proton.listFiles()?.filter { it.isDirectory }?.flatMap { version ->
COMPONENTS.mapNotNull { comp ->
@@ -777,24 +783,36 @@ object ComponentsManager {
fun deletePackage(context: Context, file: String): String = synchronized(lock) {
val name = safeName(file)
val state = loadState(context)
- val queued = state.sub("queued")
- for (pid in queued.keys()) {
- val comps = queued.optJSONObject(pid) ?: continue
- for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
- }
- val active = state.sub("active")
- for (pid in active.keys()) {
- val comps = active.optJSONObject(pid) ?: continue
- for (comp in comps.keys()) check(comps.optJSONObject(comp)?.optString("file") != name) { context.getString(R.string.cmgr_package_in_use, name, pid) }
- }
- val profiles = state.sub("profiles")
- for (scope in profiles.keys()) {
- val profile = profiles.optJSONObject(scope) ?: continue
- check(COMPONENTS.none { profile.optString(it) == name }) { context.getString(R.string.cmgr_package_in_use, name, scope) }
+ val installed = protons(context).distinctBy { it.id }.associateBy { it.id }
+ var changed = false
+ for (key in listOf("queued", "active")) {
+ val byProton = state.sub(key)
+ for (pid in byProton.keys().asSequence().toList()) {
+ val comps = byProton.optJSONObject(pid) ?: continue
+ for (comp in comps.keys().asSequence().toList()) {
+ val entry = comps.optJSONObject(comp) ?: continue
+ if (entry.optString("file") != name) continue
+ check(!liveSelection(key, entry, installed[pid])) { context.getString(R.string.cmgr_package_in_use, name, installed[pid]?.name ?: pid) }
+ comps.remove(comp)
+ changed = true
+ }
+ if (comps.length() == 0) byProton.remove(pid)
+ }
}
val f = File(packagesDir(context), name)
check(f.isFile) { context.getString(R.string.cmgr_package_not_stored, name) }
- f.delete()
+ val profiles = state.sub("profiles")
+ for (scope in profiles.keys().asSequence().toList()) {
+ val profile = profiles.optJSONObject(scope) ?: continue
+ for (comp in COMPONENTS.filter { profile.optString(it) == name }) {
+ rememberProfiledComponent(state, comp)
+ profile.remove(comp)
+ changed = true
+ }
+ if (profile.length() == 0) profiles.remove(scope)
+ }
+ check(f.delete()) { name }
+ if (changed) saveState(context, state)
context.getString(R.string.cmgr_deleted, name)
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt
index 7855c4959..40135f08d 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/GpuClockPin.kt
@@ -26,6 +26,7 @@ object GpuClockPin {
pinned = true
"gpu clock: held at its top"
} catch (t: Throwable) {
+ File(context.filesDir, MARKER).delete()
Log.w(TAG, "pin", t)
"gpu clock: pin failed"
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
index 0dab068d8..4824a4442 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
@@ -58,6 +58,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-install" to "usr/local/bin/droiddeck-steam-install",
"usr/local/bin/droiddeck-steam-ui-scale" to "usr/local/bin/droiddeck-steam-ui-scale",
"usr/local/bin/droiddeck-steam-language" to "usr/local/bin/droiddeck-steam-language",
+ "usr/local/bin/droiddeck-steam-shaders" to "usr/local/bin/droiddeck-steam-shaders",
"usr/local/bin/droiddeck-steam-desktop-ui" to "usr/local/bin/droiddeck-steam-desktop-ui",
"usr/local/bin/droiddeck-steam-library" to "usr/local/bin/droiddeck-steam-library",
"usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists",
@@ -78,6 +79,7 @@ object SessionFiles {
"usr/local/bin/droiddeck-steam-games" to "usr/local/bin/droiddeck-steam-games",
"usr/local/bin/droiddeck-pad-defaults" to "usr/local/bin/droiddeck-pad-defaults",
"usr/local/bin/droiddeck-steam-x64" to "usr/local/bin/droiddeck-steam-x64",
+ "usr/local/bin/droiddeck-steamrt-shim" to "usr/local/bin/droiddeck-steamrt-shim",
"usr/local/bin/droiddeck-x64-arm-proton" to "usr/local/bin/droiddeck-x64-arm-proton",
"usr/local/bin/droiddeck-x64-arm-webhelper" to "usr/local/bin/droiddeck-x64-arm-webhelper",
"usr/local/bin/droiddeck-x64-webhelper" to "usr/local/bin/droiddeck-x64-webhelper",
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
index d9d2d8e1d..e8041fe0b 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import com.droiddeck.launcher.R
-import com.droiddeck.launcher.core.CpuAllocationDefaults
import com.droiddeck.launcher.core.GameEnvironment
import com.droiddeck.launcher.core.TextureFiltering
import com.droiddeck.launcher.gpu.Cardboard
@@ -50,9 +49,9 @@ object SessionPrefs {
)
fun orientation(context: Context): Int =
- prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
+ prefs(context).getInt("orientation", ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
.takeIf { value -> orientationOptions.any { it.first == value } }
- ?: ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ ?: ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
fun setOrientation(context: Context, orientation: Int) {
require(orientationOptions.any { it.first == orientation })
@@ -263,6 +262,19 @@ object SessionPrefs {
const val GPU_DRIVERS_MANUAL = "manual"
const val GPU_DRIVERS_MIX = "mix"
+ const val TP_UBWC_AUTO = "auto"
+ const val TP_UBWC_ON = "on"
+ const val TP_UBWC_OFF = "off"
+
+ fun tpUbwcMode(context: Context): String =
+ prefs(context).getString("tpUbwcMode", TP_UBWC_AUTO)
+ ?.takeIf { it in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF) } ?: TP_UBWC_AUTO
+
+ fun setTpUbwcMode(context: Context, mode: String) {
+ require(mode in setOf(TP_UBWC_AUTO, TP_UBWC_ON, TP_UBWC_OFF))
+ prefs(context).edit().putString("tpUbwcMode", mode).commit()
+ }
+
fun gpuDriverMode(context: Context): String =
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
@@ -304,6 +316,34 @@ object SessionPrefs {
prefs(context).edit().putBoolean("returnAfterGame", on).apply()
}
+ private fun shaderPrecacheApplied(context: Context) =
+ java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.bl-shader-precache-applied")
+
+ /**
+ * Shader pre-caching as the launcher last set it, until a session reports it written into the
+ * client's config.vdf; after that the client's own toggle is the user's again.
+ */
+ fun pendingShaderPrecache(context: Context): Boolean? {
+ val prefs = prefs(context)
+ if (!prefs.contains("shaderPrecache")) return null
+ val on = prefs.getBoolean("shaderPrecache", true)
+ val applied = shaderPrecacheApplied(context)
+ val written = runCatching { applied.readText().trim() }.getOrNull() ?: return on
+ applied.delete()
+ if (written != if (on) "1" else "0") return on
+ prefs.edit().remove("shaderPrecache").apply()
+ return null
+ }
+
+ fun setShaderPrecache(context: Context, on: Boolean) {
+ shaderPrecacheApplied(context).delete()
+ prefs(context).edit().putBoolean("shaderPrecache", on).apply()
+ }
+
+ fun shaderPrecache(context: Context): Boolean = pendingShaderPrecache(context)
+ ?: runCatching { com.droiddeck.launcher.storage.SteamStorage.shaderPrecache(
+ java.io.File(steamRoot(context, steamClientArch(context)), "config/config.vdf").readText()) }.getOrDefault(true)
+
fun stretch16x9(context: Context): Boolean = prefs(context).getBoolean("stretch16x9", false)
fun setStretch16x9(context: Context, on: Boolean) {
@@ -341,30 +381,19 @@ object SessionPrefs {
prefs(context).edit().putBoolean("wifiDiscoveryAsked", true).apply()
}
- internal data class CoreAllocation(val clientOverride: Boolean, val clientCpus: String, val gameCpus: String)
-
- internal fun coreAllocation(context: Context, defaults: CpuAllocationDefaults.Allocation? = CpuAllocationDefaults.device): CoreAllocation {
- val saved = prefs(context)
- return CoreAllocation(
- saved.getBoolean("clientCpusOverride", defaults != null && !saved.contains("clientCpus")),
- saved.getString("clientCpus", defaults?.client.orEmpty()).orEmpty(),
- saved.getString("gameCpus", defaults?.game.orEmpty()).orEmpty(),
- )
- }
-
- fun clientCpusOverride(context: Context): Boolean = coreAllocation(context).clientOverride
+ fun clientCpusOverride(context: Context): Boolean = prefs(context).getBoolean("clientCpusOverride", false)
fun setClientCpusOverride(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("clientCpusOverride", on).apply()
}
- fun clientCpus(context: Context): String = coreAllocation(context).clientCpus
+ fun clientCpus(context: Context): String = prefs(context).getString("clientCpus", "").orEmpty()
fun setClientCpus(context: Context, list: String) {
prefs(context).edit().putString("clientCpus", list).apply()
}
- fun gameCpus(context: Context): String = coreAllocation(context).gameCpus
+ fun gameCpus(context: Context): String = prefs(context).getString("gameCpus", "").orEmpty()
fun setGameCpus(context: Context, list: String) {
prefs(context).edit().putString("gameCpus", list).apply()
@@ -376,7 +405,7 @@ object SessionPrefs {
prefs(context).edit().putBoolean("noXalia", on).apply()
}
- fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false)
+ fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", true)
fun setFastSync(context: Context, on: Boolean) {
prefs(context).edit().putBoolean("fastSync", on).apply()
@@ -411,6 +440,17 @@ object SessionPrefs {
.apply()
}
+ fun applyNtsyncDefault(context: Context) {
+ val prefs = prefs(context)
+ if (prefs.getBoolean("ntsyncDefaultV1", false)) return
+ prefs.edit()
+ .putBoolean("fastSync", true)
+ .putBoolean("fsyncFirst", false)
+ .putBoolean("syncFallback", true)
+ .putBoolean("ntsyncDefaultV1", true)
+ .apply()
+ }
+
fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true)
fun setSyncFallback(context: Context, on: Boolean) {
@@ -444,6 +484,13 @@ object SessionPrefs {
prefs(context).edit().putBoolean("prootFastPath", on).apply()
}
+ /** gamescope draws the cursor itself. Off by default: on a phone that plane costs frames. */
+ fun cursorComposite(context: Context): Boolean = prefs(context).getBoolean("cursorComposite", false)
+
+ fun setCursorComposite(context: Context, on: Boolean) {
+ prefs(context).edit().putBoolean("cursorComposite", on).apply()
+ }
+
const val DEFAULT_GUEST_HOSTNAME = "DroidDeck"
@JvmStatic
@@ -764,6 +811,14 @@ object SessionPrefs {
it.getInt("gradientSecondary", com.droiddeck.launcher.ui.Themes.DEFAULT_SECONDARY)
}
+ /** How fast the Inner Gamer theme cycles its colours, 1 to 10. */
+ fun rgbSpeed(context: Context): Int =
+ prefs(context).getInt("innerGamerSpeed", com.droiddeck.launcher.ui.Themes.DEFAULT_RGB_SPEED).coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)
+
+ fun setRgbSpeed(context: Context, speed: Int) {
+ prefs(context).edit().putInt("innerGamerSpeed", speed.coerceIn(1, com.droiddeck.launcher.ui.Themes.RGB_SPEEDS)).apply()
+ }
+
fun setGradientColors(context: Context, primary: Int, secondary: Int) {
prefs(context).edit().putInt("gradientPrimary", primary).putInt("gradientSecondary", secondary).apply()
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index e463165b3..558e978e0 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -335,6 +335,9 @@ class SessionService : Service() {
suspendAttemptFailed = false
suspendController = null
SessionState.suspended = false
+ SessionState.resumeRequested = false
+ SessionState.resuming = false
+ SessionState.resumeFailed = false
SessionState.pipActive = false
pipTask = false
SessionState.program = intent?.getStringExtra(EXTRA_PROGRAM)
@@ -401,7 +404,7 @@ class SessionService : Service() {
private fun runSession(gen: Int) {
SessionTerminal.clear()
- com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()
+ com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)
try {
LinuxRuntime.writeAccounts(this)
} catch (e: Exception) {
@@ -678,6 +681,7 @@ class SessionService : Service() {
com.droiddeck.launcher.core.AppLanguage.system(this).country,
))
guest.add("TZ=" + java.util.TimeZone.getDefault().id)
+ if (SessionState.mode == MODE_STEAM) SessionPrefs.pendingShaderPrecache(this)?.let { guest.add("BL_SHADER_PRECACHE=" + if (it) "1" else "0") }
guest.add("XDG_RUNTIME_DIR=" + LinuxRuntime.GUEST_RUNTIME_DIR)
guest.add("XDG_SESSION_TYPE=wayland")
guest.add("WAYLAND_DISPLAY=wayland-0")
@@ -700,7 +704,6 @@ class SessionService : Service() {
val tuOverride = tuDebugOverride()
val tuDriver = sysmemDriver(linuxDriverId)
(tuOverride ?: "sysmem".takeIf { tuDriver || SessionPrefs.tuSysmem(this) })?.let { guest.add("TU_DEBUG=$it") }
- com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice()?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
if (SessionPrefs.zinkLazy(this)) {
guest.add("ZINK_DESCRIPTORS=lazy")
guest.add("ZINK_DEBUG=compact")
@@ -724,11 +727,13 @@ class SessionService : Service() {
guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0"))
guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0"))
guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0"))
+ if (SessionPrefs.cursorComposite(this)) guest.add("BL_CURSOR_COMPOSITE=1")
if (com.droiddeck.launcher.core.ZramSupport.supported()) guest.add("BL_ZRAM=1")
guest.add("BANNER_AUDIO_DIRECT_DECAY=0")
val extra = extraEnv() + com.droiddeck.launcher.agent.AgentEnv.beginSession(this)
if (extra.none { it.startsWith("TU_DEBUG=") }) guest.add("$GAME_TU_DEBUG=" + (tuOverride ?: if (tuDriver) "sysmem" else ""))
extra.forEach { guest.add(it) }
+ com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice(this)?.let { guest.add("${com.droiddeck.launcher.gpu.TpUbwcHint.ENV}=$it") }
val clientPinned = steamHere && SessionPrefs.clientCpusOverride(this)
if (clientPinned) {
guest.add("BL_CLIENT_CPUS=" + CpuCores.listOrAll(SessionPrefs.clientCpus(this)))
@@ -1218,6 +1223,7 @@ class SessionService : Service() {
}
private fun resumeSession(panelVisible: Boolean = true) {
+ if (SessionState.suspended) SessionState.resumeRequested = true
if (panelVisible) activityVisible = true
screenOn = readScreenOn()
manualPauseRequested = false
@@ -1418,6 +1424,9 @@ class SessionService : Service() {
val downloadsBlockingSuspend = downloadsKeepAwake() && hidden && !suspendDownloadNow
if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && hidden && !downloadsBlockingSuspend) {
manualPauseRequested = true
+ } else if (!hidden && !SessionState.suspended) {
+ // Coming back before the freeze finished keeps the game running instead of pausing it a moment later.
+ manualPauseRequested = false
}
val controller = suspendController ?: return
if (downloadsBlockingSuspend) acquireLocks()
@@ -1443,6 +1452,7 @@ class SessionService : Service() {
freezeCancelled = true
controller.cancelFreeze()
}
+ if (shouldSuspend && SessionState.suspended && !suspendOperationPending) SessionState.resumeRequested = false
if (suspendOperationPending || suspendAttemptFailed || shouldSuspend == SessionState.suspended) return
val gen = sessionGen
suspendOperationPending = true
@@ -1466,6 +1476,7 @@ class SessionService : Service() {
freezePending = false
if (success) {
SessionState.suspended = true
+ SessionState.resumeFailed = false
SessionEvents.transition(SessionPhase.SUSPENDED, "session.suspended")
releaseLocks()
refreshNotification()
@@ -1484,16 +1495,22 @@ class SessionService : Service() {
}
} else {
acquireLocks()
+ SessionState.resuming = true
controller.resume { success ->
mainHandler.post {
if (gen != sessionGen || !SessionState.running) return@post
suspendOperationPending = false
+ SessionState.resuming = false
+ SessionState.resumeRequested = false
if (success) {
SessionState.suspended = false
+ SessionState.resumeFailed = false
SessionEvents.transition(SessionEvents.resumePhase(), "session.resumed")
refreshNotification()
} else {
suspendAttemptFailed = true
+ SessionState.resumeFailures++
+ SessionState.resumeFailed = true
Log.w(TAG, "could not confirm that the session resumed")
}
updateSuspendPolicy()
@@ -1583,6 +1600,9 @@ class SessionService : Service() {
return@post
}
SessionState.suspended = false
+ SessionState.resumeRequested = false
+ SessionState.resuming = false
+ SessionState.resumeFailed = false
SessionState.pipActive = false
pipTask = false
SessionState.guestPid = -1
@@ -2047,6 +2067,7 @@ class SessionService : Service() {
fun resume(context: Context) {
if (!SessionState.running) return
+ if (SessionState.suspended) SessionState.resumeRequested = true
context.startService(Intent(context, SessionService::class.java).setAction(ACTION_RESUME))
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index cebc33caa..5ff739e47 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.session
import java.io.File
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -46,6 +47,10 @@ object SessionState {
@Volatile
var runtimeDriver: String? = null
var suspended by mutableStateOf(false)
+ var resumeRequested by mutableStateOf(false)
+ var resuming by mutableStateOf(false)
+ var resumeFailures by mutableIntStateOf(0)
+ var resumeFailed by mutableStateOf(false)
@Volatile var pipActive = false
@Volatile
var program: String? = null
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
index 0c430c1c5..27bc081b0 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt
@@ -20,7 +20,7 @@ object SettingsReset {
"gpuDriverMode", "androidDriver", "linuxDriver", "linuxDriver.steam", "linuxDriver.desktop",
"tuSysmem", "glThread", "noGlError", "zinkLazy", "mangoapp", "gpuClockPin", "gamescopeRealtime",
"directAudio", "mic", "wifiDiscovery", "clientCpusOverride", "clientCpus", "gameCpus",
- "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath",
+ "noXalia", "fastSync", "fsyncFirst", "syncFallback", "prootNoSeccomp", "prootFastPath", "cursorComposite",
"guestHostname", "fexPreset", "forceSsbs", "logs", "storageDiagnostics",
"upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias",
"steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds",
diff --git a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
index e707fd59f..9697addd6 100644
--- a/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/storage/SteamStorage.kt
@@ -28,6 +28,8 @@ object SteamStorage {
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val MAPPING = Regex("\"(\\d+)\"\\s*\\{\\s*\"name\"\\s*\"([^\"]*)\"")
private val COMPAT_NAME = Regex("\"compat_tools\"\\s*\\{\\s*\"([^\"]+)\"")
+ private val VDF_TOKEN = Regex("\"((?:\\\\.|[^\"\\\\])*)\"|[{}]|//[^\n]*")
+ private val SHADER_CACHE_OFF = listOf("InstallConfigStore", "Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache")
data class Manifest(
val file: File, val appId: Int, val name: String, val installDir: String?,
@@ -118,6 +120,28 @@ object SteamStorage {
return MAPPING.findAll(configVdf.substring(open, end)).associate { it.groupValues[1] to it.groupValues[2] }
}
+ fun shaderPrecache(configVdf: String): Boolean = vdfValue(configVdf, SHADER_CACHE_OFF) != "1"
+
+ private fun vdfValue(text: String, path: List): String? {
+ val blocks = ArrayList()
+ var key: String? = null
+ for (token in VDF_TOKEN.findAll(text)) {
+ val name = key
+ when {
+ token.value.startsWith("//") -> {}
+ token.value == "{" -> { blocks.add(name.orEmpty()); key = null }
+ token.value == "}" -> { blocks.removeLastOrNull(); key = null }
+ name == null -> key = token.groupValues[1]
+ else -> {
+ val at = blocks + name
+ if (at.size == path.size && at.zip(path).all { (a, b) -> a.equals(b, ignoreCase = true) }) return token.groupValues[1]
+ key = null
+ }
+ }
+ }
+ return null
+ }
+
fun compatToolName(compatibilityToolVdf: String): String? = COMPAT_NAME.find(compatibilityToolVdf)?.groupValues?.get(1)
private val ARM_IN_NAME = Regex("arm64|aarch64")
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt
index 5e63d7b0f..b3b88f0aa 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt
@@ -308,6 +308,7 @@ internal fun AppDialog(
width: Dp? = null, fitContent: Boolean = false,
back: (() -> Unit)? = null,
scrollable: Boolean = true,
+ scrollState: androidx.compose.foundation.ScrollState = rememberScrollState(),
content: @Composable ColumnScope.() -> Unit,
) {
val pal = LocalPalette.current
@@ -345,7 +346,7 @@ internal fun AppDialog(
.blockTouches()
.controllerBack(back ?: close)
.then(modifier)
- .then(if (scrollable) Modifier.verticalScroll(rememberScrollState()) else Modifier)
+ .then(if (scrollable) Modifier.verticalScroll(scrollState) else Modifier)
.padding(horizontal = 18.dp, vertical = 16.dp),
content = content,
)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt
index e94f193e9..d83e1abe1 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ColorPickerDialog.kt
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -70,12 +71,15 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni
var hexText by remember { mutableStateOf(ThemeColors.hex(initial)) }
val current = ThemeColors.fromHsv(hue, sat, value)
val first = remember { FocusRequester() }
+ val scroll = rememberScrollState()
+ var typingHex by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { focusSoon { first } }
+ LaunchedEffect(typingHex, scroll.maxValue) { if (typingHex) scroll.animateScrollTo(scroll.maxValue) }
fun picked(h: Float = hue, s: Float = sat, v: Float = value) {
hue = h.coerceIn(0f, 360f); sat = s.coerceIn(0f, 1f); value = v.coerceIn(0f, 1f)
hexText = ThemeColors.hex(ThemeColors.fromHsv(hue, sat, value))
}
- AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp) {
+ AppDialog(shown, close, "colorPicker", wide = false, maxWidth = 460.dp, scrollState = scroll) {
DialogTitleRow(title, close)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
ColorWheel(hue, sat, value) { h, s -> picked(h = h, s = s) }
@@ -95,6 +99,7 @@ internal fun ColorPickerDialog(title: String, initial: Int, onDismiss: () -> Uni
ThemeColors.parseHex(text)?.let { argb -> ThemeColors.toHsv(argb).let { hsv -> hue = hsv.h; sat = hsv.s; value = hsv.v } }
},
label = "", keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, placeholder = stringResource(R.string.theme_color_hex),
+ onFocusChange = { typingHex = it },
onDone = { ThemeColors.parseHex(hexText)?.let { onApply(it); close() } },
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt
index 901bf6485..9fd363155 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ExePickerDialog.kt
@@ -27,6 +27,8 @@ import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.InsertDriveFile
import androidx.compose.material.icons.outlined.SdCard
import androidx.compose.material.icons.outlined.Smartphone
+import androidx.compose.material.icons.outlined.Visibility
+import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -81,8 +83,8 @@ internal class PickFolder(val dir: File, val readable: Boolean)
internal class PickListing(val folders: List, val files: List)
-internal fun pickListing(dir: File, kind: PickKind): PickListing {
- val all = dir.listFiles()?.filter { !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() }
+internal fun pickListing(dir: File, kind: PickKind, showHidden: Boolean = false): PickListing {
+ val all = dir.listFiles()?.filter { showHidden || !it.name.startsWith(".") }.orEmpty().sortedBy { it.name.lowercase() }
return PickListing(
all.filter { it.isDirectory }.map { PickFolder(it, it.canRead() && it.list() != null) },
all.filter { it.isFile && it.extension.lowercase() in kind.extensions },
@@ -92,11 +94,13 @@ internal fun pickListing(dir: File, kind: PickKind): PickListing {
@Composable
internal fun ExePickerDialog(
title: String, kind: PickKind = PickKind.EXE, start: File? = null,
- onAddAll: ((File) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit,
+ onAddAll: ((File, Boolean) -> Unit)? = null, onPick: (File) -> Unit, onDismiss: () -> Unit,
) {
val ctx = LocalContext.current
val colors = MaterialTheme.colorScheme
val density = LocalDensity.current
+ val prefs = remember(ctx) { ctx.getSharedPreferences("game_picker", Context.MODE_PRIVATE) }
+ var showHidden by remember { mutableStateOf(prefs.getBoolean("show_hidden", false)) }
val shown = rememberShown(onDismiss)
val close = { shown.targetState = false }
val volumes = remember { pickVolumes(ctx) }
@@ -107,14 +111,23 @@ internal fun ExePickerDialog(
val back = { if (dir == null) close() else up() }
val first = remember(dir) { FocusRequester() }
LaunchedEffect(dir) { focusWithinFrames({ false }) { first } }
- val listing = remember(dir) { dir?.let { pickListing(it, kind) } }
+ val listing = remember(dir, showHidden) { dir?.let { pickListing(it, kind, showHidden) } }
var locked by remember { mutableStateOf(null) }
AppDialog(
shown, close, "filePicker", wide = false, maxWidth = 520.dp, width = locked, fitContent = locked == null, back = back,
modifier = Modifier.onSizeChanged { if (locked == null && it.width > 0) locked = with(density) { it.width.toDp() } },
) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
- DialogTitleRow(title, onClose = close)
+ DialogTitleRow(title, onClose = close, leading = {
+ SmallIconButton(
+ if (showHidden) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
+ stringResource(if (showHidden) R.string.fm_hide_hidden else R.string.fm_show_hidden),
+ if (showHidden) colors.primary else colors.onSurfaceVariant,
+ ) {
+ showHidden = !showHidden
+ prefs.edit().putBoolean("show_hidden", showHidden).apply()
+ }
+ })
if (dir != null && volume != null) {
val crumbs = listOf(volume.label) + dir!!.path.removePrefix(volume.root.path).split('/').filter { it.isNotEmpty() }
Text(crumbs.joinToString(" › "), fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -135,7 +148,7 @@ internal fun ExePickerDialog(
val shownListing = listing ?: PickListing(emptyList(), emptyList())
PickerRow(Icons.AutoMirrored.Outlined.ArrowBack, "..", null, focus = first, onClick = up)
if (onAddAll != null && shownListing.folders.any { it.readable }) {
- PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!); close() }
+ PickerRow(Icons.Filled.Add, stringResource(R.string.games_add_all), null, accent = true) { onAddAll(dir!!, showHidden); close() }
}
for (f in shownListing.folders) key(f.dir.path) {
PickerRow(Icons.Outlined.Folder, f.dir.name, null, enabled = f.readable) { dir = f.dir }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
index c7b78ee05..477ab9c50 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
@@ -424,7 +424,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List) {
val st = GamesDialogState
if (st.adding) ExePickerDialog(
stringResource(R.string.games_add_title),
- onAddAll = { dir ->
+ onAddAll = { dir, showHidden ->
st.summary = emptyList() to 0
st.checking = dir.name
scope.launch {
@@ -433,6 +433,7 @@ private fun GamesDialogs(a: FrontEndActions, games: List) {
ctx.applicationContext, dir,
onGame = { g -> scope.launch { st.summary?.let { (list, n) -> st.summary = (list + g) to n } } },
onChecking = { name -> scope.launch { st.checking = name } },
+ showHidden = showHidden,
)
}
st.checking = null
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index 1840d0f07..6e44aca35 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -132,7 +132,7 @@ class FrontEndState(
val controller: com.droiddeck.launcher.input.ControllerPrefs.Settings? = null,
val phantomProcessStatus: PhantomProcessStatus = PhantomProcessStatus.NOT_APPLICABLE,
val showPhantomGate: Boolean = false,
- val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
+ val orientation: Int = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE,
val launcherFullscreen: Boolean = true,
val downloadSource: String = DownloadSource.MODE_AUTO,
val downloadMirror: String = "",
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index b5bbaa2e9..c2582837f 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -359,6 +359,7 @@ internal fun SetupPanel(
}
}
}
+ if (s.theme == Themes.INNER_GAMER) RgbSpeedRow()
if (s.theme == Themes.GRADIENT) GradientColorRows()
ChoiceRow(
host, "app-scale", stringResource(R.string.setup_app_scale), stringResource(R.string.setup_app_scale_hint),
@@ -792,6 +793,13 @@ private fun LanguageRow(host: MenuHost, chosen: String, onPick: (String) -> Unit
}
}
+/** How fast Inner Gamer cycles its colours. */
+@Composable
+private fun RgbSpeedRow() {
+ val ctx = androidx.compose.ui.platform.LocalContext.current.applicationContext
+ SliderRow(stringResource(R.string.theme_rgb_speed), stringResource(R.string.theme_rgb_speed_hint), Themes.rgbSpeed, 1..Themes.RGB_SPEEDS) { Themes.setRgbSpeed(ctx, it) }
+}
+
/** The two colours of the Gradient theme, each a small square that opens the colour picker. */
@Composable
private fun GradientColorRows() {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt
index 47905485d..8474050c3 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/GpuDriversPanel.kt
@@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import com.droiddeck.launcher.gpu.DeviceInfo
+import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.res.stringResource
@@ -128,6 +129,8 @@ class GpuDriversState(
val runtime: DriverSlot = DriverSlot(),
val canRestart: Boolean = true,
val checkedAt: Long = 0L,
+ val tpUbwcMode: String? = null,
+ val tpUbwcRestartRequired: Boolean = false,
) {
val activePair: PairRow? get() = pairs.firstOrNull { it.active }
}
@@ -147,6 +150,7 @@ class GpuDriversActions(
val onRestoreBundled: () -> Unit = {},
val onMode: (String) -> Unit = {},
val onRestart: () -> Unit = {},
+ val onTpUbwcMode: (String) -> Unit = {},
)
@Composable
@@ -259,6 +263,27 @@ internal fun GpuDriversBody(
GPU_MODE_PAIR -> PairBody(gpu, actions)
else -> MixBody(single, actions, slotFocus, onOpenSlot)
}
+ gpu.tpUbwcMode?.let { selected ->
+ var advanced by rememberSaveable { mutableStateOf(false) }
+ SecondaryButton(stringResource(R.string.gpu_advanced) + if (advanced) " ▴" else " ▾", modifier = Modifier.fillMaxWidth()) { advanced = !advanced }
+ AnimatedVisibility(advanced) {
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ ChoiceRow(
+ rememberMenuHost(), "tp-ubwc", stringResource(R.string.gpu_ubwc_title),
+ stringResource(R.string.gpu_ubwc_hint),
+ listOf(
+ SessionPrefs.TP_UBWC_AUTO to stringResource(R.string.common_auto),
+ SessionPrefs.TP_UBWC_ON to stringResource(R.string.widgets_on),
+ SessionPrefs.TP_UBWC_OFF to stringResource(R.string.widgets_off),
+ ), selected, onPick = actions.onTpUbwcMode,
+ )
+ if (gpu.tpUbwcRestartRequired) {
+ Text(stringResource(R.string.gpu_ubwc_restart), fontSize = 13.sp, color = AttentionAmber)
+ if (gpu.canRestart) SecondaryButton(stringResource(R.string.gpu_restart_now), onClick = actions.onRestart)
+ }
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
index a444f3711..c546a34c3 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt
@@ -82,6 +82,8 @@ class ModeSettings(
val steamClientArch: String? = null,
/** Steam only: a game launched from the front end returns there when it closes; null hides the row. */
val returnAfterGame: Boolean? = null,
+ /** Steam only: the client's Shader Pre-Caching setting; null hides the row. */
+ val shaderPrecache: Boolean? = null,
val steamRepairQueued: Boolean = false,
val mangoapp: Boolean = true,
val steamController: String? = null,
@@ -136,6 +138,7 @@ class ModeSettingsActions(
val onSteamClientArch: (String) -> Unit = {},
val onMangoapp: (Boolean) -> Unit = {},
val onReturnAfterGame: (Boolean) -> Unit = {},
+ val onShaderPrecache: (Boolean) -> Unit = {},
val onSteamController: (String) -> Unit = {},
val onUnfoldedControls: (Boolean) -> Unit = {},
val onUnfoldedControlsWithController: (Boolean) -> Unit = {},
@@ -478,6 +481,11 @@ fun ModeSheetRows(s: ModeSettings, a: ModeSettingsActions, tab: String, extras:
stringResource(R.string.mode_return_after_game_hint),
s.returnAfterGame, onChange = a.onReturnAfterGame,
)
+ if (s.shaderPrecache != null) ToggleRow(
+ host, "shaderprecache", stringResource(R.string.mode_shader_precache),
+ stringResource(R.string.mode_shader_precache_hint),
+ s.shaderPrecache, onChange = a.onShaderPrecache,
+ )
SettingsRow(
stringResource(R.string.mode_steam_repair),
stringResource(if (s.steamRepairQueued) R.string.mode_steam_repair_queued else R.string.mode_steam_repair_hint),
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt
index b46133463..25f344a77 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt
@@ -46,6 +46,7 @@ fun PerformancePage(
gpuClockPin: Boolean,
prootNoSeccomp: Boolean,
prootFastPath: Boolean,
+ cursorComposite: Boolean,
guestHostname: String,
phantomWarning: String?,
onClientOverride: (Boolean) -> Unit,
@@ -61,6 +62,7 @@ fun PerformancePage(
onGpuClockPin: (Boolean) -> Unit,
onProotNoSeccomp: (Boolean) -> Unit,
onProotFastPath: (Boolean) -> Unit,
+ onCursorComposite: (Boolean) -> Unit,
onGuestHostname: (String) -> Unit,
onClientCore: (Int, Boolean) -> Unit,
onGameCore: (Int, Boolean) -> Unit,
@@ -158,6 +160,11 @@ fun PerformancePage(
else stringResource(R.string.perf_fastpath_hint),
prootFastPath && !prootNoSeccomp, enabled = !prootNoSeccomp, onChange = onProotFastPath,
)
+ ToggleRow(
+ host, "cursorcomposite", stringResource(R.string.perf_cursor_composite),
+ stringResource(R.string.perf_cursor_composite_hint),
+ cursorComposite, onChange = onCursorComposite,
+ )
}
SettingsGroup(stringResource(R.string.perf_identity)) {
var draft by remember(guestHostname) { mutableStateOf(guestHostname) }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt
index 4a0adea2d..d1c478eb0 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionLoading.kt
@@ -24,7 +24,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.heightIn
-import androidx.compose.material.icons.filled.Pause
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.foundation.Canvas
@@ -345,37 +344,3 @@ private fun CancelHint(onCancel: () -> Unit, modifier: Modifier = Modifier) {
Text(stringResource(R.string.common_cancel), fontSize = 13.sp, color = colors.onSurfaceVariant)
}
}
-
-@Composable
-fun SessionPausedOverlay(title: String = stringResource(R.string.load_steam_paused), onResume: () -> Unit) {
- val colors = MaterialTheme.colorScheme
- val pal = LocalPalette.current
- val resumeFocus = remember { FocusRequester() }
- LaunchedEffect(Unit) {
- androidx.compose.runtime.withFrameNanos { }
- runCatching { resumeFocus.requestFocus() }
- }
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(pal.background.copy(alpha = 0.94f))
- .blockTouches(),
- contentAlignment = Alignment.Center,
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(10.dp),
- modifier = Modifier.widthIn(max = 440.dp).padding(32.dp),
- ) {
- Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(bottom = 6.dp).size(64.dp).border(2.dp, pal.line2, CircleShape)) {
- Icon(Icons.Filled.Pause, contentDescription = null, tint = colors.onBackground, modifier = Modifier.size(30.dp))
- }
- Text(title, fontSize = 28.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, textAlign = TextAlign.Center)
- PrimaryButton(
- stringResource(R.string.resume_session),
- modifier = Modifier.padding(top = 14.dp).focusRequester(resumeFocus).controllerConfirm(onClick = onResume),
- onClick = onResume,
- )
- }
- }
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt
new file mode 100644
index 000000000..b7f5cbfeb
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionPausedOverlay.kt
@@ -0,0 +1,276 @@
+package com.droiddeck.launcher.ui
+
+import android.os.Build
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.CubicBezierEasing
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.LinearOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import com.droiddeck.launcher.R
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/** The last game frame, captured as the session left the screen, and where it sat in the overlay. */
+class PausedFrame(val image: ImageBitmap, val bounds: android.graphics.Rect)
+
+private const val CARD_SCALE = 0.82f
+private const val FRAME_WAIT_MS = 600L
+
+private fun tone(saturation: Float, brightness: Float): ColorMatrix =
+ ColorMatrix().apply {
+ setToSaturation(saturation)
+ timesAssign(ColorMatrix().apply { setToScale(brightness, brightness, brightness, 1f) })
+ }
+
+@Composable
+fun SessionPausedOverlay(
+ suspended: Boolean,
+ engaged: Boolean,
+ failures: Int,
+ frame: PausedFrame?,
+ title: String,
+ onResume: () -> Unit,
+ onFrameNeeded: () -> Unit,
+ onHidden: () -> Unit,
+) {
+ val currentFrame by rememberUpdatedState(frame)
+ var shown by remember { mutableStateOf(suspended) }
+ val lift = remember { Animatable(if (suspended) 1f else 0f) }
+ val orb = remember { Animatable(if (suspended) 1f else 0f) }
+ val press = remember { Animatable(1f) }
+ val ring = remember { Animatable(0f) }
+ val shake = remember { Animatable(0f) }
+
+ LaunchedEffect(suspended, engaged) {
+ when {
+ suspended && !engaged -> {
+ if (!shown) {
+ if (currentFrame == null) {
+ onFrameNeeded()
+ withTimeoutOrNull(FRAME_WAIT_MS) { snapshotFlow { currentFrame }.first { it != null } }
+ }
+ // Pausing mostly happens while the screen is off or the app is away, so it appears already settled.
+ press.snapTo(1f)
+ ring.snapTo(0f)
+ lift.snapTo(1f)
+ orb.snapTo(1f)
+ shown = true
+ return@LaunchedEffect
+ }
+ launch { press.animateTo(1f, Motion.sp(0.5f, Spring.StiffnessMedium)) }
+ launch { ring.animateTo(0f, Motion.tw(200)) }
+ orb.animateTo(1f, Motion.tw(200))
+ }
+ suspended -> {
+ if (!shown) return@LaunchedEffect
+ launch { orb.animateTo(1f, Motion.tw(200)) }
+ launch { press.animateTo(0.92f, Motion.tw(140)) }
+ ring.animateTo(0.25f, Motion.tw(140))
+ ring.animateTo(0.8f, Motion.tw(650, easing = CubicBezierEasing(0.1f, 0.6f, 0.3f, 1f)))
+ ring.animateTo(0.92f, Motion.tw(2000, easing = LinearOutSlowInEasing))
+ }
+ shown -> {
+ ring.animateTo(1f, Motion.tw(110))
+ coroutineScope {
+ launch { orb.animateTo(0f, Motion.tw(220, easing = FastOutLinearInEasing)) }
+ lift.animateTo(0f, Motion.sp(0.62f, 380f))
+ }
+ shown = false
+ ring.snapTo(0f)
+ press.snapTo(1f)
+ onHidden()
+ }
+ }
+ }
+
+ val seenFailures = remember { mutableIntStateOf(failures) }
+ LaunchedEffect(failures) {
+ if (failures == seenFailures.intValue) return@LaunchedEffect
+ seenFailures.intValue = failures
+ if (!shown || Motion.scale == 0f) return@LaunchedEffect
+ shake.animateTo(0f, keyframes {
+ durationMillis = Motion.ms(360)
+ 10f at Motion.ms(60)
+ -8f at Motion.ms(140)
+ 5f at Motion.ms(220)
+ -2f at Motion.ms(300)
+ })
+ }
+
+ if (!shown) return
+
+ val presence = lift.value.coerceIn(0f, 1f)
+ val idle = suspended && !engaged
+ val focus = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ withFrameNanos { }
+ runCatching { focus.requestFocus() }
+ }
+ val resumeLabel = stringResource(R.string.resume_session)
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxSize()
+ .semantics { contentDescription = title }
+ .focusRequester(focus)
+ .controllerConfirm(enabled = idle, onClick = onResume)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ role = Role.Button,
+ onClickLabel = resumeLabel,
+ ) { if (idle) onResume() },
+ ) {
+ if (frame != null) {
+ PausedCard(frame, lift.value, presence)
+ } else {
+ Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black.copy(alpha = 0.55f)))
+ }
+ ResumeOrb(orb.value, press.value, ring.value, shake.value, idle)
+ }
+}
+
+@Composable
+private fun BoxScope.PausedCard(frame: PausedFrame, lift: Float, presence: Float) {
+ val density = LocalDensity.current
+ val bounds = frame.bounds
+ val ambient = remember { ColorFilter.colorMatrix(tone(0.1f, 0.3f)) }
+ Box(Modifier.fillMaxSize().graphicsLayer { alpha = presence }.background(Color.Black)) {
+ Image(
+ frame.image, contentDescription = null, contentScale = ContentScale.Crop, colorFilter = ambient,
+ modifier = Modifier.fillMaxSize().then(if (Build.VERSION.SDK_INT >= 31) Modifier.blur(32.dp) else Modifier),
+ )
+ }
+ Image(
+ frame.image, contentDescription = null, contentScale = ContentScale.FillBounds,
+ colorFilter = ColorFilter.colorMatrix(tone(1f - 0.92f * presence, 1f - 0.45f * presence)),
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset { IntOffset(bounds.left, bounds.top) }
+ .size(with(density) { bounds.width().toDp() }, with(density) { bounds.height().toDp() })
+ .graphicsLayer {
+ val scale = 1f - (1f - CARD_SCALE) * lift
+ scaleX = scale
+ scaleY = scale
+ shape = RoundedCornerShape((20f * presence).dp)
+ clip = true
+ shadowElevation = 24.dp.toPx() * presence
+ },
+ )
+}
+
+@Composable
+private fun ResumeOrb(presence: Float, press: Float, ring: Float, shake: Float, idle: Boolean) {
+ val glintAlpha by animateFloatAsState(if (idle) 1f else 0f, Motion.tw(160), label = "glintAlpha")
+ val glint: State? = if (Motion.scale > 0f) {
+ rememberInfiniteTransition(label = "glint").animateFloat(
+ 0f, 360f, infiniteRepeatable(tween(Motion.ms(3600), easing = LinearEasing)), label = "glintAngle",
+ )
+ } else null
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(96.dp)
+ .graphicsLayer {
+ val scale = press * (0.4f + 0.6f * presence)
+ scaleX = scale
+ scaleY = scale
+ alpha = presence.coerceIn(0f, 1f)
+ translationX = shake.dp.toPx()
+ }
+ .drawBehind {
+ val halo = size.minDimension * 0.8f
+ drawCircle(Brush.radialGradient(0.6f to Color.Black.copy(alpha = 0.35f), 1f to Color.Transparent, radius = halo), radius = halo)
+ }
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.08f))
+ .drawWithContent {
+ drawContent()
+ val stroke = 2.5.dp.toPx()
+ val radius = size.minDimension / 2f * 0.92f
+ if (glint != null && glintAlpha > 0f) {
+ rotate(glint.value) {
+ drawCircle(
+ Brush.sweepGradient(
+ 0f to Color.Transparent,
+ 0.083f to Color.White.copy(alpha = 0.4f * glintAlpha),
+ 0.166f to Color.Transparent,
+ 1f to Color.Transparent,
+ ),
+ radius = radius, style = Stroke(stroke),
+ )
+ }
+ }
+ if (ring > 0f) {
+ drawArc(
+ Color.White, startAngle = -90f, sweepAngle = 360f * ring.coerceAtMost(1f), useCenter = false,
+ topLeft = Offset(center.x - radius, center.y - radius), size = Size(radius * 2f, radius * 2f),
+ style = Stroke(stroke, cap = StrokeCap.Round),
+ )
+ }
+ },
+ ) {
+ Icon(Icons.Rounded.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(48.dp))
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt
index 0f3843fca..14527f594 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/Theme.kt
@@ -3,7 +3,15 @@ package com.droiddeck.launcher.ui
import androidx.annotation.StringRes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.getValue
@@ -13,6 +21,7 @@ import android.content.Context
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import com.droiddeck.launcher.R
import androidx.compose.ui.platform.LocalConfiguration
@@ -33,6 +42,7 @@ object Themes {
const val PAPER = "paper"
const val ICON_BLUE = "blue"
const val ELECTRIC = "electric"
+ const val INNER_GAMER = "inner_gamer"
const val GRADIENT = "gradient"
const val DEFAULT_PRIMARY = 0xFF8B5CF6.toInt()
@@ -42,10 +52,42 @@ object Themes {
var gradientColors by mutableStateOf(DEFAULT_PRIMARY to DEFAULT_SECONDARY)
private set
+ const val RGB_SPEEDS = 10
+ const val DEFAULT_RGB_SPEED = 3
+ const val RGB_HUE_STEP = 3f
+
+ /** How fast Inner Gamer cycles, 1 (slow) to [RGB_SPEEDS] (fast). */
+ var rgbSpeed by mutableStateOf(DEFAULT_RGB_SPEED)
+ private set
+
+ /** Where Inner Gamer is on the colour wheel, in whole [RGB_HUE_STEP] steps so the app restyles only when the colour visibly moves. */
+ var rgbHue by mutableStateOf(0f)
+ private set
+
+ fun moveRgbHue(hue: Float) {
+ rgbHue = hue
+ }
+
+ fun setRgbSpeed(context: Context, speed: Int) {
+ val value = speed.coerceIn(1, RGB_SPEEDS)
+ SessionPrefs.setRgbSpeed(context, value)
+ rgbSpeed = value
+ }
+
+ /** Milliseconds for one trip round the colour wheel. */
+ fun rgbCycleMillis(speed: Int): Int = 30_000 / speed.coerceIn(1, RGB_SPEEDS)
+
fun loadGradient(context: Context) {
gradientColors = SessionPrefs.gradientColors(context)
+ rgbSpeed = SessionPrefs.rgbSpeed(context)
}
+ /** The Inner Gamer palette at one point on the colour wheel. */
+ fun innerGamer(hue: Float): Palette = ThemeColors.palette(
+ Color.hsv(hue % 360f, 0.9f, 1f).toArgb(), Color.hsv((hue + 60f) % 360f, 0.9f, 1f).toArgb(),
+ R.string.theme_inner_gamer, R.string.theme_inner_gamer_detail, INNER_GAMER,
+ )
+
fun setGradient(context: Context, primary: Int, secondary: Int) {
SessionPrefs.setGradientColors(context, primary, secondary)
gradientColors = primary to secondary
@@ -54,7 +96,7 @@ object Themes {
/** The theme the person chose, with the custom colours read first; for code that is not in a composition. */
fun current(context: Context): Palette {
val id = SessionPrefs.theme(context)
- if (id == GRADIENT) loadGradient(context)
+ if (id == GRADIENT || id == INNER_GAMER) loadGradient(context)
return byId(id)
}
@@ -87,10 +129,12 @@ object Themes {
primary = Color(0xFF2E86FF), primary2 = Color(0xFF2684FF), onPrimary = Color(0xFF03111F), signal = Color(0xFF2E86FF),
good = Color(0xFF4CD37F),
),
+ innerGamer(0f),
ThemeColors.palette(DEFAULT_PRIMARY, DEFAULT_SECONDARY, R.string.theme_gradient, R.string.theme_gradient_detail),
)
fun byId(id: String): Palette {
+ if (id == INNER_GAMER) return innerGamer(rgbHue)
if (id == GRADIENT) return ThemeColors.palette(gradientColors.first, gradientColors.second, R.string.theme_gradient, R.string.theme_gradient_detail)
return all.firstOrNull { it.id == id } ?: all[0]
}
@@ -103,6 +147,7 @@ val Palette.onSignal: Color get() = if (signal.luminance() > 0.18f) Color(0xFF03
@Composable
fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCALE, content: @Composable () -> Unit) {
val id = theme ?: SessionPrefs.theme(LocalContext.current)
+ if (id == Themes.INNER_GAMER) InnerGamerHue(Themes.rgbSpeed)
val p = Themes.byId(id)
val scheme = darkColorScheme(
primary = p.primary, onPrimary = p.onPrimary, secondary = p.onSurfaceVariant,
@@ -121,3 +166,21 @@ fun DroidDeckTheme(theme: String? = null, appScale: Int = AppUiPrefs.DEFAULT_SCA
LocalConfiguration provides configuration,
) { MaterialTheme(colorScheme = scheme, content = content) }
}
+
+/** Turns the colour wheel on the system's own animation clock; with animations off, in the app or in Android, the colour holds still. */
+@Composable
+private fun InnerGamerHue(speed: Int) {
+ if (Motion.scale == 0f) return
+ key(speed) { InnerGamerCycle(speed) }
+}
+
+/** One run of the cycle at one speed, picking up from the colour on screen so a new speed takes over at once without a jump. */
+@Composable
+private fun InnerGamerCycle(speed: Int) {
+ val start = remember { Themes.rgbHue }
+ val hue by rememberInfiniteTransition(label = "innerGamer").animateFloat(
+ start, start + 360f, infiniteRepeatable(tween(Themes.rgbCycleMillis(speed).coerceAtLeast(1), easing = LinearEasing), RepeatMode.Restart), label = "hue",
+ )
+ val stepped = ((hue % 360f) / Themes.RGB_HUE_STEP).toInt() * Themes.RGB_HUE_STEP
+ SideEffect { Themes.moveRgbHue(stepped) }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt
index b4b57737e..b2db63562 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ThemeColors.kt
@@ -132,7 +132,7 @@ internal object ThemeColors {
}
}
- fun palette(primary: Int, secondary: Int, label: Int, detail: Int): Palette {
+ fun palette(primary: Int, secondary: Int, label: Int, detail: Int, id: String = Themes.GRADIENT): Palette {
val seen = readable(primary)
val seenSecond = readable(secondary)
val ink = inkFor(seen, seenSecond)
@@ -146,7 +146,7 @@ internal object ThemeColors {
second = nextSecond
}
return Palette(
- Themes.GRADIENT, label, detail,
+ id, label, detail,
background = tint(first, 0.50f, 0.045f), surface = tint(first, 0.45f, 0.075f), surfaceVariant = tint(first, 0.42f, 0.115f),
line = tint(first, 0.40f, 0.16f), line2 = tint(first, 0.36f, 0.22f),
onBackground = tint(first, 0.05f, 0.97f), onSurfaceVariant = tint(first, 0.16f, 0.68f),
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
index bb466c133..348e8ff1f 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt
@@ -23,6 +23,7 @@ object CompositorHost {
@Synchronized
fun startOrAttach(
+ context: android.content.Context,
surface: Surface,
xdgRuntimeDir: String,
driverPath: String?,
@@ -41,7 +42,7 @@ object CompositorHost {
resumeVsync()
return false
}
- com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess()
+ com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess(context)
WaylandCompositor.nativeStartWithSurface(
surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir,
)
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 7001fa916..932827b75 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -549,8 +549,8 @@
Turnip: التصيير عبر sysmem
أسرع لعميل Steam؛ في الألعاب يختار برنامج التشغيل بين العرض المقسّم إلى مربعات والعرض في ذاكرة النظام لكل مرحلة. مطلوب على Adreno 710/720/722.
تجاوز مساعد xalia في Steam
- droiddeck-ntsync (تجريبي)
- بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux، للاختبار. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية.
+ droiddeck-ntsync
+ بديل في مساحة المستخدم لبرنامج تشغيل ntsync في Linux. أثناء تفعيله، يستخدمه Proton بدلًا من droiddeck-esync وdroiddeck-fsync. يعمل مع Proton Experimental ARM64 وGE-Proton وproton-cachyos. يُطبَّق في الجلسة التالية.
droiddeck-esync
droiddeck-fsync أولًا
يشغّل خاصية fsync المدمجة في Proton على ملفات Proton الأصلية، مع توفير futex_waitv في مساحة المستخدم، فلا يحتاج إلى حزمة ويستمر في العمل لحظة تحديث Steam لـ Proton. أثناء تفعيله، تستخدمه الألعاب قبل droiddeck-esync، باستثناء القليل الذي يبقيه Proton معطّلًا لـ fsync. يُطبَّق في الجلسة التالية.
@@ -1457,6 +1457,10 @@
خلفية سوداء ونص أبيض، والأزرق لكل عنصر محدَّد أو مُركَّز عليه أو مضغوط.
كحلي كهربائي
أزرق أكثر سطوعًا، على أسطح تميل إلى الكحلي بدلاً من الرمادي.
+ Inner Gamer
+ ألوان تتدرّج عبر ألوان قوس قزح. تتبع إعدادات الرسوم المتحركة في Android.
+ السرعة
+ مدى سرعة تغيّر الألوان.
تدرج لوني
لونك الأساسي والثانوي، ممزوجان على الأزرار وعناصر التمييز.
الأساسي
@@ -1598,6 +1602,13 @@
تخطّي
اختيار هذا المجلد
رجوع
+ خروج
+ العودة إلى المجلد الأول
+ لم يعد المجلد الأول متاحًا
+ إلغاء العملية؟
+ سيتم إلغاء العملية. هل أنت متأكد؟
+ موافق
+ رفض
تم تركيب %1$s لكن تتعذّر قراءته حاليًا
عرض القائمة
عرض الشبكة
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index 16db21a9b..411747242 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -536,8 +536,8 @@
Turnip: renderizado sysmem
Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.
Omitir el asistente xalia de Steam
- droiddeck-ntsync (experimental)
- Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.
+ droiddeck-ntsync
+ Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.
droiddeck-esync
droiddeck-fsync primero
Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión.
@@ -1466,6 +1466,10 @@
Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o presionado.
Azul marino eléctrico
Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris.
+ Inner Gamer
+ Colores que recorren el arcoíris. Respeta los ajustes de animación de Android.
+ Velocidad
+ Qué tan rápido cambian los colores.
Degradado
Tus propios colores primario y secundario, combinados en botones y resaltados.
Primario
@@ -1592,6 +1596,13 @@
Omitir
Seleccionar esta carpeta
Atrás
+ Salir
+ Volver a la carpeta inicial
+ La carpeta inicial ya no está disponible
+ ¿Cancelar la operación?
+ La operación se cancelará. ¿Estás seguro?
+ Aceptar
+ Rechazar
%1$s está montado, pero ahora mismo no se puede leer
Vista de lista
Vista de cuadrícula
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index 9dcc424a1..6eab6e3e5 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -541,8 +541,8 @@
Turnip: рендиране sysmem
По-бързо за клиента на Steam; в игрите драйверът избира за всеки проход между рендериране на плочки и в системната памет. Задължително на Adreno 710/720/722.
Пропускане на помощника xalia на Steam
- droiddeck-ntsync (експериментално)
- Заместител на потребителско ниво за драйвера ntsync на Linux, за тестване. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия.
+ droiddeck-ntsync
+ Заместител на потребителско ниво за драйвера ntsync на Linux. Докато е включен, Proton го използва вместо droiddeck-esync и droiddeck-fsync. Работи с Proton Experimental ARM64, GE-Proton и proton-cachyos. Прилага се от следващата сесия.
droiddeck-esync
droiddeck-fsync първо
Стартира собствения fsync на Proton върху оригиналните файлове на Proton, с futex_waitv, подаден от потребителско ниво, затова не изисква пакет и продължава да работи веднага след актуализация на Proton от Steam. Докато е включен, игрите го използват преди droiddeck-esync, освен малкото, които Proton държи извън fsync. Прилага се от следващата сесия.
@@ -1401,6 +1401,10 @@
Черен фон, бял текст, синьото за всичко избрано, на фокус или натиснато.
Яркосиньо
По-яркото синьо, върху повърхности, по-близки до тъмносиньо, вместо сиво.
+ Inner Gamer
+ Цветове, които преминават през дъгата. Следва настройките за анимации на Android.
+ Скорост
+ Колко бързо се сменят цветовете.
Градиент
Вашите основен и вторичен цвят, смесени в бутоните и акцентите.
Основен
@@ -1522,6 +1526,13 @@
Пропускане
Избор на тази папка
Назад
+ Изход
+ Връщане в началната папка
+ Началната папка вече не е достъпна
+ Отмяна на операцията?
+ Операцията ще бъде отменена. Сигурни ли сте?
+ Приемам
+ Отказвам
%1$s е монтирано, но в момента не може да се прочете
Изглед като списък
Изглед като мрежа
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 97e679960..ae2a28953 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -545,8 +545,8 @@
Turnip: vykreslování sysmem
Rychlejší pro klienta Steam; ve hrách ovladač pro každý průchod volí dlaždicové vykreslování, nebo vykreslování v systémové paměti. Vyžadováno na Adreno 710/720/722.
Vynechat Steamova pomocníka xalia
- droiddeck-ntsync (experimentální)
- Uživatelská náhrada linuxového ovladače ntsync, pro testování. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace.
+ droiddeck-ntsync
+ Uživatelská náhrada linuxového ovladače ntsync. Když je zapnuto, Proton ho používá místo droiddeck-esync a droiddeck-fsync. Funguje s Proton Experimental ARM64, GE-Proton a proton-cachyos. Použije se od příští relace.
droiddeck-esync
droiddeck-fsync jako první
Spouští vlastní fsync Protonu na původních souborech Protonu, s futex_waitv dodaným v uživatelském prostoru, takže nepotřebuje balíček a funguje i hned po aktualizaci Protonu Steamem. Když je zapnuto, hry ho používají před droiddeck-esync, kromě těch pár, které Proton u fsync nechává vypnuté. Použije se od příští relace.
@@ -1429,6 +1429,10 @@
Černé pozadí, bílé písmo, modrá pro vše vybrané, zaměřené nebo stisknuté.
Elektrická modř
Jasnější modrá na povrchách, které jsou spíše tmavomodré než šedé.
+ Inner Gamer
+ Barvy, které procházejí celou duhu. Respektuje nastavení animací v Androidu.
+ Rychlost
+ Jak rychle se barvy střídají.
Přechod
Vaše vlastní primární a sekundární barva, prolnuté na tlačítkách a zvýrazněních.
Primární
@@ -1560,6 +1564,13 @@
Přeskočit
Vybrat tuto složku
Zpět
+ Ukončit
+ Zpět do výchozí složky
+ Výchozí složka již není dostupná
+ Zrušit operaci?
+ Operace bude zrušena. Opravdu chcete pokračovat?
+ Přijmout
+ Odmítnout
%1$s je připojeno, ale momentálně není čitelné
Zobrazení seznamu
Zobrazení mřížky
diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml
index 0c2bc230b..98d0bca46 100644
--- a/app/src/main/res/values-da/strings.xml
+++ b/app/src/main/res/values-da/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem-rendering
Hurtigere til Steam-klienten; i spil vælger driveren flise- eller systemhukommelses-rendering for hvert pass. Påkrævet på Adreno 710/720/722.
Spring Steams xalia-hjælper over
- droiddeck-ntsync (eksperimentel)
- En stand-in til ntsync-driveren i Linux-kernen, i brugerrum, til test. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session.
+ droiddeck-ntsync
+ En stand-in til ntsync-driveren i Linux-kernen, i brugerrum. Mens den er til, bruger Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gælder fra næste session.
droiddeck-esync
droiddeck-fsync først
Kører Protons egen fsync på de uændrede Proton-filer, med futex_waitv leveret i brugerrum, så det kræver ingen pack og fortsætter med at virke, lige så snart Steam opdaterer Proton. Mens den er til, bruger spil den før droiddeck-esync, undtagen de få, Proton holder uden for fsync. Gælder fra næste session.
@@ -1401,6 +1401,10 @@
Sort baggrund, hvid skrift, blå til alt, der er valgt, fokuseret eller trykket.
Elektrisk marineblå
Den blå er klarere, på overflader der trækker mod marineblåt i stedet for gråt.
+ Inner Gamer
+ Farver, der skifter gennem regnbuen. Følger Androids animationsindstillinger.
+ Hastighed
+ Hvor hurtigt farverne skifter.
Gradient
Dine egne primære og sekundære farver, blandet på knapper og fremhævninger.
Primær
@@ -1522,6 +1526,13 @@
Spring over
Vælg denne mappe
Tilbage
+ Afslut
+ Tilbage til startmappen
+ Startmappen er ikke længere tilgængelig
+ Annuller handlingen?
+ Handlingen bliver annulleret. Er du sikker?
+ Accepter
+ Afvis
%1$s er monteret, men kan ikke læses lige nu
Listevisning
Gittervisning
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index c09e49dd7..1b0ae75cd 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -541,8 +541,8 @@
Turnip: Sysmem-Rendering
Schneller für den Steam-Client; in Spielen wählt der Treiber pro Durchgang zwischen Kachel- und Systemspeicher-Rendering. Erforderlich bei Adreno 710/720/722.
Steams xalia-Helfer überspringen
- droiddeck-ntsync (experimentell)
- Ein Userspace-Ersatz für den Linux-Treiber ntsync, zum Testen. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung.
+ droiddeck-ntsync
+ Ein Userspace-Ersatz für den Linux-Treiber ntsync. Solange er aktiv ist, nutzt Proton ihn statt droiddeck-esync und droiddeck-fsync. Funktioniert mit Proton Experimental ARM64, GE-Proton und proton-cachyos. Gilt ab der nächsten Sitzung.
droiddeck-esync
droiddeck-fsync bevorzugen
Führt Protons eigenes fsync mit den unveränderten Proton-Dateien aus, wobei futex_waitv im Userspace bereitgestellt wird. Es braucht daher kein Pack und funktioniert weiter, sobald Steam Proton aktualisiert. Solange es aktiv ist, nutzen Spiele es vor droiddeck-esync, außer den wenigen, die Proton von fsync fernhält. Gilt ab der nächsten Sitzung.
@@ -1393,6 +1393,10 @@
Schwarzer Grund, weiße Schrift, das Blau für alles Ausgewählte, Fokussierte oder Gedrückte.
Elektrisches Marineblau
Das Blau heller, auf Flächen, die eher zu Marineblau als zu Grau tendieren.
+ Inner Gamer
+ Farben, die durch den Regenbogen wechseln. Folgt den Animationseinstellungen von Android.
+ Geschwindigkeit
+ Wie schnell die Farben wechseln.
Farbverlauf
Eigene Primär- und Sekundärfarbe, als Verlauf auf Schaltflächen und Hervorhebungen.
Primär
@@ -1514,6 +1518,13 @@
Überspringen
Diesen Ordner auswählen
Zurück
+ Beenden
+ Zurück zum Startordner
+ Startordner ist nicht mehr verfügbar
+ Vorgang abbrechen?
+ Der Vorgang wird abgebrochen. Bist du sicher?
+ Akzeptieren
+ Ablehnen
%1$s ist eingebunden, aber derzeit nicht lesbar
Listenansicht
Rasteransicht
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 181822f35..709eb2244 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -541,8 +541,8 @@
Turnip: απόδοση sysmem
Ταχύτερο για το πρόγραμμα-πελάτη Steam· στα παιχνίδια ο οδηγός επιλέγει σε κάθε πέρασμα απόδοση με πλακίδια ή στη μνήμη συστήματος. Απαιτείται σε Adreno 710/720/722.
Παράβλεψη του βοηθού xalia του Steam
- droiddeck-ntsync (πειραματικό)
- Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux, για δοκιμές. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία.
+ droiddeck-ntsync
+ Ένα υποκατάστατο σε επίπεδο χρήστη για το πρόγραμμα οδήγησης ntsync του Linux. Όσο είναι ενεργό, το Proton το χρησιμοποιεί αντί για το droiddeck-esync και το droiddeck-fsync. Λειτουργεί με Proton Experimental ARM64, GE-Proton και proton-cachyos. Εφαρμόζεται στην επόμενη συνεδρία.
droiddeck-esync
droiddeck-fsync πρώτα
Εκτελεί το δικό του fsync του Proton στα απλά αρχεία Proton, με το futex_waitv παρεχόμενο σε επίπεδο χρήστη, οπότε δεν χρειάζεται πακέτο και συνεχίζει να λειτουργεί τη στιγμή που το Steam ενημερώνει το Proton. Όσο είναι ενεργό, τα παιχνίδια το χρησιμοποιούν πριν από το droiddeck-esync, εκτός από τα λίγα που το Proton κρατά εκτός fsync. Εφαρμόζεται στην επόμενη συνεδρία.
@@ -1401,6 +1401,10 @@
Μαύρο φόντο, λευκά γράμματα, το μπλε για ό,τι είναι επιλεγμένο, εστιασμένο ή πατημένο.
Ηλεκτρικό ναυτικό μπλε
Το μπλε πιο έντονο, σε επιφάνειες που τείνουν προς το ναυτικό μπλε αντί για γκρι.
+ Inner Gamer
+ Χρώματα που διατρέχουν το ουράνιο τόξο. Ακολουθεί τις ρυθμίσεις animation του Android.
+ Ταχύτητα
+ Πόσο γρήγορα αλλάζουν τα χρώματα.
Διαβάθμιση
Δικά σας πρωτεύον και δευτερεύον χρώμα, σε μίξη στα κουμπιά και τις επισημάνσεις.
Πρωτεύον
@@ -1522,6 +1526,13 @@
Παράλειψη
Επιλογή αυτού του φακέλου
Πίσω
+ Έξοδος
+ Επιστροφή στον αρχικό φάκελο
+ Ο αρχικός φάκελος δεν είναι πλέον διαθέσιμος
+ Ακύρωση ενέργειας;
+ Η ενέργεια θα ακυρωθεί. Είστε σίγουροι;
+ Αποδοχή
+ Απόρριψη
Το %1$s είναι προσαρτημένο αλλά δεν είναι αναγνώσιμο αυτή τη στιγμή
Προβολή λίστας
Προβολή πλέγματος
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index ec99ab719..38019dade 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -545,8 +545,8 @@
Turnip: renderizado sysmem
Más rápido para el cliente de Steam; en los juegos, el controlador elige entre renderizado por mosaicos o en memoria del sistema en cada pasada. Obligatorio en Adreno 710/720/722.
Omitir el asistente xalia de Steam
- droiddeck-ntsync (experimental)
- Un sustituto en espacio de usuario del controlador ntsync de Linux, para pruebas. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.
+ droiddeck-ntsync
+ Un sustituto en espacio de usuario del controlador ntsync de Linux. Mientras está activado, Proton lo usa en lugar de droiddeck-esync y droiddeck-fsync. Funciona con Proton Experimental ARM64, GE-Proton y proton-cachyos. Se aplica en la próxima sesión.
droiddeck-esync
droiddeck-fsync primero
Usa el fsync propio de Proton con los archivos originales de Proton, con futex_waitv proporcionado en espacio de usuario, así que no necesita paquete y sigue funcionando en cuanto Steam actualiza Proton. Mientras está activado, los juegos lo usan antes que droiddeck-esync, salvo los pocos que Proton mantiene sin fsync. Se aplica en la próxima sesión.
@@ -1417,6 +1417,10 @@
Fondo negro, texto blanco y el azul para todo lo seleccionado, enfocado o pulsado.
Azul marino eléctrico
Un azul más vivo, sobre superficies que tiran a azul marino en vez de gris.
+ Inner Gamer
+ Colores que recorren el arcoíris. Respeta los ajustes de animación de Android.
+ Velocidad
+ Con qué rapidez cambian los colores.
Degradado
Tus propios colores primario y secundario, combinados en botones y resaltados.
Primario
@@ -1543,6 +1547,13 @@
Omitir
Seleccionar esta carpeta
Atrás
+ Salir
+ Volver a la carpeta inicial
+ La carpeta inicial ya no está disponible
+ ¿Cancelar la operación?
+ La operación se cancelará. ¿Estás seguro?
+ Aceptar
+ Rechazar
%1$s está montado, pero ahora mismo no se puede leer
Vista de lista
Vista de cuadrícula
diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml
index ac12cc7e8..6aafc85b6 100644
--- a/app/src/main/res/values-fi/strings.xml
+++ b/app/src/main/res/values-fi/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem-renderöinti
Nopeampi Steam-asiakasohjelmalle; peleissä ajuri valitsee jokaiselle vaiheelle ruuduittaisen tai järjestelmämuistiin tehtävän renderöinnin. Pakollinen Adreno 710/720/722:lla.
Ohita Steamin xalia-avustaja
- droiddeck-ntsync (kokeellinen)
- Käyttäjätilainen korvike Linuxin ntsync-ajurille, testausta varten. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa.
+ droiddeck-ntsync
+ Käyttäjätilainen korvike Linuxin ntsync-ajurille. Kun se on päällä, Proton käyttää sitä droiddeck-esyncin ja droiddeck-fsyncin sijaan. Toimii Proton Experimental ARM64:n, GE-Protonin ja proton-cachyosin kanssa. Tulee voimaan seuraavassa istunnossa.
droiddeck-esync
droiddeck-fsync etusijalle
Käynnistää Protonin omaa fsynciä vakiotiedostoilla, futex_waitv tarjottuna käyttäjätilassa, niin se ei tarvitse pakettia ja toimii edelleen heti kun Steam päivittää Protonin. Kun se on päällä, pelit käyttävät sitä droiddeck-esyncin edellä, paitsi harvat, jotka Proton pitää pois fsyncistä. Tulee voimaan seuraavassa istunnossa.
@@ -1401,6 +1401,10 @@
Musta pohja, valkoinen teksti, sininen kaikessa valitussa, kohdistetussa tai painetussa.
Sähköinen laivastonsininen
Sininen kirkkaampana pinnoilla, jotka taittuvat harmaan sijaan laivastonsiniseen.
+ Inner Gamer
+ Värit, jotka kiertävät sateenkaaren läpi. Noudattaa Androidin animaatioasetuksia.
+ Nopeus
+ Kuinka nopeasti värit vaihtuvat.
Liukuväri
Omat ensisijainen ja toissijainen värisi, sulautettuina painikkeisiin ja korostuksiin.
Ensisijainen
@@ -1522,6 +1526,13 @@
Ohita
Valitse tämä kansio
Takaisin
+ Poistu
+ Palaa aloituskansioon
+ Aloituskansio ei ole enää käytettävissä
+ Peruutetaanko toiminto?
+ Toiminto peruutetaan. Oletko varma?
+ Hyväksy
+ Hylkää
%1$s on liitetty, mutta ei ole luettavissa juuri nyt
Luettelonäkymä
Ruudukkonäkymä
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 143d11449..f345150b6 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -545,8 +545,8 @@
Turnip : rendu sysmem
Le client Steam sera plus rapide ; en jeu, le pilote choisit à chaque passe entre rendu par tuiles et rendu en mémoire système. Requis pour Adreno 710/720/722.
Ignorer l\'assistant xalia de Steam
- droiddeck-ntsync (expérimental)
- Un substitut en espace utilisateur du pilote Linux ntsync, pour les tests. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session.
+ droiddeck-ntsync
+ Un substitut en espace utilisateur du pilote Linux ntsync. Lorsqu\'il est activé, Proton l\'utilise à la place de droiddeck-esync et de droiddeck-fsync. Fonctionne avec Proton Experimental ARM64, GE-Proton et proton-cachyos. Prendra effet à la prochaine session.
droiddeck-esync
droiddeck-fsync en priorité
Exécute le fsync propre à Proton sur les fichiers Proton d\'origine, avec futex_waitv fourni en espace utilisateur : aucun pack n\'est nécessaire et il continue de fonctionner dès que Steam met à jour Proton. Lorsqu\'il est activé, les jeux l\'utilisent avant droiddeck-esync, sauf les quelques-uns que Proton exclut de fsync. Prendra effet à la prochaine session.
@@ -1417,6 +1417,10 @@
Fond noir, texte blanc, et le bleu pour tout élément sélectionné, ciblé ou pressé.
Marine électrique
Un bleu plus vif, sur des surfaces qui tirent vers le marine plutôt que vers le gris.
+ Inner Gamer
+ Des couleurs qui parcourent l\'arc-en-ciel. Respecte les réglages d\'animation d\'Android.
+ Vitesse
+ La vitesse de défilement des couleurs.
Dégradé
Vos propres couleurs primaire et secondaire, fondues sur les boutons et les éléments en surbrillance.
Primaire
@@ -1543,6 +1547,13 @@
Ignorer
Sélectionner ce dossier
Retour
+ Quitter
+ Revenir au dossier de départ
+ Le dossier de départ est indisponible
+ Annuler l’opération ?
+ Cette opération sera annulée. Confirmez-vous ?
+ Accepter
+ Refuser
%1$s est monté, mais illisible pour le moment
Vue en liste
Vue en grille
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index 113507232..d74e5c0d6 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem रेंडरिंग
Steam क्लाइंट के लिए तेज़; गेम में ड्राइवर हर पास के लिए टाइल या सिस्टम मेमोरी रेंडरिंग चुनता है। Adreno 710/720/722 पर ज़रूरी।
Steam के xalia हेल्पर को छोड़ें
- droiddeck-ntsync (एक्सपेरिमेंटल)
- जांच के लिए Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है।
+ droiddeck-ntsync
+ Linux ntsync ड्राइवर की यूज़रस्पेस जगह लेने वाली चीज़। चालू रहते हुए, Proton इसे droiddeck-esync और droiddeck-fsync के बदले इस्तेमाल करता है। यह Proton Experimental ARM64, GE-Proton और proton-cachyos के साथ काम करता है। अगले सेशन से लागू होता है।
droiddeck-esync
droiddeck-fsync पहले
स्टॉक Proton फ़ाइलों पर Proton का अपना fsync चलाता है, futex_waitv यूज़रस्पेस में दिया जाता है, इसलिए इसे किसी पैक की ज़रूरत नहीं और Steam के Proton अपडेट करते ही यह काम करता रहता है। चालू रहते हुए, गेम इसे droiddeck-esync से पहले इस्तेमाल करते हैं, सिवाय उन कुछ के जिन्हें Proton fsync से दूर रखता है। अगले सेशन से लागू होता है।
@@ -1401,6 +1401,10 @@
काली ज़मीन, सफ़ेद टाइप, चुने, फ़ोकस या दबाए गए हर चीज़ के लिए ब्लू रंग।
इलेक्ट्रिक नेवी
ब्लू रंग ज़्यादा चमकीला, उन सतहों पर जो ग्रे के बजाय नेवी की ओर झुकती हैं।
+ Inner Gamer
+ इंद्रधनुष के रंगों में बदलते रंग। Android की एनिमेशन सेटिंग का पालन करती है।
+ गति
+ रंग कितनी तेज़ी से बदलें।
ग्रेडिएंट
आपके अपने प्राथमिक और द्वितीयक रंग, बटनों और हाइलाइट पर मिश्रित।
प्राथमिक
@@ -1522,6 +1526,13 @@
छोड़ें
यह फ़ोल्डर चुनें
बैक
+ बाहर निकलें
+ शुरुआती फ़ोल्डर पर लौटें
+ शुरुआती फ़ोल्डर अब उपलब्ध नहीं है
+ कार्रवाई रद्द करें?
+ कार्रवाई रद्द कर दी जाएगी। क्या आप सुनिश्चित हैं?
+ स्वीकार करें
+ अस्वीकार करें
%1$s माउंट है लेकिन अभी पढ़ा नहीं जा सकता
लिस्ट व्यू
ग्रिड व्यू
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index a1638350a..e8571e492 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem renderelés
Gyorsabb a Steam kliensnek; játékokban a meghajtó menetenként választ a csempés és a rendszermemóriás renderelés között. Adreno 710/720/722-n szükséges.
A Steam xalia segítőjének kihagyása
- droiddeck-ntsync (kísérleti)
- A Linux ntsync illesztőprogram felhasználói szintű helyettesítője, teszteléshez. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes.
+ droiddeck-ntsync
+ A Linux ntsync illesztőprogram felhasználói szintű helyettesítője. Amíg be van kapcsolva, a Proton ezt használja a droiddeck-esync és a droiddeck-fsync helyett. A Proton Experimental ARM64-gyel, a GE-Protonnal és a proton-cachyosszal működik. A következő munkamenettől érvényes.
droiddeck-esync
droiddeck-fsync előre
A Proton saját fsyncjét futtatja az eredeti Proton-fájlokon, felhasználói szinten biztosított futex_waitv-vel, így nincs szükség csomagra, és azonnal működik, amint a Steam frissíti a Protont. Amíg be van kapcsolva, a játékok ezt használják a droiddeck-esync előtt, kivéve azt a néhányat, amelyet a Proton kizár az fsyncből. A következő munkamenettől érvényes.
@@ -1401,6 +1401,10 @@
Fekete alap, fehér betűk, a kék minden kijelölt, fókuszált vagy lenyomott elemhez.
Elektromos tengerészkék
Fényesebb kék, szürke helyett tengerészkék felületeken.
+ Inner Gamer
+ A szivárvány színein végigfutó színek. Követi az Android animációs beállításait.
+ Sebesség
+ Milyen gyorsan váltanak a színek.
Színátmenet
Saját elsődleges és másodlagos színed, átmenettel a gombokon és a kiemeléseken.
Elsődleges
@@ -1522,6 +1526,13 @@
Kihagyás
Ennek a mappának a kiválasztása
Vissza
+ Kilépés
+ Vissza a kezdőmappába
+ A kezdőmappa már nem érhető el
+ Művelet megszakítása?
+ A művelet megszakad. Biztos benne?
+ Elfogadom
+ Elutasítom
A %1$s csatolva van, de jelenleg nem olvasható
Listanézet
Rácsnézet
diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml
index 61bb207a0..80927811f 100644
--- a/app/src/main/res/values-in/strings.xml
+++ b/app/src/main/res/values-in/strings.xml
@@ -539,8 +539,8 @@
Turnip: rendering sysmem
Lebih cepat untuk klien Steam; di gim, driver memilih rendering ubin atau memori sistem untuk setiap pass. Diperlukan pada Adreno 710/720/722.
Lewati bantuan xalia milik Steam
- droiddeck-ntsync (eksperimental)
- Pengganti userspace untuk driver ntsync Linux, untuk pengujian. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya.
+ droiddeck-ntsync
+ Pengganti userspace untuk driver ntsync Linux. Saat aktif, Proton menggunakannya sebagai pengganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton, dan proton-cachyos. Berlaku pada sesi berikutnya.
droiddeck-esync
droiddeck-fsync lebih dulu
Menjalankan fsync milik Proton sendiri pada file Proton standar, dengan futex_waitv yang disediakan di userspace, sehingga tidak memerlukan paket dan tetap berfungsi begitu Steam memperbarui Proton. Saat aktif, gim menggunakannya sebelum droiddeck-esync, kecuali beberapa gim yang oleh Proton dijaga tetap di luar fsync. Berlaku pada sesi berikutnya.
@@ -1387,6 +1387,10 @@
Dasar hitam, tipe putih, biru untuk semua yang dipilih, difokuskan, atau ditekan.
Navy elektrik
Biru yang lebih cerah, pada permukaan yang condong ke navy, bukan abu-abu.
+ Inner Gamer
+ Warna yang berganti menyusuri pelangi. Mengikuti pengaturan animasi Android.
+ Kecepatan
+ Seberapa cepat warna berganti.
Gradasi
Warna primer dan sekunder pilihan Anda, dipadukan pada tombol dan sorotan.
Primer
@@ -1503,6 +1507,13 @@
Lewati
Pilih folder ini
Kembali
+ Keluar
+ Kembali ke folder awal
+ Folder awal tidak lagi tersedia
+ Batalkan operasi?
+ Operasi akan dibatalkan. Anda yakin?
+ Terima
+ Tolak
%1$s terpasang tetapi tidak dapat dibaca saat ini
Tampilan daftar
Tampilan kisi
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 0d8fd5882..e5aa836ad 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -543,8 +543,8 @@
Turnip: rendering sysmem
Più veloce per il client di Steam; nei giochi il driver sceglie per ogni passata tra rendering a tile e in memoria di sistema. Necessario su Adreno 710/720/722.
Salta l\'assistente xalia di Steam
- droiddeck-ntsync (sperimentale)
- Un sostituto in userspace del driver ntsync di Linux, per i test. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione.
+ droiddeck-ntsync
+ Un sostituto in userspace del driver ntsync di Linux. Quando è attivo, Proton lo usa al posto di droiddeck-esync e droiddeck-fsync. Funziona con Proton Experimental ARM64, GE-Proton e proton-cachyos. Si applica dalla prossima sessione.
droiddeck-esync
Prima droiddeck-fsync
Esegue il fsync di Proton sui file Proton originali, con futex_waitv fornito in userspace, quindi non richiede pacchetti e continua a funzionare appena Steam aggiorna Proton. Quando è attivo, i giochi lo usano prima di droiddeck-esync, tranne i pochi che Proton esclude da fsync. Si applica dalla prossima sessione.
@@ -1407,6 +1407,10 @@
Fondo nero, testo bianco, il blu per tutto ciò che è selezionato, attivo o premuto.
Blu elettrico
Il blu più brillante, su superfici che tendono al blu navy invece che al grigio.
+ Inner Gamer
+ Colori che scorrono lungo l\'arcobaleno. Rispetta le impostazioni di animazione di Android.
+ Velocità
+ Quanto velocemente cambiano i colori.
Gradiente
I tuoi colori primario e secondario, sfumati su pulsanti ed evidenziazioni.
Primario
@@ -1533,6 +1537,13 @@
Salta
Seleziona questa cartella
Indietro
+ Esci
+ Torna alla cartella iniziale
+ La cartella iniziale non è più disponibile
+ Annullare operazione?
+ Questa operazione verrà annullata. Confermi?
+ Accetta
+ Rifiuta
%1$s: montata ma al momento non leggibile
Visualizzazione elenco
Visualizzazione griglia
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index 7b719791f..03e8ae200 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -541,8 +541,8 @@
Turnip:sysmemレンダリング
Steamクライアントが高速になります。ゲームではドライバーがパスごとにタイルレンダリングとシステムメモリレンダリングを選びます。Adreno 710/720/722では必須です。
Steamのxalia補助をスキップ
- droiddeck-ntsync(実験的)
- Linuxのntsyncドライバーをユーザー空間で代替するテスト用機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。
+ droiddeck-ntsync
+ Linuxのntsyncドライバーをユーザー空間で代替する機能です。オンの間、Protonはdroiddeck-esyncとdroiddeck-fsyncの代わりにこれを使用します。Proton Experimental ARM64、GE-Proton、proton-cachyosで動作します。次回のセッションで有効になります。
droiddeck-esync
droiddeck-fsyncを優先
futex_waitvをユーザー空間で提供し、標準のProtonファイルのままProton自身のfsyncを実行します。パックが不要なため、SteamがProtonを更新してもすぐに動作し続けます。オンの間、Protonがfsyncを使わない一部のゲームを除き、ゲームはdroiddeck-esyncよりこちらを優先します。次回のセッションで有効になります。
@@ -1389,6 +1389,10 @@
黒の背景に白の文字、選択・フォーカス・押下時には青を使用します。
エレクトリックネイビー
より明るい青を、グレーではなくネイビー寄りの画面に使用します。
+ Inner Gamer
+ 虹色に変化し続けるカラー。Android のアニメーション設定に従います。
+ 速度
+ 色が切り替わる速さ。
グラデーション
自分で選んだプライマリとセカンダリの色を、ボタンやハイライトにグラデーションで使います。
プライマリ
@@ -1505,6 +1509,13 @@
スキップ
このフォルダを選択
戻る
+ 終了
+ 開始フォルダーに戻る
+ 開始フォルダーは利用できなくなりました
+ 操作をキャンセルしますか?
+ 操作がキャンセルされます。よろしいですか?
+ 承諾
+ 拒否
%1$sはマウントされていますが、現在読み取れません
リスト表示
グリッド表示
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 0ea3c7b1b..5096684e2 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem 렌더링
Steam 클라이언트가 빨라집니다. 게임에서는 드라이버가 패스마다 타일 렌더링과 시스템 메모리 렌더링 중 하나를 고릅니다. Adreno 710/720/722에 필수입니다.
Steam의 xalia 도우미 건너뛰기
- droiddeck-ntsync(실험적)
- 테스트용으로 Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다.
+ droiddeck-ntsync
+ Linux ntsync 드라이버를 사용자 공간에서 대신하는 기능입니다. 켜져 있으면 Proton이 droiddeck-esync와 droiddeck-fsync 대신 이 기능을 사용합니다. Proton Experimental ARM64, GE-Proton 및 proton-cachyos에서 작동합니다. 다음 세션에 적용됩니다.
droiddeck-esync
droiddeck-fsync 우선
futex_waitv를 사용자 공간에서 제공하여 기본 Proton 파일로 Proton 자체의 fsync를 실행하므로, 팩이 필요 없고 Steam이 Proton을 업데이트해도 바로 작동합니다. 켜져 있으면 게임이 droiddeck-esync보다 이 기능을 우선 사용합니다. 단, Proton이 fsync를 끄는 일부 게임은 예외입니다. 다음 세션에 적용됩니다.
@@ -1389,6 +1389,10 @@
검은색 배경에 흰색 글자, 선택·포커스·누름 상태에는 파란색을 사용합니다.
일렉트릭 네이비
회색 대신 네이비 톤의 화면에 더 밝은 파란색을 사용합니다.
+ Inner Gamer
+ 무지개 색으로 계속 바뀌는 색상입니다. Android의 애니메이션 설정을 따릅니다.
+ 속도
+ 색상이 바뀌는 속도입니다.
그라데이션
직접 고른 기본색과 보조색이 버튼과 강조 표시에 섞여 적용됩니다.
기본색
@@ -1505,6 +1509,13 @@
건너뛰기
이 폴더 선택
뒤로
+ 종료
+ 시작 폴더로 돌아가기
+ 시작 폴더를 더 이상 사용할 수 없습니다
+ 작업을 취소할까요?
+ 작업이 취소됩니다. 계속하시겠습니까?
+ 수락
+ 거절
%1$s이(가) 마운트되었지만 지금은 읽을 수 없습니다
목록 보기
격자 보기
diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml
index 2e3eb1205..82f9e7b2c 100644
--- a/app/src/main/res/values-ms/strings.xml
+++ b/app/src/main/res/values-ms/strings.xml
@@ -539,8 +539,8 @@
Turnip: rendering sysmem
Lebih pantas untuk klien Steam; dalam permainan, pemacu memilih pemaparan jubin atau memori sistem bagi setiap laluan. Diperlukan pada Adreno 710/720/722.
Langkau pembantu xalia Steam
- droiddeck-ntsync (eksperimen)
- Pengganti ruang pengguna untuk pemacu ntsync Linux, untuk pengujian. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya.
+ droiddeck-ntsync
+ Pengganti ruang pengguna untuk pemacu ntsync Linux. Semasa ia hidup, Proton menggunakannya sebagai ganti droiddeck-esync dan droiddeck-fsync. Berfungsi dengan Proton Experimental ARM64, GE-Proton dan proton-cachyos. Berkuat kuasa pada sesi seterusnya.
droiddeck-esync
droiddeck-fsync dahulu
Menjalankan fsync Proton sendiri pada fail Proton standard, dengan futex_waitv dibekalkan dalam ruang pengguna, jadi ia tidak memerlukan pek dan terus berfungsi sebaik sahaja Steam mengemas kini Proton. Semasa ia hidup, permainan menggunakannya mendahului droiddeck-esync, kecuali beberapa Proton yang mematikan fsync. Berkuat kuasa pada sesi seterusnya.
@@ -1387,6 +1387,10 @@
Latar hitam, teks putih, biru untuk semua yang dipilih, difokus atau ditekan.
Navy elektrik
Biru lebih cerah, pada permukaan yang lebih cenderung navy daripada kelabu.
+ Inner Gamer
+ Warna yang bertukar merentasi pelangi. Mengikut tetapan animasi Android.
+ Kelajuan
+ Seberapa pantas warna bertukar.
Kecerunan
Warna utama dan sekunder pilihan anda, diadun pada butang dan sorotan.
Utama
@@ -1503,6 +1507,13 @@
Langkau
Pilih folder ini
Kembali
+ Keluar
+ Kembali ke folder asal
+ Folder asal tidak lagi tersedia
+ Batalkan operasi?
+ Operasi akan dibatalkan. Adakah anda pasti?
+ Terima
+ Tolak
%1$s dilekapkan tetapi tidak dapat dibaca sekarang
Paparan senarai
Paparan grid
diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml
index e0ee85176..af48f21a9 100644
--- a/app/src/main/res/values-nb/strings.xml
+++ b/app/src/main/res/values-nb/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem-rendering
Raskere for Steam-klienten; i spill velger driveren flis- eller systemminnerendering for hvert pass. Påkrevd på Adreno 710/720/722.
Hopp over Steams xalia-hjelper
- droiddeck-ntsync (eksperimentell)
- En erstatning i brukerrom for Linux-driveren ntsync, for testing. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon.
+ droiddeck-ntsync
+ En erstatning i brukerrom for Linux-driveren ntsync. Mens dette er på, bruker Proton den i stedet for droiddeck-esync og droiddeck-fsync. Fungerer med Proton Experimental ARM64, GE-Proton og proton-cachyos. Gjelder fra neste sesjon.
droiddeck-esync
droiddeck-fsync først
Kjører Protons egen fsync på de uendrede Proton-filene, med futex_waitv levert i brukerrom, så den trenger ingen pakke og fortsetter å fungere i det Steam oppdaterer Proton. Mens dette er på, bruker spill den foran droiddeck-esync, med unntak av de få Proton holder utenfor fsync. Gjelder fra neste sesjon.
@@ -1401,6 +1401,10 @@
Svart bakgrunn, hvit skrift, det blå for alt som er valgt, fokusert eller trykket.
Elektrisk marineblå
Det blå lysere, på flater som lener mer mot marineblått enn grått.
+ Inner Gamer
+ Farger som går gjennom regnbuen. Følger Androids animasjonsinnstillinger.
+ Hastighet
+ Hvor raskt fargene skifter.
Gradient
Dine egne primær- og sekundærfarger, blandet på knapper og uthevinger.
Primær
@@ -1522,6 +1526,13 @@
Hopp over
Velg denne mappen
Tilbake
+ Avslutt
+ Tilbake til startmappen
+ Startmappen er ikke lenger tilgjengelig
+ Avbryt operasjonen?
+ Operasjonen blir avbrutt. Er du sikker?
+ Godta
+ Avslå
%1$s er montert, men ikke lesbar akkurat nå
Listevisning
Rutenettvisning
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 93b6ab06f..d591de6dd 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem-rendering
Sneller voor de Steam-client; in games kiest het stuurprogramma per pass tussen tegel- en systeemgeheugenrendering. Vereist op Adreno 710/720/722.
Sla de xalia-helper van Steam over
- droiddeck-ntsync (experimenteel)
- Een userspace-vervanger voor de ntsync-driver van Linux, voor tests. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie.
+ droiddeck-ntsync
+ Een userspace-vervanger voor de ntsync-driver van Linux. Terwijl dit aan staat, gebruikt Proton dit in plaats van droiddeck-esync en droiddeck-fsync. Werkt met Proton Experimental ARM64, GE-Proton en proton-cachyos. Geldt vanaf de volgende sessie.
droiddeck-esync
droiddeck-fsync eerst
Draait de eigen fsync van Proton op de standaard Proton-bestanden, met futex_waitv in userspace geleverd, zodat er geen pakket nodig is en dit blijft werken zodra Steam Proton bijwerkt. Terwijl dit aan staat, gebruiken games dit vóór droiddeck-esync, behalve de paar die Proton niet op fsync laat draaien. Geldt vanaf de volgende sessie.
@@ -1401,6 +1401,10 @@
Zwarte achtergrond, witte tekst, het blauw voor alles wat geselecteerd, gefocust of ingedrukt is.
Elektrisch marineblauw
Het blauw feller, op vlakken die naar marineblauw neigen in plaats van grijs.
+ Inner Gamer
+ Kleuren die door de regenboog lopen. Volgt de animatie-instellingen van Android.
+ Snelheid
+ Hoe snel de kleuren wisselen.
Kleurverloop
Je eigen primaire en secundaire kleur, doorlopend op knoppen en accenten.
Primair
@@ -1522,6 +1526,13 @@
Overslaan
Deze map selecteren
Terug
+ Afsluiten
+ Terug naar beginmap
+ De beginmap is niet meer beschikbaar
+ Bewerking annuleren?
+ De bewerking wordt geannuleerd. Weet je het zeker?
+ Accepteren
+ Weigeren
%1$s is gekoppeld, maar nu niet leesbaar
Lijstweergave
Rasterweergave
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 288e644ba..b1cdccf7b 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -545,8 +545,8 @@
Turnip: renderowanie sysmem
Szybsze dla klienta Steam; w grach sterownik dla każdego przebiegu wybiera renderowanie kafelkowe lub w pamięci systemowej. Wymagane na Adreno 710/720/722.
Pomijaj pomocnika xalia w Steam
- droiddeck-ntsync (eksperymentalne)
- Zastępnik sterownika Linux ntsync w przestrzeni użytkownika, do testów. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji.
+ droiddeck-ntsync
+ Zastępnik sterownika Linux ntsync w przestrzeni użytkownika. Gdy jest włączony, Proton używa go zamiast droiddeck-esync i droiddeck-fsync. Zgodny z Proton Experimental ARM64, GE-Proton i proton-cachyos. Działa od następnej sesji.
droiddeck-esync
Najpierw droiddeck-fsync
Uruchamia własny fsync Proton na standardowych plikach Proton, z futex_waitv dostarczanym w przestrzeni użytkownika, więc nie wymaga pakietu i działa od razu po aktualizacji Proton przez Steam. Gdy jest włączony, gry używają go przed droiddeck-esync, z wyjątkiem nielicznych, którym Proton wyłącza fsync. Działa od następnej sesji.
@@ -1429,6 +1429,10 @@
Czarne tło, biały tekst i niebieski dla wszystkiego, co zaznaczone, aktywne lub naciśnięte.
Elektryczny granat
Jaśniejszy niebieski na powierzchniach wpadających w granat zamiast w szarość.
+ Inner Gamer
+ Kolory przechodzące przez całą tęczę. Szanuje ustawienia animacji Androida.
+ Prędkość
+ Jak szybko zmieniają się kolory.
Gradient
Własny kolor podstawowy i dodatkowy, płynnie przechodzące na przyciskach i wyróżnieniach.
Podstawowy
@@ -1560,6 +1564,13 @@
Pomiń
Wybierz ten folder
Wstecz
+ Wyjdź
+ Wróć do folderu początkowego
+ Folder początkowy nie jest już dostępny
+ Anulować operację?
+ Operacja zostanie anulowana. Czy na pewno?
+ Akceptuj
+ Odrzuć
%1$s: zamontowano, ale obecnie nie można odczytać
Widok listy
Widok siatki
diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml
index 91e75d38f..0d5d739f3 100644
--- a/app/src/main/res/values-pt-rPT/strings.xml
+++ b/app/src/main/res/values-pt-rPT/strings.xml
@@ -543,8 +543,8 @@
Turnip: renderização em sysmem
Mais rápido para o cliente Steam; nos jogos, o controlador escolhe em cada passagem entre renderização em mosaico ou na memória do sistema. Obrigatório no Adreno 710/720/722.
Ignorar o assistente xalia do Steam
- droiddeck-ntsync (experimental)
- Um substituto em espaço de utilizador para o controlador ntsync do Linux, para testes. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão.
+ droiddeck-ntsync
+ Um substituto em espaço de utilizador para o controlador ntsync do Linux. Enquanto estiver ativado, o Proton utiliza-o em vez do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Aplica-se na próxima sessão.
droiddeck-esync
droiddeck-fsync primeiro
Executa o fsync do próprio Proton sobre os ficheiros originais do Proton, com o futex_waitv fornecido em espaço de utilizador, por isso não precisa de pacote e continua a funcionar assim que o Steam atualiza o Proton. Enquanto estiver ativado, os jogos utilizam-no antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Aplica-se na próxima sessão.
@@ -1407,6 +1407,10 @@
Fundo preto, texto branco e o azul para tudo o que está selecionado, focado ou premido.
Azul-marinho elétrico
O azul mais vivo, em superfícies que tendem para o azul-marinho em vez do cinzento.
+ Inner Gamer
+ Cores que percorrem o arco-íris. Respeita as definições de animação do Android.
+ Velocidade
+ Com que rapidez as cores mudam.
Gradiente
As suas próprias cores primária e secundária, combinadas nos botões e destaques.
Primária
@@ -1533,6 +1537,13 @@
Ignorar
Selecionar esta pasta
Voltar
+ Sair
+ Voltar à pasta inicial
+ A pasta inicial já não está disponível
+ Cancelar operação?
+ A operação será cancelada. Tem a certeza?
+ Aceitar
+ Recusar
%1$s está montado, mas não é possível lê-lo de momento
Vista de lista
Vista de grelha
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 45bd06550..3e5cd06e7 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -543,8 +543,8 @@
Turnip: renderização sysmem
Mais rápido para o cliente da Steam; nos jogos, o driver escolhe a cada passagem entre renderização em blocos ou na memória do sistema. Obrigatório nas Adreno 710/720/722.
Pular o assistente xalia da Steam
- droiddeck-ntsync (experimental)
- Um substituto em espaço de usuário para o driver ntsync do Linux, para testes. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão.
+ droiddeck-ntsync
+ Um substituto em espaço de usuário para o driver ntsync do Linux. Enquanto estiver ativado, o Proton o usa no lugar do droiddeck-esync e do droiddeck-fsync. Funciona com o Proton Experimental ARM64, o GE-Proton e o proton-cachyos. Vale a partir da próxima sessão.
droiddeck-esync
droiddeck-fsync primeiro
Roda o fsync do próprio Proton nos arquivos originais do Proton, com o futex_waitv fornecido em espaço de usuário, então não precisa de pacote e continua funcionando assim que a Steam atualiza o Proton. Enquanto estiver ativado, os jogos o usam antes do droiddeck-esync, exceto os poucos que o Proton mantém fora do fsync. Vale a partir da próxima sessão.
@@ -1407,6 +1407,10 @@
Fundo preto, texto branco e o azul em tudo que estiver selecionado, em foco ou pressionado.
Marinho elétrico
O azul mais vivo, sobre superfícies puxadas para o marinho em vez do cinza.
+ Inner Gamer
+ Cores que percorrem o arco-íris. Respeita as configurações de animação do Android.
+ Velocidade
+ Com que rapidez as cores mudam.
Gradiente
Suas próprias cores primária e secundária, combinadas em botões e destaques.
Primária
@@ -1533,6 +1537,13 @@
Pular
Selecionar esta pasta
Voltar
+ Sair
+ Voltar à pasta inicial
+ A pasta inicial não está mais disponível
+ Cancelar operação?
+ A operação será cancelada. Tem certeza?
+ Aceitar
+ Recusar
%1$s está montado, mas não pode ser lido agora
Visualização em lista
Visualização em grade
diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml
index 4b434247a..60f025f87 100644
--- a/app/src/main/res/values-ro/strings.xml
+++ b/app/src/main/res/values-ro/strings.xml
@@ -543,8 +543,8 @@
Turnip: randare sysmem
Mai rapidă pentru clientul Steam; în jocuri, driverul alege la fiecare trecere între randarea pe dale și cea în memoria sistemului. Necesară pe Adreno 710/720/722.
Omite asistentul xalia al Steam
- droiddeck-ntsync (experimental)
- Un substitut la nivel de utilizator pentru driverul Linux ntsync, pentru testare. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare.
+ droiddeck-ntsync
+ Un substitut la nivel de utilizator pentru driverul Linux ntsync. Cât timp este activ, Proton îl folosește în locul droiddeck-esync și droiddeck-fsync. Funcționează cu Proton Experimental ARM64, GE-Proton și proton-cachyos. Se aplică la sesiunea următoare.
droiddeck-esync
droiddeck-fsync primul
Rulează propriul fsync al Proton pe fișierele Proton standard, cu futex_waitv furnizat la nivel de utilizator, deci nu are nevoie de pachet și continuă să funcționeze imediat ce Steam actualizează Proton. Cât timp este activ, jocurile îl folosesc înaintea droiddeck-esync, cu excepția celor puține pe care Proton le ține în afara fsync. Se aplică la sesiunea următoare.
@@ -1415,6 +1415,10 @@
Fundal negru, text alb, albastrul pentru tot ce este selectat, focalizat sau apăsat.
Bleumarin electric
Albastrul mai strălucitor, pe suprafețe care tind spre bleumarin în loc de gri.
+ Inner Gamer
+ Culori care parcurg curcubeul. Respectă setările de animație din Android.
+ Viteză
+ Cât de repede se schimbă culorile.
Gradient
Culorile tale primară și secundară, combinate pe butoane și evidențieri.
Primară
@@ -1541,6 +1545,13 @@
Omite
Selectează acest folder
Înapoi
+ Ieșire
+ Înapoi la dosarul inițial
+ Dosarul inițial nu mai este disponibil
+ Anulați operația?
+ Operația va fi anulată. Sunteți sigur?
+ Acceptă
+ Refuză
%1$s este montat, dar nu poate fi citit acum
Vizualizare listă
Vizualizare grilă
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 592685d83..39582774c 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -547,8 +547,8 @@
Turnip: рендеринг sysmem
Клиент Steam будет быстрее; в играх драйвер для каждого прохода выбирает тайловый рендеринг или рендеринг в системной памяти. Требуется для Adreno 710/720/722.
Пропустить помощник xalia в Steam
- droiddeck-ntsync (экспериментально)
- Замена драйвера ntsync Linux в пространстве пользователя, для тестирования. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии.
+ droiddeck-ntsync
+ Замена драйвера ntsync Linux в пространстве пользователя. Пока включено, Proton использует ее вместо droiddeck-esync и droiddeck-fsync. Работает с Proton Experimental ARM64, GE-Proton и proton-cachyos. Вступит в силу в следующей сессии.
droiddeck-esync
droiddeck-fsync в приоритете
Запускает собственный fsync Proton на стандартных файлах Proton, а futex_waitv предоставляется в пространстве пользователя: пакет не нужен, и все продолжает работать сразу после обновления Proton в Steam. Пока включено, игры используют его раньше droiddeck-esync, кроме тех немногих, для которых Proton отключает fsync. Вступит в силу в следующей сессии.
@@ -1431,6 +1431,10 @@
Черный фон, белый текст, синий — для всего выбранного, активного или нажатого.
Электрик
Более яркий синий на поверхностях темно-синего, а не серого оттенка.
+ Inner Gamer
+ Цвета, плавно переходящие по всей радуге. Учитывает настройки анимации Android.
+ Скорость
+ Как быстро меняются цвета.
Градиент
Ваши основной и дополнительный цвета, плавно переходящие на кнопках и выделениях.
Основной
@@ -1562,6 +1566,13 @@
Пропустить
Выбрать эту папку
Назад
+ Выход
+ Вернуться в начальную папку
+ Начальная папка больше недоступна
+ Отменить операцию?
+ Операция будет отменена. Вы уверены?
+ Принять
+ Отклонить
%1$s: подключено, но сейчас недоступно для чтения
Список
Сетка
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 5f75e2177..d245c20a0 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem-rendering
Snabbare för Steam-klienten; i spel väljer drivrutinen kakel- eller systemminnesrendering för varje pass. Krävs på Adreno 710/720/722.
Hoppa över Steams xalia-hjälp
- droiddeck-ntsync (experimentell)
- En ersättning i användarutrymmet för Linux ntsync-drivrutinen, för testning. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session.
+ droiddeck-ntsync
+ En ersättning i användarutrymmet för Linux ntsync-drivrutinen. Medan det är på använder Proton det i stället för droiddeck-esync och droiddeck-fsync. Fungerar med Proton Experimental ARM64, GE-Proton och proton-cachyos. Gäller från nästa session.
droiddeck-esync
droiddeck-fsync först
Kör Protons egen fsync på de ursprungliga Proton-filerna, med futex_waitv tillhandahållen i användarutrymmet, så det behöver inget paket och fortsätter fungera så snart Steam uppdaterar Proton. Medan det är på använder spel det före droiddeck-esync, förutom de få som Proton håller borta från fsync. Gäller från nästa session.
@@ -1401,6 +1401,10 @@
Svart bakgrund, vit text, den blå färgen för allt som är markerat, i fokus eller nedtryckt.
Elektrisk marinblå
Den blå färgen klarare, på ytor som lutar åt marinblått i stället för grått.
+ Inner Gamer
+ Färger som växlar genom regnbågen. Följer Androids animationsinställningar.
+ Hastighet
+ Hur snabbt färgerna växlar.
Gradient
Dina egna primära och sekundära färger, blandade på knappar och markeringar.
Primär
@@ -1522,6 +1526,13 @@
Hoppa över
Välj den här mappen
Tillbaka
+ Avsluta
+ Tillbaka till startmappen
+ Startmappen är inte längre tillgänglig
+ Avbryt åtgärden?
+ Åtgärden avbryts. Är du säker?
+ Acceptera
+ Avböj
%1$s är monterad men inte läsbar just nu
Listvy
Rutnätsvy
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index d2c04317c..ae74b6e9f 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -539,8 +539,8 @@
Turnip: การเรนเดอร์ sysmem
เร็วขึ้นสำหรับไคลเอนต์ Steam ในเกมไดรเวอร์จะเลือกการเรนเดอร์แบบไทล์หรือในหน่วยความจำระบบในแต่ละพาส จำเป็นสำหรับ Adreno 710/720/722
ข้ามตัวช่วย xalia ของ Steam
- droiddeck-ntsync (ทดลอง)
- ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux สำหรับการทดสอบ เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป
+ droiddeck-ntsync
+ ตัวแทนในระดับยูสเซอร์สเปซของไดรเวอร์ ntsync ของ Linux เมื่อเปิดใช้ Proton จะใช้สิ่งนี้แทน droiddeck-esync และ droiddeck-fsync ใช้ได้กับ Proton Experimental ARM64, GE-Proton และ proton-cachyos จะมีผลในเซสชันถัดไป
droiddeck-esync
droiddeck-fsync ก่อน
รัน fsync ของ Proton เองบนไฟล์ Proton มาตรฐาน โดยมี futex_waitv ที่จัดเตรียมในระดับยูสเซอร์สเปซ จึงไม่ต้องใช้แพ็กและยังคงทำงานได้ทันทีที่ Steam อัปเดต Proton เมื่อเปิดใช้ เกมจะใช้สิ่งนี้ก่อน droiddeck-esync ยกเว้นบางส่วนที่ Proton ปิด fsync ไว้ จะมีผลในเซสชันถัดไป
@@ -1387,6 +1387,10 @@
พื้นดำ ตัวอักษรขาว สีฟ้าสำหรับทุกสิ่งที่ถูกเลือก โฟกัส หรือกด
กรมท่าไฟฟ้า
สีฟ้าสว่างขึ้น บนพื้นผิวที่เอียงไปทางกรมท่ามากกว่าเทา
+ Inner Gamer
+ สีที่เปลี่ยนวนไปตามสายรุ้ง ทำงานตามการตั้งค่าแอนิเมชันของ Android
+ ความเร็ว
+ สีเปลี่ยนเร็วแค่ไหน
ไล่เฉดสี
สีหลักและสีรองที่คุณเลือกเอง ผสมกันบนปุ่มและส่วนที่เน้น
สีหลัก
@@ -1503,6 +1507,13 @@
ข้าม
เลือกโฟลเดอร์นี้
กลับ
+ ออก
+ กลับไปยังโฟลเดอร์เริ่มต้น
+ โฟลเดอร์เริ่มต้นไม่พร้อมใช้งานแล้ว
+ ยกเลิกการดำเนินการ?
+ การดำเนินการจะถูกยกเลิก คุณแน่ใจหรือไม่?
+ ยอมรับ
+ ปฏิเสธ
%1$s ติดตั้งอยู่แต่อ่านไม่ได้ในขณะนี้
มุมมองรายการ
มุมมองตาราง
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 24fc12e92..146f57d80 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -541,8 +541,8 @@
Turnip: sysmem işleme
Steam istemcisi için daha hızlı; oyunlarda sürücü her geçişte döşeme tabanlı veya sistem belleğinde işlemeyi seçer. Adreno 710/720/722\'de gereklidir.
Steam\'in xalia yardımcısını atla
- droiddeck-ntsync (deneysel)
- Linux ntsync sürücüsünün test amaçlı, kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır.
+ droiddeck-ntsync
+ Linux ntsync sürücüsünün kullanıcı alanında çalışan bir yedeği. Açıkken Proton, droiddeck-esync ve droiddeck-fsync yerine bunu kullanır. Proton Experimental ARM64, GE-Proton ve proton-cachyos ile çalışır. Sonraki oturumda uygulanır.
droiddeck-esync
Önce droiddeck-fsync
Proton\'un kendi fsync\'ini standart Proton dosyalarıyla, futex_waitv kullanıcı alanında sağlanarak çalıştırır; bu yüzden paket gerektirmez ve Steam Proton\'u güncellediği anda da çalışmaya devam eder. Açıkken oyunlar, Proton\'un fsync dışında tuttuğu birkaç oyun hariç, bunu droiddeck-esync\'ten önce kullanır. Sonraki oturumda uygulanır.
@@ -1395,6 +1395,10 @@
Siyah zemin, beyaz yazı; seçilen, odaklanan veya basılan her şey için mavi.
Elektrik laciverti
Daha parlak mavi; gri yerine laciverte çalan yüzeylerde.
+ Inner Gamer
+ Gökkuşağında dolaşan renkler. Android\'in animasyon ayarlarına uyar.
+ Hız
+ Renklerin ne kadar hızlı değiştiği.
Geçiş
Kendi birincil ve ikincil renklerin, düğmelerde ve vurgularda harmanlanır.
Birincil
@@ -1516,6 +1520,13 @@
Atla
Bu klasörü seç
Geri
+ Çıkış
+ Başlangıç klasörüne dön
+ Başlangıç klasörü artık kullanılamıyor
+ İşlem iptal edilsin mi?
+ İşlem iptal edilecek. Emin misiniz?
+ Kabul et
+ Reddet
%1$s bağlı ancak şu anda okunamıyor
Liste görünümü
Izgara görünümü
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 0fe47636a..5b8bfd4c4 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -545,8 +545,8 @@
Turnip: рендеринг через sysmem
Швидше для клієнта Steam; в іграх драйвер для кожного проходу вибирає тайловий рендеринг або рендеринг у системній пам’яті. Потрібно для Adreno 710/720/722.
Пропускати помічник Steam xalia
- droiddeck-ntsync (експериментально)
- Програмна заміна драйвера ntsync ядра Linux у просторі користувача, для тестування. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі.
+ droiddeck-ntsync
+ Програмна заміна драйвера ntsync ядра Linux у просторі користувача. Поки це увімкнено, Proton використовує її замість droiddeck-esync і droiddeck-fsync. Працює з Proton Experimental ARM64, GE-Proton і proton-cachyos. Застосовується в наступному сеансі.
droiddeck-esync
droiddeck-fsync першим
Запускає власний fsync Proton на стандартних файлах Proton, а futex_waitv надається в просторі користувача, тому не потрібен пакет і все працює відразу після оновлення Proton у Steam. Поки це увімкнено, ігри використовують його раніше за droiddeck-esync, окрім кількох, де Proton тримає fsync вимкненим. Застосовується в наступному сеансі.
@@ -1429,6 +1429,10 @@
Чорне тло, білий текст і синій для всього вибраного, у фокусі чи натиснутого.
Електричний темно-синій
Яскравіший синій на поверхнях із темно-синім відтінком замість сірого.
+ Inner Gamer
+ Кольори, що плавно змінюються по всій веселці. Враховує налаштування анімації Android.
+ Швидкість
+ Як швидко змінюються кольори.
Градієнт
Ваші основний і додатковий кольори, що плавно переходять на кнопках і виділеннях.
Основний
@@ -1560,6 +1564,13 @@
Пропустити
Вибрати цю папку
Назад
+ Вийти
+ Повернутися до початкової папки
+ Початкова папка більше недоступна
+ Скасувати операцію?
+ Операцію буде скасовано. Ви впевнені?
+ Прийняти
+ Відхилити
%1$s: підключено, але читання зараз недоступне
Список
Сітка
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index c7e88e065..3effbb2a0 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -539,8 +539,8 @@
Turnip: render sysmem
Nhanh hơn cho ứng dụng Steam; trong game, trình điều khiển chọn kết xuất theo ô hoặc trong bộ nhớ hệ thống cho từng lượt. Bắt buộc trên Adreno 710/720/722.
Bỏ qua trình trợ giúp xalia của Steam
- droiddeck-ntsync (thử nghiệm)
- Một bản thay thế ở không gian người dùng cho driver ntsync của Linux, dùng để thử nghiệm. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau.
+ droiddeck-ntsync
+ Một bản thay thế ở không gian người dùng cho driver ntsync của Linux. Khi đang bật, Proton dùng nó thay cho droiddeck-esync và droiddeck-fsync. Hoạt động với Proton Experimental ARM64, GE-Proton và proton-cachyos. Áp dụng từ phiên sau.
droiddeck-esync
droiddeck-fsync trước
Chạy fsync riêng của Proton trên các tệp Proton gốc, với futex_waitv được cung cấp ở không gian người dùng, nên không cần pack và vẫn hoạt động ngay khi Steam cập nhật Proton. Khi đang bật, game dùng nó trước droiddeck-esync, trừ một số ít Proton giữ fsync tắt. Áp dụng từ phiên sau.
@@ -1387,6 +1387,10 @@
Nền đen, chữ trắng, xanh cho mọi thứ được chọn, lấy nét hoặc nhấn.
Xanh điện navy
Màu xanh sáng hơn, trên các bề mặt nghiêng về navy thay vì xám.
+ Inner Gamer
+ Màu sắc chuyển dần qua cả dải cầu vồng. Tuân theo cài đặt hiệu ứng động của Android.
+ Tốc độ
+ Màu đổi nhanh đến mức nào.
Chuyển màu
Màu chính và màu phụ do bạn chọn, hòa trộn trên các nút và phần nổi bật.
Chính
@@ -1503,6 +1507,13 @@
Bỏ qua
Chọn thư mục này
Quay lại
+ Thoát
+ Quay lại thư mục ban đầu
+ Thư mục ban đầu không còn khả dụng
+ Hủy thao tác?
+ Thao tác sẽ bị hủy. Bạn có chắc không?
+ Chấp nhận
+ Từ chối
%1$s đã được gắn nhưng hiện không đọc được
Xem dạng danh sách
Xem dạng lưới
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 68d927ebb..d7c20634c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -541,8 +541,8 @@
Turnip:sysmem 渲染
Steam 客户端更快;游戏中驱动会为每个渲染通道选择分块渲染或系统内存渲染。Adreno 710/720/722 必需。
跳过 Steam 的 xalia 助手
- droiddeck-ntsync(实验性)
- Linux ntsync 驱动的用户态替代品,供测试使用。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。
+ droiddeck-ntsync
+ Linux ntsync 驱动的用户态替代品。开启时,Proton 使用它代替 droiddeck-esync 和 droiddeck-fsync。适用于 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。将在下次会话生效。
droiddeck-esync
droiddeck-fsync 优先
在原始 Proton 文件上运行 Proton 自带的 fsync,由用户态提供 futex_waitv,因此无需任何包,且 Steam 更新 Proton 后立即继续可用。开启时,除 Proton 自身禁用 fsync 的少数游戏外,各游戏优先使用它而非 droiddeck-esync。将在下次会话生效。
@@ -1465,6 +1465,10 @@
黑色背景、白色文字,选中、聚焦或按下的元素使用蓝色。
电光藏青
更明亮的蓝色,配以偏藏青而非灰色的界面。
+ Inner Gamer
+ 在彩虹色之间循环变化的颜色。遵循 Android 的动画设置。
+ 速度
+ 颜色循环的快慢。
渐变
你自己选择的主色和辅色,在按钮和高亮处渐变呈现。
主色
@@ -1581,6 +1585,13 @@
跳过
选择此文件夹
返回
+ 退出
+ 返回初始文件夹
+ 初始文件夹已不可用
+ 取消操作?
+ 操作将被取消。确定吗?
+ 接受
+ 拒绝
%1$s 已挂载,但当前无法读取
列表视图
网格视图
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index a39c7b101..e021df01e 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -541,8 +541,8 @@
Turnip:sysmem 渲染
Steam 用戶端會更快;遊戲中驅動程式會為每個渲染通道選擇分塊渲染或系統記憶體渲染。Adreno 710/720/722 必須開啟。
略過 Steam 的 xalia 輔助程式
- droiddeck-ntsync(實驗性)
- 在用戶空間代替 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。
+ droiddeck-ntsync
+ 在用戶空間代替 Linux ntsync 驅動程式。開啟時,Proton 會使用它而非 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次會話生效。
droiddeck-esync
優先使用 droiddeck-fsync
在原裝 Proton 檔案上執行 Proton 本身的 fsync,並在用戶空間提供 futex_waitv,因此無需套件,Steam 更新 Proton 後亦能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 刻意不用 fsync 的少數遊戲除外。下次會話生效。
@@ -1389,6 +1389,10 @@
黑色背景、白色文字,所有已選取、焦點所在或按下的項目都使用藍色。
電光海軍藍
更明亮的藍色,搭配偏海軍藍而非灰色的介面。
+ Inner Gamer
+ 在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。
+ 速度
+ 顏色循環的快慢。
漸層
你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。
主色
@@ -1505,6 +1509,13 @@
略過
選擇此資料夾
返回
+ 離開
+ 返回初始資料夾
+ 初始資料夾已無法使用
+ 取消操作?
+ 操作將會取消。確定嗎?
+ 接受
+ 拒絕
%1$s 已掛載,但目前無法讀取
清單檢視
格狀檢視
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index d29f3cef4..44e41ea6a 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -541,8 +541,8 @@
Turnip:sysmem 渲染
Steam 用戶端會更快;遊戲中驅動程式會為每個算繪通道選擇分塊算繪或系統記憶體算繪。Adreno 710/720/722 必需。
跳過 Steam 的 xalia 輔助程式
- droiddeck-ntsync(實驗性)
- 以使用者空間替代 Linux ntsync 驅動程式,供測試使用。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。
+ droiddeck-ntsync
+ 以使用者空間替代 Linux ntsync 驅動程式。開啟時,Proton 會使用它來取代 droiddeck-esync 和 droiddeck-fsync。適用於 Proton Experimental ARM64、GE-Proton 和 proton-cachyos。下次工作階段生效。
droiddeck-esync
優先使用 droiddeck-fsync
在原版 Proton 檔案上執行 Proton 自己的 fsync,並在使用者空間提供 futex_waitv,因此不需要套件,Steam 更新 Proton 後也能立即繼續運作。開啟時,遊戲會優先使用它而非 droiddeck-esync,Proton 本身停用 fsync 的少數遊戲除外。下次工作階段生效。
@@ -1389,6 +1389,10 @@
黑色背景、白色文字,選取、聚焦或按下的項目使用藍色。
電光藏青
更亮的藍色,搭配偏藏青而非灰色的介面。
+ Inner Gamer
+ 在彩虹色之間循環變化的顏色。遵循 Android 的動畫設定。
+ 速度
+ 顏色循環的快慢。
漸層
你自行選擇的主色與輔色,在按鈕與強調處以漸層呈現。
主色
@@ -1505,6 +1509,13 @@
略過
選擇此資料夾
返回
+ 離開
+ 返回起始資料夾
+ 起始資料夾已無法使用
+ 取消操作?
+ 操作將會取消。確定嗎?
+ 接受
+ 拒絕
%1$s 已掛載,但目前無法讀取
清單檢視
格狀檢視
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c449a05be..bbe2d6fc3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -320,6 +320,8 @@
Turn off if games crash or go black in Deck mode. Applies next session.
Return to DroidDeck after a game
When a game you started from DroidDeck or another launcher closes, Steam exits cleanly (cloud saves finish first) and you go back there. Off: stay in Steam.
+ Shader pre-caching
+ Steam downloads shaders prebuilt for your games and processes them in the background, so games stutter less. Off saves data, storage and battery; games build shaders as they play instead. The same setting as Steam’s Downloads > Shader Pre-Caching. Applies next session.
Repair Steam client
Downloads the Steam client again the next time Steam starts. Use it if Steam stays on its loading screen. Games, sign-in and settings are kept.
The Steam client will be downloaded again the next time Steam starts.
@@ -554,8 +556,8 @@
Turnip: sysmem rendering
Faster for the Steam client; in games the driver picks tile or system-memory rendering per pass. Required on Adreno 710/720/722.
Skip Steam\'s xalia helper
- droiddeck-ntsync (experimental)
- A userspace stand-in for the Linux ntsync driver, for testing. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session.
+ droiddeck-ntsync
+ A userspace stand-in for the Linux ntsync driver. While it is on, Proton uses it instead of droiddeck-esync and droiddeck-fsync. Works with Proton Experimental ARM64, GE-Proton and proton-cachyos. Applies next session.
droiddeck-esync
droiddeck-fsync first
Runs Proton\'s own fsync on the stock Proton files, with futex_waitv supplied in userspace, so it needs no pack and keeps working the moment Steam updates Proton. While it is on, games use it ahead of droiddeck-esync, except the few Proton keeps off fsync. Applies next session.
@@ -566,6 +568,8 @@
proot fast path
Needs proot\'s seccomp filter.
Faster file lookups and less stutter. Turn off if a program can\'t find its files.
+ Draw the cursor inside gamescope
+ Gamescope paints the pointer itself instead of handing it to Android. Turn on for a controller-driven cursor. Off is faster on a phone. Applies next session.
Session identity
Host name
The name Steam sees for this device. Blank restores %1$s.
@@ -1099,6 +1103,9 @@
Hide drivers for other GPUs
Show drivers for other GPUs
Advanced
+ UBWC compatibility
+ Try On or Off if Steam or Android shows stripes or flickering. Applies to both drivers after restarting DroidDeck.
+ Restart DroidDeck to apply.
Set together with the display driver: %1$s carries both.
Set together with the runtime driver: %1$s carries both.
Used by games and the desktop.
@@ -1458,6 +1465,10 @@
Black ground, white type, the blue for everything selected, focused or pressed.
Electric navy
The blue brighter, on surfaces that lean navy instead of grey.
+ Inner Gamer
+ Colors that cycle through the rainbow. Follows Android\'s animation settings.
+ Speed
+ How fast the colors cycle.
Gradient
Your own primary and secondary colors, blended on buttons and highlights.
Primary
@@ -1579,6 +1590,13 @@
Skip
Select this folder
Back
+ Exit
+ Return to start
+ Starting folder is no longer available
+ Cancel operation?
+ The operation will be cancelled. Are you sure?
+ Accept
+ Decline
%1$s is mounted but not readable right now
List view
Grid view
diff --git a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt
index afcb0d4b5..4be7a14e6 100644
--- a/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/DriverMenusAutoTest.kt
@@ -6,9 +6,11 @@ import android.os.Handler
import android.os.Looper
import com.droiddeck.launcher.session.QuestExperimental
import com.droiddeck.launcher.session.SessionPrefs
+import com.droiddeck.launcher.gpu.TpUbwcHint
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -17,11 +19,15 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.robolectric.shadows.ShadowBuild
+import org.robolectric.util.ReflectionHelpers
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], manifest = Config.NONE)
class DriverMenusAutoTest {
@Before fun reset() {
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false)
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null)
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null)
RuntimeEnvironment.getApplication().getSharedPreferences("session", Context.MODE_PRIVATE)
.edit().clear().commit()
ShadowBuild.setModel("Quest 3")
@@ -53,4 +59,36 @@ class DriverMenusAutoTest {
assertEquals("chosen-display", SessionPrefs.androidDriver(activity))
assertEquals("chosen-linux", SessionPrefs.linuxDriver(activity))
}
+
+ @Test fun driverMenusCanBeConstructedBeforeActivityContextIsAttached() {
+ val menus = DriverMenus(Activity(), Handler(Looper.getMainLooper()))
+ assertEquals(SessionPrefs.TP_UBWC_AUTO, menus.tpUbwcMode)
+ }
+
+ @Test fun refreshingDriversLoadsTheSavedUbwcModeAfterContextAttachment() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON)
+ val menus = DriverMenus(activity, Handler(Looper.getMainLooper()))
+ menus.refreshDrivers()
+ assertEquals(SessionPrefs.TP_UBWC_ON, menus.tpUbwcMode)
+ assertFalse(menus.state().tpUbwcRestartRequired)
+ }
+
+ @Test fun changingUbwcBeforeTheFirstSessionDoesNotRequireRestart() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val menus = DriverMenus(activity, Handler(Looper.getMainLooper()))
+ menus.refreshDrivers()
+ SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON)
+ menus.refreshDrivers()
+ assertFalse(menus.state().tpUbwcRestartRequired)
+ }
+
+ @Test fun recreatingTheMenuKeepsTheActiveHintRestartWarning() {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ TpUbwcHint.forThisDevice(activity)
+ SessionPrefs.setTpUbwcMode(activity, SessionPrefs.TP_UBWC_ON)
+ val menus = DriverMenus(activity, Handler(Looper.getMainLooper()))
+ menus.refreshDrivers()
+ assertTrue(menus.state().tpUbwcRestartRequired)
+ }
}
diff --git a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt b/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt
deleted file mode 100644
index ba6e31a10..000000000
--- a/app/src/test/java/com/droiddeck/launcher/core/CpuAllocationDefaultsTest.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.droiddeck.launcher.core
-
-import org.junit.Assert.*
-import org.junit.Test
-
-class CpuAllocationDefaultsTest {
- private val thor = (0..7).associateWith { when (it) { in 0..2 -> 2.016; in 3..6 -> 2.8032; else -> 3.1872 } }
-
- @Test fun gen2UsesTheFourMiddleCoresAndOnePrime() {
- for (model in listOf("SM8550", "QCS8550", "sm8550-ac")) {
- assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice(model, "", thor))
- }
- assertEquals(CpuAllocationDefaults.Allocation("7", "3,4,5,6"), CpuAllocationDefaults.forDevice("", "kalama", thor))
- }
-
- @Test fun coreNumbersAndClockVariantsAreDetectedInsteadOfHardcoded() {
- val reordered = mapOf(0 to 3.36, 1 to 2.0, 2 to 2.0, 3 to 2.0, 4 to 2.8, 5 to 2.8, 6 to 2.8, 7 to 2.8)
- assertEquals(CpuAllocationDefaults.Allocation("0", "4,5,6,7"), CpuAllocationDefaults.forDevice("SM8550-AC", "kalama", reordered))
- }
-
- @Test fun anotherOrUnknownChipDoesNotAcquireThisDefault() {
- assertNull(CpuAllocationDefaults.forDevice("SM8650", "pineapple", thor))
- assertNull(CpuAllocationDefaults.forDevice("SM8650", "kalama", thor))
- assertNull(CpuAllocationDefaults.forDevice("", "", thor))
- }
-
- @Test fun missingOrUnexpectedTopologyKeepsTheSchedulerDefault() {
- assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor - 7))
- assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to null)))
- assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to 0.0)))
- assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", thor + (7 to Double.NaN)))
- assertNull(CpuAllocationDefaults.forDevice("SM8550", "kalama", (0..7).associateWith { 2.8 }))
- }
-
- @Test fun allCoreGamesUndoTheClientsInheritedRestriction() {
- val all = CpuCores.listOrAll("")
- assertEquals(all, CpuCores.gameLaunchList("", clientPinned = true))
- assertEquals(all, CpuCores.gameLaunchList(all, clientPinned = true))
- assertEquals("", CpuCores.gameLaunchList("", clientPinned = false))
- assertEquals("0", CpuCores.gameLaunchList("0", clientPinned = true))
- }
-}
diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt
new file mode 100644
index 000000000..cbcf20755
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/files/FileManagerNavigationTest.kt
@@ -0,0 +1,20 @@
+package com.droiddeck.launcher.files
+
+import java.io.File
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+class FileManagerNavigationTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ @Test fun backCannotNavigateAboveTheCurrentDriveRoot() {
+ val drive = tmp.newFolder("drive")
+ val child = File(drive, "folder").apply { mkdir() }
+
+ assertTrue(canNavigateUp(child, drive))
+ assertFalse(canNavigateUp(drive, drive))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt
new file mode 100644
index 000000000..8ba15591e
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCancellationTest.kt
@@ -0,0 +1,121 @@
+package com.droiddeck.launcher.files
+
+import java.io.File
+import java.util.concurrent.CancellationException
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class FileOpsCancellationTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ @Test fun cancellingCopyRemovesAnIncompleteNewFile() {
+ val source = tmp.newFile("source").apply { writeBytes(ByteArray(5 * 1024 * 1024)) }
+ val destination = File(tmp.root, "destination")
+ var cancelled = false
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.copyWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled }
+ }
+
+ assertTrue(source.exists())
+ assertFalse(destination.exists())
+ }
+
+ @Test fun cancellingMoveFallbackPreservesTheSourceAndExistingDestination() {
+ val source = tmp.newFile("source").apply { writeText("replacement") }
+ val destination = tmp.newFile("destination").apply { writeText("original") }
+ var cancelled = false
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.moveWithProgress(source, destination, { _, _ -> cancelled = true }) { cancelled }
+ }
+
+ assertEquals("replacement", source.readText())
+ assertEquals("original", destination.readText())
+ }
+
+ @Test fun aCommittedMoveFinishesRemovingTheSourceDespiteCancellation() {
+ val source = tmp.newFile("source").apply { writeText("replacement") }
+ val destination = tmp.newFile("destination").apply { writeText("original") }
+ val isCancelled = { destination.readText() == "replacement" }
+
+ assertTrue(FileOps.moveWithProgress(source, destination, null, isCancelled))
+
+ assertTrue(isCancelled())
+ assertEquals("replacement", destination.readText())
+ assertFalse(source.exists())
+ assertEquals(setOf("destination"), tmp.root.list()!!.toSet())
+ }
+
+ @Test fun cancellingTheSizeScanStopsBeforeCreatingTheDestination() {
+ val source = tmp.newFolder("source")
+ repeat(3) { File(source, "file-$it").writeText("content") }
+ val destination = File(tmp.root, "destination")
+ var checks = 0
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.copyWithProgress(source, destination, null) {
+ assertFalse(destination.exists())
+ ++checks > 2
+ }
+ }
+
+ assertEquals(3, checks)
+ assertFalse(destination.exists())
+ assertEquals(3, source.listFiles()?.size)
+ }
+
+ @Test fun cancellingCopyRemovesANewDestinationDirectory() {
+ val source = tmp.newFolder("source")
+ val payload = File(source, "nested/payload").apply {
+ parentFile!!.mkdirs()
+ writeText("content")
+ }
+ val destination = File(tmp.root, "destination")
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") })
+ }
+
+ assertEquals("content", payload.readText())
+ assertFalse(destination.exists())
+ }
+
+ @Test fun cancellingCopyPreservesAnExistingDestinationDirectory() {
+ val source = tmp.newFolder("source")
+ File(source, "nested/payload").apply {
+ parentFile!!.mkdirs()
+ writeText("replacement")
+ }
+ val destination = tmp.newFolder("destination")
+ val existing = File(destination, "keep").apply { writeText("original") }
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.copyWithProgress(source, destination, { _, _ -> throw CancellationException("cancel") })
+ }
+
+ assertEquals("original", existing.readText())
+ assertEquals(setOf("keep"), destination.list()!!.toSet())
+ }
+
+ @Test fun cancellingRecursiveDeleteStopsBeforeTheNextFile() {
+ val directory = tmp.newFolder("directory")
+ repeat(3) { File(directory, "file-$it").writeText("content") }
+ var checks = 0
+
+ assertThrows(CancellationException::class.java) {
+ FileOps.delete(directory) { ++checks > 3 }
+ }
+
+ assertTrue(directory.exists())
+ assertEquals(2, directory.listFiles()?.size)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt
index ac13ac781..e92ca2aec 100644
--- a/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/files/FileOpsCopyTest.kt
@@ -53,27 +53,25 @@ class FileOpsCopyTest {
assertEquals("replacement", src.readText())
}
- @Test fun cancellingStopsATreeOfEmptyFiles() {
+ @Test fun cancellingRemovesANewTreeOfEmptyFiles() {
val src = tmp.newFolder("source")
for (name in listOf("a", "b", "c")) File(src, name).createNewFile()
val dst = File(tmp.root, "dest")
- var checks = 0
assertThrows(CancellationException::class.java) {
- FileOps.copyWithProgress(src, dst, null) { checks++ > 1 }
+ FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true }
}
- assertEquals(1, dst.list()!!.size)
+ assertFalse(dst.exists())
assertEquals(3, src.list()!!.size)
}
- @Test fun cancellingStopsATreeOfEmptyFolders() {
+ @Test fun cancellingRemovesANewTreeOfEmptyFolders() {
val src = tmp.newFolder("source")
for (name in listOf("a", "b", "c")) File(src, name).mkdir()
val dst = File(tmp.root, "dest")
- var checks = 0
assertThrows(CancellationException::class.java) {
- FileOps.copyWithProgress(src, dst, null) { checks++ > 1 }
+ FileOps.copyWithProgress(src, dst, null) { dst.list()?.isNotEmpty() == true }
}
- assertEquals(1, dst.list()!!.size)
+ assertFalse(dst.exists())
}
@Test fun aCompletedCopyReplacesTheDestination() {
diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt
index 1f1e30c81..095cf9a5c 100644
--- a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtLookupTest.kt
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.frontend
+import com.droiddeck.launcher.BuildConfig
import com.droiddeck.launcher.stores.CredentialCipher
import org.json.JSONArray
import org.json.JSONObject
@@ -92,8 +93,9 @@ class AddedGameArtLookupTest {
assertEquals("build", SteamGridDb.pick("", "build"))
assertEquals("build", SteamGridDb.pick(" ", "build"))
assertEquals("", SteamGridDb.pick("", ""))
- assertEquals("", SteamGridDb.builtInKey())
- assertEquals("", SteamGridDb.key(app))
+ val builtIn = SteamGridDb.builtInKey()
+ assertTrue(builtIn == BuildConfig.SGDB_API_KEY)
+ assertTrue(SteamGridDb.key(app) == builtIn.trim())
}
@Test fun theUsersKeyIsSealedOnDiskAndClears() {
@@ -106,7 +108,7 @@ class AddedGameArtLookupTest {
assertEquals("abcdef0123456789secretkey", SteamGridDb.key(app))
SteamGridDb.setUserKey(app, "")
assertFalse(file.exists())
- assertEquals("", SteamGridDb.key(app))
+ assertTrue(SteamGridDb.key(app) == SteamGridDb.builtInKey().trim())
}
@Test fun aFailedLookupIsNotRememberedAsNoMatch() {
diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
index fe5183cfa..3335a55aa 100644
--- a/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/gpu/TpUbwcHintTest.kt
@@ -3,13 +3,23 @@ package com.droiddeck.launcher.gpu
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
+import com.droiddeck.launcher.session.SessionPrefs
class TpUbwcHintTest {
@Test fun a740OnABlobV6xxTurnsTheHintOff() {
assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.53"))
+ assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676.75"))
+ assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.676"))
+ assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.699.10"))
+ }
+
+ @Test fun a740OnThePocoBlobTurnsTheHintOn() {
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.76"))
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.676.80"))
}
@Test fun a740OnANewerBlobTurnsTheHintOn() {
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.700.0"))
assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, "512.762.10"))
}
@@ -18,6 +28,24 @@ class TpUbwcHintTest {
assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, null, samsung = true))
}
+ @Test fun onOverridesThePocoRuleEvenWhenTheVersionIsUnreadable() {
+ for (version in listOf("512.676.1", "512.676.53", "garbage", null)) {
+ assertEquals("enable_tp_ubwc_flag_hint=1", TpUbwcHint.features(740, version, mode = SessionPrefs.TP_UBWC_ON))
+ }
+ }
+
+ @Test fun offOverridesSamsungAndNewerBlobs() {
+ assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, "512.762.10", mode = SessionPrefs.TP_UBWC_OFF))
+ assertEquals("enable_tp_ubwc_flag_hint=0", TpUbwcHint.features(740, null, samsung = true, mode = SessionPrefs.TP_UBWC_OFF))
+ }
+
+ @Test fun overridesDoNotApplyToOtherGpus() {
+ for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF)) {
+ assertNull(TpUbwcHint.features(750, "512.762.10", mode = mode))
+ assertNull(TpUbwcHint.features(0, null, mode = mode))
+ }
+ }
+
@Test fun otherGpusKeepTheirDriverDefault() {
assertNull(TpUbwcHint.features(750, "512.762.10"))
assertNull(TpUbwcHint.features(830, "512.676.53"))
diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt
new file mode 100644
index 000000000..e7a521263
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/runtime/SeededMacTest.kt
@@ -0,0 +1,21 @@
+package com.droiddeck.launcher.runtime
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Test
+
+class SeededMacTest {
+ @Test
+ fun theAndroidIdSeedsAStableLocalMac() {
+ val mac = LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" }
+ assertEquals(mac, LinuxNetworkLinkComponent.seededMac { "0123456789abcdef" })
+ assertNotEquals(mac, LinuxNetworkLinkComponent.seededMac { null })
+ assertEquals(0x02, mac.substringBefore(':').toInt(16) and 0x03)
+ }
+
+ @Test
+ fun aRomThatThrowsOnTheAndroidIdReadFallsBackToTheDefaultSeed() {
+ val mac = LinuxNetworkLinkComponent.seededMac { throw IllegalArgumentException("Illegal char: - at offset 8") }
+ assertEquals(LinuxNetworkLinkComponent.seededMac { null }, mac)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt
new file mode 100644
index 000000000..517d6ad2b
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/ComponentsSelectionTest.kt
@@ -0,0 +1,32 @@
+package com.droiddeck.launcher.session
+
+import org.json.JSONObject
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+class ComponentsSelectionTest {
+ private val proton = ComponentsManager.Proton("DroidDeck-Proton_11-11.1", "DroidDeck-Proton 11-11.1", File("/p"), "/p", "DroidDeck-Proton-11-11.1", false)
+ private val entry = JSONObject().put("file", "dxvk-low-latency-arm64ec-3.1.1-2-linux.wcp").put("protonVersion", "DroidDeck-Proton-11-11.1")
+
+ @Test
+ fun `a package swapped into an installed Proton is in use`() {
+ assertTrue(ComponentsManager.liveSelection("active", entry, proton))
+ assertTrue(ComponentsManager.liveSelection("queued", JSONObject().put("file", "x.wcp"), proton))
+ }
+
+ @Test
+ fun `a package left on a removed Proton is not in use`() {
+ assertFalse(ComponentsManager.liveSelection("active", entry, null))
+ assertFalse(ComponentsManager.liveSelection("queued", entry, null))
+ }
+
+ @Test
+ fun `a package from before a Proton update is not in use`() {
+ assertFalse(ComponentsManager.liveSelection("active", JSONObject(entry.toString()).put("protonVersion", "DroidDeck-Proton-11-9.1"), proton))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt
index 8a25bf4e7..901a82d3f 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/CoreAllocationPrefsTest.kt
@@ -1,7 +1,6 @@
package com.droiddeck.launcher.session
import android.content.Context
-import com.droiddeck.launcher.core.CpuAllocationDefaults
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
@@ -14,36 +13,22 @@ import org.robolectric.annotation.Config
@Config(sdk = [33])
class CoreAllocationPrefsTest {
private val app get() = RuntimeEnvironment.getApplication()
- private val defaults = CpuAllocationDefaults.Allocation("7", "3,4,5,6")
private val saved get() = app.getSharedPreferences("session", Context.MODE_PRIVATE)
@Before fun clear() { saved.edit().clear().commit() }
- @Test fun absentPreferencesUseTheDeviceDefaultWithoutWritingIt() {
- assertEquals(SessionPrefs.CoreAllocation(true, "7", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults))
- assertTrue(saved.all.isEmpty())
- assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, null))
+ @Test fun absentPreferencesLeaveEveryCoreToTheScheduler() {
+ assertFalse(SessionPrefs.clientCpusOverride(app))
+ assertEquals("", SessionPrefs.clientCpus(app))
+ assertEquals("", SessionPrefs.gameCpus(app))
}
- @Test fun explicitDisableAndAllCoreChoicesSurviveAnUpdate() {
- saved.edit().putBoolean("clientCpusOverride", false).putString("clientCpus", "").putString("gameCpus", "").commit()
- assertEquals(SessionPrefs.CoreAllocation(false, "", ""), SessionPrefs.coreAllocation(app, defaults))
- }
-
- @Test fun existingCustomMasksArePreserved() {
- saved.edit().putBoolean("clientCpusOverride", true).putString("clientCpus", "0,1").putString("gameCpus", "2,7").commit()
- assertEquals(SessionPrefs.CoreAllocation(true, "0,1", "2,7"), SessionPrefs.coreAllocation(app, defaults))
- }
-
- @Test fun anOldClientMaskIsNotEnabledWithoutAnOverrideChoice() {
- saved.edit().putString("clientCpus", "0,1").commit()
- assertEquals(SessionPrefs.CoreAllocation(false, "0,1", "3,4,5,6"), SessionPrefs.coreAllocation(app, defaults))
- }
-
- @Test fun changingOneChoiceDoesNotReplaceTheOtherDefaults() {
- SessionPrefs.setGameCpus(app, "0,1,2,3,4,5,6,7")
- assertEquals(SessionPrefs.CoreAllocation(true, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults))
- SessionPrefs.setClientCpusOverride(app, false)
- assertEquals(SessionPrefs.CoreAllocation(false, "7", "0,1,2,3,4,5,6,7"), SessionPrefs.coreAllocation(app, defaults))
+ @Test fun savedChoicesAreKept() {
+ SessionPrefs.setClientCpusOverride(app, true)
+ SessionPrefs.setClientCpus(app, "7")
+ SessionPrefs.setGameCpus(app, "3,4,5,6")
+ assertTrue(SessionPrefs.clientCpusOverride(app))
+ assertEquals("7", SessionPrefs.clientCpus(app))
+ assertEquals("3,4,5,6", SessionPrefs.gameCpus(app))
}
}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt
index 5ce8030d5..5d67d041a 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/OrientationTest.kt
@@ -20,7 +20,7 @@ class OrientationTest {
}
@Test fun landscapeChoicesPersistGloballyAndCanRestoreAutomaticRotation() {
- assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context))
+ assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
for (value in listOf(
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE,
ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE,
@@ -34,7 +34,7 @@ class OrientationTest {
@Test fun invalidStoredOrientationFallsBackAndInvalidChoicePreservesTheSavedValue() {
val prefs = context.getSharedPreferences("session", Context.MODE_PRIVATE)
prefs.edit().putInt("orientation", ActivityInfo.SCREEN_ORIENTATION_PORTRAIT).commit()
- assertEquals(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE, SessionPrefs.orientation(context))
+ assertEquals(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE, SessionPrefs.orientation(context))
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE)
assertThrows(IllegalArgumentException::class.java) {
SessionPrefs.setOrientation(context, ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt
index 844931bbb..85cf481e9 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt
@@ -13,9 +13,33 @@ class SyncBackendTest {
private val context: Context = RuntimeEnvironment.getApplication()
@Test
- fun aFreshInstallStartsOnEsync() {
- assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context))
+ fun aFreshInstallStartsOnNtsync() {
+ assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context))
assertEquals(false, SessionPrefs.fsyncFirst(context))
+ SessionPrefs.applyNtsyncDefault(context)
+ assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context))
+ }
+
+ @Test
+ fun anEarlierChoiceMovesToNtsyncOnce() {
+ for (earlier in listOf(SessionPrefs.SYNC_ESYNC, SessionPrefs.SYNC_FSYNC, SessionPrefs.SYNC_WINESERVER)) {
+ context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
+ SessionPrefs.setSyncBackend(context, earlier)
+ SessionPrefs.applyNtsyncDefault(context)
+ assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context))
+ }
+ }
+
+ @Test
+ fun aChoiceMadeAfterTheMoveIsKept() {
+ SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC)
+ SessionPrefs.applyNtsyncDefault(context)
+ SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC)
+ SessionPrefs.applyNtsyncDefault(context)
+ assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context))
+ SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_WINESERVER)
+ SessionPrefs.applyNtsyncDefault(context)
+ assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context))
}
@Test
@@ -34,6 +58,7 @@ class SyncBackendTest {
@Test
fun pickingATabTurnsSyncBackOn() {
+ SessionPrefs.setFastSync(context, false)
SessionPrefs.setSyncFallback(context, false)
SessionPrefs.setFsyncFirst(context, false)
assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context))
diff --git a/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt
new file mode 100644
index 000000000..2315fada3
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/TpUbwcPrefsTest.kt
@@ -0,0 +1,55 @@
+package com.droiddeck.launcher.session
+
+import android.content.Context
+import com.droiddeck.launcher.gpu.TpUbwcHint
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+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.util.ReflectionHelpers
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28], manifest = Config.NONE)
+class TpUbwcPrefsTest {
+ private val context: Context get() = RuntimeEnvironment.getApplication()
+
+ @Before fun reset() {
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "resolved", false)
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "cached", null)
+ ReflectionHelpers.setStaticField(TpUbwcHint::class.java, "activeMode", null)
+ context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().clear().commit()
+ }
+
+ @Test fun missingOrInvalidChoiceUsesAuto() {
+ assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context))
+ context.getSharedPreferences("session", Context.MODE_PRIVATE).edit().putString("tpUbwcMode", "invalid").commit()
+ assertEquals(SessionPrefs.TP_UBWC_AUTO, SessionPrefs.tpUbwcMode(context))
+ }
+
+ @Test fun overrideIsStoredWithoutChangingDriverChoices() {
+ SessionPrefs.setAndroidDriver(context, "display")
+ SessionPrefs.setLinuxDriver(context, "runtime")
+ for (mode in listOf(SessionPrefs.TP_UBWC_ON, SessionPrefs.TP_UBWC_OFF, SessionPrefs.TP_UBWC_AUTO)) {
+ SessionPrefs.setTpUbwcMode(context, mode)
+ assertEquals(mode, SessionPrefs.tpUbwcMode(context))
+ assertEquals("display", SessionPrefs.androidDriver(context))
+ assertEquals("runtime", SessionPrefs.linuxDriver(context))
+ }
+ }
+
+ @Test fun changingTheSavedChoiceKeepsTheProcessChoiceUntilRestart() {
+ SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON)
+ val initial = TpUbwcHint.forThisDevice(context)
+ SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_OFF)
+ assertTrue(TpUbwcHint.restartRequired(context))
+ assertEquals(SessionPrefs.TP_UBWC_ON, TpUbwcHint.activeMode)
+ assertEquals(initial, TpUbwcHint.forThisDevice(context))
+ SessionPrefs.setTpUbwcMode(context, SessionPrefs.TP_UBWC_ON)
+ assertFalse(TpUbwcHint.restartRequired(context))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt
index 794e01d44..0cfbf2e05 100644
--- a/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/storage/SteamStorageTest.kt
@@ -92,6 +92,30 @@ class SteamStorageTest {
assertEquals(emptyMap(), SteamStorage.compatMappings("\"x\" {}"))
}
+ @Test fun readsShaderPrecacheFromConfig() {
+ val vdf = """
+ "InstallConfigStore"
+ {
+ "Software" { "Valve" { "Steam" { "ShaderCacheManager"
+ {
+ "HasCurrentBucket" "1"
+ "DisableShaderCache" "1"
+ } } } }
+ }
+ """.trimIndent()
+ assertEquals(false, SteamStorage.shaderPrecache(vdf))
+ assertEquals(true, SteamStorage.shaderPrecache(vdf.replace("\"DisableShaderCache\"\t\t\"1\"", "\"DisableShaderCache\"\t\t\"0\"")))
+ assertEquals(true, SteamStorage.shaderPrecache("\"InstallConfigStore\" {}"))
+ assertEquals(true, SteamStorage.shaderPrecache("""
+ "InstallConfigStore"
+ {
+ // "DisableShaderCache" "1"
+ "Other" { "DisableShaderCache" "1" }
+ "Software" { "Valve" { "Steam" { "ShaderCacheManager" { "DisableShaderCache" "0" } } } }
+ }
+ """.trimIndent()))
+ }
+
@Test fun readsCustomToolName() {
val vdf = "\"compatibilitytools\"\n{\n \"compat_tools\"\n {\n \"GE-Proton11-9-aarch64\"\n {\n \"install_path\" \".\"\n }\n }\n}"
assertEquals("GE-Proton11-9-aarch64", SteamStorage.compatToolName(vdf))
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt
index 9ed572671..d1487fcac 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/PickListingTest.kt
@@ -29,6 +29,17 @@ class PickListingTest {
assertEquals(listOf("alarms", "Android", "DCIM", "Documents", "Download", "Games", "Winlator"), listing.folders.map { it.dir.name })
}
+ @Test fun hiddenDirectoriesAndExecutablesCanBeShownOnRequest() {
+ val root = storage()
+ val hidden = File(root, ".Games").apply { mkdirs() }
+ File(hidden, "Game.exe").writeText("game")
+ assertFalse(pickListing(root, PickKind.EXE).folders.any { it.dir.name == ".Games" })
+ val listing = pickListing(root, PickKind.EXE, showHidden = true)
+ assertTrue(listing.folders.any { it.dir.name == ".Games" })
+ assertTrue(listing.files.any { it.name == ".hidden.exe" })
+ assertEquals(listOf("Game.exe"), pickListing(hidden, PickKind.EXE).files.map { it.name })
+ }
+
@Test fun onlyExesForAGameOnlyImagesForArt() {
val root = storage()
assertEquals(listOf("game.EXE", "Setup.exe"), pickListing(root, PickKind.EXE).files.map { it.name })
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt
index b6f4de204..fdbb8b44b 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/ThemeColorsTest.kt
@@ -132,4 +132,40 @@ class ThemeColorsTest {
assertTrue(Themes.all.any { it.id == Themes.GRADIENT })
assertEquals(Themes.GRAPHITE, Themes.byId("nonsense").id)
}
+
+ @Test fun innerGamerSitsJustAboveGradientInTheThemeList() {
+ val ids = Themes.all.map { it.id }
+ assertEquals(ids.indexOf(Themes.GRADIENT) - 1, ids.indexOf(Themes.INNER_GAMER))
+ }
+
+ @Test fun innerGamerButtonTextIsReadableAlongTheGradientAtEveryHue() {
+ var hue = 0f
+ while (hue < 360f) {
+ val p = Themes.innerGamer(hue)
+ assertEquals(Themes.INNER_GAMER, p.id)
+ for (i in 0..100) {
+ val f = i / 100f
+ val blend = Color(
+ p.primary.red + (p.primary2.red - p.primary.red) * f,
+ p.primary.green + (p.primary2.green - p.primary.green) * f,
+ p.primary.blue + (p.primary2.blue - p.primary.blue) * f,
+ )
+ assertTrue("hue $hue at $f", contrast(p.onPrimary, blend) >= 4.49f)
+ }
+ hue += Themes.RGB_HUE_STEP
+ }
+ }
+
+ @Test fun innerGamerFollowsTheHueAndTheSpeedIsSavedAndKeptInRange() {
+ Themes.moveRgbHue(120f)
+ assertEquals(Themes.innerGamer(120f).primary, Themes.byId(Themes.INNER_GAMER).primary)
+ Themes.setRgbSpeed(app, 7)
+ assertEquals(7, SessionPrefs.rgbSpeed(app))
+ Themes.setRgbSpeed(app, 99)
+ assertEquals(Themes.RGB_SPEEDS, Themes.rgbSpeed)
+ Themes.setRgbSpeed(app, 0)
+ assertEquals(1, SessionPrefs.rgbSpeed(app))
+ Themes.setRgbSpeed(app, 1)
+ assertTrue(Themes.rgbCycleMillis(1) > Themes.rgbCycleMillis(10))
+ }
}
diff --git a/docs/development/arm64-steam-client.md b/docs/development/arm64-steam-client.md
new file mode 100644
index 000000000..bb9120017
--- /dev/null
+++ b/docs/development/arm64-steam-client.md
@@ -0,0 +1,59 @@
+# Native arm64 Steam client: channel and runtime
+
+The default client is Valve's arm64 Linux build (`steamrtarm64/steam`). Valve does not support it, but
+publishes it on the Steam Deck client channels. `droiddeck-steam-install` installs it and
+`droiddeck-session` starts it with `STEAM_RUNTIME=1`.
+
+## Channel
+
+- The default is `steamdeck_publicbeta` (manifest `steam_client_steamdeck_publicbeta_linuxarm64`).
+ Valve also publishes `steam_client_steamdeck_stable_linuxarm64`.
+- `droiddeck-steam-install --channel` prints the channel in use. The session passes that value as
+ `-clientbeta` and writes it to `package/beta`. The two must match, or the client loops on rc 42.
+- Setting: `BL_STEAM_CHANNEL`, otherwise `~/.config/droiddeck/steam-channel`, set to `beta` or
+ `stable`. A set channel never changes on its own.
+- With no setting, the client falls back to `steamdeck_stable` automatically. It stays on stable
+ (`package/droiddeck-channel`) until a setting says otherwise. The fallback happens:
+ - when the client was started two more times without being shown after the start-failure repair
+ (`package/droiddeck-unconfirmed`: two unconfirmed starts trigger a repair, which writes
+ `repaired`; two more unconfirmed starts trigger the fallback);
+ - when the beta manifest has no `*_linuxarm64_linuxarm64` component. The install log then lists the
+ manifest's block names, so a renamed or removed arm64 build can be seen.
+- The x86-64 client keeps `steamdeck_publicbeta` (see [x86-64-steam-client.md](x86-64-steam-client.md)).
+
+## Steam Runtime container
+
+Valve's 2026-10-15 beta made the x86 Linux client 64-bit and put it in a Steam Runtime container
+(pressure-vessel). The container needs unprivileged user and mount namespaces and a D-Bus session bus,
+which an Android app does not have. The x86-64 path already works around this. The arm64 client gets the
+same treatment if Valve containerizes it too:
+
+- `STEAM_STEAMRT_RECURSION_GUARD=1` is exported for both clients. It tells the client that it is
+ already inside its container, so after an update it does not re-execute itself through
+ `pressure-vessel-wrap` (bwrap).
+- `droiddeck-steamrt-shim ` builds the shim when the client carries
+ `pv-runtime/steam-runtime-steamrt` or a `steam-runtime-check-requirements` probe:
+ - Valve's runtime is linked file by file;
+ - the probe is replaced with `exit 0`;
+ - its own `steam-runtime-launch-client` runs commands directly.
+
+ For the arm64 client the session then starts it with `STEAM_RUNTIME_STEAMRT` and `PATH` pointing
+ at `~/.droiddeck-steamrt-shim` and logs "Valve containerized the arm64 Steam client". Without those
+ files the shim is not built and the start is unchanged.
+
+## What Valve's manifests carry (checked 2026-10-22)
+
+- `steam_client_steamdeck_publicbeta_linuxarm64` (1792626604) and `steam_client_steamdeck_stable_linuxarm64`
+ (1792618285) have the same blocks. Three platforms share them:
+ - `*_linuxarm64_linuxarm64`: the native client (`steamrtarm64/`, `linuxarm64/`). No pressure-vessel, no
+ `steam-runtime-check-requirements`. These are the only platform blocks the installer keeps.
+ - `*_steamrt_linuxarm64`: Valve's x86-64 SteamRT 3c client (`steamrt64/steam` is an x86-64 ELF) with
+ `steamrt64/pv-runtime/steam-runtime-steamrt` (pressure-vessel, `steam-runtime-check-requirements`,
+ `steam-runtime-launch-client`). This is the containerized client the 2026-10-15 beta made the default on
+ x86. Valve ships it to arm64 hosts as well, presumably to run under FEX. The installer skips it.
+ - `bins_androidarm64_linuxarm64`: `androidarm64/libsteamclient.so`, `steamservice.so` and friends.
+- `steam_client_steamdeck_publicbeta_ubuntu12` has stopped at 1792108788, while stable `ubuntu12` follows
+ stable (1792618285). On beta the 32-bit `ubuntu12` client no longer gets updates.
+- If a future manifest drops `*_linuxarm64_linuxarm64`, the installer logs the block list and moves to
+ stable. If stable drops it too, the remaining option is the x86-64 client under FEX
+ ([x86-64-steam-client.md](x86-64-steam-client.md)).
diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md
index a6e02b8e2..62530ca48 100644
--- a/docs/development/game-environment.md
+++ b/docs/development/game-environment.md
@@ -52,6 +52,12 @@ environment entries and is intentionally narrower than **Reset game profile**.
Values are literal strings, with no shell expansion; quotes are only needed when
the consuming program expects them.
+A Unity game under FEX (`UnityPlayer.dll` for Windows builds under Proton,
+`UnityPlayer.so` or `GameAssembly.so` for native Linux builds started by
+**Linux x86 (FEX)**) keeps FEX's TSO on when the Default profile's FEX preset
+turns it off: without TSO such games can deadlock or crash (seen with Megabonk
+and Crab Game). A FEX preset chosen for the game itself still applies.
+
## Defaults and available suggestions
The initial configuration enables Mesa shader caching with
diff --git a/docs/development/themes.md b/docs/development/themes.md
index b7f1a01eb..4bb122c6f 100644
--- a/docs/development/themes.md
+++ b/docs/development/themes.md
@@ -23,3 +23,17 @@ Setup shows Primary and Secondary under the theme when Gradient is chosen, each
square. It opens `ColorPickerDialog`: a wheel (hue and saturation, touch), hue / saturation /
brightness sliders (they work with a controller) and a hex box, with the old and new colour side by
side. OK applies it.
+
+## Inner Gamer theme
+
+`Themes.INNER_GAMER` is an RGB theme: the accent walks round the colour wheel. It sits just above
+Gradient in the list, and Setup shows a Speed slider (1 to 10, `SessionPrefs.rgbSpeed`) under the
+theme while it is chosen.
+
+`Themes.innerGamer(hue)` builds the palette for one hue through `ThemeColors.palette`, so button text
+keeps the same contrast along the gradient at every hue. `DroidDeckTheme` runs one
+`rememberInfiniteTransition` while Inner Gamer is chosen, so the system's animation clock drives it.
+Compose applies Android's animator scale to it, and the colour holds still when animations are off
+in the app (`Motion.scale`) or in Android's own settings. The hue is written to `Themes.rgbHue` in 3° steps, which restyles the
+app only when the colour visibly moves; a slow cycle costs few recompositions.
+
diff --git a/docs/development/x86-64-steam-client.md b/docs/development/x86-64-steam-client.md
index f11923fe7..3e0d867a2 100644
--- a/docs/development/x86-64-steam-client.md
+++ b/docs/development/x86-64-steam-client.md
@@ -53,7 +53,8 @@ arm64 client down first.
("Steam now requires user namespaces") when `steam-runtime-check-requirements`' bwrap probe cannot
create a user namespace, which no Android app can. `STEAM_RUNTIME_STEAMRT` points at
`steamrt-shim`: Valve's runtime linked file by file with only the probe replaced (`exit 0`). proot
- is the container, as for the arm64 client.
+ is the container, as for the arm64 client. `droiddeck-steamrt-shim` builds it, and builds the same
+ shim for the arm64 client if that one is containerized too ([arm64-steam-client.md](arm64-steam-client.md)).
- **Re-exec after an update.** The client re-executes itself inside the SteamRT container through
`pressure-vessel-wrap` (bwrap). `STEAM_STEAMRT_RECURSION_GUARD=1` says it is already inside one; the
FEX rootfs with the client's `steamrt64` libraries is that container.
diff --git a/tools/ci/install-ubuntu-packages.sh b/tools/ci/install-ubuntu-packages.sh
new file mode 100644
index 000000000..1597613aa
--- /dev/null
+++ b/tools/ci/install-ubuntu-packages.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+set -euo pipefail
+missing=()
+for package in "$@"; do
+ if [ "$(dpkg-query -W -f='${Status}' "$package" 2>/dev/null || true)" != 'install ok installed' ]; then
+ missing+=("$package")
+ fi
+done
+if [ "${#missing[@]}" = 0 ]; then
+ echo "Ubuntu build dependencies are already installed."
+ exit 0
+fi
+options=(
+ -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources
+ -o Dir::Etc::sourceparts=-
+ -o Acquire::Retries=1
+ -o Acquire::http::Timeout=15
+ -o Acquire::https::Timeout=15
+ -o DPkg::Lock::Timeout=30
+)
+install_packages() {
+ echo "Updating Ubuntu package indexes."
+ sudo timeout --kill-after=10s 60s apt-get "${options[@]}" -o APT::Update::Error-Mode=any update -q || return $?
+ echo "Installing: ${missing[*]}"
+ sudo timeout --kill-after=10s 120s env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get "${options[@]}" install -y -q --no-install-recommends "${missing[@]}"
+}
+if install_packages; then
+ exit 0
+fi
+echo "::warning::Runner mirror failed or timed out; retrying with the official Ubuntu mirror."
+sources=$(mktemp --suffix=.sources)
+trap 'rm -f "$sources"' EXIT
+mirror=https://archive.ubuntu.com/ubuntu
+case "$(dpkg --print-architecture)" in
+ arm64) mirror=https://ports.ubuntu.com/ubuntu-ports ;;
+esac
+sed -E "s@^URIs:.*@URIs: $mirror@" /etc/apt/sources.list.d/ubuntu.sources > "$sources"
+chmod 644 "$sources"
+options[1]="Dir::Etc::sourcelist=$sources"
+install_packages
diff --git a/tools/ci/resolve-ndk.sh b/tools/ci/resolve-ndk.sh
new file mode 100644
index 000000000..61df33c50
--- /dev/null
+++ b/tools/ci/resolve-ndk.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+repo=$(cd "$(dirname "$0")/../.." && pwd)
+source "$repo/tools/ci/toolchains.env"
+ndk="$ANDROID_HOME/ndk/$CI_ANDROID_NDK_VERSION"
+if [ ! -f "$ndk/source.properties" ]; then
+ sdkmanager --sdk_root="$ANDROID_HOME" --install "ndk;$CI_ANDROID_NDK_VERSION"
+fi
+actual=$(sed -n 's/^Pkg.Revision *= *//p' "$ndk/source.properties" | tr -d '\r')
+test "$actual" = "$CI_ANDROID_NDK_VERSION"
+{
+ echo "NDK_VERSION=$actual"
+ echo "ANDROID_NDK_HOME=$ndk"
+ echo "ANDROID_NDK_ROOT=$ndk"
+ echo "ANDROID_NDK_LATEST_HOME=$ndk"
+} >> "$GITHUB_ENV"
diff --git a/tools/ci/toolchains.env b/tools/ci/toolchains.env
new file mode 100644
index 000000000..ac365f2bb
--- /dev/null
+++ b/tools/ci/toolchains.env
@@ -0,0 +1 @@
+CI_ANDROID_NDK_VERSION=30.0.16248370
diff --git a/tools/components/README.md b/tools/components/README.md
new file mode 100644
index 000000000..782d8b9f1
--- /dev/null
+++ b/tools/components/README.md
@@ -0,0 +1,65 @@
+# Runtime component preparation
+
+`recipes.json` owns component source inputs, builders, outputs, package requirements
+and pinned downloads. `prepare.py` runs those recipes, calculates cache identities,
+installs the union of missing tools and verifies the prepared artifact handoff.
+Builders live in `recipes/`; the workflow describes scheduling and gates.
+
+```mermaid
+flowchart LR
+ Source[Pin source tree] --> Components[Prepare verified components]
+ Source --> Xbox[Xbox ARM64 backend]
+ Source --> Tests[Python and native tests]
+ Components --> APK[Compile APK and Android tests]
+ Xbox --> APK
+ APK --> Gate[Compile and test APK gate]
+ Tests --> Gate
+ Gate --> Publish[Main only: signing and publishing]
+```
+
+All workers verify the same source tree. The component artifact records that tree,
+recipe identities and every output hash; the APK worker verifies the inventory
+before building. This handoff verifies consistency within the same CI run. It is
+not independent provenance for arbitrary artifacts. Source policy checks, tests,
+APK content/ABI checks and signing/publication gates remain in place.
+
+`tools/ci/toolchains.env` pins the Android NDK shared by component, APK and native
+test workers. Linux builds use Ubuntu 24.04. Recipe and input changes invalidate
+the relevant component cache; ordinary caches remain performance aids.
+
+## Verified preload snapshots
+
+`preloads.lock.json` pins a small immutable preload archive. It records each
+binary's hash, its producing main CI run, the original APK artifact identity and
+digest, source/header hashes, the ARM64 build recipe and x86 compiler-image inputs.
+It covers core ARM64 preloads and the x86 preload libraries. VR, Android native
+libraries, Comet and Xbox use their registered recipes and existing release pins
+or component caches.
+
+Every build compares current source inputs, including transitive quoted headers,
+and build recipes with this manifest. Changed groups rebuild from source. A
+missing release also selects a rebuild. Corrupt archives, unexpected files and
+binary hash mismatches fail the build. Existing preload checks, runtime packaging
+checks and all test jobs still run.
+
+The trust anchor is the manifest in the pinned main base. A new or changed PR
+manifest is independently checked against a successful `push` build of main:
+GitHub must confirm the run/source/workflow and original APK artifact digest, and
+every claimed binary must match that APK. The claimed source inputs and recipe
+must match that run's Git source. A PR cannot establish trust by changing both a
+binary and its claimed checksum. This is approved-build reuse, not a claim of
+bit-for-bit reproducible compilation or a signed SLSA attestation.
+
+To refresh after source or compiler changes:
+
+1. Let a main build compile and test the new inputs.
+2. Run `python tools/components/preloads.py snapshot --run RUN_ID`.
+3. Publish `app/build/component-snapshot/preloads.tar.gz` to
+ DroidDeck-Components as `preloads-`, without replacing an
+ existing release. Include the manifest and producing run in the release notes.
+4. Review and commit the updated manifest. CI independently checks its origin.
+
+The workflow dispatch `rebuild_preloads` option forces source compilation even
+when an approved snapshot matches. Use it when refreshing a compiler or system
+header revision. Runner image updates do not silently replace an approved binary;
+the producing run remains the record of its compiler environment.
diff --git a/tools/components/build-arm64-preloads.sh b/tools/components/build-arm64-preloads.sh
new file mode 100644
index 000000000..a38a1edaa
--- /dev/null
+++ b/tools/components/build-arm64-preloads.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+D=${1:?output directory}
+mkdir -p "$D"
+aarch64-linux-gnu-g++ -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \
+ -pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \
+ -o "$D/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl
+aarch64-linux-gnu-strip --strip-unneeded "$D/libfakeinput.so"
+aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
+ -o "$D/libblsession.so" tools/linuxfs/preload/*.c -ldl
+aarch64-linux-gnu-strip --strip-unneeded "$D/libblsession.so"
+aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
+ -o "$D/libblfastpath.so" tools/proot/fastpath/fastpath.c -ldl
+aarch64-linux-gnu-strip --strip-unneeded "$D/libblfastpath.so"
+aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -nostdlib -ffreestanding -fno-stack-protector \
+ -fno-tree-loop-distribute-patterns -Wl,-z,defs -o "$D/libblaudit.so" tools/proot/fastpath/audit.c
+aarch64-linux-gnu-strip --strip-unneeded "$D/libblaudit.so"
+aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -pthread \
+ -o "$D/libssbs.so" tools/linuxfs/ssbs/ssbs_adapter.c -ldl
+aarch64-linux-gnu-strip --strip-unneeded "$D/libssbs.so"
+mkdir -p "$D/usr/local/bin"
+aarch64-linux-gnu-gcc -O2 -Wall -Wextra -o "$D/usr/local/bin/droiddeck-clipboard" tools/linuxfs/clipboard/clipboard.c -ldl
+aarch64-linux-gnu-strip --strip-unneeded "$D/usr/local/bin/droiddeck-clipboard"
diff --git a/tools/components/preloads.lock.json b/tools/components/preloads.lock.json
new file mode 100644
index 000000000..45beb4152
--- /dev/null
+++ b/tools/components/preloads.lock.json
@@ -0,0 +1,126 @@
+{
+ "archive_sha256": "cb6487a94f25a3068fd07958174eb5b5e14f709655ff4ef0a8cdbd99138513b2",
+ "arm64_recipe": "1c92a1b0669277de3e78e9f292634affb5b5a19d5ea49d3ad8610c1dc638c2fb",
+ "groups": {
+ "arm64": {
+ "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77",
+ "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf",
+ "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5",
+ "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7",
+ "tools/linuxfs/clipboard/clipboard.c": "96291c2bbc040c83caabf059ab3e95a9ed90d6a768c759d7cb4c0b64706bddfa",
+ "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b",
+ "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7",
+ "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c",
+ "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09",
+ "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb",
+ "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089",
+ "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670",
+ "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64",
+ "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66",
+ "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e",
+ "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c",
+ "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b",
+ "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d",
+ "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e",
+ "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3",
+ "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9",
+ "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65",
+ "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe",
+ "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a",
+ "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3",
+ "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2",
+ "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e",
+ "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8",
+ "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6",
+ "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1",
+ "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852",
+ "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9",
+ "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8",
+ "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188",
+ "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8",
+ "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240",
+ "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6",
+ "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262",
+ "tools/linuxfs/ssbs/ssbs_adapter.c": "9e4e0fe637f7b8f91f12d8151f02e8d31bcb4294be2b2c62d3cd3ce76f200306",
+ "tools/proot/fastpath/audit.c": "bfe347a2ddaceef40a78d7d590e4e679724ee4c82f3f1f1e3c7017a18ad536d4",
+ "tools/proot/fastpath/fastpath.c": "4cd07b25ba14f6faa6bac5091ae323ded6b646dbb34b9e5a92ea6385e4d37cb5",
+ "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd",
+ "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf"
+ },
+ "x86": {
+ "@runner": "84f825635599f81ed33f62c5fc4c73f66044d1468b060d65f83855eb9c8bfe77",
+ "app/src/main/cpp/adrenotools/src/hook/kgsl.h": "396c56761034a4ed783543ee3312c6ec64d63b2a1cf7eecf1939c23251f25adf",
+ "app/src/main/cpp/fakeinput_steam.cpp": "3d11e859ca58a42a9da92a3fc589d404df790e151f7e18ea6114645750e70ef5",
+ "app/src/main/cpp/venc/venc_proto.h": "2242ddbc8981d14a33bac70264bb3e7f701fd1c3ebb988e17bbb2b19b6b808f7",
+ "tools/linuxfs/build-x86-preloads.sh": "d0820252809881db89db5a17427e74b9aa4964aae70623cb7f4b474e90c7a23b",
+ "tools/linuxfs/debian-image.env": "1c79dea4314c784042a5c59208765c8401771098f293185d61a157d89edf6e52",
+ "tools/linuxfs/fex/faultreport.c": "573bf780f32f7256dc2fbf40fe850d67e67c354509fdedcac3ff7f1a261daac3",
+ "tools/linuxfs/fex/vulkanpin.c": "f2693ac2be8d15f7881115e61b1c6b3fe4aeec8f603584c6fce5b0f046563d51",
+ "tools/linuxfs/fex/webkitinput.c": "693ffd6c8c8c97e87156444ad2255c84dbf22cd0053d135fb8d9be094b5534d1",
+ "tools/linuxfs/preload/binfmt.c": "0933cc49cb53f0aa1206e2eae516d38dc3337e74174be07e10619c42aa56792b",
+ "tools/linuxfs/preload/dircache.c": "8e7f19ea4a1198783d53a3f357326c6177da157b0cc7e0d22e1670963e1c26e7",
+ "tools/linuxfs/preload/drm.c": "e0eabab0d8ed9e841918e827bf87168efa7f2dfb8404169e9bed3ab5eda2836c",
+ "tools/linuxfs/preload/fsync.c": "795c114c2a05d3f250d07145d04c5658091699da3b96290da36348ccadfb0c09",
+ "tools/linuxfs/preload/inputudev.c": "2648248d3d235a582eb8459709e0a85de537c03ac10c5040fbfb50d9846c68eb",
+ "tools/linuxfs/preload/kgslid.c": "88a7875fd5289930204d155d92ac9d15492cd06db16ed87aa9c95af37f3e7089",
+ "tools/linuxfs/preload/kgslpoll.c": "f0d853d919a52487c04cbda5fb3fa22269da54b7f4ee2f7dd3b5bd9909418670",
+ "tools/linuxfs/preload/links.c": "b7bbcf9112393b152f358fe6f8dc9a1955f0c8532e1eddf016a971415cd13c64",
+ "tools/linuxfs/preload/lock.c": "f71b3fd40e6a2b74908d3cdc601e8748699ae9c7a89b542d2445b999015ffd66",
+ "tools/linuxfs/preload/net.c": "335853c8ea0e9a8ebbc5a5500dc4bded9c34c4310a6ab8aa46b07d5ce1c2b55e",
+ "tools/linuxfs/preload/netif.c": "cc26d50b8d4639d8dd4697811bbf85c9d9f31761f9504a4af42473de3cb22b3c",
+ "tools/linuxfs/preload/noexec.c": "1855aeaeda3d506329bd9af1107580c74d1f56787c411b2e01c0a8a9d4059c7b",
+ "tools/linuxfs/preload/ntsync.c": "0614c48af0c062bfcb4c30141efe1b792b340767a67d2fe92ee9877d2ba16e0d",
+ "tools/linuxfs/preload/opens.c": "92ae4aea16aa457a898dedf4a70eb332d80d3b1e77f3a60bace7715a4317399e",
+ "tools/linuxfs/preload/pathcache.c": "eb0d4b5238fe6c8891e805d90bd837e466754100c978fad2d0ff717116b9b8a3",
+ "tools/linuxfs/preload/perms.c": "f600619c4ca130f69001ed7ad7bd74a3dc215b14c61479c45bb770f916d244b9",
+ "tools/linuxfs/preload/pulseguard.c": "57674ea40283e53a27bc78362de1ba41ee972d172ea38b1f818b1d6eed0abe65",
+ "tools/linuxfs/preload/quest_kgsl.c": "59d9b8416be6a1c680f1d1e4c2427d2cbb7b415344261adb357d9d211ec95cfe",
+ "tools/linuxfs/preload/robust.c": "9eb6c0ed50e0f48a8cfb86bd92587f471354bdf2531b9177f4b3ab83bdfed39a",
+ "tools/linuxfs/preload/rptouch.c": "0e3f8bf2f9a41324361f38fa4da8d2d47593c9555f0cabf8228767397846c7c3",
+ "tools/linuxfs/preload/space.c": "118389bcc89dfa2b4230b80915e21d2d4331c4cff1f5a58265f85e6ecb5c29d2",
+ "tools/linuxfs/preload/storage.c": "08a8b46c3588f3160bb8608489768010ec36d5ccdb2b25a431ec9d6fcd413a6e",
+ "tools/linuxfs/preload/sysv.c": "49774ee5e2e39b84dcfadf63abdc741b3df236cb11783744a1280adee36965e8",
+ "tools/linuxfs/preload/tracefs.c": "be3f8a2f032b56e6e08a931e77a9386aea4bb93421a482271e06386ec0c469b6",
+ "tools/linuxfs/preload/tracer.c": "f34e5377c891b0a8d00489245d66f96bc352df4cd90f47a951dcadae980af4c1",
+ "tools/linuxfs/preload/udevmon.c": "2045dfb883cfb6e2016c359a3dc3af17f3b8f383f6dd0d5dfee407ef0d344852",
+ "tools/linuxfs/preload/usr2.c": "6ae704380b45a9b3a74c4fe2b3ce4482470dae59ced26cf0aee64f4b4dcc11f9",
+ "tools/linuxfs/preload/venc.c": "24c3c46fe49d28adfc8c977e52c9cc407f534b26f56d94b3425b8bb01803dba8",
+ "tools/linuxfs/preload/vmwrite.c": "1a3ea2ec4354e0716c17f7db537b19ab82bcd524f2aece2fa5dec89b076a2188",
+ "tools/linuxfs/preload/webhelper.c": "a8213a7a96b407459c83fb93365cfe58fd05964e7bb262b8061c1c0792a6aac8",
+ "tools/linuxfs/preload/writewatch.c": "61e395d1caa184d329dead0e36c4e92cb1069b1154ac36581f37406c1c242240",
+ "tools/linuxfs/preload/xmode.c": "9a09995bf52d40b07f16828bcc545b59b8fb1ba60d9cb09f14f7f31dba1e65a6",
+ "tools/linuxfs/preload/zram.c": "12bcf07459728d4a7ede6fa0fea8deb330f7c5d0f0dd61e610e81b9efecd4262",
+ "tools/quest/kgsl_compat.c": "111afffda7a6df2469610f6c342ae086b1a97a559c586efc4a28015b86efd6bd",
+ "tools/quest/kgsl_compat.h": "2e9e0eb647530a35277954b7e61a22ee958067fe46056a608fb32f40f273f1bf"
+ }
+ },
+ "origin": {
+ "artifact": 12520502054,
+ "artifact_sha256": "b7e800a1884a516ec52b050b7f904f9881427bca4579f660b909fd18f15e49ff",
+ "commit": "b76f13f7acc0af0cc00369dde61fb88e40529e6d",
+ "run": 39992155294,
+ "url": "https://github.com/Droid-Deck/DroidDeck/actions/runs/39992155294"
+ },
+ "outputs": {
+ "i386/libblsession.so": "5343cdb704217ddc81ee593e67d5987363dc7b5ac78bd4bd36000219e5063268",
+ "i386/libfakeinput.so": "188d88dfb13b6e2f2df948e7b8b4d9ec604770f55d1ea5b6b9e21ad56a74ce5e",
+ "i386/libfaultreport.so": "95f9ef60f04a13ee60cfbd4d2d2efb60a7063b722e646c78c9dc27162292877b",
+ "libblaudit.so": "6c6abe89baf5d7309babee0c3372a4b00c2b689e2a63de38c5e9a660626509b5",
+ "libblfastpath.so": "0e1f151b5c1c5ea9dd380ad58d09f8fe7ba3ae037d0991cb3eef6899e9d5ec83",
+ "libblsession.so": "29d4b5ea46dba941107e02c0ee544244b137a339cada43bff65c350f2ef2755a",
+ "libfakeinput.so": "5f6dc3aea05de2dc1fbe0bbfac2ce4d11e3d47f761c6b06c8badb15fe1904392",
+ "libssbs.so": "c288ed0da26c2481aff270d1cdafe1205061c4a300c2c46ae1b53c6e0c94f44b",
+ "usr/local/bin/droiddeck-clipboard": "ec50e8f67fdf76372927189b0fdb670f9b70fd3484f2fe3e833bc8f9fdc44205",
+ "x86_64/libblsession.so": "61d19f06aebab6a789f8c99f687af9903376bfc853e46b70c48a2ce5e497accc",
+ "x86_64/libfakeinput.so": "8663dd2086bf8ae53069a480dd9ae4619109d32f0947a29e2ce38f07a21ae2e6",
+ "x86_64/libfaultreport.so": "bb0dc9165d0d764473e552508650edd586df16d620fc5b16b8b1c5740a28ce22",
+ "x86_64/libvulkan-thunk.so": "74f01b652d7e8179561a096a552328b68da640cc4bad3981edd38e6afbf90243",
+ "x86_64/libwebkitinput.so": "9b07380a7b017397b9c56bb7bb1161e046b0c6a647fc413888904606b0def6d0"
+ },
+ "toolchain": {
+ "arm64": "Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe",
+ "x86": "Debian bullseye compiler image and recipe pinned by source inputs"
+ },
+ "version": 1,
+ "x86_recipe": "36d06d49f7a4bff5ef675c1e03ce5a5b61498df4c36b9d15a938b41564f755cc"
+}
diff --git a/tools/components/preloads.py b/tools/components/preloads.py
new file mode 100644
index 000000000..5f16610c3
--- /dev/null
+++ b/tools/components/preloads.py
@@ -0,0 +1,290 @@
+"""Reuse reviewed preload snapshots; changed snapshots must prove their main-build origin."""
+import argparse
+import gzip
+import http.client
+import hashlib
+import io
+import json
+import os
+import posixpath
+import re
+from pathlib import Path
+import subprocess
+import tarfile
+import textwrap
+import urllib.error
+import urllib.request
+from zipfile import ZipFile
+
+ROOT = Path(__file__).resolve().parents[2]
+REPO = 'Droid-Deck/DroidDeck'
+LOCK = 'tools/components/preloads.lock.json'
+DEFINITIONS = json.loads((ROOT / 'tools/components/recipes.json').read_text())
+INPUTS = {group: DEFINITIONS[name]['source_inputs'] for group, name in
+ [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]}
+OUTPUTS = {group: DEFINITIONS[name]['snapshot_outputs'] for group, name in
+ [('arm64', 'arm64-preloads'), ('x86', 'x86-preloads')]}
+COMMON = [name for name in INPUTS['arm64'] if name in INPUTS['x86']]
+FILES = set(sum(OUTPUTS.values(), []))
+
+
+def sha(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def run(*args, root=ROOT):
+ return subprocess.check_output(args, cwd=root)
+
+
+def api(path):
+ return json.loads(run('gh', 'api', 'repos/' + REPO + '/' + path))
+
+
+def source_files(group, root=ROOT, commit=None, patterns=None):
+ patterns = patterns if patterns is not None else INPUTS[group]
+ if commit:
+ paths = run('git', 'ls-tree', '-r', '--name-only', commit, '--', *patterns, root=root).decode().splitlines()
+ read = lambda path: run('git', 'show', f'{commit}:{path}', root=root)
+ else:
+ paths = run('git', 'ls-files', '--cached', '--others', '--exclude-standard', '--', *patterns, root=root).decode().splitlines()
+ read = lambda path: (root / path).read_bytes()
+ paths = [p for p in paths if not p.endswith('.md') and '__pycache__' not in p and not p.endswith('.pyc')]
+ if not paths:
+ raise ValueError('No preload inputs found')
+ result = {}
+ pending = list(paths)
+ while pending:
+ path = pending.pop()
+ if path in result:
+ continue
+ if path.startswith('../') or path.startswith('/'):
+ raise ValueError('Preload include escapes repository')
+ content = read(path).replace(b'\r\n', b'\n')
+ result[path] = sha(content)
+ for include in re.findall(rb'^\s*#\s*include\s*"([^"\n]+)"', content, re.M):
+ pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(path), include.decode())))
+ workflow = (run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode() if commit
+ else (root / '.github/workflows/build.yml').read_text())
+ job = 'components' if re.search(r'(?m)^ components:', workflow) else 'build'
+ runner = re.search(r'(?ms)^ ' + job + r':\n.*?^ runs-on: ([^\n]+)', workflow)
+ if runner is None:
+ raise ValueError('APK build runner policy is missing')
+ result['@runner'] = sha(runner[1].strip().encode())
+ return dict(sorted(result.items()))
+
+
+def recipe(root=ROOT, commit=None):
+ def read(path):
+ return (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit
+ else (root / path).read_text())
+ script = 'tools/components/build-arm64-preloads.sh'
+ if commit and run('git', 'ls-tree', commit, script, root=root) == b'':
+ workflow = read('.github/workflows/build.yml')
+ text = workflow[workflow.index(' aarch64-linux-gnu-g++ -shared'):]
+ text = textwrap.dedent(text[:text.index(' VK_INCLUDE=')])
+ else:
+ text = read(script)
+ preamble = '#!/usr/bin/env bash\nset -euo pipefail\nD=${1:?output directory}\nmkdir -p "$D"\n'
+ if not text.startswith(preamble):
+ return sha(text.encode())
+ text = text[len(preamble):]
+ return sha(text.replace('\r\n', '\n').encode())
+
+
+def x86_recipe(root=ROOT, commit=None):
+ path = 'tools/components/recipes/x86-preloads.sh'
+ if commit and not run('git', 'ls-tree', commit, path, root=root):
+ workflow = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode()
+ section = workflow[workflow.index(' - name: Build the x86 preloads'):]
+ text = section.split(' run: |\n', 1)[1].split('\n - name:', 1)[0]
+ text = textwrap.dedent(text).rstrip() + '\n'
+ else:
+ text = (run('git', 'show', f'{commit}:{path}', root=root).decode() if commit
+ else (root / path).read_text())
+ text = text.removeprefix('#!/usr/bin/env bash\n')
+ return sha(text.replace('\r\n', '\n').encode())
+
+
+def artifact_bytes(origin, cache):
+ build = api(f"actions/runs/{origin['run']}")
+ if (build['event'] != 'push' or build['head_branch'] != 'main' or build['conclusion'] != 'success'
+ or build['head_sha'] != origin['commit'] or build['path'] != '.github/workflows/build.yml'
+ or build['repository']['full_name'] != REPO):
+ raise ValueError('Snapshot is not from a successful main APK build')
+ artifact = api(f"actions/artifacts/{origin['artifact']}")
+ if (artifact['name'] != 'droiddeck-apk' or artifact['workflow_run']['id'] != origin['run']
+ or artifact['digest'] != 'sha256:' + origin['artifact_sha256']):
+ raise ValueError('Original APK artifact identity or digest mismatch')
+ cache.mkdir(parents=True, exist_ok=True)
+ path = cache / (origin['artifact_sha256'] + '.zip')
+ if not path.exists():
+ path.write_bytes(run('gh', 'api', f"repos/{REPO}/actions/artifacts/{origin['artifact']}/zip"))
+ content = path.read_bytes()
+ if sha(content) != origin['artifact_sha256']:
+ raise ValueError('Original APK archive checksum mismatch')
+ with ZipFile(io.BytesIO(content)) as archive:
+ names = [name for name in archive.namelist() if name.endswith('.apk')]
+ if len(names) != 1:
+ raise ValueError('Expected one original APK')
+ with ZipFile(io.BytesIO(archive.read(names[0]))) as apk:
+ return {name: apk.read('assets/linuxfs/' + name) for name in FILES}
+
+
+def validate_lock(data):
+ if data.get('version') != 1 or set(data['groups']) != set(INPUTS):
+ raise ValueError('Unsupported preload snapshot')
+ if set(data['outputs']) != FILES:
+ raise ValueError('Unexpected preload output set')
+ for digest in [data['archive_sha256'], *data['outputs'].values()]:
+ if len(digest) != 64 or any(c not in '0123456789abcdef' for c in digest):
+ raise ValueError('Invalid preload checksum')
+
+
+def producer_sources(group, commit, root=ROOT):
+ config = 'tools/components/recipes.json'
+ if run('git', 'ls-tree', commit, config, root=root):
+ definitions = json.loads(run('git', 'show', f'{commit}:{config}', root=root))
+ name = 'arm64-preloads' if group == 'arm64' else 'x86-preloads'
+ patterns = definitions[name]['source_inputs']
+ else:
+ # Older approved builds kept ARM64 commands inline, before the Vulkan/VR build.
+ if group == 'arm64':
+ text = run('git', 'show', f'{commit}:.github/workflows/build.yml', root=root).decode()
+ text = text[text.index(' aarch64-linux-gnu-g++ -shared'):]
+ text = text[:text.index(' VK_INCLUDE=')]
+ else:
+ text = run('git', 'show', f'{commit}:tools/linuxfs/build-x86-preloads.sh', root=root).decode()
+ patterns = re.findall(r'\b((?:tools|app)/[\w./*+-]+\.(?:cpp|c|h))(?=\s|$)', text)
+ if group == 'x86':
+ patterns += ['tools/linuxfs/fex', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/debian-image.env']
+ return source_files(group, root, commit, patterns)
+
+
+def prove_origin(data, cache, root=ROOT):
+ content = artifact_bytes(data['origin'], cache)
+ if {name: sha(value) for name, value in content.items()} != data['outputs']:
+ raise ValueError('Snapshot binaries differ from the original main APK')
+ commit = data['origin']['commit']
+ run('git', 'fetch', '--no-tags', 'origin', commit, root=root)
+ for group in INPUTS:
+ if source_files(group, root, commit) != data['groups'][group]:
+ raise ValueError('Snapshot inputs differ from the producing source')
+ for name, checksum in producer_sources(group, commit, root).items():
+ if data['groups'][group].get(name) != checksum:
+ raise ValueError('Snapshot omits or changes a producing build dependency')
+ if recipe(root, commit) != data['arm64_recipe']:
+ raise ValueError('Snapshot recipe differs from the producing source')
+ if x86_recipe(root, commit) != data['x86_recipe']:
+ raise ValueError('Snapshot x86 recipe differs from the producing source')
+
+
+def unpack(content, data):
+ if sha(content) != data['archive_sha256']:
+ raise ValueError('Preload archive checksum mismatch')
+ result = {}
+ total = 0
+ with tarfile.open(fileobj=io.BytesIO(content), mode='r:gz') as archive:
+ for entry in archive:
+ if entry.name not in FILES or entry.name in result or not entry.isfile() or entry.size > 32 * 1024 * 1024:
+ raise ValueError('Unsafe or unexpected preload archive entry')
+ total += entry.size
+ if total > 64 * 1024 * 1024:
+ raise ValueError('Preload archive exceeds expanded size limit')
+ value = archive.extractfile(entry).read()
+ if sha(value) != data['outputs'][entry.name]:
+ raise ValueError('Preload binary checksum mismatch')
+ result[entry.name] = value
+ if set(result) != FILES:
+ raise ValueError('Incomplete preload archive')
+ return result
+
+
+def restore(root=ROOT):
+ data = json.loads((root / LOCK).read_text())
+ validate_lock(data)
+ matching = {group: source_files(group, root) == data['groups'][group] for group in INPUTS}
+ matching['arm64'] &= recipe(root) == data['arm64_recipe']
+ matching['x86'] &= x86_recipe(root) == data.get('x86_recipe')
+ if os.environ.get('FORCE_PRELOAD_REBUILD') == 'true':
+ matching = dict.fromkeys(INPUTS, False)
+ if any(matching.values()):
+ base = os.environ.get('COMPONENT_TRUSTED_BASE')
+ approved = None
+ if base:
+ result = subprocess.run(['git', 'show', f'{base}:{LOCK}'], cwd=root, capture_output=True)
+ if result.returncode == 0:
+ approved = json.loads(result.stdout)
+ if approved != data:
+ try:
+ prove_origin(data, root / 'app/build/component-downloads', root)
+ except (subprocess.CalledProcessError, OSError, http.client.HTTPException):
+ print('Snapshot origin is unavailable; rebuilding without trusting the snapshot.')
+ matching = dict.fromkeys(INPUTS, False)
+ if any(matching.values()):
+ url = (f"https://github.com/Droid-Deck/DroidDeck-Components/releases/download/"
+ f"preloads-{data['archive_sha256']}/preloads.tar.gz")
+ try:
+ with urllib.request.urlopen(url, timeout=60) as response:
+ content = response.read(64 * 1024 * 1024)
+ except (OSError, http.client.HTTPException) as error:
+ if isinstance(error, urllib.error.HTTPError) and error.code not in (404, 429) and error.code < 500:
+ raise
+ print('Preload snapshot is unavailable; rebuilding from source.')
+ matching = dict.fromkeys(INPUTS, False)
+ else:
+ binaries = unpack(content, data)
+ for group, matches in matching.items():
+ if matches:
+ for name in OUTPUTS[group]:
+ path = root / 'app/src/main/assets/linuxfs' / name
+ if not path.resolve().is_relative_to(root.resolve()) or path.is_symlink():
+ raise ValueError('Preload output escapes repository or is a symlink')
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(binaries[name])
+ path.chmod(0o755 if name.endswith('droiddeck-clipboard') else 0o644)
+ for group, matches in matching.items():
+ print(f"{group}: {'verified snapshot reused' if matches else 'inputs changed or snapshot unavailable; rebuild required'}")
+ if os.environ.get('GITHUB_OUTPUT'):
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
+ output.write(f'{group}={str(matches).lower()}\n')
+ return matching
+
+
+def snapshot(run_id, destination, root=ROOT):
+ build = api(f'actions/runs/{run_id}')
+ candidates = api(f'actions/runs/{run_id}/artifacts?per_page=100')['artifacts']
+ artifact = next(a for a in candidates if a['name'] == 'droiddeck-apk')
+ origin = dict(run=run_id, commit=build['head_sha'], artifact=artifact['id'],
+ artifact_sha256=artifact['digest'].removeprefix('sha256:'),
+ url=build['html_url'])
+ content = artifact_bytes(origin, root / 'app/build/component-downloads')
+ run('git', 'fetch', '--no-tags', 'origin', origin['commit'], root=root)
+ data = dict(version=1, origin=origin, groups={g: source_files(g, root, origin['commit']) for g in INPUTS},
+ arm64_recipe=recipe(root, origin['commit']), x86_recipe=x86_recipe(root, origin['commit']),
+ outputs={name: sha(value) for name, value in content.items()},
+ toolchain=dict(arm64='Ubuntu 24.04 GCC cross compiler; exact build commands pinned by arm64_recipe',
+ x86='Debian bullseye compiler image and recipe pinned by source inputs'))
+ stream = io.BytesIO()
+ with gzip.GzipFile(fileobj=stream, mode='wb', mtime=0) as compressed:
+ with tarfile.open(fileobj=compressed, mode='w') as archive:
+ for name, value in sorted(content.items()):
+ entry = tarfile.TarInfo(name)
+ entry.size, entry.mode = len(value), 0o644
+ archive.addfile(entry, io.BytesIO(value))
+ destination.mkdir(parents=True, exist_ok=True)
+ (destination / 'preloads.tar.gz').write_bytes(stream.getvalue())
+ data['archive_sha256'] = sha(stream.getvalue())
+ (root / LOCK).write_text(json.dumps(data, indent=2, sort_keys=True) + '\n')
+ print('Snapshot SHA-256: ' + data['archive_sha256'])
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('operation', choices=['restore', 'snapshot'])
+ parser.add_argument('--run', type=int)
+ parser.add_argument('--out', type=Path, default=ROOT / 'app/build/component-snapshot')
+ args = parser.parse_args()
+ if args.operation == 'restore':
+ restore()
+ else:
+ snapshot(args.run, args.out)
diff --git a/tools/components/prepare.py b/tools/components/prepare.py
new file mode 100644
index 000000000..064b452be
--- /dev/null
+++ b/tools/components/prepare.py
@@ -0,0 +1,167 @@
+"""One entry point for runtime recipes, cache identities and verified job handoff."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import shutil
+import subprocess
+import tarfile
+
+ROOT = Path(__file__).resolve().parents[2]
+REGISTRY = 'tools/components/recipes.json'
+TREES = ['app/src/main/assets/linuxfs', 'app/src/main/assets/directaudio',
+ 'app/src/main/assets/droiddeck-esync', 'app/build/prepared-assets',
+ 'app/build/component-records']
+LIBRARIES = ['app/src/main/jniLibs/arm64-v8a/' + name for name in
+ ('libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so')]
+MANIFEST = 'app/build/prepared-components.json'
+
+
+def digest(path):
+ with path.open('rb') as stream:
+ return hashlib.file_digest(stream, 'sha256').hexdigest()
+
+
+def registry(root=ROOT):
+ return json.loads((root / REGISTRY).read_text())
+
+
+def identity(name, root=ROOT):
+ spec = registry(root)[name]
+ paths = subprocess.check_output(['git', 'ls-files', '--cached', '--others', '--exclude-standard',
+ '--', *spec['inputs'], *spec.get('source_inputs', [])], cwd=root, text=True).splitlines()
+ values = {}
+ for name_ in sorted(set(paths)):
+ if '__pycache__' in name_ or name_.endswith(('.pyc', '.md')):
+ continue
+ content = (root / name_).read_bytes()
+ if b'\0' not in content:
+ content = content.replace(b'\r\n', b'\n')
+ values[name_] = hashlib.sha256(content).hexdigest()
+ policy = dict(spec=spec, sources=values, runner=os.environ.get('RUNNER_OS', 'Linux'),
+ architecture=os.environ.get('RUNNER_ARCH', 'X64'))
+ entrypoint = root / 'tools/components/prepare.py'
+ if entrypoint.is_file():
+ policy['entrypoint'] = hashlib.sha256(entrypoint.read_bytes().replace(b'\r\n', b'\n')).hexdigest()
+ installer = root / 'tools/ci/install-ubuntu-packages.sh'
+ if spec.get('packages') and installer.is_file():
+ policy['installer'] = hashlib.sha256(installer.read_bytes().replace(b'\r\n', b'\n')).hexdigest()
+ if spec.get('ndk'):
+ policy['ndk'] = os.environ.get('NDK_VERSION', '')
+ policy['ndk_policy'] = hashlib.sha256((root / 'tools/ci/toolchains.env').read_bytes().replace(b'\r\n', b'\n')).hexdigest()
+ return hashlib.sha256(json.dumps(policy, sort_keys=True).encode()).hexdigest()
+
+
+def prepare(name, root=ROOT):
+ spec = registry(root)[name]
+ prepare_tools([name], root)
+ if 'command' in spec:
+ command = spec['command']
+ else:
+ script = (root / spec['script']).resolve()
+ if not script.is_relative_to((root / 'tools/components').resolve()):
+ raise ValueError('Recipe script escapes components directory')
+ command = ['bash', str(script)]
+ environment = dict(os.environ)
+ if spec.get('source'):
+ environment.update(COMPONENT_SOURCE_URL=spec['source']['url'],
+ COMPONENT_SOURCE_SHA256=spec['source']['sha256'])
+ subprocess.run(command, cwd=root, env=environment, check=True)
+ missing = [name_ for name_ in spec['outputs'] if not (root / name_).is_file()]
+ if missing:
+ raise ValueError(f'{name} did not prepare required outputs: {missing}')
+ record = dict(component=name, input_sha256=identity(name, root),
+ outputs={p: digest(root / p) for p in spec['outputs']})
+ destination = root / 'app/build/component-records' / (name + '.json')
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ destination.write_text(json.dumps(record, sort_keys=True, indent=2) + '\n')
+
+
+def prepare_tools(names, root=ROOT):
+ packages = sorted({package for name in names for package in registry(root)[name].get('packages', [])})
+ if packages:
+ subprocess.run(['bash', 'tools/ci/install-ubuntu-packages.sh', *packages], cwd=root, check=True)
+
+
+def files(root=ROOT):
+ result = []
+ for name in TREES:
+ folder = root / name
+ if not folder.is_dir() or folder.is_symlink():
+ raise ValueError('Missing prepared component tree: ' + name)
+ for path in folder.rglob('*'):
+ if path.is_symlink():
+ raise ValueError('Symlink in prepared components: ' + str(path))
+ if path.is_file():
+ result.append(path.relative_to(root).as_posix())
+ for name in LIBRARIES:
+ if not (root / name).is_file() or (root / name).is_symlink():
+ raise ValueError('Missing prepared component library: ' + name)
+ result.append(name)
+ return sorted(result)
+
+
+def pack(destination, root=ROOT):
+ names = files(root)
+ tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=root, text=True).strip()
+ data = dict(version=1, source_tree=tree, run=os.environ.get('GITHUB_RUN_ID', ''),
+ recipes={name: identity(name, root) for name in registry(root)},
+ files={name: digest(root / name) for name in names})
+ (root / MANIFEST).write_text(json.dumps(data, sort_keys=True, indent=2) + '\n')
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with tarfile.open(destination, mode='w:gz', compresslevel=1) as archive:
+ for name in names + [MANIFEST]:
+ archive.add(root / name, arcname=name, recursive=False)
+
+
+def unpack(archive_path, source_tree, root=ROOT):
+ total = 0
+ seen = set()
+ with tarfile.open(archive_path, mode='r:gz') as archive:
+ for entry in archive:
+ allowed = entry.name in LIBRARIES + [MANIFEST] or any(entry.name.startswith(p + '/') for p in TREES)
+ path = root / entry.name
+ total += entry.size
+ if (not allowed or '\\' in entry.name or '..' in PurePosixPath(entry.name).parts
+ or PurePosixPath(entry.name).as_posix() != entry.name
+ or not entry.isfile() or entry.name in seen or len(seen) >= 100000
+ or entry.size > 512 * 1024 * 1024
+ or total > 2 * 1024**3 or not path.resolve().is_relative_to(root.resolve())
+ or path.is_symlink()):
+ raise ValueError('Unsafe prepared component archive')
+ seen.add(entry.name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open('wb') as output:
+ shutil.copyfileobj(archive.extractfile(entry), output)
+ path.chmod(entry.mode & 0o777)
+ data = json.loads((root / MANIFEST).read_text())
+ actual = {name: digest(root / name) for name in files(root)}
+ recipes = {name: identity(name, root) for name in registry(root)}
+ if (data.get('version') != 1 or data['source_tree'] != source_tree or data['files'] != actual
+ or data['recipes'] != recipes):
+ raise ValueError('Prepared components do not match the pinned source tree and file hashes')
+ print('Prepared component source identity and all output hashes verified.')
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('recipe')
+ parser.add_argument('--archive', type=Path, default=ROOT / 'app/build/prepared-components.tar.gz')
+ parser.add_argument('--tree')
+ args = parser.parse_args()
+ if args.recipe == 'plan':
+ for name in registry():
+ value = identity(name)
+ print(f'{name}: {value}')
+ if os.environ.get('GITHUB_OUTPUT'):
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as stream:
+ stream.write(f'{name}={value}\n')
+ elif args.recipe == 'tools':
+ prepare_tools(os.environ.get('COMPONENTS_TO_BUILD', '').split())
+ elif args.recipe == 'pack':
+ pack(args.archive)
+ elif args.recipe == 'unpack':
+ unpack(args.archive, args.tree)
+ else:
+ prepare(args.recipe)
diff --git a/tools/components/recipes.json b/tools/components/recipes.json
new file mode 100644
index 000000000..19144018f
--- /dev/null
+++ b/tools/components/recipes.json
@@ -0,0 +1,225 @@
+{
+ "runtime": {
+ "script": "tools/components/recipes/runtime.sh",
+ "inputs": [
+ "tools/components/recipes/runtime.sh",
+ "tools/quest",
+ "tools/linuxfs/overlay",
+ "tools/linuxfs/desktop",
+ "tools/linuxfs/licenses",
+ "tools/linuxfs/uruntime.env",
+ "tools/gamescope/release.env",
+ "tools/wlroots/release.env",
+ "tools/mangoapp",
+ "tools/msitools",
+ "tools/pipewire",
+ "tools/vr",
+ "app/src/main/cpp/waylandcomp/generated",
+ "app/src/main/cpp/waylandcomp/src/headpose_shm.h",
+ "app/src/main/cpp/waylandcomp/src/spatialpose_shm.h",
+ "app/src/main/cpp/venc/venc_proto.h"
+ ],
+ "outputs": [
+ "app/src/main/assets/linuxfs/usr/local/bin/gamescope",
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/bin/linuxarm64/driver_droiddeck.so"
+ ],
+ "ndk": true,
+ "packages": [
+ "gcc-aarch64-linux-gnu",
+ "g++-aarch64-linux-gnu",
+ "libvulkan1",
+ "mesa-vulkan-drivers"
+ ]
+ },
+ "esync": {
+ "script": "tools/components/recipes/esync.sh",
+ "inputs": [
+ "tools/components/recipes/esync.sh",
+ "tools/droiddeck-esync/release.env"
+ ],
+ "outputs": [
+ "app/src/main/assets/droiddeck-esync/index.json",
+ "app/src/main/assets/droiddeck-esync/index.json.sig"
+ ]
+ },
+ "debian-image": {
+ "script": "tools/components/recipes/debian-image.sh",
+ "inputs": [
+ "tools/components/recipes/debian-image.sh"
+ ],
+ "outputs": []
+ },
+ "x86-preloads": {
+ "script": "tools/components/recipes/x86-preloads.sh",
+ "inputs": [
+ "tools/components/recipes/x86-preloads.sh",
+ "tools/linuxfs/build-x86-preloads.sh",
+ "tools/linuxfs/preload",
+ "tools/linuxfs/fex"
+ ],
+ "outputs": [
+ "app/src/main/assets/linuxfs/x86_64/libfakeinput.so",
+ "app/src/main/assets/linuxfs/x86_64/libblsession.so",
+ "app/src/main/assets/linuxfs/x86_64/libfaultreport.so",
+ "app/src/main/assets/linuxfs/i386/libfakeinput.so",
+ "app/src/main/assets/linuxfs/i386/libblsession.so",
+ "app/src/main/assets/linuxfs/i386/libfaultreport.so",
+ "app/src/main/assets/linuxfs/x86_64/libwebkitinput.so",
+ "app/src/main/assets/linuxfs/x86_64/libvulkan-thunk.so"
+ ],
+ "source_inputs": [
+ "app/src/main/cpp/fakeinput_steam.cpp",
+ "app/src/main/cpp/adrenotools/src/hook/kgsl.h",
+ "app/src/main/cpp/venc/venc_proto.h",
+ "tools/linuxfs/preload",
+ "tools/quest/kgsl_compat.c",
+ "tools/quest/kgsl_compat.h",
+ "tools/linuxfs/fex",
+ "tools/linuxfs/build-x86-preloads.sh",
+ "tools/linuxfs/debian-image.env"
+ ],
+ "snapshot_outputs": [
+ "x86_64/libfakeinput.so",
+ "x86_64/libblsession.so",
+ "x86_64/libfaultreport.so",
+ "i386/libfakeinput.so",
+ "i386/libblsession.so",
+ "i386/libfaultreport.so",
+ "x86_64/libwebkitinput.so",
+ "x86_64/libvulkan-thunk.so"
+ ]
+ },
+ "pulseaudio-source": {
+ "script": "tools/components/recipes/pulseaudio-source.sh",
+ "inputs": [
+ "tools/components/recipes/pulseaudio-source.sh"
+ ],
+ "outputs": [
+ "pa13/src/pulsecore/module.h"
+ ],
+ "source": {
+ "url": "https://github.com/pulseaudio/pulseaudio/archive/refs/tags/v13.0.tar.gz",
+ "sha256": "bd7980bc9bd44bd3b3363545dabd19476ebf4d6bb6d2527e7d179256394a6758"
+ }
+ },
+ "directaudio": {
+ "script": "tools/components/recipes/directaudio.sh",
+ "inputs": [
+ "tools/components/recipes/directaudio.sh",
+ "tools/directaudio"
+ ],
+ "outputs": [
+ "app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so"
+ ]
+ },
+ "audio": {
+ "script": "tools/components/recipes/audio.sh",
+ "inputs": [
+ "tools/aaudio-sink",
+ "tools/directaudio",
+ "tools/components/recipes/audio.sh",
+ "app/src/main/assets/pulseaudio.tzst"
+ ],
+ "outputs": [
+ "app/build/prepared-assets/pulseaudio.tzst"
+ ],
+ "ndk": true
+ },
+ "proot": {
+ "script": "tools/components/recipes/proot.sh",
+ "inputs": [
+ "tools/proot",
+ "tools/components/recipes/proot.sh"
+ ],
+ "outputs": [
+ "app/src/main/jniLibs/arm64-v8a/libproot.so",
+ "app/src/main/jniLibs/arm64-v8a/libproot-loader.so"
+ ],
+ "ndk": true
+ },
+ "comet": {
+ "script": "tools/components/recipes/comet.sh",
+ "inputs": [
+ "tools/comet",
+ "tools/components/recipes/comet.sh"
+ ],
+ "outputs": [
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/comet",
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-comet/version"
+ ],
+ "packages": [
+ "gcc-aarch64-linux-gnu"
+ ]
+ },
+ "arm64-preloads": {
+ "script": "tools/components/recipes/arm64-preloads.sh",
+ "inputs": [
+ "tools/components/build-arm64-preloads.sh",
+ "tools/linuxfs/preload",
+ "app/src/main/cpp/fakeinput_steam.cpp",
+ "tools/components/recipes/arm64-preloads.sh"
+ ],
+ "outputs": [
+ "app/src/main/assets/linuxfs/libfakeinput.so",
+ "app/src/main/assets/linuxfs/libblsession.so",
+ "app/src/main/assets/linuxfs/libblfastpath.so",
+ "app/src/main/assets/linuxfs/libblaudit.so",
+ "app/src/main/assets/linuxfs/libssbs.so",
+ "app/src/main/assets/linuxfs/usr/local/bin/droiddeck-clipboard"
+ ],
+ "packages": [
+ "gcc-aarch64-linux-gnu",
+ "g++-aarch64-linux-gnu"
+ ],
+ "source_inputs": [
+ "app/src/main/cpp/fakeinput_steam.cpp",
+ "app/src/main/cpp/adrenotools/src/hook/kgsl.h",
+ "app/src/main/cpp/venc/venc_proto.h",
+ "tools/linuxfs/preload",
+ "tools/quest/kgsl_compat.c",
+ "tools/quest/kgsl_compat.h",
+ "tools/proot/fastpath",
+ "tools/linuxfs/ssbs",
+ "tools/linuxfs/clipboard"
+ ],
+ "snapshot_outputs": [
+ "libfakeinput.so",
+ "libblsession.so",
+ "libblfastpath.so",
+ "libblaudit.so",
+ "libssbs.so",
+ "usr/local/bin/droiddeck-clipboard"
+ ]
+ },
+ "preload-snapshot": {
+ "command": [
+ "python3",
+ "tools/components/preloads.py",
+ "restore"
+ ],
+ "inputs": [
+ "tools/components/preloads.py",
+ "tools/components/preloads.lock.json"
+ ],
+ "outputs": []
+ },
+ "xbox": {
+ "script": "tools/components/recipes/xbox.sh",
+ "inputs": [
+ "tools/xbox",
+ "tools/components/recipes/xbox.sh"
+ ],
+ "outputs": [
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-cli",
+ "app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox/xodus-service"
+ ],
+ "packages": [
+ "build-essential",
+ "pkg-config",
+ "libssl-dev",
+ "protobuf-compiler",
+ "cmake",
+ "xz-utils"
+ ]
+ }
+}
diff --git a/tools/components/recipes/arm64-preloads.sh b/tools/components/recipes/arm64-preloads.sh
new file mode 100644
index 000000000..c6a227c2e
--- /dev/null
+++ b/tools/components/recipes/arm64-preloads.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -euo pipefail
+bash tools/components/build-arm64-preloads.sh app/src/main/assets/linuxfs
diff --git a/tools/components/recipes/audio.sh b/tools/components/recipes/audio.sh
new file mode 100644
index 000000000..819421d7c
--- /dev/null
+++ b/tools/components/recipes/audio.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
+tools/aaudio-sink/build.sh "$PWD/pa13" "$PWD/sink-out"
+rm -rf pa-bundle && mkdir -p pa-bundle
+tar --use-compress-program=unzstd -xf app/src/main/assets/pulseaudio.tzst -C pa-bundle
+test ! -f pa-bundle/modules/arm64/module-aaudio-sink.so
+install -m755 sink-out/module-aaudio-sink.so pa-bundle/modules/arm64/module-aaudio-sink.so
+install -m755 sink-out/module-directaudio-native-sink.so pa-bundle/modules/arm64/module-directaudio-native-sink.so
+(cd pa-bundle && tar --use-compress-program='zstd -19' -cf ../pulseaudio.tzst .)
+mkdir -p app/build/prepared-assets
+mv pulseaudio.tzst app/build/prepared-assets/pulseaudio.tzst
+tar --use-compress-program=unzstd -tf app/build/prepared-assets/pulseaudio.tzst | grep -c '\.so$'
+rm -rf pa-bundle sink-out
diff --git a/tools/components/recipes/comet.sh b/tools/components/recipes/comet.sh
new file mode 100644
index 000000000..e47d3e3d5
--- /dev/null
+++ b/tools/components/recipes/comet.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+rustup toolchain install 1.90.0 --profile minimal
+rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
+RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
diff --git a/tools/components/recipes/debian-image.sh b/tools/components/recipes/debian-image.sh
new file mode 100644
index 000000000..f982225dc
--- /dev/null
+++ b/tools/components/recipes/debian-image.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+. tools/linuxfs/debian-image.env
+image=public.ecr.aws/docker/library/debian:bullseye
+if gh release download "$DEBIAN_IMAGE_TAG" -R "$GITHUB_REPOSITORY" -p "$DEBIAN_IMAGE_ASSET" -O /tmp/debian-image.tar.zst \
+ && { [ -z "$DEBIAN_IMAGE_SHA256" ] || echo "$DEBIAN_IMAGE_SHA256 /tmp/debian-image.tar.zst" | sha256sum -c -; }; then
+ zstd -dc /tmp/debian-image.tar.zst | docker load
+ rm -f /tmp/debian-image.tar.zst
+else
+ echo "::warning::the hosted Debian image could not be fetched; pulling from the registry instead"
+ for attempt in 1 2 3 4 5; do
+ docker pull "$image" && break
+ [ "$attempt" -lt 5 ] || { echo "::error::could not pull $image after $attempt attempts"; exit 1; }
+ echo "pull attempt $attempt failed (registry busy?); retrying in $((attempt * 30)) s"
+ sleep $((attempt * 30))
+ done
+fi
+docker image ls "$image"
diff --git a/tools/components/recipes/directaudio.sh b/tools/components/recipes/directaudio.sh
new file mode 100644
index 000000000..c684f0262
--- /dev/null
+++ b/tools/components/recipes/directaudio.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+set -euo pipefail
+tools/directaudio/fetch.sh "$PWD" "$PWD/sink-out"
\ No newline at end of file
diff --git a/tools/components/recipes/esync.sh b/tools/components/recipes/esync.sh
new file mode 100644
index 000000000..691f77e99
--- /dev/null
+++ b/tools/components/recipes/esync.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+set -euo pipefail
+if [ -f tools/droiddeck-esync/release.env ]; then
+ . tools/droiddeck-esync/release.env
+ release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
+ curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
+ echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
+ mkdir -p app/src/main/assets/droiddeck-esync
+ tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
+ rm sync-bundle.tzst
+ curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
+ revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
+ for id in $revoked; do
+ if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
+ echo "::warning::$SYNC_BUNDLE_ASSET carries revoked pack $id; it is left out of the APK (point tools/droiddeck-esync/release.env at a newer bundle)"
+ rm -f "app/src/main/assets/droiddeck-esync/packs/$id.tzst"
+ fi
+ done
+ rm sync-index.json
+ test -f app/src/main/assets/droiddeck-esync/index.json
+ test -f app/src/main/assets/droiddeck-esync/index.json.sig
+ ls -lR app/src/main/assets/droiddeck-esync
+fi
diff --git a/tools/components/recipes/proot.sh b/tools/components/recipes/proot.sh
new file mode 100644
index 000000000..9bf7699e8
--- /dev/null
+++ b/tools/components/recipes/proot.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+export NDK="${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"
+tools/proot/build.sh app/src/main/jniLibs/arm64-v8a
diff --git a/tools/components/recipes/pulseaudio-source.sh b/tools/components/recipes/pulseaudio-source.sh
new file mode 100644
index 000000000..5a2a6ed09
--- /dev/null
+++ b/tools/components/recipes/pulseaudio-source.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+curl -fsSL --retry 3 -o pa.tar.gz "$COMPONENT_SOURCE_URL"
+echo "$COMPONENT_SOURCE_SHA256 pa.tar.gz" | sha256sum -c -
+mkdir -p pa13 && tar -xzf pa.tar.gz -C pa13 --strip-components=1 && rm pa.tar.gz
diff --git a/tools/components/recipes/runtime.sh b/tools/components/recipes/runtime.sh
new file mode 100644
index 000000000..0e640ab11
--- /dev/null
+++ b/tools/components/recipes/runtime.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+set -euo pipefail
+D=app/src/main/assets/linuxfs
+mkdir -p "$D"
+VK_INCLUDE=$(ls -d "${ANDROID_NDK_LATEST_HOME:-${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}}"/toolchains/llvm/prebuilt/*/sysroot/usr/include | head -1) \
+ tools/vr/build.sh --native "$D/usr/local/lib/droiddeck-vr"
+for script in tools/linuxfs/overlay/usr/local/bin/droiddeck-* tools/linuxfs/overlay/usr/local/bin/steam-compatibility; do
+ install -Dm644 "$script" "$D/usr/local/bin/$(basename "$script")"
+done
+for f in tools/linuxfs/overlay/usr/bin/* tools/linuxfs/overlay/usr/bin/steamos-polkit-helpers/*; do
+ [ -f "$f" ] && install -Dm644 "$f" "$D/${f#tools/linuxfs/overlay/}"
+done
+if [ -f tools/gamescope/release.env ]; then
+ . tools/gamescope/release.env
+ curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
+ echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
+ tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
+ test -f "$D/usr/local/bin/gamescope"
+ rm gamescope.tzst
+fi
+. tools/linuxfs/uruntime.env
+curl -fsSL --retry 3 -o uruntime "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}"
+echo "${URUNTIME_SHA256} uruntime" | sha256sum -c -
+install -Dm644 uruntime "$D/usr/local/lib/droiddeck/uruntime"
+install -Dm644 tools/linuxfs/licenses/uruntime-LICENSE "$D/usr/local/share/licenses/uruntime/LICENSE"
+rm uruntime
+M="$D/usr/local/lib/mangoapp"
+mkdir -p "$M" mango-pkgs
+grep -v '^#' tools/mangoapp/packages.txt | while read -r sum url; do
+ [ -n "$url" ] || continue
+ curl -fsSL --retry 3 -o mango-pkgs/pkg.tar.zst "$url"
+ echo "$sum mango-pkgs/pkg.tar.zst" | sha256sum -c -
+ tar --use-compress-program=unzstd -xf mango-pkgs/pkg.tar.zst -C mango-pkgs
+ rm mango-pkgs/pkg.tar.zst
+done
+install -m644 mango-pkgs/usr/bin/mangoapp "$M/mangoapp"
+for so in libfmt.so.10 libspdlog.so.1.13 libglfw.so.3 libtraceevent.so.1; do
+ cp -L "mango-pkgs/usr/lib/$so" "$M/$so"
+done
+aarch64-linux-gnu-gcc -shared -fPIC -O2 -Wall -Wl,-soname,libtracefs.so.1 \
+ -o "$M/libtracefs.so.1" tools/mangoapp/libtracefs-shim.c
+aarch64-linux-gnu-strip --strip-unneeded "$M/libtracefs.so.1"
+install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
+rm -rf mango-pkgs
+ls -l "$M"
+mkdir -p pw-pkgs
+grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
+ [ -n "$url" ] || continue
+ curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
+ echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
+ tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
+ rm pw-pkgs/pkg.tar.zst
+done
+tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
+rm -rf pw-pkgs
+ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
+T="$D/usr/local/lib/droiddeck-msitools"
+mkdir -p "$T" msi-pkgs
+grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
+ [ -n "$url" ] || continue
+ curl -fsSL --retry 3 -o msi-pkgs/pkg.tar.xz "$url"
+ echo "$sum msi-pkgs/pkg.tar.xz" | sha256sum -c -
+ tar -xJf msi-pkgs/pkg.tar.xz -C msi-pkgs
+ rm msi-pkgs/pkg.tar.xz
+done
+install -m644 msi-pkgs/usr/bin/msiinfo "$T/msiinfo"
+install -m644 msi-pkgs/usr/bin/cabextract "$T/cabextract"
+install -m644 msi-pkgs/usr/lib/7zip/7z "$T/7z"
+install -m644 msi-pkgs/usr/lib/7zip/7z.so "$T/7z.so"
+for so in libmsi-1.0.so.0 libgsf-1.so.114 libgcab-1.0.so.0; do
+ cp -L "msi-pkgs/usr/lib/$so" "$T/$so"
+done
+for f in NOTICE GPL-2 GPL-3 LGPL-2.1 7zip-License 7zip-unRarLicense; do
+ install -Dm644 "tools/msitools/licenses/$f" "$D/usr/local/share/licenses/droiddeck-msitools/$f"
+done
+rm -rf msi-pkgs
+ls -l "$T"
+install -Dm644 tools/linuxfs/desktop/droiddeck-desktop "$D/usr/local/bin/droiddeck-desktop"
+install -Dm644 tools/linuxfs/desktop/droiddeck-gpu "$D/usr/local/bin/droiddeck-gpu"
+install -Dm644 tools/linuxfs/desktop/droiddeck-desktop-gpu "$D/usr/local/bin/droiddeck-desktop-gpu"
+install -Dm644 tools/linuxfs/desktop/kwin_wayland_wrapper "$D/usr/local/bin/kwin_wayland_wrapper"
+install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js"
+ls -lR "$D"
diff --git a/tools/components/recipes/x86-preloads.sh b/tools/components/recipes/x86-preloads.sh
new file mode 100644
index 000000000..c107c543b
--- /dev/null
+++ b/tools/components/recipes/x86-preloads.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+set -euo pipefail
+docker run --rm -v "$PWD:/src" -w /src public.ecr.aws/docker/library/debian:bullseye bash -c '
+ set -euo pipefail
+ install_compilers() {
+ apt-get -o Acquire::Check-Valid-Until=false update -qq &&
+ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends gcc g++ gcc-multilib g++-multilib binutils >/dev/null
+ }
+ printf "deb http://deb.debian.org/debian bullseye main\ndeb http://security.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
+ if ! install_compilers; then
+ printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list
+ install_compilers
+ fi
+ tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs
+'
+sudo chown -R "$(id -u):$(id -g)" app/src/main/assets/linuxfs
+ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
diff --git a/tools/components/recipes/xbox.sh b/tools/components/recipes/xbox.sh
new file mode 100644
index 000000000..87c953656
--- /dev/null
+++ b/tools/components/recipes/xbox.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+rustup toolchain install 1.99.0 --profile minimal
+bash tools/xbox/build.sh
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
index 331dcfecc..6ca792143 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex
@@ -589,7 +589,8 @@ def guest_environment(fex, env):
result["FEX_THUNKHOSTLIBS"] = fex["host_thunks"]
result["FEX_THUNKGUESTLIBS"] = fex["guest_thunks"]
result["FEX_THUNKCONFIG"] = os.path.join(CONFIG_DIR, "ThunksConfig.json")
- preload = [part for part in split_paths(result.get("LD_PRELOAD")) if os.path.exists(part) and not is_arm(part)]
+ preload = [part for part in split_paths(result.get("LD_PRELOAD"))
+ if "/" not in part or (os.path.exists(part) and not is_arm(part))]
library_path = [part for part in (result.get("LD_LIBRARY_PATH") or "").split(":")
if part and "arm64" not in part and "aarch64" not in part]
for name, parts in (("LD_PRELOAD", preload), ("LD_LIBRARY_PATH", library_path)):
@@ -671,15 +672,26 @@ def launch(command, env, mode, decide=None):
return os.execve(fex["bin"], [fex["bin"], program] + command[1:], guest)
-def game_environment(env):
+def game_environment(env, command):
try:
game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-env"))
env = game_env["game_gpu"](env)
app = game_env["game_id"](env) or env.get("SteamAppId") or env.get("SteamGameId")
if not app:
return env
- with open(GAME_ENVIRONMENT, encoding="utf-8") as source:
- return game_env["apply_config"](env, json.load(source), str(app))
+ fixes = {}
+ game = game_env["Game"](resolve(command[0], env), command[1:])
+ for check in game_env["FIXES"]:
+ found = check(game)
+ if found and found[0]:
+ fixes.update(found[0])
+ say(found[2])
+ try:
+ with open(GAME_ENVIRONMENT, encoding="utf-8") as source:
+ config = json.load(source)
+ except FileNotFoundError:
+ config = {"version": 1}
+ return game_env["apply_config"](env, config, str(app), fixes)
except FileNotFoundError:
return env
except Exception as error:
@@ -810,7 +822,7 @@ def main(argv):
return 0
if not command:
return 2
- return launch(command, game_environment(dict(os.environ)), os.environ.get("DROIDDECK_FEX", "auto"))
+ return launch(command, game_environment(dict(os.environ), command), os.environ.get("DROIDDECK_FEX", "auto"))
if action == "run":
try:
mode, decide, command = parse_run(args)
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
index a38d77d8c..75154ab6d 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
@@ -87,6 +87,12 @@ def dotnet_memory_ordering(game):
return DOTNET_FEX, [], ".NET runtime: FEX with full TSO and without multiblock"
+@fix
+def unity_memory_ordering(game):
+ if {"unityplayer.so", "gameassembly.so", "unityplayer.dll"} & game.files:
+ return {"FEX_TSOENABLED": "1"}, [], "Unity: FEX with TSO"
+
+
@fix
def godot_vulkan(game):
if is_godot(game) and not game.has_arg("--rendering-driver"):
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
index 8bd91bde7..3eb5ff17a 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session
@@ -227,7 +227,9 @@ if [ -z "${BL_INSIDE:-}" ]; then
echo "== gamescope: games at ${nested}x${height} stretched to the ${width}x${height} output"
fi
fi
- if [ "$mode" = steam ]; then
+ # Off unless Performance asks for it. Compositing the cursor here adds a second
+ # plane on the phone's compositor; a controller-driven cursor is the reason to turn it on.
+ if [ "$mode" = steam ] && [ "${BL_CURSOR_COMPOSITE:-0}" = 1 ]; then
export gamescope_cursor_composite=2
fi
args=(--backend wayland --expose-wayland -f -W "$width" -H "$height" -w "$nested" -h "$height")
@@ -405,6 +407,11 @@ case $mode in
droiddeck-steam-x64 --sync-login || true
fi
steam_client=("$steam_root/steamrtarm64/steam")
+ _bl_srt_shim=$HOME/.droiddeck-steamrt-shim
+ if droiddeck-steamrt-shim "$steam_root/steamrtarm64" "$_bl_srt_shim"; then
+ echo "== steam: Valve containerized the arm64 Steam client (it carries a Steam Runtime container); starting it through $_bl_srt_shim"
+ steam_client=(env STEAM_RUNTIME_STEAMRT="$_bl_srt_shim" PATH="$_bl_srt_shim/bin:$PATH" "${steam_client[@]}")
+ fi
fi
_bl_proton_root=$steam_root
if [ "$steam_x64" = 1 ]; then
@@ -528,6 +535,11 @@ case $mode in
fi
droiddeck-seed-redists "$steam_root" || true
fi
+ if [ -n "${BL_SHADER_PRECACHE:-}" ]; then
+ droiddeck-steam-shaders "$BL_SHADER_PRECACHE" "$XDG_DATA_HOME/Steam" \
+ "${BL_X64_HOME:-$HOME/.droiddeck-x64}/.local/share/Steam" &&
+ echo "$BL_SHADER_PRECACHE" > "$HOME/.bl-shader-precache-applied" || true
+ fi
if [ "$steam_x64" = 0 ] && [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then
step "asking the client to fetch the ARM64 Proton"
set -- "$@" steam://install/4427310
@@ -748,10 +760,13 @@ BUSCONF
fi
# The UI bridge launches SteamVR, which needs the guest agent's library environment.
- _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME)
+ _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME -u STEAM_STEAMRT_RECURSION_GUARD)
[ "${LD_LIBRARY_PATH+x}" ] && _bl_compat_env+=("LD_LIBRARY_PATH=$LD_LIBRARY_PATH")
[ "${STEAM_RUNTIME+x}" ] && _bl_compat_env+=("STEAM_RUNTIME=$STEAM_RUNTIME")
+ [ "${STEAM_STEAMRT_RECURSION_GUARD+x}" ] && _bl_compat_env+=("STEAM_STEAMRT_RECURSION_GUARD=$STEAM_STEAMRT_RECURSION_GUARD")
export STEAM_RUNTIME=1
+ # A containerized client re-executes itself through pressure-vessel-wrap (bwrap) unless told it is already inside.
+ export STEAM_STEAMRT_RECURSION_GUARD=1
export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
export SDL_VIDEO_X11_DGAMOUSE=0
decky_loader=$HOME/homebrew/services/PluginLoader
@@ -831,6 +846,9 @@ BUSCONF
unset _stale _f
fi
steam_channel=steamdeck_publicbeta
+ if [ "$steam_x64" = 0 ]; then
+ steam_channel=$(droiddeck-steam-install --channel 2>/dev/null || echo steamdeck_publicbeta)
+ fi
_bl_lang=()
[ -n "${BL_STEAM_LANGUAGE:-}" ] && _bl_lang=(-language "$BL_STEAM_LANGUAGE")
steam_beta=$steam_root/package/beta
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat
index 3972faa9b..1de4bcf1c 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat
@@ -583,9 +583,8 @@ class Helper:
picked = COMPAT["picked_apps"](state)
runnable = {t["name"].lower() for t in live}
apps = sorted(set(installed) | set(picks) | set(prior_auto) | native_choices, key=COMPAT["app_order"])
- overrides = COMPAT["requested_games"](
- COMPAT["game_requests"](), catalog, [t["name"] for t in live])
- apps = sorted(set(apps) | set(overrides), key=COMPAT["app_order"])
+ asked = [app for app, request in COMPAT["game_requests"]().items() if COMPAT["valid_game_request"](app, request)]
+ apps = sorted(set(apps) | set(asked), key=COMPAT["app_order"])
current = dict(page.compat("many(%s)" % json.dumps(apps)) or {})
refused = set(apps) - set(current)
refused_native = native_choices & refused
@@ -597,7 +596,6 @@ class Helper:
if isinstance(state.get("auto"), dict):
state["auto"] = prior_auto
native_choices -= refused
- overrides = {app: tool for app, tool in overrides.items() if app not in refused}
current["0"] = top["selected"]
native = COMPAT["native_targets"](self.steam_root, apps)
raw_mapping = COMPAT["read_config_mapping"](
@@ -619,6 +617,9 @@ class Helper:
current[app] = ""
elif not current.get(app):
current.pop(app, None)
+ requests = COMPAT["settled_game_requests"](state, current, catalog)
+ overrides = COMPAT["requested_games"](requests or {}, catalog, [t["name"] for t in live])
+ overrides = {app: tool for app, tool in overrides.items() if app not in refused}
changes, auto = COMPAT["plan_mapping"](
current, installed, [t["name"] for t in live], default,
COMPAT["followers"](state, current), native, picks, overrides,
@@ -630,7 +631,8 @@ class Helper:
state.update(
auto={app: tool for app, tool in auto.items() if tool is not COMPAT["NATIVE_CHOICE"]},
native_choices=sorted(refused_native | {app for app, tool in auto.items() if tool is COMPAT["NATIVE_CHOICE"]}),
- picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True)
+ picked=COMPAT["picked_after"](current, changes), tools=catalog, live=True,
+ games_applied=COMPAT["applied_game_requests"](requests, overrides, state))
state.pop("native", None)
if waiting:
state["pending"] = wanted
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install
index 503e6871f..291ec8ed4 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install
@@ -1,9 +1,26 @@
#!/bin/bash
set -eu
cdn_list="https://client-update.steamstatic.com https://client-update.akamai.steamstatic.com https://client-update.fastly.steamstatic.com"
-steam_channel=steamdeck_publicbeta
-manifest=steam_client_${steam_channel}_linuxarm64
+beta_channel=steamdeck_publicbeta
+stable_channel=steamdeck_stable
steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam
+channel_state=$steam_root/package/droiddeck-channel
+pinned=1
+wanted=${BL_STEAM_CHANNEL:-$(cat "$HOME/.config/droiddeck/steam-channel" 2>/dev/null || true)}
+case $wanted in
+ stable|"$stable_channel") steam_channel=$stable_channel ;;
+ beta|"$beta_channel") steam_channel=$beta_channel ;;
+ *)
+ pinned=
+ steam_channel=$beta_channel
+ [ "$(cat "$channel_state" 2>/dev/null)" = "$stable_channel" ] && steam_channel=$stable_channel
+ ;;
+esac
+if [ "${1:-}" = --channel ]; then
+ echo "$steam_channel"
+ exit 0
+fi
+manifest=steam_client_${steam_channel}_linuxarm64
stamp=$steam_root/package/droiddeck-installed
legacy_stamp=$steam_root/package/bannerlator-installed
unconfirmed=$steam_root/package/droiddeck-unconfirmed
@@ -15,11 +32,21 @@ if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then
mv -f "$legacy_stamp" "$stamp" || true
fi
repair=""
+fallback=""
+starts=0
+[ -f "$unconfirmed" ] && starts=$(grep -cx start "$unconfirmed" || true)
if [ -e "$repair_request" ]; then
repair="requested"
-elif [ -f "$unconfirmed" ] && ! grep -qx repaired "$unconfirmed" \
- && [ "$(grep -cx start "$unconfirmed")" -ge 2 ]; then
- repair="the client was started $(grep -cx start "$unconfirmed") times without showing anything"
+elif [ "$starts" -ge 2 ] && ! grep -qx repaired "$unconfirmed"; then
+ repair="the client was started $starts times without showing anything"
+elif [ "$starts" -ge 2 ] && [ -z "$pinned" ] && [ "$steam_channel" = "$beta_channel" ] && [ "${1:-}" != --sync ]; then
+ repair="the repaired $beta_channel client was started $starts more times without showing anything"
+ fallback=1
+fi
+if [ -n "$fallback" ]; then
+ echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $repair"
+ steam_channel=$stable_channel
+ manifest=steam_client_${steam_channel}_linuxarm64
fi
if [ -n "$repair" ]; then
echo "== STEP $(date -u +%H:%M:%S) repairing the Steam client ($repair)"
@@ -113,20 +140,43 @@ fetch() {
done
}
-cdn=""
-for candidate in $cdn_list; do
- if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \
- "$candidate/$manifest" -o "$work/manifest"; then
- cdn=$candidate
- break
+fetch_manifest() {
+ cdn=""
+ for candidate in $cdn_list; do
+ if curl -fsSL --connect-timeout 10 --max-time 30 --retry 2 --retry-connrefused --retry-delay 3 \
+ "$candidate/$manifest" -o "$work/manifest"; then
+ cdn=$candidate
+ break
+ fi
+ done
+ [ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; }
+ cdn_order=("$cdn")
+ for candidate in $cdn_list; do
+ [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate")
+ done
+ version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
+ awk -F'"' '
+ /^\t"/ && NF >= 3 && $4 == "" { block = $2 }
+ $2 == "file" { file[block] = $4 }
+ $2 == "sha2" { sha[block] = $4 }
+ $2 == "size" { size[block] = $4 }
+ END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b], size[b] + 0 }
+ ' "$work/manifest" | sort > "$work/parts"
+ [ -s "$work/parts" ] && return 0
+ echo "droiddeck-steam-install: no client components in $manifest; its blocks are:" \
+ "$(awk -F'"' '/^\t"/ && NF >= 3 && $4 == "" { printf "%s ", $2 }' "$work/manifest")" >&2
+ return 1
+}
+if ! fetch_manifest; then
+ if [ -n "$sync" ] || [ -n "$pinned" ] || [ "$steam_channel" = "$stable_channel" ]; then
+ exit 1
fi
-done
-[ -n "$cdn" ] || { echo "droiddeck-steam-install: no Steam client CDN answered for $manifest" >&2; exit 1; }
-cdn_order=("$cdn")
-for candidate in $cdn_list; do
- [ "$candidate" = "$cdn" ] || cdn_order+=("$candidate")
-done
-version=$(awk -F'"' '$2 == "version" { print $4; exit }' "$work/manifest")
+ echo "== STEP $(date -u +%H:%M:%S) falling back to Valve's $stable_channel Steam client: $manifest has no arm64 client"
+ steam_channel=$stable_channel
+ manifest=steam_client_${steam_channel}_linuxarm64
+ fallback=1
+ fetch_manifest || exit 1
+fi
if [ -n "$sync" ]; then
installed=$(awk -F'"' '$2 == "version" { print $4; exit }' "$steam_root/package/$manifest.manifest" 2>/dev/null || true)
if [ -x "$steam_root/steamrtarm64/steam" ] && [ "$installed" = "$version" ]; then
@@ -135,15 +185,6 @@ if [ -n "$sync" ]; then
fi
echo "== STEP $(date -u +%H:%M:%S) updating the arm64 Steam client to $version (was ${installed:-none}) for the x86-64 client"
fi
-# Every component block: its name, then the plain zip's file name, sha256 and size.
-awk -F'"' '
- /^\t"/ && NF >= 3 && $4 == "" { block = $2 }
- $2 == "file" { file[block] = $4 }
- $2 == "sha2" { sha[block] = $4 }
- $2 == "size" { size[block] = $4 }
- END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b], size[b] + 0 }
-' "$work/manifest" | sort > "$work/parts"
-[ -s "$work/parts" ] || { echo "droiddeck-steam-install: no client components in $manifest" >&2; exit 1; }
total=$(grep -c . "$work/parts" 2>/dev/null || echo 0)
total_bytes=$(awk '{ n += $4 } END { print n + 0 }' "$work/parts")
@@ -206,6 +247,10 @@ ln -sfn "$steam_root/steamrtarm64" "$HOME/.steam/binarm64"
ln -sfn "$steam_root/linux32" "$HOME/.steam/sdk32"
echo "$version" > "$stamp"
rm -rf "$parts"
+if [ -n "$fallback" ]; then
+ echo "$stable_channel" > "$channel_state"
+ echo "== STEP $(date -u +%H:%M:%S) the Steam client now follows $stable_channel (beta in ~/.config/droiddeck/steam-channel returns to $beta_channel)"
+fi
if [ -n "$repair" ]; then
echo repaired > "$unconfirmed"
rm -f "$repair_request"
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders
new file mode 100755
index 000000000..da93ebec4
--- /dev/null
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shaders
@@ -0,0 +1,61 @@
+#!/usr/bin/python3
+"""Sets the Steam client's shader pre-caching (Settings > Downloads), before the client starts.
+
+ droiddeck-steam-shaders <0|1> ...
+
+The client stores the toggle as InstallConfigStore/Software/Valve/Steam/ShaderCacheManager/
+DisableShaderCache in config/config.vdf: "1" when off, "0" when on. It keeps the setting in memory
+and writes config.vdf back on exit, so this runs only while the client is stopped. The launcher
+passes a value only after its own toggle changes; otherwise the client's toggle stays the user's."""
+from pathlib import Path
+import runpy
+import sys
+
+
+VDF = runpy.run_path(str(Path(__file__).with_name("droiddeck-steam-ui-scale")))
+PATH = ("Software", "Valve", "Steam", "ShaderCacheManager", "DisableShaderCache")
+
+
+def set_text(text, enabled):
+ value = "0" if enabled else "1"
+ updated, changed = VDF["seed_value"](text, PATH, value)
+ if changed:
+ return updated, True
+ block = VDF["parse"](text).get("InstallConfigStore")
+ for section in PATH[:-1]:
+ block = block.get(section)
+ current = block.get(PATH[-1])
+ if isinstance(current, VDF["Block"]):
+ raise ValueError("invalid DisableShaderCache setting")
+ if current == value:
+ return text, False
+ at = block.offset(PATH[-1])
+ end = VDF["TOKEN"].match(text, at).end()
+ return text[:at] + f'"{value}"' + text[end:], True
+
+
+def apply(steam_root, enabled):
+ config = Path(steam_root) / "config/config.vdf"
+ text = config.read_text() if config.exists() else '"InstallConfigStore"\n{\n}\n'
+ updated, changed = set_text(text, enabled)
+ if changed:
+ VDF["atomic_write"](config, updated)
+ return changed
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 3 or sys.argv[1] not in ("0", "1"):
+ print("usage: droiddeck-steam-shaders <0|1> ...", file=sys.stderr)
+ sys.exit(2)
+ enabled = sys.argv[1] == "1"
+ status = 0
+ for root in sys.argv[2:]:
+ if not Path(root).is_dir():
+ continue
+ try:
+ if apply(root, enabled):
+ print(f"== steam: shader pre-caching {'on' if enabled else 'off'} in {root}")
+ except (OSError, ValueError, RecursionError) as error:
+ print(f"== steam: left shader pre-caching unchanged in {root}: {error}", file=sys.stderr)
+ status = 1
+ sys.exit(status)
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale
index 4a5997684..af809d124 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale
@@ -26,9 +26,10 @@ TOKEN = re.compile(r'\s+|//[^\n]*|"(?:\\.|[^"\\])*"|[{}]')
class Block:
- def __init__(self, pairs, end):
+ def __init__(self, pairs, end, offsets=()):
self.pairs = pairs
self.end = end
+ self.offsets = offsets
def get(self, name):
matches = [value for key, value in self.pairs if key.lower() == name.lower()]
@@ -36,6 +37,9 @@ class Block:
raise ValueError("duplicate UI setting")
return matches[0] if matches else None
+ def offset(self, name):
+ return next(at for (key, _), at in zip(self.pairs, self.offsets) if key.lower() == name.lower())
+
def parse(text):
tokens = []
@@ -51,17 +55,17 @@ def parse(text):
raise ValueError("invalid config.vdf")
def block(index, nested=False):
- pairs = []
+ pairs, offsets = [], []
while index < len(tokens):
token, offset = tokens[index]
if token == '}':
if not nested:
raise ValueError("unexpected closing brace")
- return Block(pairs, offset), index + 1
+ return Block(pairs, offset, offsets), index + 1
if not token.startswith('"') or index + 1 == len(tokens):
raise ValueError("missing config value")
key = token[1:-1]
- value = tokens[index + 1][0]
+ value, at = tokens[index + 1]
index += 2
if value == '{':
value, index = block(index, True)
@@ -70,9 +74,10 @@ def parse(text):
else:
raise ValueError("invalid config value")
pairs.append((key, value))
+ offsets.append(at)
if nested:
raise ValueError("unclosed config block")
- return Block(pairs, len(text)), index
+ return Block(pairs, len(text), offsets), index
return block(0)[0]
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64
index e52a4e985..f6f11d187 100755
--- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-x64
@@ -520,39 +520,8 @@ ln -sfn "$STEAM_X64/ubuntu12_32" "$X64_HOME/.steam/bin32"
ln -sfn "$STEAM_X64/ubuntu12_64" "$X64_HOME/.steam/bin64"
ln -sfn "$STEAM_X64/linux32" "$X64_HOME/.steam/sdk32"
ln -sfn "$STEAM_X64/linux64" "$X64_HOME/.steam/sdk64"
-# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has.
-srt_real=$STEAM_X64/steamrt64/pv-runtime/steam-runtime-steamrt
-srt_shim=$X64_HOME/steamrt-shim
-if [ -d "$srt_real" ]; then
- rm -rf "$srt_shim"
- mkdir -p "$srt_shim/bin"
- for e in "$srt_real"/* "$srt_real"/.[!.]*; do
- [ -e "$e" ] || continue
- [ "$(basename "$e")" = bin ] || ln -s "$e" "$srt_shim/$(basename "$e")"
- done
- for e in "$srt_real"/bin/*; do ln -s "$e" "$srt_shim/bin/$(basename "$e")"; done
- rm -f "$srt_shim/bin/steam-runtime-check-requirements"
- printf '#!/bin/sh
-exit 0
-' > "$srt_shim/bin/steam-runtime-check-requirements"
- chmod 755 "$srt_shim/bin/steam-runtime-check-requirements"
- rm -f "$srt_shim/bin/steam-runtime-launch-client"
- cat > "$srt_shim/bin/steam-runtime-launch-client" <<'LAUNCH'
-#!/bin/sh
-while [ $# -gt 0 ]; do
- case $1 in
- --) shift; break ;;
- --directory=*) cd "${1#--directory=}" || exit 127 ;;
- --env=*) export "${1#--env=}" ;;
- --unset-env=*) unset "${1#--unset-env=}" ;;
- esac
- shift
-done
-[ $# -gt 0 ] || exit 0
-exec "$@"
-LAUNCH
- chmod 755 "$srt_shim/bin/steam-runtime-launch-client"
-fi
+# Valve's Steam Runtime container needs user namespaces and a session-bus launcher; steamrt-shim stands in for both.
+droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim"
# Valve's platform libraries ship without soname links (pressure-vessel makes them); ldconfig adds them here.
srt_libs=$X64_HOME/steamrt-libs
rm -rf "$srt_libs"
diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim
new file mode 100755
index 000000000..feb4c5711
--- /dev/null
+++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamrt-shim
@@ -0,0 +1,58 @@
+#!/bin/bash
+# droiddeck-steamrt-shim
+# Builds from the Steam Runtime container the client carries and exits 0, or removes it and exits 1
+# when the client carries none.
+set -eu
+client=$1
+shim=$2
+srt_real=$client/pv-runtime/steam-runtime-steamrt
+if [ ! -d "$srt_real" ]; then
+ probe=$(find "$client" -maxdepth 4 -name steam-runtime-check-requirements -path '*/bin/*' -print -quit 2>/dev/null || true)
+ srt_real=${probe:+${probe%/bin/*}}
+fi
+if [ -z "$srt_real" ] || [ ! -d "$srt_real" ]; then
+ rm -rf "$shim"
+ exit 1
+fi
+rm -rf "$shim"
+trap 'rm -rf "$shim"' ERR
+mkdir -p "$shim/bin"
+for e in "$srt_real"/* "$srt_real"/.[!.]*; do
+ [ -e "$e" ] || continue
+ [ "$(basename "$e")" = bin ] || ln -s "$e" "$shim/$(basename "$e")"
+done
+for e in "$srt_real"/bin/*; do
+ [ -e "$e" ] || continue
+ ln -s "$e" "$shim/bin/$(basename "$e")"
+done
+# The client's bwrap probe (steam-runtime-check-requirements) needs user namespaces, which no Android app has.
+rm -f "$shim/bin/steam-runtime-check-requirements"
+printf '#!/bin/sh
+exit 0
+' > "$shim/bin/steam-runtime-check-requirements"
+chmod 755 "$shim/bin/steam-runtime-check-requirements"
+# Valve's launcher asks a service on the session bus; proot is already the container, so the command runs here.
+rm -f "$shim/bin/steam-runtime-launch-client"
+cat > "$shim/bin/steam-runtime-launch-client" <<'LAUNCH'
+#!/bin/sh
+while [ $# -gt 0 ]; do
+ case $1 in
+ --) shift; break ;;
+ --directory=*) cd "${1#--directory=}" || exit 127 ;;
+ --env=*) export "${1#--env=}" ;;
+ --unset-env=*) unset "${1#--unset-env=}" ;;
+ --directory|--env|--unset-env)
+ [ $# -ge 2 ] || exit 127
+ case $1 in
+ --directory) cd "$2" || exit 127 ;;
+ --env) export "$2" ;;
+ --unset-env) unset "$2" ;;
+ esac
+ shift ;;
+ esac
+ shift
+done
+[ $# -gt 0 ] || exit 0
+exec "$@"
+LAUNCH
+chmod 755 "$shim/bin/steam-runtime-launch-client"
diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
index 41a37a9aa..29fe4f8e4 100755
--- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
+++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility
@@ -1113,10 +1113,89 @@ def requested_tool(request, catalog):
def game_requests(directory=None):
- value = read_json(state_path("games.json", directory))
- if value.get("version") != 1 or not isinstance(value.get("games"), dict):
- return {}
- return value["games"]
+ return read_game_requests(directory)[0]
+
+
+def read_game_requests(directory=None):
+ try:
+ with open(state_path("games.json", directory), encoding="utf-8") as f:
+ text = f.read()
+ value = json.loads(text)
+ except (OSError, ValueError):
+ return {}, None
+ if not isinstance(value, dict) or value.get("version") != 1 or not isinstance(value.get("games"), dict):
+ return {}, text
+ return value["games"], text
+
+
+def write_game_requests(requests, original, directory=None):
+ path = state_path("games.json", directory)
+ try:
+ with open(path, encoding="utf-8") as f:
+ if f.read() != original:
+ return False
+ except OSError:
+ if original is not None:
+ return False
+ return write_json(path, {"version": 1, "games": requests})
+
+
+def valid_game_request(app, request):
+ return (isinstance(app, str) and re.fullmatch(r"[1-9][0-9]*", app) is not None and int(app) <= 0xFFFFFFFF and
+ (request is None or isinstance(request, dict) and isinstance(request.get("dir"), str) and
+ request["dir"] != "" and isinstance(request.get("valve"), bool)))
+
+
+def settle_game_requests(requests, state, current, catalog, missing_is_cleared=False):
+ recorded = state.get("games_applied")
+ if isinstance(recorded, dict):
+ applied = dict(recorded)
+ else:
+ applied = dict(requests) if "tools" in state else {}
+ by_name = {t["name"]: t for t in catalog}
+ out = dict(requests)
+ for app, request in requests.items():
+ if not valid_game_request(app, request) or app not in applied or applied[app] != request:
+ continue
+ if request is None:
+ del out[app]
+ continue
+ wanted = requested_tool(request, catalog)
+ if wanted is None:
+ continue
+ if app in current:
+ picked = current[app]
+ elif missing_is_cleared:
+ picked = ""
+ else:
+ continue
+ if picked == wanted:
+ continue
+ tool = by_name.get(picked)
+ if tool and tool["dir"]:
+ out[app] = {"valve": tool["valve"], "dir": tool["dir"]}
+ else:
+ del out[app]
+ return out, out != requests
+
+
+def settled_game_requests(state, current, catalog, missing_is_cleared=False, directory=None):
+ for _ in range(2):
+ requests, text = read_game_requests(directory)
+ settled, moved = settle_game_requests(requests, state, current, catalog, missing_is_cleared)
+ if not moved or write_game_requests(settled, text, directory):
+ return settled
+ return None
+
+
+def applied_game_requests(requests, overrides, state):
+ recorded = state.get("games_applied")
+ before = recorded if isinstance(recorded, dict) else {}
+ if requests is None:
+ return dict(before)
+ return {app: request if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL else before[app]
+ for app, request in requests.items()
+ if app in overrides and overrides[app] is not DEFERRED_GAME_TOOL or app in before}
def requested_games(requests, catalog, runnable=None):
@@ -1306,7 +1385,8 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
else:
default = state.get("default") if state.get("default") in names else TOOL
steam_root = os.path.dirname(os.path.dirname(os.path.abspath(config_path)))
- overrides = requested_games(game_requests(directory), catalog)
+ requests = settled_game_requests(state, current, catalog, not steam_running(), directory)
+ overrides = requested_games(requests or {}, catalog)
managed = state.get("auto", {}).keys() if isinstance(state.get("auto"), dict) else ()
native_choices = state.get("native_choices", ())
native = native_targets(steam_root, set(apps) | set(picks) | set(managed) | set(native_choices))
@@ -1325,6 +1405,7 @@ def register_default(config_path, apps=(), protect=(), default=None, catalog=Non
state["auto"] = {app: tool for app, tool in auto.items() if tool is not NATIVE_CHOICE}
state["native_choices"] = sorted(app for app, tool in auto.items() if tool is NATIVE_CHOICE)
state["picked"] = picked_after(current, changes)
+ state["games_applied"] = applied_game_requests(requests, overrides, state)
state.pop("native", None)
state["tools"] = catalog
state.setdefault("default", default)
diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c
index b920b49f9..d49bb42b7 100644
--- a/tools/linuxfs/preload/ntsync.c
+++ b/tools/linuxfs/preload/ntsync.c
@@ -17,6 +17,7 @@
#include
#include
#include
+#include
#include
struct ns_sem_args {
@@ -68,12 +69,13 @@ struct ns_wait_args {
#define NS_PROCS 256u
#define NS_FDS 65536
#define NS_DEVS 8
-#define NS_VERSION 1u
+#define NS_VERSION 2u
#define NS_HEAD_MAGIC 0x31434e5953544e44ull
#define NS_DEV_MAGIC 0x4e54445600000000ull
#define NS_OBJ_MAGIC 0x27ull
#define NS_WAITERS 0x80000000u
#define NS_SWEEP_NS 2000000000ull
+#define NS_SPIN_NS 4000ull
#define NS_DEAD 0xffffffffu
#ifndef CLOSE_RANGE_CLOEXEC
@@ -144,6 +146,7 @@ struct ns_q {
uint32_t waiting;
uint32_t inuse;
uint32_t next_free;
+ uint32_t sleeping;
struct ns_ent ent[NS_ENTS];
};
@@ -168,6 +171,7 @@ struct ns_tls {
struct ns_dev *d;
uint32_t q;
int waiting;
+ int sleep;
};
static struct ns_dev ns_devs[NS_DEVS];
@@ -255,19 +259,148 @@ static void ns_unlock(uint32_t *w) {
}
}
-static void ns_block(sigset_t *old) {
- sigset_t s;
- sigfillset(&s);
- sigdelset(&s, SIGSEGV);
- sigdelset(&s, SIGBUS);
- sigdelset(&s, SIGILL);
- sigdelset(&s, SIGFPE);
- sigdelset(&s, SIGTRAP);
- pthread_sigmask(SIG_BLOCK, &s, old);
+#define NS_DEFER 4
+#define NS_LEVELS 8
+
+struct ns_cs {
+ int depth;
+ int count;
+ int level;
+ unsigned held_levels;
+ int sigs[NS_DEFER];
+ siginfo_t info[NS_DEFER];
+ sigset_t held[NS_LEVELS];
+};
+
+static __thread struct ns_cs ns_cs;
+static uintptr_t ns_handlers[_NSIG];
+static uint8_t ns_siginfo[_NSIG];
+
+int bl_from_ntdll(const void *caller) __attribute__((visibility("hidden")));
+
+static int ns_deferrable(int sig) {
+ return sig > 0 && sig < _NSIG && sig != SIGKILL && sig != SIGSTOP && sig != SIGSEGV && sig != SIGBUS && sig != SIGILL
+ && sig != SIGFPE && sig != SIGTRAP && sig != SIGSYS;
}
-static void ns_restore(const sigset_t *old) {
- pthread_sigmask(SIG_SETMASK, old, NULL);
+static int ns_defer(int sig, const siginfo_t *si) {
+ struct ns_cs *cs = &ns_cs;
+ if (sig < SIGRTMIN)
+ for (int i = 0; i < NS_DEFER; i++)
+ if (__atomic_load_n(&cs->sigs[i], __ATOMIC_RELAXED) == sig) return 1;
+ int i = __atomic_fetch_add(&cs->count, 1, __ATOMIC_RELAXED);
+ if (i >= NS_DEFER) {
+ __atomic_fetch_sub(&cs->count, 1, __ATOMIC_RELAXED);
+ return 0;
+ }
+ if (si) memcpy(&cs->info[i], si, sizeof(*si));
+ else memset(&cs->info[i], 0, sizeof(*si));
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+ __atomic_store_n(&cs->sigs[i], sig, __ATOMIC_RELAXED);
+ return 1;
+}
+
+static int ns_park(int sig, const siginfo_t *si, ucontext_t *uc) {
+ int level = ns_cs.level;
+ if (level < 0 || level >= NS_LEVELS) return 0;
+ sigset_t one;
+ sigemptyset(&one);
+ sigaddset(&one, sig);
+ int saved = errno;
+ pthread_sigmask(SIG_BLOCK, &one, NULL);
+ pid_t pid = getpid(), tid = (pid_t)ns_self();
+ int ok = (si && syscall(SYS_rt_tgsigqueueinfo, pid, tid, sig, si) == 0) || syscall(SYS_tgkill, pid, tid, sig) == 0;
+ if (!ok) pthread_sigmask(SIG_UNBLOCK, &one, NULL);
+ errno = saved;
+ if (!ok) return 0;
+ sigaddset(&uc->uc_sigmask, sig);
+ unsigned long *words = (unsigned long *)&ns_cs.held[level];
+ unsigned bits = 8 * sizeof(unsigned long);
+ __atomic_fetch_or(&words[(unsigned)(sig - 1) / bits], 1ul << ((unsigned)(sig - 1) % bits), __ATOMIC_RELAXED);
+ __atomic_fetch_or(&ns_cs.held_levels, 1u << level, __ATOMIC_RELAXED);
+ return 1;
+}
+
+static void ns_trampoline(int sig, siginfo_t *si, void *uc) {
+ if (__atomic_load_n(&ns_cs.depth, __ATOMIC_RELAXED) > 0 && (ns_defer(sig, si) || (uc && ns_park(sig, si, uc)))) return;
+ uintptr_t h = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE);
+ if (!h) return;
+ ns_cs.level++;
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+ if (__atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED)) ((void (*)(int, siginfo_t *, void *))h)(sig, si, uc);
+ else ((void (*)(int))h)(sig);
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+ ns_cs.level--;
+}
+
+static void ns_flush(void) {
+ struct ns_cs *cs = &ns_cs;
+ sigset_t all, mask;
+ sigfillset(&all);
+ int saved = errno;
+ pthread_sigmask(SIG_BLOCK, &all, &mask);
+ int n = cs->count < NS_DEFER ? cs->count : NS_DEFER;
+ pid_t pid = getpid(), tid = (pid_t)ns_self();
+ for (int i = 0; i < n; i++)
+ if (cs->sigs[i] > 0 && syscall(SYS_rt_tgsigqueueinfo, pid, tid, cs->sigs[i], &cs->info[i]) != 0) syscall(SYS_tgkill, pid, tid, cs->sigs[i]);
+ memset(cs->sigs, 0, sizeof(cs->sigs));
+ cs->count = 0;
+ int level = cs->level;
+ if (level >= 0 && level < NS_LEVELS && (cs->held_levels & (1u << level))) {
+ for (int sig = 1; sig < _NSIG; sig++)
+ if (sigismember(&cs->held[level], sig) == 1) sigdelset(&mask, sig);
+ sigemptyset(&cs->held[level]);
+ cs->held_levels &= ~(1u << level);
+ }
+ pthread_sigmask(SIG_SETMASK, &mask, NULL);
+ errno = saved;
+}
+
+static void ns_block(void) {
+ ns_cs.depth++;
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+}
+
+static void ns_restore(void) {
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+ if (--ns_cs.depth == 0) {
+ __atomic_signal_fence(__ATOMIC_SEQ_CST);
+ if (ns_cs.count || (ns_cs.held_levels && ns_cs.level >= 0 && ns_cs.level < NS_LEVELS && (ns_cs.held_levels & (1u << ns_cs.level))))
+ ns_flush();
+ }
+}
+
+int sigaction(int sig, const struct sigaction *act, struct sigaction *old) {
+ static int (*real)(int, const struct sigaction *, struct sigaction *);
+ if (!real) real = (int (*)(int, const struct sigaction *, struct sigaction *))dlsym(RTLD_NEXT, "sigaction");
+ if (!real) {
+ errno = ENOSYS;
+ return -1;
+ }
+ if (!ns_deferrable(sig) || !ns_on()) return real(sig, act, old);
+ uintptr_t prev = __atomic_load_n(&ns_handlers[sig], __ATOMIC_ACQUIRE);
+ uint8_t prev_info = __atomic_load_n(&ns_siginfo[sig], __ATOMIC_RELAXED);
+ struct sigaction mine;
+ const struct sigaction *use = act;
+ if (act && act->sa_handler != SIG_DFL && act->sa_handler != SIG_IGN && !(act->sa_flags & SA_RESETHAND) &&
+ bl_from_ntdll(__builtin_return_address(0))) {
+ mine = *act;
+ mine.sa_sigaction = ns_trampoline;
+ mine.sa_flags |= SA_SIGINFO;
+ __atomic_store_n(&ns_siginfo[sig], (act->sa_flags & SA_SIGINFO) ? 1 : 0, __ATOMIC_RELAXED);
+ __atomic_store_n(&ns_handlers[sig], (uintptr_t)act->sa_sigaction, __ATOMIC_RELEASE);
+ use = &mine;
+ }
+ int r = real(sig, use, old);
+ if (r != 0 && use == &mine) {
+ __atomic_store_n(&ns_siginfo[sig], prev_info, __ATOMIC_RELAXED);
+ __atomic_store_n(&ns_handlers[sig], prev, __ATOMIC_RELEASE);
+ }
+ if (r == 0 && old && old->sa_sigaction == ns_trampoline) {
+ old->sa_sigaction = (void (*)(int, siginfo_t *, void *))prev;
+ if (!prev_info) old->sa_flags &= ~SA_SIGINFO;
+ }
+ return r;
}
static inline struct ns_obj *ns_obj(struct ns_head *h, uint32_t s) {
@@ -378,10 +511,11 @@ static int ns_claim(struct ns_q *q, int32_t index) {
if (dead) return 0;
}
int32_t want = -1;
- return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED);
+ return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_SEQ_CST, __ATOMIC_RELAXED);
}
static void ns_wake(struct ns_q *q) {
+ if (!__atomic_load_n(&q->sleeping, __ATOMIC_SEQ_CST)) return;
int saved = errno;
ns_futex(&q->signaled, FUTEX_WAKE, 1, NULL, 0);
errno = saved;
@@ -618,14 +752,13 @@ static void ns_dev_put_locked(struct ns_dev *d) {
static void ns_dev_put(struct ns_dev *d) {
if (__atomic_sub_fetch(&d->refs, 1, __ATOMIC_ACQ_REL)) return;
- sigset_t old;
int saved = errno;
- ns_block(&old);
+ ns_block();
pthread_mutex_lock(&ns_mx);
uint32_t zero = 0;
if (d->h && __atomic_compare_exchange_n(&d->refs, &zero, NS_DEAD, 0, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) ns_release(d);
pthread_mutex_unlock(&ns_mx);
- ns_restore(&old);
+ ns_restore();
errno = saved;
}
@@ -682,20 +815,18 @@ static void ns_forget_locked(int fd) {
}
static void ns_forget(int fd) {
- sigset_t old;
int saved = errno;
- ns_block(&old);
+ ns_block();
pthread_mutex_lock(&ns_mx);
ns_forget_locked(fd);
pthread_mutex_unlock(&ns_mx);
- ns_restore(&old);
+ ns_restore();
errno = saved;
}
static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, uint32_t s) {
uint64_t e = 0;
- sigset_t old;
- ns_block(&old);
+ ns_block();
pthread_mutex_lock(&ns_mx);
if (!d->h || d->tag != tag || !ns_table() || ns_attach(d) < 0 || s >= NS_SLOTS) goto out;
if ((e = __atomic_load_n(&ns_fdt[fd], __ATOMIC_ACQUIRE))) goto out;
@@ -710,7 +841,7 @@ static uint64_t ns_adopt(struct ns_dev *d, uint32_t tag, int fd, uint32_t gen, u
ns_unlock(&o->lock);
out:
pthread_mutex_unlock(&ns_mx);
- ns_restore(&old);
+ ns_restore();
return e;
}
@@ -719,8 +850,7 @@ static uint64_t ns_note_device(int fd) {
uint64_t e = 0;
if (syscall(SYS_fstat, fd, &st) != 0 || !S_ISREG(st.st_mode)) return 0;
if (((uint64_t)st.st_size & ~0xffffull) != NS_DEV_MAGIC) return 0;
- sigset_t old;
- ns_block(&old);
+ ns_block();
pthread_mutex_lock(&ns_mx);
struct ns_dev *d = ns_table() ? ns_map(fd, (uint32_t)st.st_size & 0xffff, (uint64_t)st.st_ino) : NULL;
if (d) {
@@ -732,7 +862,7 @@ static uint64_t ns_note_device(int fd) {
}
}
pthread_mutex_unlock(&ns_mx);
- ns_restore(&old);
+ ns_restore();
return e;
}
@@ -773,15 +903,14 @@ static int ns_ready(struct ns_dev *d) {
static void ns_thread_gone(void *p) {
struct ns_tls *t = p;
- if (!t || !t->d || !t->q) return;
- sigset_t old;
+ if (!t || !t->d) return;
struct ns_dev *d = t->d;
- ns_block(&old);
- ns_free_q(d->h, t->q - 1);
+ ns_block();
+ if (t->q) ns_free_q(d->h, t->q - 1);
t->q = 0;
t->waiting = 0;
t->d = NULL;
- ns_restore(&old);
+ ns_restore();
ns_dev_put(d);
}
@@ -789,24 +918,28 @@ static void ns_key_init(void) {
pthread_key_create(&ns_key, ns_thread_gone);
}
-static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) {
- struct ns_head *h = d->h;
- if (ns_tq.d && ns_tq.q && ns_tq.waiting) {
- ns_drop_q(ns_tq.d->h, ns_tq.q - 1);
- ns_tq.waiting = 0;
- }
- if (ns_tq.d == d && ns_tq.q) {
- *qi = ns_tq.q - 1;
- return ns_q(h, *qi);
- }
- if (ns_tq.d && ns_tq.q) {
- struct ns_dev *old = ns_tq.d;
- ns_free_q(old->h, ns_tq.q - 1);
+static int ns_hold(struct ns_dev *d) {
+ if (ns_tq.d == d) return 1;
+ if (!ns_dev_get(d)) return 0;
+ if (ns_tq.sleep) return 2;
+ struct ns_dev *old = ns_tq.d;
+ if (old) {
+ ns_block();
+ if (ns_tq.q) ns_free_q(old->h, ns_tq.q - 1);
ns_tq.q = 0;
+ ns_tq.waiting = 0;
ns_tq.d = NULL;
+ ns_restore();
ns_dev_put(old);
}
pthread_once(&ns_once, ns_key_init);
+ ns_tq.d = d;
+ pthread_setspecific(ns_key, &ns_tq);
+ return 1;
+}
+
+static struct ns_q *ns_new_q(struct ns_dev *d, uint32_t *qi) {
+ struct ns_head *h = d->h;
uint32_t i;
if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) {
ns_sweep_dead(h);
@@ -815,22 +948,35 @@ static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) {
struct ns_q *q = ns_q(h, i);
q->proc = (uint32_t)d->proc;
q->waiting = 0;
+ q->sleeping = 0;
q->inuse = 1;
- __atomic_add_fetch(&d->refs, 1, __ATOMIC_ACQ_REL);
- ns_tq.d = d;
- ns_tq.q = i + 1;
- ns_tq.waiting = 0;
- pthread_setspecific(ns_key, &ns_tq);
*qi = i;
return q;
}
+static struct ns_q *ns_thread_q(struct ns_dev *d, uint32_t *qi) {
+ struct ns_head *h = d->h;
+ if (ns_tq.d != d) return NULL;
+ if (ns_tq.q && ns_tq.waiting) {
+ ns_drop_q(h, ns_tq.q - 1);
+ ns_tq.waiting = 0;
+ }
+ if (ns_tq.q) {
+ *qi = ns_tq.q - 1;
+ return ns_q(h, *qi);
+ }
+ struct ns_q *q = ns_new_q(d, qi);
+ if (!q) return NULL;
+ ns_tq.q = *qi + 1;
+ ns_tq.waiting = 0;
+ return q;
+}
+
static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) {
struct ns_head *h = d->h;
- sigset_t old;
int fd = -1, err = 0;
uint32_t s = 0;
- ns_block(&old);
+ ns_block();
pthread_mutex_lock(&ns_mx);
if (!ns_table() || ns_attach(d) < 0) {
err = ENOMEM;
@@ -879,7 +1025,7 @@ static int ns_create(struct ns_dev *d, int type, uint32_t a, uint32_t b) {
ns_unlock(&o->lock);
out:
pthread_mutex_unlock(&ns_mx);
- ns_restore(&old);
+ ns_restore();
if (fd < 0) errno = err;
return fd;
}
@@ -890,6 +1036,20 @@ static int ns_expired(const struct ns_wait_args *args) {
return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec >= args->timeout;
}
+static uint64_t ns_now(void) {
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec;
+}
+
+static void ns_spin(struct ns_q *q) {
+ uint64_t end = ns_now() + NS_SPIN_NS;
+ for (unsigned i = 1; __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1; i++) {
+ ns_relax();
+ if (!(i & 63) && ns_now() >= end) return;
+ }
+}
+
static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) {
struct timespec ts, *tsp = NULL;
int op = FUTEX_WAIT_BITSET | ((args->flags & NS_WAIT_REALTIME) ? FUTEX_CLOCK_REALTIME : 0);
@@ -898,14 +1058,45 @@ static int ns_sleep(struct ns_q *q, const struct ns_wait_args *args) {
ts.tv_nsec = (long)(args->timeout % 1000000000ull);
tsp = &ts;
}
- while (__atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) {
- if (tsp && ns_expired(args)) return ETIMEDOUT;
- if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1) {
- if (errno == ETIMEDOUT) return ETIMEDOUT;
- if (errno == EINTR) return EINTR;
- }
+ ns_spin(q);
+ int ret = 0;
+ __atomic_store_n(&q->sleeping, 1, __ATOMIC_SEQ_CST);
+ while (!ret && __atomic_load_n(&q->signaled, __ATOMIC_SEQ_CST) == -1) {
+ if (tsp && ns_expired(args)) ret = ETIMEDOUT;
+ else if (ns_futex(&q->signaled, op, (uint32_t)-1, tsp, FUTEX_BITSET_MATCH_ANY) == -1 && (errno == ETIMEDOUT || errno == EINTR))
+ ret = errno;
}
- return 0;
+ __atomic_store_n(&q->sleeping, 0, __ATOMIC_RELAXED);
+ return ret;
+}
+
+static int ns_try_any(struct ns_head *h, const uint32_t *slots, const uint32_t *gens, uint32_t total, uint32_t owner, int *dead) {
+ for (uint32_t i = 0; i < total; i++) {
+ struct ns_obj *o = ns_obj(h, slots[i]);
+ ns_lock(&o->lock);
+ int busy = o->type == NS_FREE || o->gen != gens[i] || o->any_head || o->all_hint;
+ if (!busy && ns_signaled(o, owner)) {
+ switch (o->type) {
+ case NS_SEM:
+ o->a--;
+ break;
+ case NS_MUTEX:
+ *dead = (int)o->ownerdead;
+ o->ownerdead = 0;
+ o->a++;
+ o->b = owner;
+ break;
+ case NS_EVENT:
+ if (!o->a) o->b = 0;
+ break;
+ }
+ ns_unlock(&o->lock);
+ return (int)i;
+ }
+ ns_unlock(&o->lock);
+ if (busy) return -1;
+ }
+ return -2;
}
static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) {
@@ -927,12 +1118,24 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) {
for (uint32_t j = 0; j < i; j++)
if (slots[j] == slots[i]) return EINVAL;
}
- sigset_t old;
- ns_block(&old);
+ int nested = ns_tq.sleep > 0;
+ ns_block();
+ if (!all && (nested || !ns_tq.waiting)) {
+ int dead = 0, got = ns_try_any(h, slots, gens, total, args.owner, &dead);
+ if (got >= 0) {
+ ns_restore();
+ ua->index = (uint32_t)got;
+ return dead ? EOWNERDEAD : 0;
+ }
+ if (got == -2 && args.timeout != UINT64_MAX && ns_expired(&args)) {
+ ns_restore();
+ return ETIMEDOUT;
+ }
+ }
uint32_t qi;
- struct ns_q *q = ns_ready(d) ? ns_thread_q(d, &qi) : NULL;
+ struct ns_q *q = !ns_ready(d) ? NULL : nested ? ns_new_q(d, &qi) : ns_thread_q(d, &qi);
if (!q) {
- ns_restore(&old);
+ ns_restore();
return ENOMEM;
}
ns_maybe_sweep(h);
@@ -952,7 +1155,7 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) {
}
q->total = total;
q->waiting = 1;
- ns_tq.waiting = 1;
+ if (!nested) ns_tq.waiting = 1;
int bad = 0;
if (!all) {
for (uint32_t i = 0; i < total && !bad; i++) {
@@ -1004,16 +1207,18 @@ static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) {
}
int ret = 0;
if (!bad && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) {
- sigset_t held;
- ns_restore(&old);
+ ns_tq.sleep++;
+ ns_restore();
ret = ns_sleep(q, &args);
- ns_block(&held);
+ ns_block();
+ ns_tq.sleep--;
}
ns_drop_q(h, qi);
- ns_tq.waiting = 0;
+ if (!nested) ns_tq.waiting = 0;
int32_t got = __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE);
int dead = (int)q->ownerdead;
- ns_restore(&old);
+ if (nested) ns_free_q(h, qi);
+ ns_restore();
if (bad) return EINVAL;
if (got != -1) {
ua->index = (uint32_t)got;
@@ -1061,12 +1266,11 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a
return ENOTTY;
}
if (type != (uint32_t)want) return EINVAL;
- sigset_t old;
- ns_block(&old);
+ ns_block();
int all = ns_lock_obj(h, o);
if (o->type != (uint32_t)want || o->gen != gen) {
ns_unlock_obj(h, o, all);
- ns_restore(&old);
+ ns_restore();
return EINVAL;
}
switch (req) {
@@ -1133,7 +1337,7 @@ static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *a
}
}
ns_unlock_obj(h, o, all);
- ns_restore(&old);
+ ns_restore();
if (ret) return ret;
if (req == NS_SEM_RELEASE || req == NS_EVENT_SET || req == NS_EVENT_RESET || req == NS_EVENT_PULSE)
memcpy(arg, &prev, sizeof(prev));
@@ -1176,9 +1380,10 @@ int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) {
uint64_t e = ns_lookup(fd);
if (!e) return 0;
struct ns_dev *d = &ns_devs[(e >> 56) & 7];
- if (!ns_dev_get(d)) return 0;
+ int held = ns_hold(d);
+ if (!held) return 0;
int out = 0, err = (e & NS_E_DEVICE) ? ns_dev_ioctl(d, req, arg, &out) : ns_obj_ioctl(d, e, req, arg);
- ns_dev_put(d);
+ if (held == 2) ns_dev_put(d);
if (err) {
errno = err;
*rc = -1;
diff --git a/tools/packs/build.sh b/tools/packs/build.sh
index d06a4c8fb..27611ab0b 100755
--- a/tools/packs/build.sh
+++ b/tools/packs/build.sh
@@ -25,14 +25,7 @@ trap 'rm -rf "$work"' EXIT
sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
fetch() {
- local url=$1 expected=$2 dest=$3
- curl -fsSL --retry 4 -o "$dest" "$url"
- local actual
- actual=$(sha256 "$dest")
- if [ "$actual" != "$expected" ]; then
- echo "checksum mismatch for $url: $actual" >&2
- exit 1
- fi
+ bash "$here/fetch-verified.sh" "$@"
}
pack() {
diff --git a/tools/packs/fetch-verified.sh b/tools/packs/fetch-verified.sh
new file mode 100644
index 000000000..41bba2819
--- /dev/null
+++ b/tools/packs/fetch-verified.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+set -euo pipefail
+url=${1:?URL required}
+expected=${2:?SHA-256 required}
+destination=${3:?destination required}
+[[ "$expected" =~ ^[a-f0-9]{64}$ ]] || { echo 'Invalid download SHA-256' >&2; exit 1; }
+cache=${DROIDDECK_PACK_CACHE:-"$HOME/.cache/droiddeck-pack-inputs"}
+mkdir -p "$cache" "$(dirname "$destination")"
+kept="$cache/$expected"
+verify() {
+ if command -v sha256sum >/dev/null; then
+ echo "$expected $1" | sha256sum -c - >/dev/null 2>&1
+ else
+ echo "$expected $1" | shasum -a 256 -c - >/dev/null 2>&1
+ fi
+}
+if [ -f "$kept" ] && verify "$kept"; then
+ echo "Verified pack input cache hit: $expected"
+else
+ part=$(mktemp "$cache/$expected.part.XXXXXX")
+ trap 'rm -f "$part"' EXIT
+ curl -fsSL --retry 4 --retry-all-errors --connect-timeout 15 --max-time 180 --retry-max-time 240 \
+ -o "$part" "$url"
+ verify "$part" || { echo "Checksum mismatch for $url" >&2; exit 1; }
+ if [ -d "$kept" ]; then
+ rm -rf -- "$kept"
+ fi
+ mv -f "$part" "$kept"
+ echo "Downloaded and verified pack input: $expected"
+fi
+cp "$kept" "$destination"
+verify "$destination" || { echo 'Copied pack input checksum mismatch' >&2; exit 1; }
diff --git a/tools/release/announce-preview.sh b/tools/release/announce-preview.sh
new file mode 100644
index 000000000..c4b63e727
--- /dev/null
+++ b/tools/release/announce-preview.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+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")
+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 Preview build** `main-%s`\n%s\nIn the app: **Updates → Preview**\n%s' "${GITHUB_SHA::7}" "$what" "$get")
+jq -n --arg content "$content" '{content: $content, allowed_mentions: {parse: []}}' |
+ curl -fsS -H 'Content-Type: application/json' -d @- "$WEBHOOK" -o /dev/null
+echo "Posted: $content"
diff --git a/tools/release/describe-build.sh b/tools/release/describe-build.sh
new file mode 100644
index 000000000..46bc339ba
--- /dev/null
+++ b/tools/release/describe-build.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
+ --jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
+if [ -n "$pr" ]; then
+ number=$(jq -r .number <<<"$pr")
+ title=$(jq -r .title <<<"$pr")
+ merged=$(jq -r '.merged_at[:10]' <<<"$pr")
+ summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
+ awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
+ {
+ echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
+ if [ -n "$summary" ]; then
+ echo
+ echo "$summary"
+ fi
+ } > notes-head.md
+else
+ subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
+ echo "**Pushed to main** without a PR - $subject" > notes-head.md
+fi
+cat notes-head.md
diff --git a/tools/release/publish-ci.sh b/tools/release/publish-ci.sh
new file mode 100644
index 000000000..987ef622d
--- /dev/null
+++ b/tools/release/publish-ci.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+tag=main-${GITHUB_SHA::7}
+cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
+rm signed/SHA256SUMS-*.txt
+notes="$(cat notes-head.md)
+
+Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
+Commit: $GITHUB_SHA
+VersionCode: $(sed -n 's/^ *versionCode \([0-9][0-9]*\)$/\1/p' app/build.gradle)
+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/*
+gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
+ --jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
+ while IFS= read -r old; do
+ if [ "$old" = "$tag" ]; then
+ continue
+ fi
+ if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
+ if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
+ echo "::error::Could not delete old release $old"
+ exit 1
+ fi
+ fi
+ done
+published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
+jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
+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"
diff --git a/tools/release/report-build.sh b/tools/release/report-build.sh
new file mode 100644
index 000000000..75b16f666
--- /dev/null
+++ b/tools/release/report-build.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+set -uo pipefail
+MARK=""
+{
+ echo "$MARK"
+ echo "## Build of this pull request"
+ echo
+ [ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md
+ echo
+ case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in
+ success:success:success)
+ echo "### Build passed"
+ echo
+ echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key."
+ echo
+ if [ "$SAME_REPO" = true ]; then
+ echo "After the parallel regression tests pass, a release-signed tester build that installs over Stable or Preview will be published and will appear in DroidDeck's **Test builds** list. Label the PR \`no-test-build\` to skip that."
+ else
+ echo "**Maintainer:** add the \`publish-test\` label to publish a release-signed tester build that installs over Stable or Preview and appears in DroidDeck's **Test builds** list. The label is removed automatically after publishing."
+ fi
+ echo
+ echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`."
+ ;;
+ success:success:*)
+ echo "### APK publication incomplete"
+ echo
+ echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)."
+ ;;
+ success:failure:*)
+ echo "### ERROR: Unit tests failed"
+ echo
+ echo "The APK compiled, but unit tests failed. No installable artifact was published."
+ echo
+ if [ -n "$TEST_DIAGNOSTICS_URL" ]; then
+ echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports."
+ else
+ echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)."
+ fi
+ ;;
+ success:*)
+ echo "### Build incomplete"
+ echo
+ echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)."
+ ;;
+ failure:*)
+ echo "### ERROR: Build failed"
+ echo
+ echo "The compiler's errors ([full log]($RUN_URL)):"
+ echo
+ echo '```'
+ { grep -E '^e: |^w: .*error|error:|CMake Error|fatal error|ninja: error|FAILED|What went wrong|Execution failed for task|Unresolved reference|Type mismatch|Unresolved' /tmp/build.log 2>/dev/null \
+ | sed -e 's#file:///home/runner/work/[^/]*/[^/]*/##' -e 's#/home/runner/work/[^/]*/[^/]*/##' \
+ | grep -v '^w: ' | awk '!seen[$0]++' | head -80; } || true
+ echo '```'
+ echo
+ echo "What went wrong (Gradle)
"
+ echo
+ echo '```'
+ awk '/^\* What went wrong/{p=1} /^\* Try:/{p=0} p{print}' /tmp/build.log 2>/dev/null | head -60
+ echo '```'
+ echo " "
+ ;;
+ skipped:*|cancelled:*)
+ echo "### Not built"
+ echo
+ echo "The build did not run (see above, or [the run]($RUN_URL))."
+ ;;
+ esac
+ echo
+} > /tmp/pr-report.md
+cat /tmp/pr-report.md >> "$GITHUB_STEP_SUMMARY"
+ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$MARK\"))) | .id" | head -1 || true)
+if [ -n "$ID" ]; then
+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${ID}" -F body=@/tmp/pr-report.md >/dev/null
+else
+ gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null
+fi
diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py
index a0ac10bfd..ccce1f0f0 100644
--- a/tools/release/runtime_inputs.py
+++ b/tools/release/runtime_inputs.py
@@ -21,9 +21,9 @@ PACKED_FILES = {'lib/arm64-v8a/libproot.so': ('proot', 'libproot.so'),
PACKED_LIBRARIES = ['lib/arm64-v8a/libopencv_java4.so', 'lib/arm64-v8a/libmediapipe_tasks_jni.so']
PACK_NAMES = ['proot', 'directaudio', 'tracking', 'xbox', 'comet']
SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/assets/pulseaudio.tzst',
- 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio',
+ 'tools/linuxfs', 'tools/proot', 'tools/aaudio-sink', 'tools/directaudio', 'tools/packs',
'tools/gamescope/release.env', 'tools/droiddeck-esync/release.env',
- 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/build_local.sh',
+ 'tools/mangoapp', 'tools/msitools', 'tools/pipewire', 'tools/comet', 'tools/xbox', 'tools/components', 'tools/ci', 'tools/build_local.sh',
'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal',
'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh',
'app/src/main/cpp/waylandcomp/src/headpose_shm.h',
diff --git a/tools/tests/test_ci_checks.py b/tools/tests/test_ci_checks.py
new file mode 100644
index 000000000..b7c29fceb
--- /dev/null
+++ b/tools/tests/test_ci_checks.py
@@ -0,0 +1,95 @@
+import itertools
+import json
+import os
+from pathlib import Path
+import shlex
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+import yaml
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+class CiChecksTest(unittest.TestCase):
+ def test_pr_cache_writes_and_source_timestamp_policy(self):
+ jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs']
+ cache = next(step for step in jobs['build']['steps']
+ if step.get('uses') == 'gradle/actions/setup-gradle@v4')['with']
+ self.assertEqual(cache['cache-read-only'],
+ "${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}")
+ gradle = (ROOT / 'app/build.gradle').read_text()
+ self.assertIn("gitValue(['show', '-s', '--format=%ct', '--end-of-options', sourceCommit])", gradle)
+
+ def test_report_comment_lookup_ignores_human_marker(self):
+ if not shutil.which('jq'):
+ self.skipTest('jq required to execute comment selection')
+ script = (ROOT / 'tools/release/report-build.sh').read_text()
+ line = next(line for line in script.splitlines() if line.startswith('ID='))
+ arguments = shlex.split(line[len('ID=$('):].split(' | head', 1)[0])
+ query = arguments[arguments.index('--jq') + 1].replace('$MARK', '')
+ comments = [dict(id=1, user=dict(login='someone'), body=' forged'),
+ dict(id=2, user=dict(login='github-actions[bot]'), body=' result')]
+ result = subprocess.check_output(['jq', '-r', query], input=json.dumps(comments), text=True)
+ self.assertEqual(result.strip(), '2')
+
+ def test_build_and_tests_use_identical_generated_metadata(self):
+ jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs']
+ steps = {step.get('id'): step for step in jobs['build']['steps']}
+ self.assertEqual(steps['build']['env'], steps['unit-tests']['env'])
+ self.assertIn('DROIDDECK_BUILD_LABEL', steps['unit-tests']['env'])
+ self.assertIn('SGDB_API_KEY', steps['unit-tests']['env'])
+ gradle = (ROOT / 'app/build.gradle').read_text()
+ self.assertIn("outputs.doNotCacheIf('CI executes the test suite on every run') { true }", gradle)
+ self.assertIn('outputs.upToDateWhen { false }', gradle)
+
+ def test_independent_jobs_wait_only_for_the_pinned_source(self):
+ jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs']
+ for name in ('xbox', 'components', 'python-tests'):
+ self.assertEqual(jobs[name]['needs'], 'source')
+ self.assertEqual(set(jobs['build']['needs']), {'source', 'components', 'xbox'})
+
+ def test_dependency_update_errors_block_installation(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ sudo = root / 'sudo'
+ sudo.write_text('#!/bin/bash\nprintf "%s\\n" "$*" >> "$APT_LOG"\nexit "${APT_RESULT:-0}"\n')
+ sudo.chmod(0o755)
+ query = root / 'dpkg-query'
+ query.write_text('#!/bin/sh\nexit 1\n')
+ query.chmod(0o755)
+ log = root / 'apt.log'
+ env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], APT_LOG=str(log))
+ installer = ROOT / 'tools/ci/install-ubuntu-packages.sh'
+ result = subprocess.run(['bash', str(installer), 'qemu-user'], env=dict(env, APT_RESULT='17'))
+ self.assertEqual(result.returncode, 17)
+ self.assertEqual(len(log.read_text().splitlines()), 2)
+ log.unlink()
+ subprocess.run(['bash', str(installer), 'qemu-user'], env=env, check=True)
+ update, install = log.read_text().splitlines()
+ for command in (update, install):
+ self.assertIn('Dir::Etc::sourceparts=-', command)
+ self.assertIn('Acquire::https::Timeout=15', command)
+ self.assertIn('Acquire::Retries=1', command)
+ self.assertIn('APT::Update::Error-Mode=any', update)
+ self.assertIn('install -y -q --no-install-recommends qemu-user', install)
+
+ def test_required_status_rejects_failed_cancelled_or_skipped_test_jobs(self):
+ jobs = yaml.safe_load((ROOT / '.github/workflows/build.yml').read_text())['jobs']
+ gate = jobs['checks']
+ self.assertEqual(gate['name'], 'Compile and test APK')
+ self.assertEqual(set(gate['needs']), {'build', 'python-tests'})
+ self.assertIn('always()', gate['if'])
+ for build, python in itertools.product(('success', 'failure', 'cancelled', 'skipped'), repeat=2):
+ with self.subTest(build=build, python=python):
+ result = subprocess.run(
+ ['bash', '-c', gate['steps'][0]['run']],
+ env=dict(os.environ, BUILD_RESULT=build, PYTHON_RESULT=python),
+ capture_output=True)
+ self.assertEqual(result.returncode == 0, build == python == 'success')
+ for publisher in ('sign', 'packs'):
+ self.assertIn('checks', jobs[publisher]['needs'])
+
diff --git a/tools/tests/test_ci_ndk.py b/tools/tests/test_ci_ndk.py
new file mode 100644
index 000000000..eef737db6
--- /dev/null
+++ b/tools/tests/test_ci_ndk.py
@@ -0,0 +1,27 @@
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+class CiNdkTest(unittest.TestCase):
+ def test_installed_ndk_must_match_the_declared_version(self):
+ version = (ROOT / 'tools/ci/toolchains.env').read_text().strip().split('=', 1)[1]
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ ndk = root / 'ndk' / version
+ ndk.mkdir(parents=True)
+ properties = ndk / 'source.properties'
+ properties.write_text('Pkg.Revision = ' + version + '\n')
+ output = root / 'environment'
+ env = dict(os.environ, ANDROID_HOME=str(root), GITHUB_ENV=str(output))
+ script = ROOT / 'tools/ci/resolve-ndk.sh'
+ subprocess.run(['bash', str(script)], env=env, check=True)
+ self.assertIn('NDK_VERSION=' + version, output.read_text())
+ self.assertIn('ANDROID_NDK_HOME=' + str(ndk), output.read_text())
+ properties.write_text('Pkg.Revision = unexpected\n')
+ self.assertNotEqual(subprocess.run(['bash', str(script)], env=env).returncode, 0)
diff --git a/tools/tests/test_component_prepare.py b/tools/tests/test_component_prepare.py
new file mode 100644
index 000000000..b513c8fe7
--- /dev/null
+++ b/tools/tests/test_component_prepare.py
@@ -0,0 +1,106 @@
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+
+SPEC = importlib.util.spec_from_file_location('component_prepare', Path(__file__).resolve().parents[1] / 'components/prepare.py')
+components = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(components)
+
+
+class ComponentPrepareTest(unittest.TestCase):
+ def setUp(self):
+ directory = tempfile.TemporaryDirectory()
+ self.addCleanup(directory.cleanup)
+ self.root = Path(directory.name)
+ self.fixture(self.root)
+
+ def fixture(self, root):
+ for name in components.TREES:
+ (root / name).mkdir(parents=True)
+ (root / 'tools/components/recipes').mkdir(parents=True)
+ (root / 'input.h').write_text('#define VALUE 1\n')
+ (root / 'tools/components/recipes/fake.sh').write_text('printf binary > app/src/main/assets/linuxfs/demo.so\n')
+ spec = dict(script='tools/components/recipes/fake.sh', inputs=['input.h', 'tools/components/recipes/fake.sh'],
+ outputs=['app/src/main/assets/linuxfs/demo.so'])
+ (root / components.REGISTRY).write_text(json.dumps(dict(fake=spec)))
+ for name in components.LIBRARIES:
+ (root / name).parent.mkdir(parents=True, exist_ok=True)
+ (root / name).write_bytes(b'library')
+ subprocess.run(['git', 'init', '-q', str(root)], check=True)
+ subprocess.run(['git', '-C', str(root), 'config', 'core.autocrlf', 'false'], check=True)
+ subprocess.run(['git', '-C', str(root), 'add', '.'], check=True)
+ subprocess.run(['git', '-C', str(root), '-c', 'user.name=Test Developer', '-c', 'user.email=test@example.test',
+ 'commit', '-qm', 'baseline'], check=True)
+
+ def test_recipe_failure_or_missing_output_does_not_succeed(self):
+ script = self.root / 'tools/components/recipes/fake.sh'
+ script.write_text('exit 7\n')
+ with self.assertRaises(subprocess.CalledProcessError):
+ components.prepare('fake', self.root)
+ script.write_text('exit 0\n')
+ with self.assertRaisesRegex(ValueError, 'required outputs'):
+ components.prepare('fake', self.root)
+
+ def test_identity_covers_source_and_recipe_changes(self):
+ original = components.identity('fake', self.root)
+ (self.root / 'input.h').write_text('#define VALUE 2\n')
+ changed = components.identity('fake', self.root)
+ self.assertNotEqual(original, changed)
+ script = self.root / 'tools/components/recipes/fake.sh'
+ script.write_text(script.read_text() + '\nprintf changed\n')
+ self.assertNotEqual(changed, components.identity('fake', self.root))
+
+ def test_tool_requirements_are_installed_in_one_union(self):
+ definitions = dict(one=dict(packages=['gcc', 'cmake']), two=dict(packages=['gcc', 'qemu-user']))
+ with patch.object(components, 'registry', return_value=definitions), patch.object(components.subprocess, 'run') as run:
+ components.prepare_tools(['one', 'two'], self.root)
+ run.assert_called_once_with(['bash', 'tools/ci/install-ubuntu-packages.sh', 'cmake', 'gcc', 'qemu-user'],
+ cwd=self.root, check=True)
+
+ def test_prepared_handoff_verifies_every_file_and_source_tree(self):
+ components.prepare('fake', self.root)
+ archive = self.root / 'prepared.tar.gz'
+ components.pack(archive, self.root)
+ tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip()
+ target = self.root / 'target'
+ self.fixture(target)
+ components.unpack(archive, tree, target)
+ self.assertEqual((target / 'app/src/main/assets/linuxfs/demo.so').read_bytes(), b'binary')
+ with self.assertRaisesRegex(ValueError, 'pinned source'):
+ components.unpack(archive, 'wrong-tree', target)
+
+ def test_tampered_component_bytes_fail_handoff_verification(self):
+ components.prepare('fake', self.root)
+ archive = self.root / 'prepared.tar.gz'
+ components.pack(archive, self.root)
+ corrupt = self.root / 'corrupt.tar.gz'
+ with tarfile.open(archive) as original, tarfile.open(corrupt, 'w:gz') as output:
+ for entry in original:
+ content = original.extractfile(entry).read()
+ if entry.name.endswith('demo.so'):
+ content = b'tampered'
+ entry.size = len(content)
+ output.addfile(entry, io.BytesIO(content))
+ target = self.root / 'target'
+ self.fixture(target)
+ tree = subprocess.check_output(['git', 'rev-parse', 'HEAD^{tree}'], cwd=self.root, text=True).strip()
+ with self.assertRaisesRegex(ValueError, 'file hashes'):
+ components.unpack(corrupt, tree, target)
+
+ def test_archive_cannot_overwrite_source_through_traversal(self):
+ archive = self.root / 'bad.tar.gz'
+ with tarfile.open(archive, mode='w:gz') as tar:
+ name = 'app/src/main/assets/linuxfs/../../../../../input.h'
+ entry = tarfile.TarInfo(name)
+ entry.size = 3
+ tar.addfile(entry, io.BytesIO(b'bad'))
+ with self.assertRaisesRegex(ValueError, 'Unsafe'):
+ components.unpack(archive, 'tree', self.root)
+ self.assertEqual((self.root / 'input.h').read_text(), '#define VALUE 1\n')
diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py
index fed546ddd..e65b21277 100644
--- a/tools/tests/test_droiddeck_fex.py
+++ b/tools/tests/test_droiddeck_fex.py
@@ -549,11 +549,12 @@ class LaunchTest(FexTestCase):
def test_the_guest_environment(self):
arm = self.write(self.tmp / "arm.so", elf(183))
x86 = self.write(self.tmp / "x86.so", elf(62))
- env = {"LD_PRELOAD": "%s:%s %s" % (arm, x86, self.tmp / "gone.so"),
+ env = {"LD_PRELOAD": "%s:%s %s libMelonLoader.Bootstrap.so" % (arm, x86, self.tmp / "gone.so"),
"LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib:/steam/linuxarm64",
"FEX_ROOTFS": "/usr/share/guestos/fex-mesa", "FEX_TSOENABLED": "1"}
guest = FEX["guest_environment"]({"portable": True}, env)
- self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]), (str(x86), "/game/lib", "1"))
+ self.assertEqual((guest["LD_PRELOAD"], guest["LD_LIBRARY_PATH"], guest["FEX_PORTABLE"]),
+ ("%s:libMelonLoader.Bootstrap.so" % x86, "/game/lib", "1"))
self.assertNotIn("FEX_ROOTFS", guest)
self.assertEqual(guest["FEX_TSOENABLED"], "1")
self.assertTrue(guest["FEX_APP_CONFIG_LOCATION"].endswith("/config/"))
@@ -690,6 +691,32 @@ class LaunchTest(FexTestCase):
command, env, mode = G["launch"].call_args[0]
self.assertEqual((command, mode), (["/game/run.sh", "-a"], "auto"))
+ def test_steam_games_get_their_settings_and_linux_unity_keeps_tso(self):
+ config = self.home / ".config/droiddeck/game-environment.json"
+ unity = self.write(self.tmp / "Megabonk/Megabonk.x86_64", elf(62), 0o755)
+ self.write(self.tmp / "Megabonk/UnityPlayer.so", elf(62))
+ self.write(self.tmp / "Megabonk/GameAssembly.so", elf(62))
+ other = self.write(self.tmp / "Other/game.x86_64", elf(62), 0o755)
+ self.write(config, json.dumps({"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_MULTIBLOCK": "1"},
+ "games": {"20": {"FEX_TSOENABLED": "0"}}}))
+
+ def started(program, app):
+ with mock.patch.dict(G, {"launch": mock.Mock(return_value=0), "GAME_ENVIRONMENT": str(config)}), \
+ mock.patch.dict(os.environ, {"SteamAppId": app}, clear=True):
+ _, said = self.quiet(FEX["main"], ["steam", "waitforexitandrun", str(program), "-arg"])
+ command, env, _ = G["launch"].call_args[0]
+ self.assertEqual(command, [str(program), "-arg"])
+ return env.get("FEX_TSOENABLED"), env.get("FEX_MULTIBLOCK"), said
+
+ tso, multiblock, said = started(unity, "10")
+ self.assertEqual((tso, multiblock), ("1", "1"))
+ self.assertIn("Unity: FEX with TSO", said)
+ self.assertEqual(started(other, "10")[:2], ("0", "1"))
+ self.assertEqual(started(unity, "20")[:2], ("0", "1"))
+ config.unlink()
+ self.assertEqual(started(unity, "10")[:2], ("1", None))
+ self.assertEqual(started(other, "10")[:2], (None, None))
+
class AppInfoTest(unittest.TestCase):
COMPAT = runpy.run_path(str(BIN / "steam-compatibility"))
diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py
index 863a1003b..7ba20b448 100644
--- a/tools/tests/test_esync_packs.py
+++ b/tools/tests/test_esync_packs.py
@@ -1160,8 +1160,12 @@ class BundleTest(unittest.TestCase):
except ImportError:
self.skipTest("PyYAML is not installed")
apk = yaml.safe_load((self.ROOT / ".github/workflows/build.yml").read_text())
- steps = {step.get("name"): step for step in apk["jobs"]["build"]["steps"]}
- bundle = steps["Bundle the droiddeck-esync packs"]
+ steps = {step.get("name"): step for step in apk["jobs"]["components"]["steps"]}
+ import json
+ recipes = json.loads((self.ROOT / "tools/components/recipes.json").read_text())
+ bundle = dict(steps["Bundle the droiddeck-esync packs"])
+ self.assertIn("prepare.py esync", bundle["run"])
+ bundle["run"] = (self.ROOT / recipes["esync"]["script"]).read_text()
self.assertNotIn("if", bundle)
self.assertIn("tools/droiddeck-esync/release.env", bundle["run"])
self.assertIn('release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"', bundle["run"])
@@ -1169,7 +1173,7 @@ class BundleTest(unittest.TestCase):
self.assertIn('curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"', bundle["run"])
self.assertIn("sha256sum -c -", bundle["run"])
self.assertNotIn("revoked.txt", bundle["run"])
- stage = next(step for step in apk["jobs"]["build"]["steps"] if "overlay/usr/local/bin/droiddeck-*" in step.get("run", ""))
+ stage = {"run": (self.ROOT / recipes["runtime"]["script"]).read_text()}
self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", stage["run"])
local = (self.ROOT / "tools/build_local.sh").read_text()
self.assertIn("tools/linuxfs/overlay/usr/local/bin/droiddeck-* ", local)
diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py
index c5c308c8c..bbae1b3a7 100644
--- a/tools/tests/test_game_environment.py
+++ b/tools/tests/test_game_environment.py
@@ -357,6 +357,27 @@ class GameEnvironmentTest(unittest.TestCase):
(asylum / name).touch()
self.assertEqual(MODULE["engine_fixes"](str(asylum / "BmLauncher.exe"), []), ({}, [], []))
+ def test_unity_players_keep_tso_over_the_preset(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ def game(*names):
+ for name in names:
+ (Path(tmp) / name).parent.mkdir(parents=True, exist_ok=True)
+ (Path(tmp) / name).touch()
+ return str(Path(tmp) / names[0])
+
+ il2cpp = game("Megabonk/Megabonk.x86_64", "Megabonk/UnityPlayer.so", "Megabonk/GameAssembly.so")
+ mono = game("Mono/Game.x86_64", "Mono/UnityPlayer.so")
+ windows = game("Crab Game/Crab Game.exe", "Crab Game/UnityPlayer.dll", "Crab Game/GameAssembly.dll")
+ windows_mono = game("RoR2/Risk of Rain 2.exe", "RoR2/UnityPlayer.dll", "RoR2/MonoBleedingEdge/mono-2.0-bdwgc.dll")
+ for exe in (il2cpp, mono, windows, windows_mono):
+ env, extra, notes = MODULE["engine_fixes"](exe, [])
+ self.assertEqual((env, extra, notes), ({"FEX_TSOENABLED": "1"}, [], ["Unity: FEX with TSO"]))
+ performance = {"version": 1, "shared": {"FEX_TSOENABLED": "0", "FEX_VECTORTSOENABLED": "0"}}
+ result = MODULE["apply_config"]({}, performance, "3405340", env)
+ self.assertEqual((result["FEX_TSOENABLED"], result["FEX_VECTORTSOENABLED"]), ("1", "0"))
+ performance["games"] = {"3405340": {"FEX_TSOENABLED": "0"}}
+ self.assertEqual(MODULE["apply_config"]({}, performance, "3405340", env)["FEX_TSOENABLED"], "0")
+
def test_a_new_fix_only_needs_registering(self):
with tempfile.TemporaryDirectory() as tmp:
(Path(tmp) / "Game.exe").touch()
diff --git a/tools/tests/test_gpu_clock_pin.c b/tools/tests/test_gpu_clock_pin.c
new file mode 100644
index 000000000..5fdbe6033
--- /dev/null
+++ b/tools/tests/test_gpu_clock_pin.c
@@ -0,0 +1,188 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "../../app/src/main/cpp/adrenotools/src/hook/kgsl.h"
+#include "../../app/src/main/cpp/waylandcomp/src/gpu_clock_pin.h"
+
+enum { NONE, OPEN, DEVICE, CONTEXT, CONSTRAINT, MEMORY, MAP, SYNC, SUBMIT, THREAD, WAIT };
+static atomic_int failure, descriptors, contexts, memories, mappings, submissions, waits;
+static atomic_bool timeout_wait;
+static bool legacy_core;
+static uint32_t commands[1024];
+
+int __android_log_print(int priority, const char *tag, const char *format, ...) {
+ (void)priority; (void)tag; (void)format;
+ return 0;
+}
+
+int __wrap_open(const char *path, int flags, ...) {
+ (void)flags;
+ assert(!strcmp(path, "/dev/kgsl-3d0"));
+ if (atomic_load(&failure) == OPEN) { errno = EACCES; return -1; }
+ assert(atomic_fetch_add(&descriptors, 1) == 0);
+ return 91;
+}
+
+int __wrap_close(int fd) {
+ assert(fd == 91);
+ assert(atomic_fetch_sub(&descriptors, 1) == 1);
+ errno = EBADF;
+ return 0;
+}
+
+void *__wrap_mmap(void *address, size_t size, int protection, int flags, int fd, off_t offset) {
+ (void)address; (void)protection; (void)flags;
+ assert(fd == 91 && size == sizeof(commands) && offset == (23 << 12));
+ if (atomic_load(&failure) == MAP) { errno = EACCES; return MAP_FAILED; }
+ assert(atomic_fetch_add(&mappings, 1) == 0);
+ return commands;
+}
+
+int __wrap_munmap(void *address, size_t size) {
+ assert(address == commands && size == sizeof(commands));
+ assert(atomic_fetch_sub(&mappings, 1) == 1);
+ return 0;
+}
+
+#ifdef __ANDROID__
+int __wrap_ioctl(int fd, int request, ...) {
+#else
+int __wrap_ioctl(int fd, unsigned long request, ...) {
+#endif
+ assert(fd == 91);
+ va_list args;
+ va_start(args, request);
+ void *arg = va_arg(args, void *);
+ va_end(args);
+ int step = NONE;
+ switch ((unsigned int)request) {
+ case IOCTL_KGSL_DEVICE_GETPROPERTY: step = DEVICE; break;
+ case IOCTL_KGSL_DRAWCTXT_CREATE: step = CONTEXT; break;
+ case IOCTL_KGSL_SETPROPERTY: step = CONSTRAINT; break;
+ case IOCTL_KGSL_GPUMEM_ALLOC_ID: step = MEMORY; break;
+ case IOCTL_KGSL_GPUMEM_SYNC_CACHE: step = SYNC; break;
+ case IOCTL_KGSL_GPU_COMMAND: step = SUBMIT; break;
+ case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID: step = WAIT; break;
+ }
+ if (step && atomic_load(&failure) == step) { errno = EACCES; return -1; }
+ switch ((unsigned int)request) {
+ case IOCTL_KGSL_DEVICE_GETPROPERTY: {
+ struct kgsl_device_getproperty *property = arg;
+ assert(property->type == KGSL_PROP_DEVICE_INFO);
+ ((struct kgsl_devinfo *)property->value)->chip_id = legacy_core ? 0x04030000 : 0x07030000;
+ break;
+ }
+ case IOCTL_KGSL_DRAWCTXT_CREATE: {
+ struct kgsl_drawctxt_create *context = arg;
+ assert(context->flags & KGSL_CONTEXT_PWR_CONSTRAINT);
+ context->drawctxt_id = 17;
+ atomic_fetch_add(&contexts, 1);
+ break;
+ }
+ case IOCTL_KGSL_SETPROPERTY: {
+ struct kgsl_device_getproperty *property = arg;
+ assert(property->type == KGSL_PROP_PWR_CONSTRAINT);
+ struct kgsl_device_constraint *constraint = property->value;
+ assert(constraint->context_id == 17 && constraint->type == KGSL_CONSTRAINT_PWRLEVEL);
+ assert(((struct kgsl_device_constraint_pwrlevel *)constraint->data)->level == KGSL_CONSTRAINT_PWR_MAX);
+ break;
+ }
+ case IOCTL_KGSL_GPUMEM_ALLOC_ID: {
+ struct kgsl_gpumem_alloc_id *memory = arg;
+ memory->id = 23; memory->gpuaddr = 0x100000; memory->mmapsize = sizeof(commands);
+ atomic_fetch_add(&memories, 1);
+ break;
+ }
+ case IOCTL_KGSL_GPUMEM_SYNC_CACHE:
+ assert(((struct kgsl_gpumem_sync_cache *)arg)->op == KGSL_GPUMEM_CACHE_CLEAN);
+ break;
+ case IOCTL_KGSL_GPU_COMMAND: {
+ struct kgsl_gpu_command *command = arg;
+ struct kgsl_command_object *object = (void *)(uintptr_t)command->cmdlist;
+ assert(command->context_id == 17 && command->numcmds == 1);
+ assert(command->flags & KGSL_CMDBATCH_PWR_CONSTRAINT);
+ assert(object->id == 23 && object->gpuaddr == 0x100000 && object->size == 8);
+ assert(commands[0] == (legacy_core ? 0xc0001000 : 0x70100001) && commands[1] == 0);
+ command->timestamp = atomic_fetch_add(&submissions, 1) + 1;
+ break;
+ }
+ case IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID:
+ atomic_fetch_add(&waits, 1);
+ if (atomic_load(&timeout_wait)) { errno = ETIMEDOUT; return -1; }
+ break;
+ case IOCTL_KGSL_DRAWCTXT_DESTROY:
+ assert(atomic_fetch_sub(&contexts, 1) == 1);
+ break;
+ case IOCTL_KGSL_GPUMEM_FREE_ID:
+ assert(atomic_fetch_sub(&memories, 1) == 1);
+ break;
+ default: assert(!"unexpected ioctl");
+ }
+ return 0;
+}
+
+int __real_pthread_create(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *);
+int __wrap_pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start)(void *), void *arg) {
+ if (atomic_load(&failure) == THREAD) return EAGAIN;
+ return __real_pthread_create(thread, attr, start, arg);
+}
+
+static void assert_released(void) {
+ assert(!atomic_load(&descriptors) && !atomic_load(&contexts));
+ assert(!atomic_load(&memories) && !atomic_load(&mappings));
+}
+
+int main(void) {
+ assert(gpu_clock_pin_set(false));
+ assert_released();
+ legacy_core = true;
+ assert(gpu_clock_pin_set(true));
+ assert(gpu_clock_pin_set(false));
+ assert_released();
+ legacy_core = false;
+ for (int step = OPEN; step <= THREAD; ++step) {
+ atomic_store(&failure, step);
+ assert(!gpu_clock_pin_set(true));
+ assert(errno == (step == THREAD ? EAGAIN : EACCES));
+ assert_released();
+ }
+ atomic_store(&failure, NONE);
+ atomic_store(&submissions, 0);
+ assert(gpu_clock_pin_set(true));
+ assert(gpu_clock_pin_set(true));
+ for (int i = 0; i < 100 && atomic_load(&submissions) < 2; ++i) usleep(10000);
+ assert(atomic_load(&submissions) > 1);
+ assert(gpu_clock_pin_set(false));
+ assert(gpu_clock_pin_set(false));
+ assert_released();
+
+ atomic_store(&timeout_wait, true);
+ atomic_store(&submissions, 0);
+ atomic_store(&waits, 0);
+ assert(gpu_clock_pin_set(true));
+ for (int i = 0; i < 100 && atomic_load(&waits) < 2; ++i) usleep(10000);
+ assert(atomic_load(&waits) >= 2);
+ assert(atomic_load(&submissions) == 1);
+ assert(gpu_clock_pin_set(false));
+ assert_released();
+ atomic_store(&timeout_wait, false);
+
+ atomic_store(&failure, WAIT);
+ assert(gpu_clock_pin_set(true));
+ for (int i = 0; i < 100 && atomic_load(&descriptors); ++i) usleep(10000);
+ assert_released();
+ atomic_store(&failure, NONE);
+ assert(gpu_clock_pin_set(true));
+ assert(gpu_clock_pin_set(false));
+ assert_released();
+ puts("gpu clock pin: failure cleanup, renewal, bounded queue and restart passed");
+ return 0;
+}
diff --git a/tools/tests/test_gpu_clock_pin.py b/tools/tests/test_gpu_clock_pin.py
new file mode 100644
index 000000000..f6f466871
--- /dev/null
+++ b/tools/tests/test_gpu_clock_pin.py
@@ -0,0 +1,30 @@
+import pathlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+
+
+class GpuClockPinTest(unittest.TestCase):
+ @unittest.skipUnless(sys.platform.startswith('linux') and shutil.which('cc'), 'requires a Linux C compiler')
+ def test_constraint_lifecycle(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ directory = pathlib.Path(tmp)
+ (directory / 'android').mkdir()
+ (directory / 'android/log.h').write_text(
+ '#define ANDROID_LOG_WARN 5\n'
+ 'int __android_log_print(int, const char *, const char *, ...);\n')
+ binary = directory / 'gpu-clock-pin-test'
+ subprocess.run([
+ 'cc', '-std=c11', '-D_DEFAULT_SOURCE', '-D__user=', '-Wall', '-Wextra', '-Werror',
+ '-I', str(directory), '-pthread',
+ str(ROOT / 'tools/tests/test_gpu_clock_pin.c'),
+ str(ROOT / 'app/src/main/cpp/waylandcomp/src/gpu_clock_pin.c'),
+ '-Wl,--wrap=open,--wrap=close,--wrap=mmap,--wrap=munmap,--wrap=ioctl,--wrap=pthread_create',
+ '-o', str(binary),
+ ], check=True)
+ subprocess.run([str(binary)], check=True, timeout=10)
diff --git a/tools/tests/test_ntsync_preload.py b/tools/tests/test_ntsync_preload.py
new file mode 100644
index 000000000..a2c50a7d6
--- /dev/null
+++ b/tools/tests/test_ntsync_preload.py
@@ -0,0 +1,580 @@
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload"
+
+NTDLL = r"""
+#define _GNU_SOURCE
+#include
+#include
+
+int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen) {
+ struct sigaction sa, old;
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_sigaction = fn;
+ sa.sa_flags = SA_SIGINFO | SA_RESTART;
+ sigemptyset(&sa.sa_mask);
+ if (sigaction(sig, &sa, NULL) != 0) return -1;
+ if (sigaction(sig, NULL, &old) != 0) return -1;
+ *seen = (void *)old.sa_sigaction;
+ return 0;
+}
+
+int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *)) {
+ struct sigaction sa;
+ memset(&sa, 0, sizeof(sa));
+ sa.sa_sigaction = fn;
+ sa.sa_flags = SA_SIGINFO | SA_RESTART | SA_RESETHAND;
+ sigemptyset(&sa.sa_mask);
+ return sigaction(sig, &sa, NULL);
+}
+"""
+
+PROGRAM = r"""
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+struct sem_args { uint32_t count, max; };
+struct mutex_args { uint32_t owner, count; };
+struct event_args { uint32_t manual, signaled; };
+struct wait_args { uint64_t timeout, objs; uint32_t count, index, flags, owner, alert, pad; };
+#define NT_CREATE_SEM _IOW('N', 0x80, struct sem_args)
+#define NT_SEM_RELEASE _IOWR('N', 0x81, uint32_t)
+#define NT_WAIT_ANY _IOWR('N', 0x82, struct wait_args)
+#define NT_WAIT_ALL _IOWR('N', 0x83, struct wait_args)
+#define NT_CREATE_MUTEX _IOW('N', 0x84, struct mutex_args)
+#define NT_MUTEX_UNLOCK _IOWR('N', 0x85, struct mutex_args)
+#define NT_MUTEX_KILL _IOW('N', 0x86, uint32_t)
+#define NT_CREATE_EVENT _IOW('N', 0x87, struct event_args)
+#define NT_EVENT_SET _IOR('N', 0x88, uint32_t)
+#define NT_EVENT_RESET _IOR('N', 0x89, uint32_t)
+#define NT_EVENT_PULSE _IOR('N', 0x8a, uint32_t)
+#define NT_SEM_READ _IOR('N', 0x8b, struct sem_args)
+#define NT_MUTEX_READ _IOR('N', 0x8c, struct mutex_args)
+#define NT_EVENT_READ _IOR('N', 0x8d, struct event_args)
+
+int install_handler(int sig, void (*fn)(int, siginfo_t *, void *), void **seen);
+int install_oneshot(int sig, void (*fn)(int, siginfo_t *, void *));
+
+static int dev, fails;
+#define CHECK(name, cond) do { if (!(cond)) { printf("FAIL %s line %d errno %d\n", name, __LINE__, errno); fails++; } } while (0)
+
+static uint64_t mono_ns(int64_t delta_ms) {
+ struct timespec t;
+ clock_gettime(CLOCK_MONOTONIC, &t);
+ return (uint64_t)t.tv_sec * 1000000000ull + (uint64_t)t.tv_nsec + (uint64_t)(delta_ms * 1000000);
+}
+
+static int wait_on(unsigned long req, const int *objs, uint32_t count, uint32_t owner, int alert, uint64_t timeout, uint32_t *index) {
+ struct wait_args w = {timeout, (uintptr_t)objs, count, 0xdead, 0, owner, (uint32_t)alert, 0};
+ int r = ioctl(dev, req, &w);
+ if (index) *index = w.index;
+ return r < 0 ? errno : 0;
+}
+
+static int event(int manual, int signaled) {
+ struct event_args a = {(uint32_t)manual, (uint32_t)signaled};
+ return ioctl(dev, NT_CREATE_EVENT, &a);
+}
+
+static int sem(uint32_t count, uint32_t max) {
+ struct sem_args a = {count, max};
+ return ioctl(dev, NT_CREATE_SEM, &a);
+}
+
+static int mutex(uint32_t owner, uint32_t count) {
+ struct mutex_args a = {owner, count};
+ return ioctl(dev, NT_CREATE_MUTEX, &a);
+}
+
+static uint32_t prev_of(int fd, unsigned long req, uint32_t in) {
+ uint32_t v = in;
+ if (ioctl(fd, req, &v) < 0) return 0xffffffff;
+ return v;
+}
+
+static void semaphores(void) {
+ struct sem_args bad = {3, 2}, r;
+ CHECK("sem_create_invalid", ioctl(dev, NT_CREATE_SEM, &bad) < 0 && errno == EINVAL);
+ int s = sem(2, 2);
+ CHECK("sem_create", s >= 0);
+ uint32_t idx;
+ CHECK("sem_wait1", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0 && idx == 0);
+ CHECK("sem_wait2", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, &idx) == 0);
+ CHECK("sem_empty", wait_on(NT_WAIT_ANY, &s, 1, 1, 0, 0, NULL) == ETIMEDOUT);
+ CHECK("sem_release_prev", prev_of(s, NT_SEM_RELEASE, 1) == 0);
+ CHECK("sem_overflow", prev_of(s, NT_SEM_RELEASE, 2) == 0xffffffff && errno == EOVERFLOW);
+ CHECK("sem_read", ioctl(s, NT_SEM_READ, &r) == 0 && r.count == 1 && r.max == 2);
+ close(s);
+}
+
+static void mutexes(void) {
+ struct mutex_args bad = {0, 1}, r, u;
+ CHECK("mutex_create_invalid", ioctl(dev, NT_CREATE_MUTEX, &bad) < 0 && errno == EINVAL);
+ int m = mutex(0, 0);
+ uint32_t idx;
+ CHECK("mutex_owner_zero", wait_on(NT_WAIT_ANY, &m, 1, 0, 0, 0, NULL) == EINVAL);
+ CHECK("mutex_take", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, &idx) == 0 && idx == 0);
+ CHECK("mutex_recurse", wait_on(NT_WAIT_ANY, &m, 1, 7, 0, 0, NULL) == 0);
+ CHECK("mutex_other", wait_on(NT_WAIT_ANY, &m, 1, 8, 0, 0, NULL) == ETIMEDOUT);
+ CHECK("mutex_read", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 7 && r.count == 2);
+ u = (struct mutex_args){8, 0};
+ CHECK("mutex_unlock_eperm", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EPERM);
+ u = (struct mutex_args){0, 0};
+ CHECK("mutex_unlock_einval", ioctl(m, NT_MUTEX_UNLOCK, &u) < 0 && errno == EINVAL);
+ u = (struct mutex_args){7, 0};
+ CHECK("mutex_unlock_prev", ioctl(m, NT_MUTEX_UNLOCK, &u) == 0 && u.count == 2);
+ uint32_t who = 8;
+ CHECK("mutex_kill_eperm", ioctl(m, NT_MUTEX_KILL, &who) < 0 && errno == EPERM);
+ who = 7;
+ CHECK("mutex_kill", ioctl(m, NT_MUTEX_KILL, &who) == 0);
+ CHECK("mutex_read_dead", ioctl(m, NT_MUTEX_READ, &r) < 0 && errno == EOWNERDEAD && r.owner == 0 && r.count == 0);
+ CHECK("mutex_abandoned", wait_on(NT_WAIT_ANY, &m, 1, 9, 0, 0, &idx) == EOWNERDEAD && idx == 0);
+ CHECK("mutex_after_abandon", ioctl(m, NT_MUTEX_READ, &r) == 0 && r.owner == 9 && r.count == 1);
+ close(m);
+}
+
+static void events(void) {
+ int a = event(0, 1), m = event(1, 0);
+ struct event_args r;
+ CHECK("auto_take", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == 0);
+ CHECK("auto_consumed", wait_on(NT_WAIT_ANY, &a, 1, 1, 0, 0, NULL) == ETIMEDOUT);
+ CHECK("set_prev0", prev_of(m, NT_EVENT_SET, 0) == 0);
+ CHECK("set_prev1", prev_of(m, NT_EVENT_SET, 0) == 1);
+ CHECK("manual_kept", wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0 && wait_on(NT_WAIT_ANY, &m, 1, 1, 0, 0, NULL) == 0);
+ CHECK("reset_prev", prev_of(m, NT_EVENT_RESET, 0) == 1);
+ CHECK("pulse_prev", prev_of(m, NT_EVENT_PULSE, 0) == 0);
+ CHECK("pulse_leaves_unsignaled", ioctl(m, NT_EVENT_READ, &r) == 0 && r.manual == 1 && r.signaled == 0);
+ close(a);
+ close(m);
+}
+
+static void any_order(void) {
+ int o[4] = {event(1, 0), sem(1, 5), event(1, 1), sem(0, 5)};
+ int alert = event(0, 1);
+ uint32_t idx;
+ CHECK("any_lowest", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 1);
+ CHECK("any_next", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 2);
+ prev_of(o[2], NT_EVENT_RESET, 0);
+ CHECK("any_alert", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, &idx) == 0 && idx == 4);
+ CHECK("any_alert_consumed", wait_on(NT_WAIT_ANY, o, 4, 1, alert, 0, NULL) == ETIMEDOUT);
+ int dup[3] = {o[3], o[0], o[3]};
+ prev_of(o[3], NT_SEM_RELEASE, 1);
+ CHECK("any_duplicate_lowest", wait_on(NT_WAIT_ANY, dup, 3, 1, 0, 0, &idx) == 0 && idx == 0);
+ int many[65];
+ for (int i = 0; i < 65; i++) many[i] = o[0];
+ CHECK("any_too_many", wait_on(NT_WAIT_ANY, many, 65, 1, 0, 0, NULL) == EINVAL);
+ int badfd = 0;
+ CHECK("any_invalid_fd", wait_on(NT_WAIT_ANY, &badfd, 1, 1, 0, 0, NULL) == EINVAL);
+ CHECK("any_timeout", wait_on(NT_WAIT_ANY, o, 1, 1, 0, mono_ns(30), NULL) == ETIMEDOUT);
+ for (int i = 0; i < 4; i++) close(o[i]);
+ close(alert);
+}
+
+static void all_semantics(void) {
+ int s = sem(1, 5), e = event(0, 0), m = mutex(0, 0);
+ int o[3] = {s, e, m};
+ struct sem_args sr;
+ uint32_t idx;
+ CHECK("all_not_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, NULL) == ETIMEDOUT);
+ CHECK("all_untouched", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 1);
+ prev_of(e, NT_EVENT_SET, 0);
+ CHECK("all_ready", wait_on(NT_WAIT_ALL, o, 3, 4, 0, 0, &idx) == 0 && idx == 0);
+ CHECK("all_took_sem", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0);
+ int dup[2] = {s, s};
+ CHECK("all_duplicate", wait_on(NT_WAIT_ALL, dup, 2, 4, 0, 0, NULL) == EINVAL);
+ int alert = event(1, 1);
+ CHECK("all_alert", wait_on(NT_WAIT_ALL, o, 3, 4, alert, 0, &idx) == 0 && idx == 3);
+ close(s);
+ close(e);
+ close(m);
+ close(alert);
+}
+
+static int wake_obj;
+static uint32_t wake_index;
+static int wake_ret;
+static void *waiter(void *p) {
+ int all = (int)(intptr_t)p;
+ int objs[2] = {wake_obj, wake_obj + 0};
+ wake_ret = wait_on(all ? NT_WAIT_ALL : NT_WAIT_ANY, objs, 1, 3, 0, UINT64_MAX, &wake_index);
+ return NULL;
+}
+
+static void cross_thread(void) {
+ wake_obj = event(0, 0);
+ pthread_t t;
+ pthread_create(&t, NULL, waiter, NULL);
+ usleep(30000);
+ prev_of(wake_obj, NT_EVENT_SET, 0);
+ pthread_join(t, NULL);
+ CHECK("thread_wake", wake_ret == 0 && wake_index == 0);
+ struct event_args r;
+ CHECK("thread_auto_reset", ioctl(wake_obj, NT_EVENT_READ, &r) == 0 && r.signaled == 0);
+ close(wake_obj);
+ int s = sem(0, 10);
+ wake_obj = s;
+ pthread_t ts[3];
+ for (int i = 0; i < 3; i++) pthread_create(&ts[i], NULL, waiter, NULL);
+ usleep(30000);
+ CHECK("sem_release_three", prev_of(s, NT_SEM_RELEASE, 3) == 0);
+ for (int i = 0; i < 3; i++) pthread_join(ts[i], NULL);
+ struct sem_args sr;
+ CHECK("sem_all_taken", ioctl(s, NT_SEM_READ, &sr) == 0 && sr.count == 0);
+ close(s);
+}
+
+static void cross_process(void) {
+ int sv[2];
+ CHECK("socketpair", socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0);
+ int inherited = event(0, 0);
+ pid_t pid = fork();
+ if (pid == 0) {
+ uint32_t idx;
+ int r1 = wait_on(NT_WAIT_ANY, &inherited, 1, 5, 0, mono_ns(5000), &idx);
+ char c;
+ struct iovec io = {&c, 1};
+ char cbuf[CMSG_SPACE(sizeof(int))];
+ struct msghdr msg = {0};
+ msg.msg_iov = &io;
+ msg.msg_iovlen = 1;
+ msg.msg_control = cbuf;
+ msg.msg_controllen = sizeof(cbuf);
+ int got = -1;
+ if (recvmsg(sv[1], &msg, 0) == 1) memcpy(&got, CMSG_DATA(CMSG_FIRSTHDR(&msg)), sizeof(int));
+ int r2 = wait_on(NT_WAIT_ANY, &got, 1, 5, 0, mono_ns(5000), NULL);
+ _exit(r1 == 0 && r2 == 0 ? 0 : 1);
+ }
+ usleep(30000);
+ prev_of(inherited, NT_EVENT_SET, 0);
+ int sent = sem(0, 1);
+ char c = 'x';
+ struct iovec io = {&c, 1};
+ char cbuf[CMSG_SPACE(sizeof(int))];
+ struct msghdr msg = {0};
+ msg.msg_iov = &io;
+ msg.msg_iovlen = 1;
+ msg.msg_control = cbuf;
+ msg.msg_controllen = sizeof(cbuf);
+ struct cmsghdr *cm = CMSG_FIRSTHDR(&msg);
+ cm->cmsg_level = SOL_SOCKET;
+ cm->cmsg_type = SCM_RIGHTS;
+ cm->cmsg_len = CMSG_LEN(sizeof(int));
+ memcpy(CMSG_DATA(cm), &sent, sizeof(int));
+ sendmsg(sv[0], &msg, 0);
+ usleep(30000);
+ prev_of(sent, NT_SEM_RELEASE, 1);
+ int status = 0;
+ waitpid(pid, &status, 0);
+ CHECK("process_wake", WIFEXITED(status) && WEXITSTATUS(status) == 0);
+ close(inherited);
+ close(sent);
+ close(sv[0]);
+ close(sv[1]);
+}
+
+static int ping_obj, pong_obj, stress_sem;
+static void *stress_ponger(void *p) {
+ long n = (long)p;
+ uint32_t v;
+ for (long i = 0; i < n; i++) {
+ if (wait_on(NT_WAIT_ANY, &ping_obj, 1, 21, 0, UINT64_MAX, NULL) != 0) return (void *)1;
+ ioctl(pong_obj, NT_EVENT_SET, &v);
+ }
+ return NULL;
+}
+static void *stress_hammer(void *p) {
+ long n = (long)p;
+ for (long i = 0; i < n; i++) {
+ if (wait_on(NT_WAIT_ANY, &stress_sem, 1, 22, 0, UINT64_MAX, NULL) != 0) return (void *)1;
+ uint32_t c = 1;
+ ioctl(stress_sem, NT_SEM_RELEASE, &c);
+ }
+ return NULL;
+}
+
+static void stress(void) {
+ long rounds = 20000;
+ ping_obj = event(0, 0);
+ pong_obj = event(0, 0);
+ pthread_t t;
+ void *bad = NULL;
+ pthread_create(&t, NULL, stress_ponger, (void *)rounds);
+ uint32_t v;
+ int ok = 1;
+ for (long i = 0; i < rounds && ok; i++) {
+ ioctl(ping_obj, NT_EVENT_SET, &v);
+ ok = wait_on(NT_WAIT_ANY, &pong_obj, 1, 20, 0, UINT64_MAX, NULL) == 0;
+ }
+ pthread_join(t, &bad);
+ CHECK("stress_pingpong", ok && bad == NULL);
+ stress_sem = sem(1, 1);
+ pthread_t th[4];
+ for (int i = 0; i < 4; i++) pthread_create(&th[i], NULL, stress_hammer, (void *)5000L);
+ int hammer_ok = 1;
+ for (int i = 0; i < 4; i++) {
+ pthread_join(th[i], &bad);
+ hammer_ok &= bad == NULL;
+ }
+ struct sem_args sr;
+ CHECK("stress_contended", hammer_ok && ioctl(stress_sem, NT_SEM_READ, &sr) == 0 && sr.count == 1);
+ close(ping_obj);
+ close(pong_obj);
+ close(stress_sem);
+}
+
+static int busy_obj;
+static volatile int handled, stop_worker;
+static sem_t parked, unpark;
+static void reentrant(int sig, siginfo_t *si, void *uc) {
+ (void)sig; (void)si; (void)uc;
+ uint32_t v = 0;
+ ioctl(busy_obj, NT_EVENT_SET, &v);
+ handled++;
+}
+static void suspending(int sig, siginfo_t *si, void *uc) {
+ (void)sig; (void)si; (void)uc;
+ sem_post(&parked);
+ while (sem_wait(&unpark) != 0) {}
+ handled++;
+}
+static void *worker(void *p) {
+ (void)p;
+ uint32_t v;
+ while (!stop_worker) {
+ ioctl(busy_obj, NT_EVENT_SET, &v);
+ wait_on(NT_WAIT_ANY, &busy_obj, 1, 11, 0, 0, NULL);
+ }
+ return NULL;
+}
+
+static void signals(void) {
+ void *seen = NULL;
+ busy_obj = event(0, 0);
+ CHECK("install_reentrant", install_handler(SIGUSR1, reentrant, &seen) == 0);
+ CHECK("query_original", seen == (void *)reentrant);
+ pthread_t w;
+ stop_worker = 0;
+ handled = 0;
+ pthread_create(&w, NULL, worker, NULL);
+ for (int i = 0; i < 2000; i++) {
+ pthread_kill(w, SIGUSR1);
+ if (i % 64 == 0) usleep(100);
+ }
+ usleep(20000);
+ stop_worker = 1;
+ pthread_join(w, NULL);
+ CHECK("reentrant_ran", handled > 0);
+ sem_init(&parked, 0, 0);
+ sem_init(&unpark, 0, 0);
+ CHECK("install_suspending", install_handler(SIGUSR1, suspending, &seen) == 0);
+ stop_worker = 0;
+ handled = 0;
+ pthread_create(&w, NULL, worker, NULL);
+ uint32_t v;
+ for (int i = 0; i < 300; i++) {
+ pthread_kill(w, SIGUSR1);
+ while (sem_wait(&parked) != 0) {}
+ ioctl(busy_obj, NT_EVENT_SET, &v);
+ wait_on(NT_WAIT_ANY, &busy_obj, 1, 12, 0, 0, NULL);
+ sem_post(&unpark);
+ }
+ stop_worker = 1;
+ pthread_join(w, NULL);
+ CHECK("suspend_ran", handled == 300);
+ close(busy_obj);
+}
+
+static volatile int burst_count;
+static void bursting(int sig, siginfo_t *si, void *uc) {
+ (void)sig; (void)si; (void)uc;
+ uint32_t v = 0;
+ ioctl(busy_obj, NT_EVENT_SET, &v);
+ __atomic_add_fetch(&burst_count, 1, __ATOMIC_RELAXED);
+}
+
+static void bursts(void) {
+ void *seen = NULL;
+ busy_obj = event(0, 0);
+ for (int s = 0; s < 8; s++) CHECK("install_burst", install_handler(SIGRTMIN + s, bursting, &seen) == 0);
+ pthread_t w;
+ stop_worker = 0;
+ burst_count = 0;
+ pthread_create(&w, NULL, worker, NULL);
+ union sigval val = {0};
+ int sent = 0;
+ for (int i = 0; i < 400; i++) {
+ for (int s = 0; s < 8; s++)
+ if (pthread_sigqueue(w, SIGRTMIN + s, val) == 0) sent++;
+ usleep(200);
+ }
+ for (int i = 0; i < 2000 && __atomic_load_n(&burst_count, __ATOMIC_RELAXED) < sent; i++) usleep(1000);
+ stop_worker = 1;
+ pthread_join(w, NULL);
+ CHECK("burst_all_handled", burst_count == sent && sent > 0);
+ close(busy_obj);
+}
+
+static volatile int oneshot_count;
+static void oneshot(int sig, siginfo_t *si, void *uc) {
+ (void)sig; (void)si; (void)uc;
+ __atomic_add_fetch(&oneshot_count, 1, __ATOMIC_RELAXED);
+}
+
+static void oneshots(void) {
+ busy_obj = event(0, 0);
+ pthread_t w;
+ stop_worker = 0;
+ oneshot_count = 0;
+ pthread_create(&w, NULL, worker, NULL);
+ int fired = 0;
+ for (int i = 0; i < 300; i++) {
+ if (install_oneshot(SIGWINCH, oneshot) != 0) break;
+ int before = __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED);
+ pthread_kill(w, SIGWINCH);
+ fired++;
+ for (int j = 0; j < 2000 && __atomic_load_n(&oneshot_count, __ATOMIC_RELAXED) == before; j++) usleep(50);
+ }
+ stop_worker = 1;
+ pthread_join(w, NULL);
+ CHECK("oneshot_handlers_run", oneshot_count == fired && fired == 300);
+ close(busy_obj);
+}
+
+static int outer_obj, inner_obj, other_dev, other_obj;
+static volatile int nested_ok, nested_done, outer_done;
+static void nesting(int sig, siginfo_t *si, void *uc) {
+ (void)sig; (void)si; (void)uc;
+ uint32_t idx = 99;
+ int ok = wait_on(NT_WAIT_ANY, &inner_obj, 1, 21, 0, mono_ns(50), &idx) == 0 && idx == 0;
+ struct wait_args w = {mono_ns(2), (uintptr_t)&other_obj, 1, 0xdead, 0, 22, 0, 0};
+ ok = ok && ioctl(other_dev, NT_WAIT_ANY, &w) < 0 && errno == ETIMEDOUT;
+ nested_ok += ok;
+ __atomic_store_n(&nested_done, 1, __ATOMIC_RELEASE);
+}
+static void *outer_waiter(void *p) {
+ struct wait_args w = {mono_ns(3000), (uintptr_t)&outer_obj, 1, 99, 0, 23, 0, 0};
+ int r;
+ do { r = ioctl(dev, NT_WAIT_ANY, &w); } while (r < 0 && errno == EINTR);
+ *(int *)p = r == 0 && w.index == 0;
+ __atomic_store_n(&outer_done, 1, __ATOMIC_RELEASE);
+ return NULL;
+}
+
+static void nested_waits(void) {
+ void *seen = NULL;
+ other_dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC);
+ CHECK("second_device", other_dev >= 0);
+ struct event_args ea = {0, 0};
+ other_obj = ioctl(other_dev, NT_CREATE_EVENT, &ea);
+ inner_obj = event(1, 1);
+ CHECK("install_nesting", install_handler(SIGUSR2, nesting, &seen) == 0);
+ int good = 0, early = 0;
+ nested_ok = 0;
+ for (int i = 0; i < 40; i++) {
+ outer_obj = event(0, 0);
+ int result = 0;
+ pthread_t t;
+ nested_done = outer_done = 0;
+ pthread_create(&t, NULL, outer_waiter, &result);
+ usleep(5000);
+ pthread_kill(t, SIGUSR2);
+ while (!__atomic_load_n(&nested_done, __ATOMIC_ACQUIRE)) usleep(200);
+ usleep(5000);
+ early += __atomic_load_n(&outer_done, __ATOMIC_ACQUIRE);
+ uint32_t v = 0;
+ ioctl(outer_obj, NT_EVENT_SET, &v);
+ pthread_join(t, NULL);
+ good += result;
+ close(outer_obj);
+ }
+ CHECK("outer_wait_not_woken_by_nested_wait", early == 0);
+ CHECK("outer_wait_survives_nested_waits", good == 40);
+ CHECK("nested_waits_complete", nested_ok == 40);
+ close(inner_obj);
+ close(other_obj);
+ close(other_dev);
+}
+
+int main(void) {
+ alarm(60);
+ dev = open("/dev/ntsync", O_RDWR | O_CLOEXEC);
+ if (dev < 0) {
+ printf("no device %d\n", errno);
+ return 1;
+ }
+ semaphores();
+ mutexes();
+ events();
+ any_order();
+ all_semantics();
+ cross_thread();
+ cross_process();
+ stress();
+ signals();
+ bursts();
+ oneshots();
+ nested_waits();
+ printf("done fails=%d\n", fails);
+ return fails != 0;
+}
+"""
+
+
+class NtsyncPreloadTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ if not shutil.which("cc"):
+ raise unittest.SkipTest("no compiler")
+ cls.tmp = tempfile.TemporaryDirectory()
+ tmp = Path(cls.tmp.name)
+ (tmp / "ntdll.c").write_text(NTDLL)
+ (tmp / "prog.c").write_text(PROGRAM)
+ sources = sorted(str(p) for p in PRELOAD.glob("*.c"))
+ subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-Wall", "-pthread", "-o", str(tmp / "libblsession.so"), *sources, "-ldl"],
+ check=True)
+ subprocess.run(["cc", "-shared", "-fPIC", "-O2", "-o", str(tmp / "ntdll.so"), str(tmp / "ntdll.c")], check=True)
+ subprocess.run(["cc", "-O1", "-pthread", "-o", str(tmp / "prog"), str(tmp / "prog.c"), str(tmp / "ntdll.so"),
+ "-Wl,-rpath," + str(tmp)], check=True)
+ cls.program = tmp / "prog"
+ cls.preload = tmp / "libblsession.so"
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.tmp.cleanup()
+
+ def run_program(self):
+ env = dict(os.environ, LD_PRELOAD=str(self.preload), BL_SYNC="1")
+ return subprocess.run([str(self.program)], env=env, capture_output=True, text=True, timeout=90)
+
+ def test_emulation_matches_the_ntsync_driver(self):
+ out = self.run_program()
+ self.assertEqual(out.returncode, 0, out.stdout + out.stderr)
+ self.assertIn("done fails=0", out.stdout)
+
+ def test_repeated_runs_stay_consistent(self):
+ for _ in range(3):
+ out = self.run_program()
+ self.assertEqual(out.returncode, 0, out.stdout + out.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/tests/test_pack_downloads.py b/tools/tests/test_pack_downloads.py
new file mode 100644
index 000000000..0b5eb6060
--- /dev/null
+++ b/tools/tests/test_pack_downloads.py
@@ -0,0 +1,125 @@
+import hashlib
+import http.server
+import json
+import os
+from pathlib import Path
+import shutil
+import socket
+import subprocess
+import tempfile
+import threading
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[2]
+FETCH = ROOT / 'tools/packs/fetch-verified.sh'
+
+
+class PackDownloadsTest(unittest.TestCase):
+ def setUp(self):
+ directory = tempfile.TemporaryDirectory()
+ self.addCleanup(directory.cleanup)
+ self.root = Path(directory.name)
+ self.cache = self.root / 'cache'
+ self.cache.mkdir()
+ self.destination = self.root / 'download'
+ self.remote = self.root / 'remote'
+ self.remote.write_bytes(b'verified input')
+ self.checksum = hashlib.sha256(self.remote.read_bytes()).hexdigest()
+ self.log = self.root / 'curl.log'
+ tool = self.root / 'bin/curl'
+ tool.parent.mkdir()
+ tool.write_text('#!/usr/bin/env python3\nimport json,os,shutil,sys\n'
+ 'args=sys.argv[1:]\n'
+ 'with open(os.environ["CURL_LOG"],"a") as f: f.write(json.dumps(args)+"\\n")\n'
+ 'shutil.copyfile(os.environ["REMOTE"],args[args.index("-o")+1])\n')
+ tool.chmod(0o755)
+ self.env = dict(os.environ, DROIDDECK_PACK_CACHE=str(self.cache), CURL_LOG=str(self.log),
+ REMOTE=str(self.remote), PATH=str(tool.parent) + os.pathsep + os.environ['PATH'])
+
+ def fetch(self, checksum=None, url='https://example.test/pinned', env=None):
+ return subprocess.run(['bash', str(FETCH), url, checksum or self.checksum, str(self.destination)],
+ env=env or self.env, capture_output=True, text=True)
+
+ def test_cold_download_then_warm_reuse_and_corruption_repair(self):
+ self.assertEqual(self.fetch().returncode, 0)
+ self.assertEqual(self.fetch().returncode, 0)
+ self.assertEqual(len(self.log.read_text().splitlines()), 1)
+ self.assertEqual(self.destination.read_bytes(), b'verified input')
+ (self.cache / self.checksum).write_bytes(b'corrupt')
+ self.assertEqual(self.fetch().returncode, 0)
+ self.assertEqual(len(self.log.read_text().splitlines()), 2)
+ self.assertEqual((self.cache / self.checksum).read_bytes(), b'verified input')
+
+ def test_directory_cache_entry_is_replaced_only_after_verification(self):
+ entry = self.cache / self.checksum
+ entry.mkdir()
+ sentinel = entry / 'old-entry'
+ sentinel.write_bytes(b'preserve until verified')
+ self.remote.write_bytes(b'tampered download')
+ self.assertNotEqual(self.fetch().returncode, 0)
+ self.assertEqual(sentinel.read_bytes(), b'preserve until verified')
+ self.assertFalse(self.destination.exists())
+ self.remote.write_bytes(b'verified input')
+ result = self.fetch()
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertTrue(entry.is_file())
+ self.assertEqual(entry.read_bytes(), b'verified input')
+ self.assertEqual(self.destination.read_bytes(), b'verified input')
+ self.assertEqual(list(self.cache.iterdir()), [entry])
+ self.assertEqual(self.fetch().returncode, 0)
+ self.assertEqual(len(self.log.read_text().splitlines()), 2)
+
+ def test_changed_pin_cannot_reuse_old_bytes(self):
+ self.assertEqual(self.fetch().returncode, 0)
+ self.remote.write_bytes(b'new approved input')
+ updated = hashlib.sha256(self.remote.read_bytes()).hexdigest()
+ self.assertEqual(self.fetch(updated).returncode, 0)
+ self.assertEqual(len(self.log.read_text().splitlines()), 2)
+ self.assertEqual(self.destination.read_bytes(), b'new approved input')
+
+ def test_bad_download_is_not_cached_or_written(self):
+ self.remote.write_bytes(b'tampered download')
+ self.assertNotEqual(self.fetch().returncode, 0)
+ self.assertFalse((self.cache / self.checksum).exists())
+ self.assertFalse(self.destination.exists())
+ self.assertEqual(list(self.cache.iterdir()), [])
+ arguments = json.loads(self.log.read_text())
+ self.assertIn('--retry-all-errors', arguments)
+ self.assertIn('--max-time', arguments)
+ self.assertIn('--retry-max-time', arguments)
+
+ def test_connection_reset_retries_then_verifies_real_download(self):
+ if not shutil.which('curl'):
+ self.skipTest('curl required for transport test')
+ content = self.remote.read_bytes()
+ attempts = []
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ attempts.append(True)
+ if len(attempts) == 1:
+ self.connection.shutdown(socket.SHUT_RDWR)
+ self.connection.close()
+ return
+ self.send_response(200)
+ self.send_header('Content-Length', str(len(content)))
+ self.end_headers()
+ self.wfile.write(content)
+
+ def log_message(self, *args):
+ pass
+
+ server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ env = dict(self.env, PATH=os.environ['PATH'])
+ result = self.fetch(url=f'http://127.0.0.1:{server.server_port}/input', env=env)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertGreaterEqual(len(attempts), 2)
+ self.assertEqual(self.destination.read_bytes(), content)
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
diff --git a/tools/tests/test_preload_snapshots.py b/tools/tests/test_preload_snapshots.py
new file mode 100644
index 000000000..464b24e7b
--- /dev/null
+++ b/tools/tests/test_preload_snapshots.py
@@ -0,0 +1,187 @@
+import copy
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+
+SPEC = importlib.util.spec_from_file_location('preload_snapshots', Path(__file__).resolve().parents[1] / 'components/preloads.py')
+snapshots = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(snapshots)
+
+
+def archive(files):
+ stream = io.BytesIO()
+ with tarfile.open(fileobj=stream, mode='w:gz') as tar:
+ for name, content in files.items():
+ entry = tarfile.TarInfo(name)
+ entry.size = len(content)
+ tar.addfile(entry, io.BytesIO(content))
+ return stream.getvalue()
+
+
+class PreloadSnapshotTest(unittest.TestCase):
+ def setUp(self):
+ directory = tempfile.TemporaryDirectory()
+ self.addCleanup(directory.cleanup)
+ self.root = Path(directory.name)
+ # Real repository inputs exercise the transitive include walker and build recipe.
+ for group in snapshots.INPUTS:
+ for name in snapshots.source_files(group):
+ if name.startswith('@'):
+ continue
+ target = self.root / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes((snapshots.ROOT / name).read_bytes())
+ recipe = 'tools/components/build-arm64-preloads.sh'
+ (self.root / recipe).parent.mkdir(parents=True, exist_ok=True)
+ (self.root / recipe).write_text((snapshots.ROOT / recipe).read_text())
+ x86 = 'tools/components/recipes/x86-preloads.sh'
+ (self.root / x86).parent.mkdir(parents=True, exist_ok=True)
+ (self.root / x86).write_text((snapshots.ROOT / x86).read_text())
+ (self.root / '.github/workflows').mkdir(parents=True)
+ (self.root / '.github/workflows/build.yml').write_text((snapshots.ROOT / '.github/workflows/build.yml').read_text())
+ self.git('init', '-q')
+ self.git('config', 'core.autocrlf', 'false')
+ self.git('config', 'user.name', 'Test Developer')
+ self.git('config', 'user.email', 'test@example.test')
+ self.git('add', '.')
+ self.binaries = {name: ('verified-' + name).encode() for name in snapshots.FILES}
+ self.content = archive(self.binaries)
+ self.data = dict(version=1, origin=dict(run=123, commit='abc', artifact=456, artifact_sha256='0' * 64),
+ groups={g: snapshots.source_files(g, self.root) for g in snapshots.INPUTS},
+ arm64_recipe=snapshots.recipe(self.root), x86_recipe=snapshots.x86_recipe(self.root),
+ archive_sha256=snapshots.sha(self.content),
+ outputs={name: snapshots.sha(value) for name, value in self.binaries.items()})
+ (self.root / snapshots.LOCK).write_text(json.dumps(self.data))
+ self.git('add', '.')
+ self.git('commit', '-qm', 'Approved baseline')
+ self.base = self.git('rev-parse', 'HEAD').strip()
+
+ def git(self, *args):
+ return subprocess.check_output(['git', *args], cwd=self.root, text=True)
+
+ def restore(self, base=None):
+ with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=base or self.base, FORCE_PRELOAD_REBUILD='false'), \
+ patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(self.content)) as fetch, \
+ patch.object(snapshots, 'prove_origin') as prove:
+ result = snapshots.restore(self.root)
+ return result, fetch, prove
+
+ def test_reviewed_unchanged_binaries_reused_and_written(self):
+ result, fetch, prove = self.restore()
+ self.assertEqual(result, dict(arm64=True, x86=True))
+ prove.assert_not_called()
+ fetch.assert_called_once()
+ for name, value in self.binaries.items():
+ self.assertEqual((self.root / 'app/src/main/assets/linuxfs' / name).read_bytes(), value)
+
+ def test_changed_common_source_requires_both_rebuilds(self):
+ path = self.root / snapshots.COMMON[0]
+ path.write_text(path.read_text() + '\n// Changed source\n')
+ result, fetch, prove = self.restore()
+ self.assertEqual(result, dict(arm64=False, x86=False))
+ fetch.assert_not_called()
+ prove.assert_not_called()
+
+ def test_new_transitive_header_is_an_input(self):
+ source = self.root / snapshots.COMMON[0]
+ source.write_text(source.read_text() + '\n#include "new-header.h"\n')
+ header = source.with_name('new-header.h')
+ header.write_text('#define VALUE 1\n')
+ before = snapshots.source_files('arm64', self.root)
+ header.write_text('#define VALUE 2\n')
+ self.assertNotEqual(before, snapshots.source_files('arm64', self.root))
+
+ def test_changed_recipe_rebuilds_arm64_only(self):
+ script = self.root / 'tools/components/build-arm64-preloads.sh'
+ script.write_text(script.read_text().replace('-O2', '-O3'))
+ result, _, _ = self.restore()
+ self.assertEqual(result, dict(arm64=False, x86=True))
+
+ def test_shell_newline_changes_invalidate_the_recipe(self):
+ script = self.root / 'tools/components/build-arm64-preloads.sh'
+ original = snapshots.recipe(self.root)
+ text = script.read_text()
+ script.write_text(text.replace('-ldl\n', '-ldl ', 1))
+ self.assertNotEqual(original, snapshots.recipe(self.root))
+
+ def test_x86_container_recipe_changes_rebuild_x86_only(self):
+ script = self.root / 'tools/components/recipes/x86-preloads.sh'
+ script.write_text(script.read_text().replace('debian:bullseye', 'debian:bookworm'))
+ result, _, _ = self.restore()
+ self.assertEqual(result, dict(arm64=True, x86=False))
+
+ def test_new_manifest_requires_main_build_proof(self):
+ result, _, prove = self.restore('HEAD~1')
+ self.assertTrue(all(result.values()))
+ prove.assert_called_once()
+
+ def test_forged_binary_hash_cannot_prove_main_origin(self):
+ data = copy.deepcopy(self.data)
+ data['outputs']['libblsession.so'] = 'f' * 64
+ with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries):
+ with self.assertRaisesRegex(ValueError, 'original main APK'):
+ snapshots.prove_origin(data, self.root / 'cache', self.root)
+
+ def test_modified_input_policy_cannot_omit_producer_dependencies(self):
+ data = copy.deepcopy(self.data)
+ data['origin']['commit'] = self.base
+ omitted = snapshots.COMMON[0]
+ del data['groups']['arm64'][omitted]
+ with patch.object(snapshots, 'artifact_bytes', return_value=self.binaries), \
+ patch.object(snapshots, 'run', return_value=b''), \
+ patch.object(snapshots, 'source_files', side_effect=lambda group, *args: data['groups'][group]), \
+ patch.object(snapshots, 'producer_sources', return_value={omitted: self.data['groups']['arm64'][omitted]}):
+ with self.assertRaisesRegex(ValueError, 'producing build dependency'):
+ snapshots.prove_origin(data, self.root / 'cache', self.root)
+
+ def test_non_main_or_unsuccessful_run_cannot_prove_origin(self):
+ build = dict(event='pull_request', head_branch='main', conclusion='success', head_sha='abc',
+ path='.github/workflows/build.yml', repository=dict(full_name=snapshots.REPO))
+ for field, value in [('event', 'pull_request'), ('head_branch', 'feature'), ('conclusion', 'failure')]:
+ candidate = dict(build, event='push')
+ candidate[field] = value
+ with self.subTest(field=field), patch.object(snapshots, 'api', return_value=candidate):
+ with self.assertRaisesRegex(ValueError, 'successful main'):
+ snapshots.artifact_bytes(self.data['origin'], self.root / 'cache')
+
+ def test_corrupt_archive_is_rejected(self):
+ with self.assertRaisesRegex(ValueError, 'checksum'):
+ snapshots.unpack(self.content + b'tampered', self.data)
+
+ def test_missing_duplicate_or_traversal_entries_are_rejected(self):
+ for files in ({}, {'../escaped': b'bad'}, {'libblsession.so': b'wrong'}):
+ content = archive(files)
+ data = dict(self.data, archive_sha256=snapshots.sha(content))
+ with self.subTest(names=list(files)), self.assertRaises(ValueError):
+ snapshots.unpack(content, data)
+
+ def test_missing_release_rebuilds_without_accepting_unverified_bytes(self):
+ error = snapshots.urllib.error.HTTPError('https://example.test', 404, 'missing', {}, None)
+ with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
+ patch.object(snapshots.urllib.request, 'urlopen', side_effect=error):
+ self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
+
+ def test_transient_download_failure_rebuilds_but_integrity_errors_fail(self):
+ errors = [TimeoutError('network'), snapshots.http.client.IncompleteRead(b'partial'),
+ snapshots.urllib.error.HTTPError('https://example.test', 503, 'unavailable', {}, None)]
+ for error in errors:
+ with self.subTest(error=type(error).__name__), patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
+ patch.object(snapshots.urllib.request, 'urlopen', side_effect=error):
+ self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
+ with patch.dict(os.environ, COMPONENT_TRUSTED_BASE=self.base), \
+ patch.object(snapshots.urllib.request, 'urlopen', return_value=io.BytesIO(b'corrupt')):
+ with self.assertRaisesRegex(ValueError, 'checksum'):
+ snapshots.restore(self.root)
+
+ def test_manual_rebuild_does_not_download_snapshot(self):
+ with patch.dict(os.environ, FORCE_PRELOAD_REBUILD='true'), \
+ patch.object(snapshots.urllib.request, 'urlopen') as fetch:
+ self.assertEqual(snapshots.restore(self.root), dict(arm64=False, x86=False))
+ fetch.assert_not_called()
diff --git a/tools/tests/test_session_cursor_routing.py b/tools/tests/test_session_cursor_routing.py
index fdc88f75d..60d28edc4 100644
--- a/tools/tests/test_session_cursor_routing.py
+++ b/tools/tests/test_session_cursor_routing.py
@@ -1,4 +1,4 @@
-"""The XTest cursor path is enabled only for Steam sessions."""
+"""Gamescope composites the cursor only when a Steam session asks for it."""
import os
from pathlib import Path
import re
@@ -8,29 +8,37 @@ import unittest
SESSION = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-session'
SOURCE = SESSION.read_text()
MATCH = re.search(
- r'^ if \[ "\$mode" = steam \]; then\n.*?^ fi\n', SOURCE, re.M | re.S)
+ r'^ if \[ "\$mode" = steam \] && \[ "\$\{BL_CURSOR_COMPOSITE:-0\}" = 1 \]; then\n.*?^ fi\n',
+ SOURCE, re.M | re.S)
assert MATCH is not None
CURSOR_SETUP = MATCH.group(0)
class SessionCursorRoutingTest(unittest.TestCase):
- def run_setup(self, mode, initial=None):
+ def run_setup(self, mode, initial=None, composite=None):
env = dict(os.environ, mode=mode)
if initial is not None:
env['gamescope_cursor_composite'] = initial
else:
env.pop('gamescope_cursor_composite', None)
+ if composite is not None:
+ env['BL_CURSOR_COMPOSITE'] = composite
+ else:
+ env.pop('BL_CURSOR_COMPOSITE', None)
result = subprocess.run(
['bash', '-c', CURSOR_SETUP + 'printf "%s\n" "${gamescope_cursor_composite-}"'],
env=env, text=True, capture_output=True, check=True)
return result.stdout.strip()
- def test_steam_enables_manual_composition_for_the_nested_cursor(self):
- self.assertEqual('2', self.run_setup('steam'))
+ def test_steam_leaves_the_cursor_with_the_host_unless_asked(self):
+ self.assertEqual('', self.run_setup('steam'))
+
+ def test_steam_composites_the_cursor_when_the_setting_is_on(self):
+ self.assertEqual('2', self.run_setup('steam', composite='1'))
def test_other_sessions_keep_their_existing_cursor_composition(self):
- self.assertEqual('', self.run_setup('desktop'))
- self.assertEqual('1', self.run_setup('run', '1'))
+ self.assertEqual('', self.run_setup('desktop', composite='1'))
+ self.assertEqual('1', self.run_setup('run', '1', composite='1'))
if __name__ == '__main__':
diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py
index a86aab864..af2d8cfb6 100644
--- a/tools/tests/test_steam_compat_live.py
+++ b/tools/tests/test_steam_compat_live.py
@@ -641,6 +641,114 @@ class ReconcileTest(unittest.TestCase):
self.assertEqual(page.mapping["10"], "GE-Proton9-1")
self.assertEqual(state["auto"], {"11": TOOL})
+ def games(self):
+ return json.loads((self.state / "games.json").read_text())["games"]
+
+ def write_games(self, games):
+ (self.state / "games.json").write_text(json.dumps({"version": 1, "games": games}))
+
+ def test_a_steam_pick_after_the_app_choice_is_kept_live(self):
+ page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]])
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ state = self.reconcile(page)
+ self.assertEqual(page.sets, [("10", TOOL_11)])
+ self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+
+ page.sets.clear()
+ page.mapping["10"] = COMPAT["FEX_TOOL"]
+ state = self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"])
+ self.assertEqual(self.games(), {})
+ self.assertEqual(state["games_applied"], {})
+
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ self.reconcile(page)
+ self.assertEqual(page.sets, [("10", TOOL_11)])
+
+ def test_a_steam_pick_of_our_proton_becomes_the_app_choice_live(self):
+ page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11])
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ self.reconcile(page)
+ page.sets.clear()
+ page.mapping["10"] = TOOL
+ state = self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}})
+ self.assertEqual(state["games_applied"], self.games())
+
+ def test_use_the_default_applies_once_then_steam_picks_stand_live(self):
+ page = FakePage({"0": TOOL, "10": TOOL_11, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]])
+ self.write_games({"10": None})
+ self.reconcile(page)
+ self.assertEqual(page.sets, [("10", TOOL)])
+
+ page.sets.clear()
+ self.reconcile(page)
+ self.assertEqual(self.games(), {})
+ page.mapping["10"] = COMPAT["FEX_TOOL"]
+ self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"])
+
+ def test_choices_an_earlier_build_applied_give_way_to_steam_picks_live(self):
+ (self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"11": TOOL}, "tools": []}))
+ self.write_games({"10": None, "11": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ page = FakePage({"0": TOOL, "10": COMPAT["FEX_TOOL"], "11": "steamlinuxruntime_sniper"},
+ [TOOL, TOOL_11, COMPAT["FEX_TOOL"]])
+ self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(self.games(), {})
+
+ def test_an_app_choice_written_during_a_sync_is_not_overwritten(self):
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ requests, text = COMPAT["read_game_requests"]()
+ self.write_games({"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}})
+ self.assertFalse(COMPAT["write_game_requests"]({}, text))
+ self.assertEqual(self.games(), {"10": {"valve": True, "dir": "Proton Experimental (ARM64)"}})
+
+ def test_an_app_the_client_briefly_refuses_keeps_its_applied_choice_live(self):
+ page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]])
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ self.reconcile(page)
+ page.refused = {"10"}
+ state = self.reconcile(page)
+ self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ page.refused = set()
+ page.sets.clear()
+ page.mapping["10"] = COMPAT["FEX_TOOL"]
+ self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(page.mapping["10"], COMPAT["FEX_TOOL"])
+
+ def test_an_app_rewriting_its_choices_during_both_tries_applies_none_of_them_live(self):
+ page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL, TOOL_11, COMPAT["FEX_TOOL"]])
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ self.reconcile(page)
+ page.sets.clear()
+ page.mapping["10"] = COMPAT["FEX_TOOL"]
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}, "11": None})
+ with mock.patch.dict(COMPAT["settled_game_requests"].__globals__, write_game_requests=lambda *_: False):
+ state = self.reconcile(page)
+ self.assertEqual(page.sets, [])
+ self.assertEqual(state["games_applied"], {"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+
+ def test_a_steam_pick_is_kept_at_the_next_start(self):
+ config = self.steam / "config/config.vdf"
+ config.parent.mkdir(parents=True)
+ vdf = '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }'
+ entry = '"%s" { "name" "%s" "config" "" "priority" "250" }'
+ catalog = COMPAT["tool_catalog"](str(self.steam))
+ self.write_games({"10": {"valve": True, "dir": "Proton 11.0 (ARM64)"}})
+ config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", TOOL)))
+ with mock.patch.dict(COMPAT["register_default"].__globals__, steam_running=lambda: False):
+ COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True)
+ self.assertIn(entry % ("10", TOOL_11), " ".join(config.read_text().split()))
+ config.write_text(vdf % (entry % ("0", TOOL) + entry % ("10", COMPAT["FEX_TOOL"])))
+ COMPAT["register_default"](str(config), ["10"], catalog=catalog, directory=str(self.state), settle=True)
+ self.assertIn(entry % ("10", COMPAT["FEX_TOOL"]), " ".join(config.read_text().split()))
+ self.assertEqual(self.games(), {})
+
def test_file_mode_defers_to_the_live_helper(self):
(self.state / "live.pid").write_text("%d\n" % os.getpid())
self.assertTrue(COMPAT["live_helper_running"]())
diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py
index 6cecaec75..75bc1c5e9 100644
--- a/tools/tests/test_steam_install.py
+++ b/tools/tests/test_steam_install.py
@@ -74,7 +74,7 @@ sys.exit(28 if end < len(data) else 0)
PATH=str(self.bin) + os.pathsep + os.environ['PATH'],
TEST_CDN=str(self.cdn), TEST_REQUESTS=str(self.requests),
TEST_PLAN=str(self.plan), BL_STEAM_RETRY_DELAYS='0 0 0')
- for channel in ('steamdeck_publicbeta',):
+ for channel in ('steamdeck_publicbeta', 'steamdeck_stable'):
archive = self.cdn / (channel + '.zip')
with zipfile.ZipFile(archive, 'w') as package:
package.writestr('steamrtarm64/steam', channel)
@@ -86,12 +86,17 @@ sys.exit(28 if end < len(data) else 0)
f'\t\t"file" "{archive.name}"\n\t\t"sha2" "{digest}"\n'
'\t}\n}\n')
- def install(self, fail_host=None):
+ def install(self, fail_host=None, *args, **extra):
env = self.env if fail_host is None else dict(self.env, TEST_FAIL_HOST=fail_host)
- return subprocess.run(['bash', str(SCRIPT)], env=env, text=True, capture_output=True)
+ return subprocess.run(['bash', str(SCRIPT), *args], env=dict(env, **extra), text=True, capture_output=True)
- def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None):
- result = self.install(fail_host)
+ def channel(self, **extra):
+ result = self.install(None, '--channel', **extra)
+ self.assertEqual(0, result.returncode, result.stderr)
+ return result.stdout.strip()
+
+ def assert_installed(self, channel='steamdeck_publicbeta', fail_host=None, expected=None, **extra):
+ result = self.install(fail_host, **extra)
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text())
self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text())
@@ -105,6 +110,7 @@ sys.exit(28 if end < len(data) else 0)
'https://client-update.steamstatic.com/' + channel + '.zip',
]
self.assertEqual(expected, self.requests.read_text().splitlines())
+ return result
def test_downloads_the_deck_beta_client(self):
self.assert_installed()
@@ -173,8 +179,80 @@ sys.exit(28 if end < len(data) else 0)
self.requests.unlink()
with (package / 'droiddeck-unconfirmed').open('a') as marker:
marker.write('start\nstart\nstart\n')
+ self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode)
+ self.assertFalse(self.requests.exists())
+
+ def test_the_default_channel_is_the_deck_beta(self):
+ self.assertEqual('steamdeck_publicbeta', self.channel())
+
+ def stable_requests(self, beta_manifest=True):
+ stable = [
+ 'https://client-update.steamstatic.com/steam_client_steamdeck_stable_linuxarm64',
+ 'https://client-update.steamstatic.com/steamdeck_stable.zip',
+ ]
+ if beta_manifest:
+ stable.insert(0, 'https://client-update.steamstatic.com/steam_client_steamdeck_publicbeta_linuxarm64')
+ return stable
+
+ def test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable(self):
+ package = self.break_client(2)
+ self.assertEqual(0, self.install().returncode)
+ self.requests.unlink()
+ with (package / 'droiddeck-unconfirmed').open('a') as marker:
+ marker.write('start\nstart\n')
+ result = self.assert_installed('steamdeck_stable', expected=self.stable_requests(False))
+ self.assertIn("falling back to Valve's steamdeck_stable Steam client", result.stdout)
+ self.assertEqual('steamdeck_stable\n', (package / 'droiddeck-channel').read_text())
+ self.assertEqual('repaired\n', (package / 'droiddeck-unconfirmed').read_text())
+ self.assertEqual('steamdeck_stable', self.channel())
+
+ def test_the_stable_fallback_is_not_repeated(self):
+ self.test_a_repaired_beta_client_that_still_shows_nothing_falls_back_to_stable()
+ self.requests.unlink()
+ with (self.steam / 'package/droiddeck-unconfirmed').open('a') as marker:
+ marker.write('start\nstart\nstart\n')
self.assertEqual(0, self.install().returncode)
self.assertFalse(self.requests.exists())
+ self.assertEqual('steamdeck_stable\n', (self.steam / 'package/beta').read_text())
+
+ def test_a_pinned_beta_channel_never_falls_back(self):
+ package = self.break_client(2)
+ self.assertEqual(0, self.install().returncode)
+ with (package / 'droiddeck-unconfirmed').open('a') as marker:
+ marker.write('start\nstart\n')
+ self.assertEqual('steamdeck_publicbeta', self.channel(BL_STEAM_CHANNEL='beta'))
+ self.assertEqual(0, self.install(BL_STEAM_CHANNEL='beta').returncode)
+ self.assertFalse((package / 'droiddeck-channel').exists())
+
+ def test_the_channel_setting_selects_stable(self):
+ setting = self.home / '.config/droiddeck/steam-channel'
+ setting.parent.mkdir(parents=True)
+ setting.write_text('stable\n')
+ self.assertEqual('steamdeck_stable', self.channel())
+ self.assert_installed('steamdeck_stable', expected=self.stable_requests(False))
+ self.assertFalse((self.steam / 'package/droiddeck-channel').exists())
+
+ def empty_beta_manifest(self):
+ (self.cdn / 'steam_client_steamdeck_publicbeta_linuxarm64').write_text(
+ '"linuxarm64"\n{\n\t"version" "123457"\n'
+ '\t"client_steamrtarm64_linuxarm64"\n\t{\n'
+ '\t\t"file" "client.zip"\n\t\t"sha2" "00"\n'
+ '\t}\n}\n')
+
+ def test_a_beta_manifest_without_an_arm64_client_falls_back_to_stable(self):
+ self.empty_beta_manifest()
+ result = self.assert_installed('steamdeck_stable', expected=self.stable_requests())
+ self.assertIn('no client components in steam_client_steamdeck_publicbeta_linuxarm64', result.stderr)
+ self.assertIn('client_steamrtarm64_linuxarm64', result.stderr)
+ self.assertEqual('steamdeck_stable\n', (self.steam / 'package/droiddeck-channel').read_text())
+ self.assertEqual('steamdeck_stable', self.channel())
+
+ def test_a_pinned_beta_manifest_without_an_arm64_client_fails_and_names_its_blocks(self):
+ self.empty_beta_manifest()
+ result = self.install(BL_STEAM_CHANNEL='beta')
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn('client_steamrtarm64_linuxarm64', result.stderr)
+ self.assertFalse((self.steam / 'package/droiddeck-installed').exists())
def test_requested_repair_runs_once_and_is_retried_until_it_completes(self):
package = self.break_client(0)
diff --git a/tools/tests/test_steam_shaders.py b/tools/tests/test_steam_shaders.py
new file mode 100644
index 000000000..1d273215a
--- /dev/null
+++ b/tools/tests/test_steam_shaders.py
@@ -0,0 +1,88 @@
+from pathlib import Path
+import runpy
+import tempfile
+import unittest
+
+
+BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
+MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-shaders'))
+apply = MODULE['apply']
+set_text = MODULE['set_text']
+parse = runpy.run_path(str(BIN / 'droiddeck-steam-ui-scale'))['parse']
+
+THOR = '''"InstallConfigStore"
+{
+\t"Software"
+\t{
+\t\t"Valve"
+\t\t{
+\t\t\t"Steam"
+\t\t\t{
+\t\t\t\t"ShaderCacheManager"
+\t\t\t\t{
+\t\t\t\t\t"HasCurrentBucket"\t\t"1"
+\t\t\t\t\t"ProcessingQueue"\t\t"207140;848350;"
+\t\t\t\t\t"App"
+\t\t\t\t\t{
+\t\t\t\t\t}
+\t\t\t\t}
+\t\t\t}
+\t\t}
+\t}
+}
+'''
+
+
+def disabled(text):
+ steam = parse(text).get('InstallConfigStore').get('Software').get('Valve').get('Steam')
+ return steam.get('ShaderCacheManager').get('DisableShaderCache')
+
+
+class SteamShadersTest(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.config = self.root / 'config/config.vdf'
+ self.config.parent.mkdir()
+
+ def test_off_adds_the_key_the_client_writes(self):
+ text, changed = set_text(THOR, False)
+ self.assertTrue(changed)
+ self.assertEqual('1', disabled(text))
+ self.assertIn('\t\t\t\t\t"DisableShaderCache"\t\t"1"\n\t\t\t\t}', text)
+ self.assertIn('"ProcessingQueue"\t\t"207140;848350;"', text)
+
+ def test_existing_value_is_replaced_in_place(self):
+ off, _ = set_text(THOR, False)
+ on, changed = set_text(off, True)
+ self.assertTrue(changed)
+ self.assertEqual('0', disabled(on))
+ self.assertEqual(off.replace('"DisableShaderCache"\t\t"1"', '"DisableShaderCache"\t\t"0"'), on)
+
+ def test_matching_value_leaves_the_file_alone(self):
+ off, _ = set_text(THOR, False)
+ self.assertEqual((off, False), set_text(off, False))
+
+ def test_missing_sections_are_created(self):
+ text, changed = set_text('"InstallConfigStore"\n{\n}\n', False)
+ self.assertTrue(changed)
+ self.assertEqual('1', disabled(text))
+
+ def test_apply_writes_config_and_creates_a_fresh_one(self):
+ self.config.write_text(THOR)
+ self.assertTrue(apply(self.root, False))
+ self.assertEqual('1', disabled(self.config.read_text()))
+ self.assertFalse(apply(self.root, False))
+ fresh = Path(self.temp.name) / 'fresh'
+ fresh.mkdir()
+ self.assertTrue(apply(fresh, True))
+ self.assertEqual('0', disabled((fresh / 'config/config.vdf').read_text()))
+
+ def test_invalid_config_is_rejected(self):
+ with self.assertRaises(ValueError):
+ set_text('"InstallConfigStore" {', False)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/tests/test_steamrt_shim.py b/tools/tests/test_steamrt_shim.py
new file mode 100644
index 000000000..05f7345fe
--- /dev/null
+++ b/tools/tests/test_steamrt_shim.py
@@ -0,0 +1,123 @@
+import os
+from pathlib import Path
+import re
+import subprocess
+import tempfile
+import unittest
+
+
+BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
+SHIM = BIN / 'droiddeck-steamrt-shim'
+SESSION = (BIN / 'droiddeck-session').read_text()
+X64 = (BIN / 'droiddeck-steam-x64').read_text()
+
+
+class SteamrtShimTest(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.client = self.root / 'steamrtarm64'
+ self.client.mkdir()
+ self.shim = self.root / 'shim'
+
+ def runtime(self, path):
+ runtime = self.client / path
+ (runtime / 'bin').mkdir(parents=True)
+ (runtime / 'bin/steam-runtime-check-requirements').write_text('#!/bin/sh\nexit 1\n')
+ (runtime / 'bin/steam-runtime-system-info').write_text('#!/bin/sh\necho info\n')
+ (runtime / 'files').mkdir()
+ (runtime / 'files/marker').write_text('runtime files')
+ return runtime
+
+ def build(self):
+ return subprocess.run(['bash', str(SHIM), str(self.client), str(self.shim)],
+ capture_output=True, text=True, timeout=30)
+
+ def sh(self, *args):
+ return subprocess.run(['sh', *map(str, args)], capture_output=True, text=True, timeout=30)
+
+ def assert_shim(self):
+ result = self.build()
+ self.assertEqual(0, result.returncode, result.stderr)
+ self.assertEqual(0, self.sh(self.shim / 'bin/steam-runtime-check-requirements').returncode)
+ self.assertEqual('info\n', self.sh(self.shim / 'bin/steam-runtime-system-info').stdout)
+ self.assertEqual('runtime files', (self.shim / 'files/marker').read_text())
+ launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env=BL_SHIM_TEST=yes',
+ f'--directory={self.root}', '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"')
+ self.assertEqual(f'yes {self.root.name}\n', launched.stdout)
+ launched = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--env', 'BL_SHIM_TEST=split',
+ '--directory', self.root, '--', 'sh', '-c', 'echo "$BL_SHIM_TEST $(basename "$PWD")"')
+ self.assertEqual(f'split {self.root.name}\n', launched.stdout)
+ missing = self.sh(self.shim / 'bin/steam-runtime-launch-client', '--directory', self.root / 'missing',
+ '--', 'true')
+ self.assertEqual(127, missing.returncode)
+
+ def test_a_client_without_a_container_gets_no_shim(self):
+ self.shim.mkdir()
+ self.assertEqual(1, self.build().returncode)
+ self.assertFalse(self.shim.exists())
+
+ def test_the_pressure_vessel_runtime_is_shimmed(self):
+ self.runtime('pv-runtime/steam-runtime-steamrt')
+ self.assert_shim()
+
+ def test_a_requirements_probe_elsewhere_is_shimmed(self):
+ self.runtime('steam-runtime')
+ self.assert_shim()
+
+ def test_the_arm64_session_starts_a_containerized_client_through_the_shim(self):
+ block = re.search(r'\n( +steam_client=\("\$steam_root/steamrtarm64/steam"\)\n.*?\n fi\n)', SESSION, re.S).group(1)
+ stub = self.root / 'droiddeck-steamrt-shim'
+ for status, shimmed in ((1, False), (0, True)):
+ stub.write_text(f'#!/bin/bash\nexit {status}\n')
+ stub.chmod(0o755)
+ script = 'if :; then\n' + block + 'printf "%s\\n" "${steam_client[@]}"\n'
+ env = dict(os.environ, HOME=str(self.root), steam_root='/steam',
+ PATH=str(self.root) + os.pathsep + os.environ['PATH'])
+ result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True, timeout=30)
+ self.assertEqual(0, result.returncode, result.stderr)
+ lines = result.stdout.splitlines()
+ self.assertEqual('/steam/steamrtarm64/steam', lines[-1])
+ self.assertEqual(shimmed, 'Valve containerized the arm64 Steam client' in result.stdout)
+ self.assertEqual(shimmed, any(line.startswith('STEAM_RUNTIME_STEAMRT=') and
+ line.endswith('/.droiddeck-steamrt-shim') for line in lines))
+
+ def test_both_clients_are_told_they_already_run_inside_the_container(self):
+ guard = SESSION.index('export STEAM_STEAMRT_RECURSION_GUARD=1')
+ self.assertLess(guard, SESSION.index('"${steam_client[@]}" "${_bl_ui[@]}"'))
+ self.assertIn('-u STEAM_STEAMRT_RECURSION_GUARD', SESSION[:guard])
+ self.assertIn('export STEAM_STEAMRT_RECURSION_GUARD=1', X64)
+ self.assertIn('droiddeck-steamrt-shim "$STEAM_X64/steamrt64" "$X64_HOME/steamrt-shim" || rm -rf "$X64_HOME/steamrt-shim"', X64)
+
+
+class SessionChannelTest(unittest.TestCase):
+ BLOCK = re.search(r'\n( +steam_channel=steamdeck_publicbeta\n.*?\n +fi\n)', SESSION, re.S).group(1)
+
+ def channel(self, x64, installer):
+ with tempfile.TemporaryDirectory() as temp:
+ stub = Path(temp) / 'droiddeck-steam-install'
+ stub.write_text('#!/bin/bash\n' + installer + '\n')
+ stub.chmod(0o755)
+ env = dict(os.environ, steam_x64=x64, PATH=temp + os.pathsep + os.environ['PATH'])
+ result = subprocess.run(['bash', '-c', self.BLOCK + 'echo "$steam_channel"\n'], env=env,
+ capture_output=True, text=True, timeout=30)
+ self.assertEqual(0, result.returncode, result.stderr)
+ return result.stdout.strip()
+
+ def test_the_arm64_client_follows_the_installed_channel(self):
+ self.assertEqual('steamdeck_stable', self.channel('0', '[ "$1" = --channel ] && echo steamdeck_stable'))
+
+ def test_an_installer_without_an_answer_keeps_the_deck_beta(self):
+ self.assertEqual('steamdeck_publicbeta', self.channel('0', 'exit 1'))
+
+ def test_the_x86_client_keeps_its_own_channel(self):
+ self.assertEqual('steamdeck_publicbeta', self.channel('1', 'echo steamdeck_stable'))
+
+ def test_the_client_branch_on_disk_and_clientbeta_use_the_same_channel(self):
+ self.assertIn('-clientbeta "$steam_channel"', SESSION)
+ self.assertIn('echo "$steam_channel" > "$steam_beta"', SESSION)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tools/tests/test_xbox_build_source.py b/tools/tests/test_xbox_build_source.py
index 8177a1cbc..427cea342 100644
--- a/tools/tests/test_xbox_build_source.py
+++ b/tools/tests/test_xbox_build_source.py
@@ -31,14 +31,14 @@ class XboxBuildSourceTest(unittest.TestCase):
git('switch', '-q', 'main')
(root / 'backend').write_text('merged backend')
git('commit', '-qam', 'backend changed after branch')
- for name in ('xbox', 'apk'):
+ for name in ('source', 'xbox', 'components', 'apk', 'tests', 'dispatch'):
git('clone', '-q', str(root), str(root / name))
git('checkout', '-q', head, cwd=root / name)
output = root / 'output'
env['GITHUB_OUTPUT'] = str(output)
- subprocess.run(['bash', '-c', step('xbox', 'Pin and merge the APK source')], cwd=root / 'xbox', env=env, check=True, capture_output=True)
+ subprocess.run(['bash', '-c', step('source', 'Pin and merge the APK source')], cwd=root / 'source', env=env, check=True, capture_output=True)
base = output.read_text().strip().split('=', 1)[1]
- tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'xbox')
+ tree = git('rev-parse', 'HEAD^{tree}', cwd=root / 'source')
(root / 'backend').write_text('newer main, after backend build')
git('commit', '-qam', 'main advanced again')
env.update(BUILD_BASE_SHA=base, XBOX_SOURCE_TREE=tree)
@@ -46,3 +46,19 @@ class XboxBuildSourceTest(unittest.TestCase):
subprocess.run(['bash', '-c', step('build', 'Verify backend and APK source match')], cwd=root / 'apk', env=env, check=True, capture_output=True)
self.assertEqual((root / 'apk/backend').read_text(), 'merged backend')
self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'apk'), tree)
+
+ subprocess.run(['bash', '-c', step('python-tests', 'Merge the pinned APK base')], cwd=root / 'tests', env=env, check=True, capture_output=True)
+ subprocess.run(['bash', '-c', step('python-tests', 'Verify backend and test source match')], cwd=root / 'tests', env=env, check=True, capture_output=True)
+ self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / 'tests'), tree)
+
+ env['SOURCE_TREE'] = tree
+ for job in ('xbox', 'components'):
+ subprocess.run(['bash', '-c', step(job, 'Merge the pinned APK base')], cwd=root / job, env=env, check=True, capture_output=True)
+ subprocess.run(['bash', '-c', step(job, 'Verify pinned source tree')], cwd=root / job, env=env, check=True, capture_output=True)
+ self.assertEqual(git('rev-parse', 'HEAD^{tree}', cwd=root / job), tree)
+
+ trusted = root / 'trusted-output'
+ env['GITHUB_OUTPUT'] = str(trusted)
+ subprocess.run(['bash', '-c', step('source', 'Resolve trusted manifest base')], cwd=root / 'dispatch', env=env, check=True, capture_output=True)
+ self.assertEqual(trusted.read_text().strip(), 'base-sha=' + git('rev-parse', 'main'))
+ self.assertNotEqual(git('rev-parse', 'HEAD', cwd=root / 'dispatch'), git('rev-parse', 'main'))