diff --git a/.github/workflows/build-droiddeck-esync-packs.yml b/.github/workflows/build-droiddeck-esync-packs.yml deleted file mode 100644 index a98ae23ab..000000000 --- a/.github/workflows/build-droiddeck-esync-packs.yml +++ /dev/null @@ -1,449 +0,0 @@ -name: Build droiddeck-esync packs - -on: - schedule: - - cron: '17 5 * * *' - push: - branches: [ main ] - paths: - - 'tools/droiddeck-esync/**' - - '.github/workflows/build-droiddeck-esync-packs.yml' - workflow_dispatch: - inputs: - flavor: - description: 'Proton flavor to look at' - type: choice - options: [ all, valve, ge, cachyos ] - default: all - ref: - description: 'One release tag to build (e.g. GE-Proton11-7); empty = the newest builds of each family' - required: false - default: '' - publish: - description: 'Publish the packs and sign a new index' - type: boolean - default: false - -permissions: - contents: write - -concurrency: - group: droiddeck-esync-packs-${{ (github.repository == 'Droid-Deck/DroidDeck' && github.ref == 'refs/heads/main' && (github.event_name != 'workflow_dispatch' || inputs.publish)) && 'publish' || format('build-{0}', github.run_id) }} - cancel-in-progress: false - -env: - SYNC_BASE_URL: https://github.com/Droid-Deck/DroidDeck/releases/download - -jobs: - discover: - if: ${{ github.repository == 'Droid-Deck/DroidDeck' || github.event_name == 'workflow_dispatch' }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - outputs: - matrix: ${{ steps.discover.outputs.matrix }} - count: ${{ steps.discover.outputs.count }} - unindexed: ${{ steps.discover.outputs.unindexed }} - publish: ${{ steps.discover.outputs.publish }} - steps: - - uses: actions/checkout@v4 - - - name: Find the Proton builds without a pack - id: discover - env: - GH_TOKEN: ${{ github.token }} - FLAVOR: ${{ inputs.flavor || 'all' }} - REF: ${{ inputs.ref }} - ASKED: ${{ github.event_name != 'workflow_dispatch' || inputs.publish }} - PUBLISH: ${{ github.repository == 'Droid-Deck/DroidDeck' && github.ref == 'refs/heads/main' && (github.event_name != 'workflow_dispatch' || inputs.publish) }} - run: | - set -euo pipefail - if [ "$ASKED" = true ] && [ "$PUBLISH" != true ]; then - echo "::notice::packs are published only from main of Droid-Deck/DroidDeck; this run only builds them" - fi - echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" - python3 tools/droiddeck-esync/discover.py matrix --flavor "$FLAVOR" --ref "$REF" \ - --index-release droiddeck-esync-index --repo "$GITHUB_REPOSITORY" --revoked tools/droiddeck-esync/revoked.txt - - build: - name: ${{ matrix.flavor }} ${{ matrix.tag }} - needs: discover - if: needs.discover.outputs.count != '0' - runs-on: ubuntu-24.04-arm - timeout-minutes: 360 - permissions: - contents: read - strategy: - fail-fast: false - max-parallel: 2 - matrix: ${{ fromJSON(needs.discover.outputs.matrix) }} - env: - FLAVOR: ${{ matrix.flavor }} - REPO: ${{ matrix.repo }} - TAG: ${{ matrix.tag }} - ASSET: ${{ matrix.asset }} - VERSION_TOKEN: ${{ matrix.token }} - DEPOT_APP: ${{ matrix.depot_app }} - REV: ${{ matrix.rev }} - SERIES: ${{ matrix.series }} - PROFILE: ${{ matrix.profile }} - SOURCE_MATCH: ${{ matrix.source_match }} - SUBMODULES: ${{ matrix.submodules }} - CLONE: ${{ matrix.clone }} - PREP: ${{ matrix.prep }} - BUILD_DIR: ${{ matrix.build_dir }} - HAVE_DEPOT_LOGIN: ${{ secrets.STEAM_DEPOT_USER != '' && secrets.STEAM_DEPOT_TOKEN != '' }} - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Check the build script and the patch series - run: | - set -euo pipefail - test -f tools/droiddeck-esync/build-pack.sh - ls "tools/droiddeck-esync/patches/$SERIES"/*.patch - - - name: Install the host tools - run: | - set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq autoconf automake git patch perl python3 rsync unzip wget xz-utils zstd >/dev/null - - - name: Fetch the stock binaries - if: matrix.asset != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - rm -rf stock-download stock - mkdir -p stock-download stock - gh release download "$TAG" -R "$REPO" -p "$ASSET" -D stock-download - tar -xf "stock-download/$ASSET" -C stock - rm -rf stock-download - mapfile -t tops < <(find stock -mindepth 1 -maxdepth 1 -type d) - if [ "${#tops[@]}" != 1 ]; then - echo "::error::$ASSET does not hold exactly one Proton directory" - exit 1 - fi - dir=$(realpath "${tops[0]}") - test -f "$dir/files/lib/wine/aarch64-unix/ntdll.so" - test -f "$dir/files/bin-arm64/wineserver" - line=$(head -n1 "$dir/version" | tr -d '\r') - if ! grep -qE '^[0-9]+[[:space:]]+[[:graph:]]+' <<<"$line"; then - echo "::error::$ASSET has no usable version file" - exit 1 - fi - echo "Stock $line" - { - echo "STOCK_DIR=$dir" - echo "VERSION_LINE=$line" - } >> "$GITHUB_ENV" - - - name: Fetch the stock depot (optional, Valve only, needs STEAM_DEPOT_USER, STEAM_DEPOT_TOKEN and DEPOT_DOWNLOADER_SHA256) - if: matrix.flavor == 'valve' && env.HAVE_DEPOT_LOGIN == 'true' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - DEPOT_DOWNLOADER_TAG: DepotDownloader_3.4.0 - DEPOT_DOWNLOADER_SHA256: ${{ vars.DEPOT_DOWNLOADER_SHA256 }} - STEAM_DEPOT_USER: ${{ secrets.STEAM_DEPOT_USER }} - STEAM_DEPOT_TOKEN: ${{ secrets.STEAM_DEPOT_TOKEN }} - run: | - set -euo pipefail - if ! grep -qxE '[0-9a-f]{64}' <<<"$DEPOT_DOWNLOADER_SHA256"; then - echo "::warning::set the repository variable DEPOT_DOWNLOADER_SHA256 to the sha256 of DepotDownloader-linux-arm64.zip from $DEPOT_DOWNLOADER_TAG to fetch the depot" - exit 0 - fi - rm -rf depotdownloader depot - gh release download "$DEPOT_DOWNLOADER_TAG" -R SteamRE/DepotDownloader -p 'DepotDownloader-linux-arm64.zip' -D depotdownloader - echo "$DEPOT_DOWNLOADER_SHA256 depotdownloader/DepotDownloader-linux-arm64.zip" | sha256sum -c - - unzip -q depotdownloader/DepotDownloader-linux-arm64.zip -d depotdownloader - chmod +x depotdownloader/DepotDownloader - printf '%s\n' version files/lib/wine/aarch64-unix/ntdll.so files/bin-arm64/wineserver > depot-files.txt - timeout 30m depotdownloader/DepotDownloader -app "$DEPOT_APP" -os linux -all-archs \ - -username "$STEAM_DEPOT_USER" -password "$STEAM_DEPOT_TOKEN" \ - -filelist depot-files.txt -dir depot > "$GITHUB_ENV" - - - name: Prepare the wine source - run: | - set -euo pipefail - git config --global user.name "ci" - git config --global user.email "ci@users.noreply.github.com" - rm -rf src - git init -q src - git -C src remote add origin "https://github.com/$REPO.git" - git -C src fetch -q --depth 1 origin "refs/tags/$TAG:refs/tags/$TAG" - git -C src checkout -q "refs/tags/$TAG" - read -r -a modules <<<"$SUBMODULES" - case "$CLONE" in - shallow) history=(--depth 1) ;; - treeless) history=(--filter=tree:0) ;; - *) echo "::error::unknown clone mode $CLONE"; exit 1 ;; - esac - git -C src submodule update -q --init "${history[@]}" -- "${modules[@]}" - if [ -n "$PREP" ]; then - if ! (cd src && bash "$PREP") > prep.log 2>&1; then - tail -n 200 prep.log - echo "::error::$PREP failed" - exit 1 - fi - fi - test -f src/wine/server/protocol.def - image=$(python3 tools/droiddeck-esync/discover.py sdk-image src/Makefile.in) - commit=$(git -C src rev-parse HEAD) - wine_commit=$(git -C src rev-parse HEAD:wine) - line=${VERSION_LINE:-} - if [ -z "$line" ]; then - if [ -z "$VERSION_TOKEN" ]; then - echo "::error::no stock version file and no version token for $TAG" - exit 1 - fi - line="$(git -C src log -1 --format=%ct HEAD) $VERSION_TOKEN" - fi - echo "SDK image $image, $REPO $commit, wine $wine_commit, version $line" - { - echo "SDK_IMAGE=$image" - echo "SOURCE_COMMIT=$commit" - echo "WINE_COMMIT=$wine_commit" - echo "VERSION_LINE=$line" - echo "SOURCE_DATE_EPOCH=${line%%[[:space:]]*}" - } >> "$GITHUB_ENV" - - - name: Build the control and the patched binaries - run: | - set -euo pipefail - docker pull -q "$SDK_IMAGE" - for kind in control patched; do - work=$PWD/build/work-$kind - out=$PWD/build/$kind - sudo rm -rf "$work" "$out" - mkdir -p "$work" "$out" - if [ "$kind" = patched ]; then - bash tools/droiddeck-esync/build-pack.sh prepare "$PWD/src/wine" "$work" "$PWD/tools/droiddeck-esync/patches/$SERIES" - else - bash tools/droiddeck-esync/build-pack.sh prepare "$PWD/src/wine" "$work" - fi - docker run --rm \ - -v "$work:$BUILD_DIR" -v "$out:/out" -v "$PWD/tools/droiddeck-esync/build-pack.sh:/build-pack.sh:ro" \ - -w "$BUILD_DIR" -e HOME=/tmp -e LANG=C.UTF-8 -e JOBS="$(nproc)" \ - -e SOURCE_DATE_EPOCH -e SYNC_PROFILE="$PROFILE" \ - "$SDK_IMAGE" /bin/bash /build-pack.sh build "$BUILD_DIR" /out - done - sudo chown -R "$(id -u):$(id -g)" build - sha256sum build/*/files/lib/wine/aarch64-unix/ntdll.so build/*/files/bin-arm64/wineserver - - - name: Make the pack - run: | - set -euo pipefail - source_json=$(python3 -c 'import json, os; print(json.dumps({"repo": os.environ["REPO"], "ref": os.environ["TAG"], "asset": os.environ["ASSET"], "commit": os.environ["SOURCE_COMMIT"], "wine_commit": os.environ["WINE_COMMIT"], "sdk_image": os.environ["SDK_IMAGE"]}, sort_keys=True))') - match=() - if [ -z "${STOCK_DIR:-}" ] && [ "$SOURCE_MATCH" = true ]; then - match=(--source-match) - fi - rm -rf out - id=$(python3 tools/droiddeck-esync/make_pack.py --flavor "$FLAVOR" --rev "$REV" --stock-dir "${STOCK_DIR:-}" \ - --version-line "$VERSION_LINE" --control build/control --patched build/patched \ - --source-json "$source_json" --patch-series "$SERIES" --out out "${match[@]}") - echo "Pack \`$id\` from $FLAVOR $TAG" >> "$GITHUB_STEP_SUMMARY" - ls -l out - - - name: Smoke-test the pack on the release it was made for - if: matrix.asset != '' - run: | - set -euo pipefail - id=$(basename out/*.tzst .tzst) - docker run --rm -v "$PWD:/work" -w /work "$SDK_IMAGE" \ - /opt/llvm-mingw/bin/aarch64-w64-mingw32-clang -O2 -o esstress.exe tools/droiddeck-esync/tests/esstress.c - set +e - bash tools/droiddeck-esync/smoke-test.sh "$STOCK_DIR" "out/$id.tzst" esstress.exe smoke - status=$? - set -e - case $status in - 0) echo "Smoke test passed for \`$id\`" >> "$GITHUB_STEP_SUMMARY" ;; - 3) echo "::warning::stock $TAG does not run on this runner, so $id could not be smoke-tested" ;; - *) echo "::error::$id failed the smoke test on $TAG"; exit 1 ;; - esac - - - uses: actions/upload-artifact@v4 - with: - name: droiddeck-esync-pack-${{ matrix.key }} - path: out/ - if-no-files-found: error - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: droiddeck-esync-logs-${{ matrix.key }} - path: | - prep.log - build/work-*/*.log - smoke/*.log - if-no-files-found: ignore - - publish: - name: Publish the packs - needs: [ discover, build ] - if: ${{ !cancelled() && needs.discover.result == 'success' && needs.discover.outputs.publish == 'true' && needs.discover.outputs.count != '0' }} - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - uses: actions/download-artifact@v4 - with: - pattern: droiddeck-esync-pack-* - path: packs - - - name: Upload the packs to their releases - env: - GH_TOKEN: ${{ github.token }} - MATRIX: ${{ needs.discover.outputs.matrix }} - run: | - set -euo pipefail - mkdir -p packs - python3 tools/droiddeck-esync/discover.py packs --matrix "$MATRIX" packs > packs.tsv - if [ ! -s packs.tsv ]; then - echo "::notice::no build made a pack" - exit 0 - fi - while IFS=$'\t' read -r -u 3 flavor dir id; do - tag="droiddeck-esync-$flavor" - if ! gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$tag" -R "$GITHUB_REPOSITORY" --latest=false --prerelease=false --title "$tag" \ - --notes "droiddeck-esync packs for $flavor Proton builds (tools/droiddeck-esync). The app fetches them through the signed index in the droiddeck-esync-index release." \ - || gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null - fi - gh release upload "$tag" -R "$GITHUB_REPOSITORY" "$dir/$id.tzst" --clobber - gh release upload "$tag" -R "$GITHUB_REPOSITORY" "$dir/$id.json" --clobber - gh release edit "$tag" -R "$GITHUB_REPOSITORY" --latest=false --prerelease=false - echo "Published \`$id\` to $tag" >> "$GITHUB_STEP_SUMMARY" - done 3< packs.tsv - - - name: Record the builds that made no pack - env: - GH_TOKEN: ${{ github.token }} - MATRIX: ${{ needs.discover.outputs.matrix }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - mkdir -p packs failed - python3 tools/droiddeck-esync/discover.py failures --matrix "$MATRIX" packs > failed.tsv - while IFS=$'\t' read -r -u 3 flavor marker; do - tag="droiddeck-esync-$flavor" - if ! gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$tag" -R "$GITHUB_REPOSITORY" --latest=false --prerelease=false --title "$tag" \ - --notes "droiddeck-esync packs for $flavor Proton builds (tools/droiddeck-esync). The app fetches them through the signed index in the droiddeck-esync-index release." \ - || gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null - fi - echo "$RUN_URL" > "failed/$marker" - gh release upload "$tag" -R "$GITHUB_REPOSITORY" "failed/$marker" --clobber - echo "::warning::$marker: no pack was made; discovery skips this build for a day unless rev or the patch series changes" - done 3< failed.tsv - - index: - name: Sign the index - needs: [ discover, build, publish ] - if: ${{ !cancelled() && needs.discover.result == 'success' && needs.discover.outputs.publish == 'true' && (needs.discover.outputs.count != '0' || needs.discover.outputs.unindexed != '0') }} - runs-on: ubuntu-24.04 - timeout-minutes: 30 - environment: droiddeck-esync-signing - steps: - - uses: actions/checkout@v4 - - - name: Collect the published packs - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - fetch() { - local out - if out=$(gh release download "$1" -R "$GITHUB_REPOSITORY" -p "$2" -D "$3" --clobber 2>&1); then - return 0 - fi - if grep -qiE 'release not found|no assets match' <<<"$out"; then - echo "::notice::$1 has no $2 yet" - return 0 - fi - echo "$out" >&2 - return 1 - } - rm -rf entries previous bundle-packs out - mkdir -p entries previous bundle-packs - for flavor in valve ge cachyos; do - fetch "droiddeck-esync-$flavor" '*.json' entries - done - fetch droiddeck-esync-index index.json previous - fetch droiddeck-esync-index index.json.sig previous - args=(--entries entries --base-url "$SYNC_BASE_URL" --revoked tools/droiddeck-esync/revoked.txt) - if [ -f previous/index.json ]; then - args+=(--previous previous/index.json --previous-sig previous/index.json.sig) - fi - python3 tools/droiddeck-esync/make_index.py "${args[@]}" --list-bundle > bundle.tsv - while IFS=$'\t' read -r flavor id; do - fetch "droiddeck-esync-$flavor" "$id.tzst" bundle-packs - done < bundle.tsv - echo "Entries: $(find entries -name '*.json' | wc -l); bundled packs: $(wc -l < bundle.tsv)" - - - name: Sign the index and make the apk bundle - id: index - env: - SYNC_INDEX_KEY: ${{ secrets.SYNC_INDEX_KEY }} - run: | - set -euo pipefail - if [ -z "$SYNC_INDEX_KEY" ]; then - echo "::error::the droiddeck-esync-signing environment has no SYNC_INDEX_KEY secret" - exit 1 - fi - args=(--entries entries --base-url "$SYNC_BASE_URL" --revoked tools/droiddeck-esync/revoked.txt) - if [ -f previous/index.json ]; then - args+=(--previous previous/index.json --previous-sig previous/index.json.sig) - fi - python3 tools/droiddeck-esync/make_index.py "${args[@]}" --out out --bundle-packs-dir bundle-packs - - - name: Publish the index - env: - GH_TOKEN: ${{ github.token }} - CHANGED: ${{ steps.index.outputs.changed }} - BUNDLE: ${{ steps.index.outputs.bundle }} - run: | - set -euo pipefail - if [ "$CHANGED" != true ]; then - echo "::notice::no pack was added, replaced or revoked; the published index stays" - exit 0 - fi - tag=droiddeck-esync-index - if ! gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$tag" -R "$GITHUB_REPOSITORY" --latest=false --prerelease=false --title "$tag" \ - --notes "Signed index of the droiddeck-esync packs (tools/droiddeck-esync). The app fetches index.json and index.json.sig; the apk build bundles the bundle-*.tzst that tools/droiddeck-esync/release.env names." \ - || gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null - fi - gh release upload "$tag" -R "$GITHUB_REPOSITORY" "out/$BUNDLE" - gh release upload "$tag" -R "$GITHUB_REPOSITORY" out/index.json out/index.json.sig --clobber - sum=$(sha256sum "out/$BUNDLE" | cut -d' ' -f1) - gh release edit "$tag" -R "$GITHUB_REPOSITORY" --latest=false --prerelease=false - { - echo "### tools/droiddeck-esync/release.env" - echo - echo '```' - echo "SYNC_BUNDLE_TAG=$tag" - echo "SYNC_BUNDLE_ASSET=$BUNDLE" - echo "SYNC_BUNDLE_SHA256=$sum" - echo '```' - } | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 647ab24d4..a9f186c82 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - PRELOAD_CACHE_VERSION: v2 + PRELOAD_CACHE_VERSION: v3 jobs: build: @@ -157,7 +157,7 @@ jobs: 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', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/clipboard/*.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/mangoapp/*') }} + key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.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/*') }} - name: Build libfakeinput.so and libblsession.so (glibc aarch64) if: steps.preloads.outputs.cache-hit != 'true' @@ -182,7 +182,7 @@ jobs: 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" - for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do + 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 @@ -204,6 +204,12 @@ jobs: test -f "$D/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" rm wlroots.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 @@ -233,6 +239,46 @@ jobs: install -Dm644 tools/linuxfs/desktop/firefox-droiddeck.js "$D/usr/lib/firefox/defaults/pref/droiddeck.js" ls -lR "$D" + - 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 debian:bullseye bash -c ' + set -euo pipefail + printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list + 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 + 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 + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -f tools/droiddeck-esync/release.env ]; then + . tools/droiddeck-esync/release.env + gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst + 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 + gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-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 @@ -297,11 +343,11 @@ jobs: echo "::error::libfakeinput.so exports the C++ runtime"; exit 1 fi SESSION=$(syms "$D/libblsession.so") - for sym in socket bind getsockname setsockopt statfs statvfs; do + for sym in socket bind getsockname setsockopt statfs statvfs syscall shm_open shm_unlink mmap mmap64 munmap; do grep -qx "$sym" <<<"$SESSION" || { echo "::error::libblsession.so does not export $sym"; exit 1; } done - test -f "$D/usr/local/bin/bannerlator-session" - test -f "$D/usr/local/bin/bannerlator-proton-extra" + test -f "$D/usr/local/bin/droiddeck-session" + test -f "$D/usr/local/bin/droiddeck-proton-extra" - name: Cache the CMake build tree uses: actions/cache@v4 diff --git a/.github/workflows/watch-proton-releases.yml b/.github/workflows/watch-proton-releases.yml deleted file mode 100644 index b5f56b39a..000000000 --- a/.github/workflows/watch-proton-releases.yml +++ /dev/null @@ -1,112 +0,0 @@ -name: Watch Proton releases - -on: - schedule: - - cron: '*/20 * * * *' - workflow_dispatch: - -permissions: - contents: write - actions: write - -concurrency: - group: watch-proton-releases - cancel-in-progress: false - -jobs: - watch: - if: ${{ github.repository == 'Droid-Deck/DroidDeck' }} - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - name: Pick the steamcmd cache week - id: week - run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" - - - uses: actions/cache@v4 - with: - path: ~/steamcmd - key: steamcmd-${{ runner.os }}-${{ steps.week.outputs.week }} - restore-keys: steamcmd-${{ runner.os }}- - - - name: Read the Proton builds Steam ships - run: | - set -euo pipefail - sudo apt-get update -qq - sudo apt-get install -y -qq lib32gcc-s1 >/dev/null - mkdir -p ~/steamcmd - if [ ! -x ~/steamcmd/steamcmd.sh ]; then - curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar -xz -C ~/steamcmd - fi - apps=$(python3 tools/droiddeck-esync/discover.py steam-apps) - args=(+login anonymous +app_info_update 1) - for app in $apps; do - args+=(+app_info_print "$app") - done - if ! timeout 300 ~/steamcmd/steamcmd.sh "${args[@]}" +quit > steam-info.txt 2>&1; then - tail -n 20 steam-info.txt - echo "::warning::steamcmd failed; this run watches the source tags only" - : > steam-info.txt - fi - - - name: Fetch the previous watch state - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p previous - if ! out=$(gh release download droiddeck-esync-index -R "$GITHUB_REPOSITORY" -p watch-state.json -D previous --clobber 2>&1); then - if grep -qiE 'release not found|no assets match' <<<"$out"; then - echo "::notice::no watch state yet; every flavor counts as new" - else - echo "$out" >&2 - exit 1 - fi - fi - - - name: Compare with the last run - id: watch - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - info=() - if [ -s steam-info.txt ]; then - info=(--steam-info steam-info.txt) - fi - python3 tools/droiddeck-esync/discover.py watch --state previous/watch-state.json "${info[@]}" --out watch-state.json - - - name: Start the pack builds - if: steps.watch.outputs.changed != '' - env: - GH_TOKEN: ${{ github.token }} - CHANGED: ${{ steps.watch.outputs.changed }} - run: | - set -euo pipefail - read -r -a flavors <<<"$CHANGED" - if [ "${#flavors[@]}" -gt 1 ]; then - flavors=(all) - fi - for flavor in "${flavors[@]}"; do - gh workflow run build-droiddeck-esync-packs.yml -R "$GITHUB_REPOSITORY" --ref main -f flavor="$flavor" -f publish=true - echo "Started the pack build for $flavor" >> "$GITHUB_STEP_SUMMARY" - done - - - name: Save the watch state - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag=droiddeck-esync-index - if ! gh release view "$tag" -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "::notice::$tag does not exist yet; the state is saved once the first index is published" - exit 0 - fi - if cmp -s watch-state.json previous/watch-state.json; then - exit 0 - fi - gh release upload "$tag" -R "$GITHUB_REPOSITORY" watch-state.json --clobber diff --git a/.gitignore b/.gitignore index 3240a2ea0..41551eec3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ app/src/main/assets/linuxfs/ app/src/main/jniLibs/arm64-v8a/libproot.so app/src/main/jniLibs/arm64-v8a/.proot-inputs app/src/main/jniLibs/arm64-v8a/libproot-loader.so +app/src/main/assets/droiddeck-esync/ # Kotlin compiler session files (build-tool state, never source). .kotlin/ diff --git a/README.md b/README.md index 003924efe..5e6cc4c39 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ 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). 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. 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). 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. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. diff --git a/app/build.gradle b/app/build.gradle index 963e1db0e..d6d2a4ab9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -180,7 +180,7 @@ android { // A stale local bundle can omit a newly required helper on a fresh runtime install. def stageSessionScripts = tasks.register('stageSessionScripts', Copy) { from(rootProject.file('tools/linuxfs/overlay')) { - include 'usr/local/bin/bannerlator-*', 'usr/bin/**' + include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**' } into(layout.projectDirectory.dir('src/main/assets/linuxfs')) } diff --git a/app/src/main/cpp/fakeinput_steam.cpp b/app/src/main/cpp/fakeinput_steam.cpp index 3e0f18f6d..d1c69d2a4 100644 --- a/app/src/main/cpp/fakeinput_steam.cpp +++ b/app/src/main/cpp/fakeinput_steam.cpp @@ -54,7 +54,7 @@ #include #include -#define EXPORT __attribute__((visibility("default"))) extern "C" +#define EXPORT extern "C" __attribute__((visibility("default"))) // glibc declares the request as unsigned long; bionic as int. The same source also serves the // Linux runtime's preload (tools/linuxfs), where Steam and SDL run against glibc. @@ -1729,10 +1729,11 @@ __attribute__((visibility("hidden"))) static bool deck_device_library(void *call return !strncmp(name, "libudev.so", 10) || !strncmp(name, "libsystemd.so", 13); } +static int real_fstat(int fd, struct stat *buf); + __attribute__((visibility("hidden"))) static bool deck_mount_id_from_fdinfo( int dirfd, const char *path, int flags, struct statx *buf) { static auto real_openat = reinterpret_cast(dlsym(RTLD_NEXT, "openat")); - static auto real_fstat = reinterpret_cast(dlsym(RTLD_NEXT, "fstat")); int target; if (!path || !*path) { if (!(flags & AT_EMPTY_PATH)) return false; @@ -1950,9 +1951,8 @@ EXPORT int openat(int dirfd, const char *pathname, int flags, ...) { return fd; } -EXPORT int stat(const char *pathname, struct stat *statbuf) { - static auto my_stat = reinterpret_cast(dlsym(RTLD_NEXT, "stat")); - +template +static int fake_stat_path(const char *pathname, S *statbuf, Real real) { if (is_withdrawn_pad_path(pathname) || (is_deck_hidraw_path(pathname) && !process_is_steam_client())) { errno = ENOENT; return -1; @@ -1988,7 +1988,7 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) { } } - int ret = my_stat(pathname, statbuf); + int ret = real(pathname, statbuf); if (ret == 0 && event && get_event_number(event) >= 0) { statbuf->st_mode = (statbuf->st_mode & ~S_IFMT) | S_IFCHR; @@ -2001,10 +2001,9 @@ EXPORT int stat(const char *pathname, struct stat *statbuf) { return ret; } -EXPORT int fstat(int fd, struct stat *buf) { - static auto my_fstat = reinterpret_cast(dlsym(RTLD_NEXT, "fstat")); - - int ret = my_fstat(fd, buf); +template +static int fake_stat_fd(int fd, S *buf, Real real) { + int ret = real(fd, buf); std::lock_guard guard(controller_mutex()); if (ret == 0 && deck_map().count(fd)) { @@ -2021,6 +2020,36 @@ EXPORT int fstat(int fd, struct stat *buf) { return ret; } +static int real_stat(const char *pathname, struct stat *statbuf) { + static auto fn = reinterpret_cast(dlsym(RTLD_NEXT, "stat")); + if (fn) return fn(pathname, statbuf); +#ifdef _STAT_VER + static auto xfn = reinterpret_cast(dlsym(RTLD_NEXT, "__xstat")); + if (xfn) return xfn(_STAT_VER, pathname, statbuf); +#endif + errno = ENOSYS; + return -1; +} + +static int real_fstat(int fd, struct stat *buf) { + static auto fn = reinterpret_cast(dlsym(RTLD_NEXT, "fstat")); + if (fn) return fn(fd, buf); +#ifdef _STAT_VER + static auto xfn = reinterpret_cast(dlsym(RTLD_NEXT, "__fxstat")); + if (xfn) return xfn(_STAT_VER, fd, buf); +#endif + errno = ENOSYS; + return -1; +} + +EXPORT int stat(const char *pathname, struct stat *statbuf) { + return fake_stat_path(pathname, statbuf, real_stat); +} + +EXPORT int fstat(int fd, struct stat *buf) { + return fake_stat_fd(fd, buf, real_fstat); +} + EXPORT int access(const char *pathname, int mode) { static auto my_access = reinterpret_cast(dlsym(RTLD_NEXT, "access")); if (fake_uinput_enabled() && is_uinput_path(pathname)) return 0; @@ -2866,31 +2895,57 @@ EXPORT int openat64(int dirfd, const char *pathname, int flags, ...) { return openat(dirfd, pathname, flags, mode); } +static int real_stat64(const char *pathname, struct stat64 *statbuf) { + static auto fn = reinterpret_cast(dlsym(RTLD_NEXT, "stat64")); + if (fn) return fn(pathname, statbuf); +#ifdef _STAT_VER + static auto xfn = reinterpret_cast(dlsym(RTLD_NEXT, "__xstat64")); + if (xfn) return xfn(_STAT_VER, pathname, statbuf); +#endif + errno = ENOSYS; + return -1; +} + +static int real_fstat64(int fd, struct stat64 *buf) { + static auto fn = reinterpret_cast(dlsym(RTLD_NEXT, "fstat64")); + if (fn) return fn(fd, buf); +#ifdef _STAT_VER + static auto xfn = reinterpret_cast(dlsym(RTLD_NEXT, "__fxstat64")); + if (xfn) return xfn(_STAT_VER, fd, buf); +#endif + errno = ENOSYS; + return -1; +} + EXPORT int stat64(const char *pathname, struct stat64 *statbuf) { - return stat(pathname, reinterpret_cast(statbuf)); + return fake_stat_path(pathname, statbuf, real_stat64); } EXPORT int fstat64(int fd, struct stat64 *buf) { - return fstat(fd, reinterpret_cast(buf)); + return fake_stat_fd(fd, buf, real_fstat64); } EXPORT int __xstat(int version, const char *pathname, struct stat *statbuf) { - (void)version; - return stat(pathname, statbuf); + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__xstat")); + if (!real) return stat(pathname, statbuf); + return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat *buf) { return real(version, path, buf); }); } EXPORT int __xstat64(int version, const char *pathname, struct stat64 *statbuf) { - (void)version; - return stat(pathname, reinterpret_cast(statbuf)); + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__xstat64")); + if (!real) return stat64(pathname, statbuf); + return fake_stat_path(pathname, statbuf, [version](const char *path, struct stat64 *buf) { return real(version, path, buf); }); } EXPORT int __fxstat(int version, int fd, struct stat *buf) { - (void)version; - return fstat(fd, buf); + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__fxstat")); + if (!real) return fstat(fd, buf); + return fake_stat_fd(fd, buf, [version](int f, struct stat *b) { return real(version, f, b); }); } EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) { - (void)version; - return fstat(fd, reinterpret_cast(buf)); + static auto real = reinterpret_cast(dlsym(RTLD_NEXT, "__fxstat64")); + if (!real) return fstat64(fd, buf); + return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); }); } #endif diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt index d942fd686..ddf19d05f 100644 --- a/app/src/main/cpp/waylandcomp/CMakeLists.txt +++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt @@ -4,7 +4,7 @@ # generated/ (no host wayland-scanner needed at app build time). Built by the app # (add_subdirectory from ../CMakeLists.txt) and on its own by standalone/ for # build-waylandcomp.yml. -add_library(bannerwayland SHARED +add_library(droiddeckwayland SHARED src/compositor.c src/vk_present.c src/effects_chain.c @@ -12,7 +12,7 @@ add_library(bannerwayland SHARED src/sc_layer.c src/ahb_swapchain.c src/waylandcomp_jni.c - src/banner_ext.c + src/droiddeck_ext.c src/wl_dmabuf.c src/wl_clipboard.c src/wl_text_input.c @@ -36,9 +36,9 @@ add_library(bannerwayland SHARED src/framegen_jni.c ) # Warnings on for the compositor's own C. They do not fail the build; they are there to be read. -target_compile_options(bannerwayland PRIVATE +target_compile_options(droiddeckwayland PRIVATE $<$:-Wall -Wextra -Wno-unused-parameter -Wno-missing-field-initializers>) -target_include_directories(bannerwayland PRIVATE +target_include_directories(droiddeckwayland PRIVATE generated prebuilt/include src @@ -54,56 +54,56 @@ target_include_directories(bannerwayland PRIVATE # framegen tree and the vendored DXBC translator (thirdparty/dxbc, for the LSFG cache format), # which only the app build has: the standalone build (standalone/) gets the stub, which reports # every engine unavailable and generates nothing. -set(BANNER_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) -set(BANNER_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg) -if (TARGET dxbc AND EXISTS ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp - AND EXISTS ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp) - message(STATUS "bannerwayland: frame-generation engines built in (LSFG Native, Win-FG Native)") - target_sources(bannerwayland PRIVATE +set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) +set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg) +if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp + AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp) + message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)") + target_sources(droiddeckwayland PRIVATE src/framegen_engine.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dll.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_dxbc.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_vkd.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_common.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_shaders.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_mipmaps.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_alpha.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_beta.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_gamma.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_delta.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_generate.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_chain.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_pacer.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp # The Lossless.dll import (validate / extract / cache) for the app's LsfgNative. - ${BANNER_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp - ${BANNER_FRAMEGEN_WINFG_DIR}/framegen.cpp - ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp - ${BANNER_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp + ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp + ${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp + ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp + ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp ) - target_include_directories(bannerwayland PRIVATE + target_include_directories(droiddeckwayland PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/.. # as the frame-generation engines see it ${CMAKE_CURRENT_SOURCE_DIR}/../framegen # VulkanRendererContext.h (VkTable), lsfg/, winfg/ ) - target_compile_options(bannerwayland PRIVATE + target_compile_options(droiddeckwayland PRIVATE $<$:-std=c++17 -fvisibility=hidden>) - target_compile_definitions(bannerwayland PRIVATE + target_compile_definitions(droiddeckwayland PRIVATE WINFG_UPSTREAM="${WINFG_UPSTREAM}" WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}" ) - target_link_libraries(bannerwayland dxbc atomic) + target_link_libraries(droiddeckwayland dxbc atomic) else() - message(STATUS "bannerwayland: frame-generation engines NOT built in (standalone build): stub") - target_sources(bannerwayland PRIVATE src/framegen_engine_stub.c) + message(STATUS "droiddeckwayland: frame-generation engines NOT built in (standalone build): stub") + target_sources(droiddeckwayland PRIVATE src/framegen_engine_stub.c) endif() add_library(wayland-server SHARED IMPORTED) set_target_properties(wayland-server PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/prebuilt/lib/libwayland-server.so) # Vulkan is loaded at runtime via adrenotools (Turnip), not linked directly - so the # compositor uses Turnip's driver, which supports dmabuf import (drm_format_modifier). -target_link_libraries(bannerwayland +target_link_libraries(droiddeckwayland wayland-server adrenotools dl diff --git a/app/src/main/cpp/waylandcomp/ZERO_COPY_SPIKE.md b/app/src/main/cpp/waylandcomp/ZERO_COPY_SPIKE.md index f4f7ea50a..37b08b233 100644 --- a/app/src/main/cpp/waylandcomp/ZERO_COPY_SPIKE.md +++ b/app/src/main/cpp/waylandcomp/ZERO_COPY_SPIKE.md @@ -29,7 +29,7 @@ the dma-buf fd through `zwp_linux_dmabuf_v1` as today (so the blit path stays as The host half - one `ASurfaceControl` layer per fullscreen window, geometry from the fullscreen mode, release fences from `ASurfaceTransaction_setOnComplete`, black base surface, HUD/pointer as Android views above - is implemented in this branch as a gated prototype (`sc_layer.c`, -`BANNER_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the +`DROIDDECK_WAYLAND_ZERO_COPY=1`) with a compositor-owned AHB pool and one blit, so the SurfaceFlinger side can be measured on the device before the driver work starts. --- @@ -240,13 +240,13 @@ Prerequisite: a build of this branch, container on Wayland, the AIO Graphics Tes D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`. 0. **UBWC modifier A/B (no layers).** Done on `feat/wayland-ubwc` (advertised when the compositor's - driver imports it, `BANNER_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md` + driver imports it, `DROIDDECK_WAYLAND_UBWC=0` = old linear-only list; see `WAYLAND_RUNTIME.md` "Compressed (UBWC) game buffers"). Run the AIO Vulkan/D3D12 with and without. Expect `vulkan: … is presenting GPU frames through Wayland: WxH, format XB24, tiled (zero-copy)` and no `dmabuf import failed`. Records how much of the 20 % is the linear swapchain alone. -1. **Layer mode smoke.** Put `BANNER_WAYLAND_ZERO_COPY=1` in the container's environment +1. **Layer mode smoke.** Put `DROIDDECK_WAYLAND_ZERO_COPY=1` in the container's environment variables, launch the AIO fullscreen. Expected log lines (tag `layer`): - `SurfaceControl "banner_wayland_game" created as a child of the screen surface`, + `SurfaceControl "droiddeck_wayland_game" created as a child of the screen surface`, `pool buffer 1920x1080 UBWC (QCOM_COMPRESSED), stride 1920 px (gralloc handle 2 fds / N ints)` (or `linear` after `import of a UBWC … failed` - also a result: it tells whether the compositor Turnip accepts gralloc's UBWC pitch), `geometry: buffer 0,0-1920,1080 -> screen …`, @@ -254,7 +254,7 @@ D3D12 tabs) as the workload, session log in `Download/Wayland-logs/`. and the probe line `kernel exports sync_file fences from the game's dma-buf …` or `DMA_BUF_IOCTL_EXPORT_SYNC_FILE … failed (…)`. Picture correct, HUD and pointer on top, fullscreen mode changes move the layer (`geometry:` lines). -2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 banner_wayland_game` +2. **Composition type.** With the AIO running: `dumpsys SurfaceFlinger | grep -A3 droiddeck_wayland_game` and the `GPU_TARGET` line of the pipe table. DEVICE/DEVICE on the game layer with an empty GPU target = the DPU does the scaling; CLIENT = no gain on this panel orientation → try the handheld's native orientation / a resolution equal to the panel's. @@ -271,7 +271,7 @@ harden: async fence instead of CPU wait, per-window layers, presentation feedbac `getLatchTime`); option (a) WSI + protocol + wcp = 2–3 days + 1 day device proving; option (c) = 3–4 days + the same proving; (b) = not buildable. -## 6. What the prototype in this branch does (`BANNER_WAYLAND_ZERO_COPY=1`) +## 6. What the prototype in this branch does (`DROIDDECK_WAYLAND_ZERO_COPY=1`) Files: `src/sc_layer.{c,h}` (new), `src/vk_present.{c,h}` (`vkp_image_import_dmabuf` with a blit-destination role, `vkp_blit_image`, `vkp_update_map`, `vkp_map_draw`, `vkp_window`, diff --git a/app/src/main/cpp/waylandcomp/src/ahb_swapchain.c b/app/src/main/cpp/waylandcomp/src/ahb_swapchain.c index a697ab19b..4a981f1ee 100644 --- a/app/src/main/cpp/waylandcomp/src/ahb_swapchain.c +++ b/app/src/main/cpp/waylandcomp/src/ahb_swapchain.c @@ -2,8 +2,8 @@ * See ahb_swapchain.h and ZERO_COPY_SPIKE.md. */ #define _GNU_SOURCE #include "ahb_swapchain.h" -#include "banner_color.h" -#include "banner_ext.h" +#include "droiddeck_color.h" +#include "droiddeck_ext.h" #include "sc_layer.h" #include "vk_present.h" #include "banner-ahb-v1-server-protocol.h" @@ -22,10 +22,10 @@ #define MOD_QCOM_COMPRESSED 0x0500000000000001ULL /* DRM_FORMAT_MOD_QCOM_COMPRESSED (UBWC) */ /* linux/dma-buf.h (not in the NDK sysroot's older headers): sync_file export / import. */ -struct banner_dma_buf_sync_file { uint32_t flags; int32_t fd; }; -#define BANNER_DMA_BUF_SYNC_READ 1u -#define BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct banner_dma_buf_sync_file) -#define BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct banner_dma_buf_sync_file) +struct droiddeck_dma_buf_sync_file { uint32_t flags; int32_t fd; }; +#define DROIDDECK_DMA_BUF_SYNC_READ 1u +#define DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE _IOWR('b', 2, struct droiddeck_dma_buf_sync_file) +#define DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE _IOW('b', 3, struct droiddeck_dma_buf_sync_file) extern volatile int g_zero_copy; @@ -90,9 +90,9 @@ static void buf_free(struct ahb_buf *ab) { if (ab->fence_src) { wl_event_source_remove(ab->fence_src); ab->fence_src = NULL; } if (ab->fence_fd >= 0) { close(ab->fence_fd); ab->fence_fd = -1; } wl_list_remove(&ab->link); - *banner_dmabuf_ahb_slot(ab->b) = NULL; + *droiddeck_dmabuf_ahb_slot(ab->b) = NULL; AHardwareBuffer_release(ab->ahb); - banner_dmabuf_unref(ab->b); + droiddeck_dmabuf_unref(ab->b); free(ab); } @@ -113,7 +113,7 @@ static void on_buffer_resource_destroyed(struct wl_listener *l, void *data) { static void send_deferred_release(struct ahb_buf *ab) { if (ab->release_pending && ab->resource) - banner_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns); + droiddeck_release_buffer(ab->surface, ab->resource, ab->surface != NULL, ab->deferred_ns); ab->release_pending = 0; ab->surface = NULL; } @@ -138,14 +138,14 @@ static void handle_released(uint64_t id, int fd) { if (!ab) { if (fd >= 0) close(fd); return; } ab->on_layer = 0; if (fd >= 0) { - struct banner_dma_buf_sync_file imp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = fd}; - int dmabuf_fd = banner_dmabuf_fd(ab->b); - if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) { + struct droiddeck_dma_buf_sync_file imp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = fd}; + int dmabuf_fd = droiddeck_dmabuf_fd(ab->b); + if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_IMPORT_SYNC_FILE, &imp) == 0) { close(fd); } else { if (!g_import_failed_logged) { g_import_failed_logged = 1; - banner_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead", + droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_IMPORT_SYNC_FILE failed (%s): releases wait for the display's fence here instead", strerror(errno)); } /* Wait it out in the event loop (a sync_file is readable once signalled). */ @@ -196,23 +196,23 @@ void ahb_swapchain_layer_released(void *token, int release_fd) { /* ---- present */ int ahb_swapchain_has_ahb(const struct dmabuf_buffer *b) { - return b && *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL; + return b && *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) != NULL; } int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_w, int scene_h) { - struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL; + struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL; if (!ab) return -1; - int dmabuf_fd = banner_dmabuf_fd(b); + int dmabuf_fd = droiddeck_dmabuf_fd(b); /* Acquire fence: the game's render fence, which its driver put into the dma-buf before the * commit (Mesa's implicit sync). The display waits on it, not the CPU. */ int acquire = -1; - struct banner_dma_buf_sync_file exp = {.flags = BANNER_DMA_BUF_SYNC_READ, .fd = -1}; - if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, BANNER_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) { + struct droiddeck_dma_buf_sync_file exp = {.flags = DROIDDECK_DMA_BUF_SYNC_READ, .fd = -1}; + if (dmabuf_fd >= 0 && ioctl(dmabuf_fd, DROIDDECK_DMA_BUF_IOCTL_EXPORT_SYNC_FILE, &exp) == 0 && exp.fd >= 0) { acquire = exp.fd; } else if (dmabuf_fd >= 0) { if (!g_export_failed_logged) { g_export_failed_logged = 1; - banner_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead", + droiddeck_log("layer", "zero-copy: DMA_BUF_IOCTL_EXPORT_SYNC_FILE failed (%s): waiting for each frame on the CPU instead", strerror(errno)); } struct pollfd p = {.fd = dmabuf_fd, .events = POLLIN}; /* readable = the writers are done */ @@ -220,7 +220,7 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_ do { r = poll(&p, 1, 100); } while (r < 0 && errno == EINTR); } int r = sc_layer_present_ahb(ab->ahb, ab->w, ab->h, acquire, (void *)(uintptr_t)ab->id, scene_w, scene_h, - s ? banner_surface_color(s) : NULL, ab->format); + s ? droiddeck_surface_color(s) : NULL, ab->format); if (r < 0) return -1; if (r == 1) return 0; /* nothing of it on screen: not on the layer, nothing to release later */ if (!ab->on_layer) { @@ -231,8 +231,8 @@ int ahb_swapchain_present(struct dmabuf_buffer *b, struct surface *s, int scene_ if (s && g_announced != s) { char name[160]; g_announced = s; - banner_surface_describe(s, name, sizeof(name)); - banner_log("layer", "zero-copy: presenting %s without a copy", name); + droiddeck_surface_describe(s, name, sizeof(name)); + droiddeck_log("layer", "zero-copy: presenting %s without a copy", name); } return 0; } @@ -245,7 +245,7 @@ int ahb_swapchain_last_frame_age_ms(void) { } int ahb_swapchain_defer_release(struct dmabuf_buffer *b, struct wl_resource *buffer, struct surface *s, int paced) { - struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot(b) : NULL; + struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot(b) : NULL; if (!ab || !ab->on_layer || !buffer || ab->resource != buffer) return 0; ab->release_pending = 1; ab->deferred_ns = now_ns(); @@ -268,7 +268,7 @@ unsigned ahb_swapchain_stats_take(void) { int ahb_swapchain_advertised(void) { return g_advertised; } uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b) { - struct ahb_buf *ab = b ? *banner_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL; + struct ahb_buf *ab = b ? *droiddeck_dmabuf_ahb_slot((struct dmabuf_buffer *)b) : NULL; return ab ? ab->format : 0; } @@ -279,14 +279,14 @@ static void ahb_destroy(struct wl_client *c, struct wl_resource *r) { wl_resourc static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_resource *buffer, int32_t sock, uint32_t width, uint32_t height, uint32_t stride, uint32_t mod_hi, uint32_t mod_lo, uint32_t image_count) { - struct dmabuf_buffer *b = banner_dmabuf_from_resource(buffer); + struct dmabuf_buffer *b = droiddeck_dmabuf_from_resource(buffer); uint64_t modifier = ((uint64_t)mod_hi << 32) | mod_lo; if (!b) { - banner_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", banner_client_name(c)); + droiddeck_log("layer", "zero-copy: %s attached an AHardwareBuffer to a non-dma-buf wl_buffer, ignored", droiddeck_client_name(c)); close(sock); return; } - void **slot = banner_dmabuf_ahb_slot(b); + void **slot = droiddeck_dmabuf_ahb_slot(b); if (*slot) { close(sock); return; } /* once per buffer */ /* The handle was written into the socket before the request was sent; a short wait is just @@ -298,17 +298,17 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res int rc = pr > 0 ? AHardwareBuffer_recvHandleFromUnixSocket(sock, &ahb) : -1; close(sock); if (rc != 0 || !ahb) { - banner_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", banner_client_name(c), + droiddeck_log("layer", "zero-copy: receiving %s's AHardwareBuffer failed (%s)", droiddeck_client_name(c), pr > 0 ? "recvHandleFromUnixSocket" : "nothing on the socket"); return; } AHardwareBuffer_Desc d; AHardwareBuffer_describe(ahb, &d); int bw, bh; - banner_dmabuf_size(b, &bw, &bh); + droiddeck_dmabuf_size(b, &bw, &bh); if ((int)d.width != bw || (int)d.height != bh || (int)width != bw || (int)height != bh) { - banner_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored", - banner_client_name(c), d.width, d.height, bw, bh); + droiddeck_log("layer", "zero-copy: %s's AHardwareBuffer is %ux%u but its wl_buffer %dx%d, ignored", + droiddeck_client_name(c), d.width, d.height, bw, bh); AHardwareBuffer_release(ahb); return; } @@ -317,7 +317,7 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res if (!ab) { AHardwareBuffer_release(ahb); wl_client_post_no_memory(c); return; } ab->id = g_next_id++; ab->b = b; - banner_dmabuf_ref(b); + droiddeck_dmabuf_ref(b); ab->ahb = ahb; ab->w = bw; ab->h = bh; ab->stride = d.stride ? d.stride : stride; @@ -335,8 +335,8 @@ static void ahb_attach(struct wl_client *c, struct wl_resource *r, struct wl_res if (g_last_chain.client != c || g_last_chain.w != bw || g_last_chain.h != bh || g_last_chain.image_count != image_count || g_last_chain.modifier != modifier || g_last_chain.format != d.format) { g_last_chain = (struct chain_seen){c, bw, bh, image_count, modifier, d.format}; - banner_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", banner_client_name(c), - image_count, bw, bh, banner_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)" + droiddeck_log("layer", "zero-copy: AHB swapchain from %s (%u images, %dx%d, %s, %s, stride %u px)", droiddeck_client_name(c), + image_count, bw, bh, droiddeck_ahb_format_name(d.format), modifier == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)" : modifier == 0 ? "linear" : "unknown modifier", ab->stride); } } @@ -363,7 +363,7 @@ static void bind_ahb(struct wl_client *c, void *data, uint32_t ver, uint32_t id) /* One line per program, not per surface-format query (each binds its own). */ if (last_named != c) { last_named = c; - banner_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", banner_client_name(c), ver, + droiddeck_log("layer", "zero-copy: %s bound banner_ahb_v1 version %u (%s)", droiddeck_client_name(c), ver, ver >= 2 ? "follows the live switch" : "version 1: decides from its launch environment only"); } } @@ -372,7 +372,7 @@ void ahb_swapchain_set_mode(int on, int live) { on = on ? 1 : 0; g_zero_copy = on; if (!g_advertised) { - if (live) banner_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change", + if (live) droiddeck_log("layer", "zero-copy switched %s from the drawer, but no display layer is available on this device: no change", on ? "on" : "off"); return; } @@ -390,37 +390,37 @@ void ahb_swapchain_set_mode(int on, int live) { memset(&g_last_chain, 0, sizeof(g_last_chain)); g_announced = NULL; if (live) - banner_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s", + droiddeck_log("layer", "zero-copy switched %s from the drawer: %d bound program%s told to rebuild their swapchains%s", on ? "on" : "off", told, told == 1 ? "" : "s", on ? "; frames go on the display layer once the new gralloc swapchain is up" : "; gralloc frames still in flight stay on the layer (or take the copy path) until then"); else - banner_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off", - on ? "BANNER_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer" + droiddeck_log("layer", "zero-copy: %s at launch (%s)", on ? "on" : "off", + on ? "DROIDDECK_WAYLAND_ZERO_COPY=1: games present their own gralloc buffers on the display layer" : "the drawer's Zero-copy presentation switch turns it on live"); - if (live) wl_display_flush_clients(banner_get_display()); - banner_request_redraw(); + if (live) wl_display_flush_clients(droiddeck_get_display()); + droiddeck_request_redraw(); } void ahb_swapchain_init(struct wl_display *display) { wl_list_init(&g_bufs); wl_list_init(&g_clients); if (!sc_layer_available()) { - banner_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised"); + droiddeck_log("layer", "zero-copy: no display layer on this device (see the line above): banner_ahb_v1 not advertised"); return; } g_loop = wl_display_get_event_loop(display); if (pipe2(g_rel_pipe, O_CLOEXEC | O_NONBLOCK) != 0) { - banner_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno)); + droiddeck_log("error", "zero-copy: release pipe creation failed (%s); zero-copy presents disabled", strerror(errno)); g_rel_pipe[0] = g_rel_pipe[1] = -1; return; } wl_event_loop_add_fd(g_loop, g_rel_pipe[0], WL_EVENT_READABLE, on_release_pipe, NULL); if (!wl_global_create(display, &banner_ahb_v1_interface, 2, NULL, bind_ahb)) { - banner_log("error", "zero-copy: banner_ahb_v1 global creation failed"); + droiddeck_log("error", "zero-copy: banner_ahb_v1 global creation failed"); return; } g_advertised = 1; - banner_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)"); + droiddeck_log("layer", "zero-copy: banner_ahb_v1 version 2 advertised (games built for it present their own gralloc buffers while the switch is on)"); ahb_swapchain_set_mode(g_zero_copy, 0); } diff --git a/app/src/main/cpp/waylandcomp/src/ahb_swapchain.h b/app/src/main/cpp/waylandcomp/src/ahb_swapchain.h index fff1bf2f7..2c72a12b5 100644 --- a/app/src/main/cpp/waylandcomp/src/ahb_swapchain.h +++ b/app/src/main/cpp/waylandcomp/src/ahb_swapchain.h @@ -1,7 +1,7 @@ #ifndef AHB_SWAPCHAIN_H #define AHB_SWAPCHAIN_H /* - * Zero-copy game frames (layer mode, BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy + * Zero-copy game frames (layer mode, DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's "Zero-copy * presentation" switch live): the guest half of the design in ZERO_COPY_SPIKE.md. Our Wayland Turnip * (banners-turnip-wayland, patches/wayland/banner_ahb_wsi.py) binds the private global banner_ahb_v1 * (version 2), which this compositor advertises on every session where a display layer is possible, @@ -63,21 +63,21 @@ uint32_t ahb_swapchain_ahb_format(const struct dmabuf_buffer *b); void ahb_swapchain_layer_released(void *token, int release_fd); /* ---- ahb_swapchain.c -> compositor.c (hooks) */ -struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer); -int banner_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */ -void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h); -void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */ -void banner_dmabuf_ref(struct dmabuf_buffer *b); -void banner_dmabuf_unref(struct dmabuf_buffer *b); +struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer); +int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b); /* plane 0's dma-buf */ +void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h); +void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b); /* this module's per-buffer state */ +void droiddeck_dmabuf_ref(struct dmabuf_buffer *b); +void droiddeck_dmabuf_unref(struct dmabuf_buffer *b); /* Give a wl_buffer back to its client now (paced = 0) or on the FPS limiter's cadence (s != NULL). * since_ns = when the compositor let go of it (CLOCK_MONOTONIC; the perf line's release latency). */ -void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns); +void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns); /* Redraw the scene on the next tick (the present path may have changed). */ -void banner_request_redraw(void); +void droiddeck_request_redraw(void); /* "" (program) of the window a surface belongs to, for the log. */ -void banner_surface_describe(const struct surface *s, char *out, size_t size); -/* The surface's current image description (banner_color.h), NULL = none / HDR gate closed. */ -struct banner_color; -const struct banner_color *banner_surface_color(const struct surface *s); +void droiddeck_surface_describe(const struct surface *s, char *out, size_t size); +/* The surface's current image description (droiddeck_color.h), NULL = none / HDR gate closed. */ +struct droiddeck_color; +const struct droiddeck_color *droiddeck_surface_color(const struct surface *s); #endif diff --git a/app/src/main/cpp/waylandcomp/src/banner_ext.h b/app/src/main/cpp/waylandcomp/src/banner_ext.h deleted file mode 100644 index f982b9533..000000000 --- a/app/src/main/cpp/waylandcomp/src/banner_ext.h +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Interface between compositor.c and the extension modules that live in their own files: - * wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only) - * wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard) - * wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered) - * banner_ext.c the host→compositor message queue shared by the modules - * Everything here runs on the compositor (dispatch) thread unless noted. - */ -#pragma once -#include <stdint.h> -#include <wayland-server.h> - -void banner_log(const char *tag, const char *fmt, ...); - -/* ---- compositor.c → modules */ -void banner_ext_init(struct wl_display *display); /* creates the globals + host queue */ -void banner_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */ -void banner_text_input_refocus(void); /* the text-input target may have changed */ -void banner_text_input_surface_gone(struct wl_resource *surface); - -/* ---- modules → compositor.c */ -const char *banner_client_name(struct wl_client *client); -struct wl_display *banner_get_display(void); -/* The surface text input follows (the last clicked window, else the topmost program window). */ -struct wl_resource *banner_ime_target(void); -/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */ -void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y); -/* Deliver a key press/release through the wl_keyboard path as if typed. */ -void banner_inject_key(uint32_t evdev, int pressed); - -/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */ -void banner_on_clipboard_text(const char *utf8, int len); -void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h); - -/* ---- app → modules (callable from any thread; queued to the compositor thread) */ -void banner_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */ -void banner_host_text_commit(const char *utf8, int len); -void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end); -void banner_host_text_delete(int before, int after); -void banner_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */ -void banner_host_hdr_output(int on); /* the drawer's HDR output switch (banner_color_set_output) */ - -/* ---- module internals reached through the queue (banner_ext.c → modules) */ -void clipboard_init(struct wl_display *display); -void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */ -void text_input_init(struct wl_display *display); -void text_input_host_commit(const char *utf8, size_t len); -void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end); -void text_input_host_delete(int before, int after); -void toplevel_icon_init(struct wl_display *display); diff --git a/app/src/main/cpp/waylandcomp/src/blend_pass.c b/app/src/main/cpp/waylandcomp/src/blend_pass.c index 471eedc9a..842287b07 100644 --- a/app/src/main/cpp/waylandcomp/src/blend_pass.c +++ b/app/src/main/cpp/waylandcomp/src/blend_pass.c @@ -2,7 +2,7 @@ #define _POSIX_C_SOURCE 200809L #include "blend_pass.h" #include "vk_loader.h" -#include "vk_present.h" /* banner_log, vkp_gpu_name */ +#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */ #include <string.h> /* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag) @@ -168,12 +168,12 @@ static int objects_ensure(void) { if (!(g_vert = shader(blend_vert_code, sizeof(blend_vert_code)))) goto fail; if (!(g_frag = shader(blend_frag_code, sizeof(blend_frag_code)))) goto fail; g_ready = 1; - banner_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them", + droiddeck_log("gpu", "alpha composition ready on %s: translucent surfaces are blended over what is under them", vkp_gpu_name()); return 0; fail: objects_destroy(); - banner_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces " + droiddeck_log("error", "gpu: the alpha composition pass could not be built on this driver; translucent surfaces " "are drawn opaque this session"); return -1; } diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 75a16311e..fbbd09886 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -1,5 +1,5 @@ /* - * bannerlator-wayland - the embedded compositor of the Wayland display path. + * droiddeck-wayland - the embedded compositor of the Wayland display path. * * Clients are the Wine processes of a container (winewayland.drv) and, through them, * Mesa's Vulkan WSI. Each process is its own client, so a Windows virtual desktop is @@ -50,12 +50,12 @@ #include "framegen_bridge.h" #include <pthread.h> #include "effects_chain.h" -#include "banner_ext.h" -#include "banner_color.h" +#include "droiddeck_ext.h" +#include "droiddeck_color.h" #include "compositor_internal.h" -#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", __VA_ARGS__) -#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__) +#define WLOGI(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", __VA_ARGS__) +#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__) /* ------------------------------------------------------------------ session log * One readable, time-stamped file per Wayland session in Download/Wayland-logs, also @@ -75,7 +75,7 @@ static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER; static char *g_prelog[PRELOG_MAX]; static int g_prelog_n; -void banner_log(const char *tag, const char *fmt, ...) { +void droiddeck_log(const char *tag, const char *fmt, ...) { char msg[512]; va_list ap; va_start(ap, fmt); @@ -121,11 +121,11 @@ static void open_session_log(void) { setvbuf(f, NULL, _IOLBF, 0); strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &tm); fprintf(f, - "Bannerlator Wayland session\n" - "===========================\n" + "DroidDeck Wayland session\n" + "=========================\n" "Started %s\n" - "Display Wayland: Windows programs draw through the Bannerlator compositor.\n" - " No X server is used for this session.\n" + "Display Wayland: programs draw through the DroidDeck compositor.\n" + " gamescope and the desktop start their own Xwayland for X11 programs.\n" "Log %s\n\n" "Time Area Event\n" "------------ -------- -----------------------------------------------------\n", @@ -178,8 +178,8 @@ static const char *client_name(struct wl_client *client) { static void on_client_destroyed(struct wl_listener *l, void *data) { struct client_info *ci = wl_container_of(l, ci, destroy), **pp; - banner_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid); - banner_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */ + droiddeck_log("program", "disconnected: %s (pid %d)", ci->name, (int)ci->pid); + droiddeck_color_client_gone(ci->client); /* HDR summary for a program that presented HDR (gate open only) */ for (pp = &g_clients; *pp; pp = &(*pp)->next) if (*pp == ci) { *pp = ci->next; break; } free(ci); @@ -211,7 +211,7 @@ static void on_client_created(struct wl_listener *l, void *data) { wl_client_add_destroy_listener(client, &ci->destroy); ci->next = g_clients; g_clients = ci; - banner_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid); + droiddeck_log("program", "connected over Wayland: %s (pid %d)", ci->name, (int)ci->pid); } /* ------------------------------------------------------------------ surfaces */ @@ -304,7 +304,7 @@ volatile int g_fps_limit; * the X11 renderer's unviewable "explorer.exe". They still exist for input routing. */ volatile int g_hide_shell; -/* Experimental layer mode (BANNER_WAYLAND_ZERO_COPY=1 in the container's environment): a single +/* Experimental layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 in the container's environment): a single * fullscreen window is shown on its own Android layer (sc_layer.c) instead of being blitted into the * screen swapchain. Set from the app before the compositor starts; read per frame. */ volatile int g_zero_copy; @@ -314,11 +314,11 @@ static int g_zero_copy_fx_skip_said; /* Compressed (UBWC) game buffers: zwp_linux_dmabuf_v1 advertises DRM_FORMAT_MOD_QCOM_COMPRESSED next * to LINEAR for every format the renderer's own driver can import that way, so Turnip's Wayland WSI * in the game allocates UBWC swapchain images instead of resolving each frame to a linear copy. - * BANNER_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set + * DROIDDECK_WAYLAND_UBWC=0 in the container's environment turns the advertisement off (A/B switch). Set * from the app before the compositor starts; read at every zwp_linux_dmabuf_v1 bind. */ volatile int g_ubwc = 1; -/* Debug (BANNER_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone +/* Debug (DROIDDECK_WAYLAND_NO_RENDER_NODE=1): name no DRM device in the dma-buf feedback, as a phone * that exposes no /dev/dri node to apps does, to reproduce its OpenGL path here. Set from the app * before the compositor starts. */ volatile int g_no_render_node; @@ -544,13 +544,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) { /* The serial alone, without the lock: the app asks on every pointer move, and the snapshot below * copies up to 256 KB of pixels across JNI and holds the lock the compositor thread takes to * change the cursor. A torn read is only a missed or an extra snapshot. */ -int banner_cursor_serial(void) { +int droiddeck_cursor_serial(void) { return __atomic_load_n(&g_cursor_serial, __ATOMIC_RELAXED); } /* Read by the app (UI thread). out = [serial, hidden, w, h, hotspotX, hotspotY, pixels...]. * Returns the number of ints written, or 0 if out is too small. */ -int banner_cursor_snapshot(int *out, int cap) { +int droiddeck_cursor_snapshot(int *out, int cap) { int n = 0; pthread_mutex_lock(&g_cursor_lock); int need = 6 + (g_cursor_hidden ? 0 : g_cursor_w * g_cursor_h); @@ -650,7 +650,7 @@ static void map_toplevel(struct surface *s) { { char name[160]; describe(s, name, sizeof(name)); - banner_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y, + droiddeck_log("window", "opened %s %dx%d at %d,%d%s", name, w, h, s->x, s->y, s->placed ? "" : " (no desktop position yet)"); } wl_list_insert(g_toplevels.prev, &s->toplevel_link); /* new windows start on top */ @@ -667,12 +667,12 @@ static void unmap_toplevel(struct surface *s) { { char name[160]; describe(s, name, sizeof(name)); - banner_log("window", "closed %s", name); + droiddeck_log("window", "closed %s", name); } wl_list_remove(&s->toplevel_link); wl_list_init(&s->toplevel_link); if (g_ime_click == s) g_ime_click = NULL; - banner_text_input_refocus(); + droiddeck_text_input_refocus(); } /* Let go of the surface's dmabuf content. paced = 1: the buffer was replaced, give it back on the @@ -752,11 +752,11 @@ static struct surface *g_hud_surface; static int64_t g_hud_last_ns; /* That window committed a frame the screen has not drawn yet. */ static int g_hud_fresh; -extern void banner_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */ -extern void banner_on_game_frame(void); +extern void droiddeck_on_game_surface(const char *window, const char *gpu); /* window NULL = gone */ +extern void droiddeck_on_game_frame(void); /* The program behind that window: its Linux pid (the Wayland client's credentials) and executable name * ("" when /proc gave none) - the app arms its CPU affinity on it (X11 does that from window events). */ -extern void banner_on_game_program(int pid, const char *program); +extern void droiddeck_on_game_program(int pid, const char *program); static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_resource *buffer) { if (s->dmabuf != buffer || s->dmabuf_buf != b) { @@ -787,7 +787,7 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re (s->buf_w != b->width || s->buf_h != b->height)) { char name[160]; describe(s, name, sizeof(name)); - banner_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name, + droiddeck_log("vulkan", "%s changed its frame size: %dx%d -> %dx%d", name, s->buf_w, s->buf_h, b->width, b->height); } s->buf_w = b->width; @@ -803,18 +803,18 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re if (b->img) /* Imported for the copy path. Whether its frames ALSO go on the display layer without a copy * is decided per frame (fullscreen, zero-copy on) and counted in the 10 s lines. */ - banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)", + droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d, format %c%c%c%c, %s (%s)", name, b->width, b->height, b->format & 0xff, (b->format >> 8) & 0xff, (b->format >> 16) & 0xff, (b->format >> 24) & 0xff, vkp_modifier_name(b->modifier), ahb_swapchain_has_ahb(b) ? "gralloc buffers: can go on the display layer without a copy" : "dma-buf: copied into the screen swapchain"); else if (ahb_swapchain_has_ahb(b)) - banner_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only " + droiddeck_log("vulkan", "%s is presenting GPU frames through Wayland: %dx%d on its own display layer only " "(gralloc buffers the compositor cannot import for the copy path)", name, b->width, b->height); else - banner_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)", + droiddeck_log("error", "could not import GPU frames from %s (%dx%d, modifier %#llx)", name, b->width, b->height, (unsigned long long)b->modifier); } /* The HUD follows the window whether its frames are copied or go straight to the layer: a @@ -830,21 +830,21 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re char name[160]; describe(s, name, sizeof(name)); g_hud_surface = s; - banner_on_game_surface(name, vkp_gpu_name()); + droiddeck_on_game_surface(name, vkp_gpu_name()); struct client_info *ci = client_info_of(wl_resource_get_client(s->resource)); - if (ci) banner_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); + if (ci) droiddeck_on_game_program((int)ci->pid, strncmp(ci->name, "pid ", 4) ? ci->name : ""); } /* HDR session (gate open): a DXVK game switches to HDR by REBUILDING its swapchain on the same * surface, so the one-shot line above never sees the 10-bit buffers - name every format change. */ - if (b->format != s->hdr_fmt_logged && banner_color_hdr_open()) { + if (b->format != s->hdr_fmt_logged && droiddeck_color_hdr_open()) { char name[160]; uint32_t af = ahb_swapchain_ahb_format(b); int ten = b->format == FOURCC('A', 'B', '3', '0') || b->format == FOURCC('X', 'B', '3', '0'); describe(s, name, sizeof(name)); - banner_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height, + droiddeck_log("color", "%s presents %dx%d buffers in %c%c%c%c (%s), %s%s%s; %s", name, b->width, b->height, b->format & 0xff, (b->format >> 8) & 0xff, (b->format >> 16) & 0xff, (b->format >> 24) & 0xff, ten ? "10-bit A2B10G10R10" : "8-bit", vkp_modifier_name(b->modifier), - af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? banner_ahb_format_name(af) : "", + af ? ", gralloc " : ", no gralloc buffer (copy path only)", af ? droiddeck_ahb_format_name(af) : "", b->img ? "the compositor imported it" : af ? "the compositor's driver could NOT import it (display layer only, fullscreen)" : "the compositor's driver could NOT import it (nothing can show it)"); @@ -854,23 +854,23 @@ static void take_dmabuf(struct surface *s, struct dmabuf_buffer *b, struct wl_re * Asking whether the compositor could import it, as this used to, is a question about the * copy path and not about whether a frame happened: a game whose buffers go straight to * the display layer draws on screen while the counter sat at 0.0 fps and 1000.0 ms. */ - if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; banner_on_game_frame(); } + if (s == g_hud_surface) { g_hud_last_ns = now_ns(); g_hud_fresh = 1; droiddeck_on_game_frame(); } } /* ---- hooks for ahb_swapchain.c (zero-copy layers) */ -struct dmabuf_buffer *banner_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); } -int banner_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; } -void banner_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; } -void **banner_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; } -void banner_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; } -void banner_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); } -void banner_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) { +struct dmabuf_buffer *droiddeck_dmabuf_from_resource(struct wl_resource *buffer) { return get_dmabuf(buffer); } +int droiddeck_dmabuf_fd(const struct dmabuf_buffer *b) { return b && b->n_planes > 0 ? b->fd[0] : -1; } +void droiddeck_dmabuf_size(const struct dmabuf_buffer *b, int *w, int *h) { *w = b->width; *h = b->height; } +void **droiddeck_dmabuf_ahb_slot(struct dmabuf_buffer *b) { return &b->ahb_state; } +void droiddeck_dmabuf_ref(struct dmabuf_buffer *b) { b->refs++; } +void droiddeck_dmabuf_unref(struct dmabuf_buffer *b) { dmabuf_buffer_unref(b); } +void droiddeck_release_buffer(struct surface *s, struct wl_resource *buffer, int paced, int64_t since_ns) { if (paced && s) release_buffer(s, buffer, since_ns); else { wl_buffer_send_release(buffer); perf_note_release(since_ns); } } -void banner_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); } -const struct banner_color *banner_surface_color(const struct surface *s) { - return s ? banner_color_of(s->resource) : NULL; +void droiddeck_surface_describe(const struct surface *s, char *out, size_t size) { describe(s, out, size); } +const struct droiddeck_color *droiddeck_surface_color(const struct surface *s) { + return s ? droiddeck_color_of(s->resource) : NULL; } /* ------------------------------------------------------------------ wl_surface */ @@ -1134,7 +1134,7 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { wl_list_insert_list(s->feedback.prev, &s->pending_feedback); wl_list_init(&s->pending_feedback); constraints_surface_commit(s); - banner_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */ + droiddeck_color_commit(s->resource); /* wp_color_management_surface_v1 state (returns at once when HDR is off) */ if (s->role == ROLE_TOPLEVEL && s->xdg_toplevel) { if (s->has_content) map_toplevel(s); @@ -1184,9 +1184,9 @@ static void surface_resource_destroy(struct wl_resource *r) { if (g_grab == s) g_grab = NULL; if (g_key_target == s) g_key_target = NULL; if (g_ime_click == s) g_ime_click = NULL; - banner_text_input_surface_gone(r); - if (g_desktop == s) { g_desktop = NULL; banner_log("desktop", "the desktop closed"); } - if (g_hud_surface == s) { g_hud_surface = NULL; banner_on_game_surface(NULL, NULL); } + droiddeck_text_input_surface_gone(r); + if (g_desktop == s) { g_desktop = NULL; droiddeck_log("desktop", "the desktop closed"); } + if (g_hud_surface == s) { g_hud_surface = NULL; droiddeck_on_game_surface(NULL, NULL); } constraints_surface_gone(s); unmap_toplevel(s); @@ -1436,7 +1436,7 @@ static void xdg_toplevel_set_title(struct wl_client *c, struct wl_resource *r, c if (announce) { char after[160]; describe(s, after, sizeof(after)); - banner_log("window", "renamed %s -> %s", before, after); + droiddeck_log("window", "renamed %s -> %s", before, after); } } static void xdg_toplevel_set_app_id(struct wl_client *c, struct wl_resource *r, const char *id) {} @@ -1613,7 +1613,7 @@ static void desktop_set_desktop(struct wl_client *c, struct wl_resource *r, stru if (!s || (s->role != ROLE_NONE && s->role != ROLE_DESKTOP)) return; s->role = ROLE_DESKTOP; g_desktop = s; - banner_log("desktop", "Windows virtual desktop created by %s", client_name(c)); + droiddeck_log("desktop", "Windows virtual desktop created by %s", client_name(c)); schedule_render(); } static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struct wl_resource *surface, @@ -1623,7 +1623,7 @@ static void desktop_set_window(struct wl_client *c, struct wl_resource *r, struc if (s->placed && (s->x != x || s->y != y) && s->mapped && log_budget()) { char name[160]; describe(s, name, sizeof(name)); - banner_log("window", "moved %s to %d,%d", name, x, y); + droiddeck_log("window", "moved %s to %d,%d", name, x, y); } s->hwnd = hwnd; s->x = x; @@ -1666,13 +1666,13 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t if (!r) { wl_client_post_no_memory(c); return; } wl_resource_set_implementation(r, &output_impl, NULL, NULL); wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN, - "Bannerlator", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL); + "DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL); wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED, g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080, g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000); if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) { - wl_output_send_name(r, "Bannerlator-1"); - wl_output_send_description(r, "Bannerlator display"); + wl_output_send_name(r, "DroidDeck-1"); + wl_output_send_description(r, "DroidDeck display"); } if (ver >= 2) { wl_output_send_scale(r, 1); @@ -1701,7 +1701,7 @@ static int g_hdr_unimported_rect[4], g_hdr_unimported_below; /* scene x,y,w,h; d static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, int ox, int oy) { if (!s->dmabuf_buf || s->dmabuf_buf->img || !ahb_swapchain_has_ahb(s->dmabuf_buf)) return; - const struct banner_color *c = banner_color_of(s->resource); + const struct droiddeck_color *c = droiddeck_color_of(s->resource); if (!c || !c->dataspace) return; if (s->src_set && (s->src[0] != 0 || s->src[1] != 0 || (int)(s->src[2] + 0.5f) != s->buf_w || (int)(s->src[3] + 0.5f) != s->buf_h)) return; /* cropped: the layer shows whole buffers */ @@ -1730,7 +1730,7 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) int dw, dh; if (!img) { - if (g_zero_copy && banner_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy); + if (g_zero_copy && droiddeck_color_hdr_open()) note_hdr_unimported(dl, s, ox, oy); return; } if (s->src_set) { sx = s->src[0]; sy = s->src[1]; sw = s->src[2]; sh = s->src[3]; } @@ -1850,7 +1850,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) { return s; } -/* ---- HDR (banner_color.h) in the scene +/* ---- HDR (droiddeck_color.h) in the scene * An HDR frame is only right on the game's own display layer: everything the compositor draws itself - * the scene blit, the effects chain, frame generation, its swapchain - is 8-bit sRGB, and a PQ-encoded * frame pushed through it as it stands comes out washed out. So a scene with HDR in it takes one of @@ -1866,7 +1866,7 @@ static struct surface *ahb_layer_only_candidate(int scene_w, int scene_h) { * tone-mapped to SDR (correct colours instead of washed out). * 3 a frame the compositor could not import: only its display layer can show it, so effects and * frame generation are skipped for it (said in the log). - * The drawer's HDR output switch (banner_color_output) OFF: the same routes, tone-mapped - route 0 + * The drawer's HDR output switch (droiddeck_color_output) OFF: the same routes, tone-mapped - route 0 * becomes route 1 (the whole scene composed, tone-mapped to sRGB, onto the game layer UNtagged), route 2 * tone-maps into an SDR swapchain, route 3 cannot (nothing can read those frames) and stays HDR, said * in the log. The game is told nothing; it keeps rendering HDR. @@ -1876,7 +1876,7 @@ struct hdr_scene { struct vkp_hdr_frame hf; unsigned char *is_hdr; /* per draw (calloc'd; free after the frame) */ int n_hdr; - const struct banner_color *color; /* the topmost HDR draw's description: what the picture is tagged */ + const struct droiddeck_color *color; /* the topmost HDR draw's description: what the picture is tagged */ struct surface *who; /* its surface, for the log */ const char *why; /* route 1: why the game cannot be alone on its layer */ }; @@ -1891,29 +1891,29 @@ static struct surface *hdr_unimported_fullscreen(const struct draw_list *dl, int } static int is_hdr_surface(struct surface *s) { - const struct banner_color *c = s ? banner_color_of(s->resource) : NULL; + const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL; return c && c->dataspace; } /* Which route this scene takes (see above); fills hs. 0 whenever the gate is closed or no HDR is drawn. */ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int framegen, struct hdr_scene *hs) { memset(hs, 0, sizeof(*hs)); - if (!banner_color_hdr_open()) return 0; + if (!droiddeck_color_hdr_open()) return 0; /* A fullscreen HDR frame this compositor could not import has no draw: layer only (route 3). */ struct surface *un = hdr_unimported_fullscreen(dl, w, h); if (!un) { struct surface *lo = ahb_layer_only_candidate(w, h); if (lo && is_hdr_surface(lo)) un = lo; } - if (un && g_zero_copy) { hs->color = banner_color_of(un->resource); hs->who = un; return 3; } - hs->hf.tonemap = !banner_color_output(); + if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; } + hs->hf.tonemap = !droiddeck_color_output(); if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n, 1))) return 0; for (int i = 0; i < dl->n; i++) { struct surface *s = surface_for_image(dl->d[i].img); if (!is_hdr_surface(s)) continue; hs->is_hdr[i] = 1; hs->n_hdr++; - hs->color = banner_color_of(s->resource); /* later draws are above: the topmost wins */ + hs->color = droiddeck_color_of(s->resource); /* later draws are above: the topmost wins */ hs->who = s; } if (!hs->n_hdr) { free(hs->is_hdr); hs->is_hdr = NULL; return 0; } @@ -1942,7 +1942,7 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra static void hdr_note_route(int route, const struct hdr_scene *hs) { static int said = -1, said_tm = -1; static const char *said_why; - const int tm = route != 0 && !banner_color_output(); /* route 0 never runs with the switch off */ + const int tm = route != 0 && !droiddeck_color_output(); /* route 0 never runs with the switch off */ if (route == said && tm == said_tm && (route != 1 || hs->why == said_why)) return; int was = said; said = route; said_why = hs->why; said_tm = tm; @@ -1951,30 +1951,30 @@ static void hdr_note_route(int route, const struct hdr_scene *hs) { switch (route) { case 1: if (tm) - banner_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR " + droiddeck_log("color", "tone-mapped picture for %s: %s - the whole scene is composed and tone-mapped to SDR " "(HDR peak %.0f nits rolled off to SDR white, screen effects applied after it) on the game's " "display layer, untagged", name, hs->why ? hs->why : "?", hs->hf.peak_nits); else - banner_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture " + droiddeck_log("color", "HDR picture for %s: %s - the whole scene is composed into one 10-bit PQ BT.2020 picture " "(SDR content at %.0f nits, screen effects applied in 10-bit) on the game's display layer, tagged " - "BT2020_PQ", name, hs->why ? hs->why : "?", banner_color_sdr_white()); + "BT2020_PQ", name, hs->why ? hs->why : "?", droiddeck_color_sdr_white()); break; case 2: if (tm) - banner_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to " + droiddeck_log("color", "HDR with frame generation for %s, HDR output switched off: the scene is tone-mapped to " "SDR and presented through an SDR screen swapchain", name); else - banner_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through " + droiddeck_log("color", "HDR with frame generation for %s: the scene is composed into PQ and presented through " "the screen swapchain (HDR10 where the surface offers it, else tone-mapped to SDR)", name); break; case 3: if (tm) - banner_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, " + droiddeck_log("color", "HDR output is switched off, but %s's HDR frames cannot be imported by the compositor, " "so nothing can tone-map them: they stay HDR on its own display layer", name); break; /* otherwise hdr_note_precedence says it */ default: if (was == 1 || was == 2) - banner_log("color", "%s is back on its own display layer (no composition needed)", name); + droiddeck_log("color", "%s is back on its own display layer (no composition needed)", name); break; } } @@ -1987,18 +1987,18 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) { if (hs) describe(hs, name, sizeof(name)); if (fx_skip != fx_said) { if (fx_skip) - banner_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the " + droiddeck_log("color", "screen effects are NOT applied to %s: its HDR frames cannot be imported by the " "compositor, so only its own display layer can show them (as they are)", name); else if (fx_said == 1) - banner_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)"); + droiddeck_log("color", "screen effects apply to the scene again (no HDR game on its layer, or effects off)"); fx_said = fx_skip; } if (fg_skip != fg_said) { if (fg_skip) - banner_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the " + droiddeck_log("color", "frame generation is NOT applied to %s: its HDR frames cannot be imported by the " "compositor, so only its own display layer can show them (as they are)", name); else if (fg_said == 1) - banner_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)"); + droiddeck_log("color", "frame generation applies again (no HDR game on its layer, or frame generation off)"); fg_said = fg_skip; } } @@ -2007,11 +2007,11 @@ static void hdr_note_precedence(struct surface *hs, int fx_on, int framegen) { static void hdr_count_copied(const struct draw_list *dl, const char *reason) { for (int i = 0; i < dl->n; i++) { struct surface *s = surface_for_image(dl->d[i].img); - const struct banner_color *c = s ? banner_color_of(s->resource) : NULL; + const struct droiddeck_color *c = s ? droiddeck_color_of(s->resource) : NULL; if (!c || !c->dataspace) continue; char name[160]; describe(s, name, sizeof(name)); - banner_color_frame_copied(name, reason); + droiddeck_color_frame_copied(name, reason); } } @@ -2028,8 +2028,8 @@ static void render_scene(void) { wl_list_for_each(s, &g_surfaces, link) s->drawn = 0; scene_size(&w, &h); if (w != g_scene_w || h != g_scene_h) { - if (g_desktop) banner_log("desktop", "size %dx%d", w, h); - else banner_log("desktop", "no desktop: showing %dx%d (largest window)", w, h); + if (g_desktop) droiddeck_log("desktop", "size %dx%d", w, h); + else droiddeck_log("desktop", "no desktop: showing %dx%d (largest window)", w, h); g_scene_w = w; g_scene_h = h; } @@ -2048,7 +2048,7 @@ static void render_scene(void) { struct hdr_scene hs; int hdr_route = hdr_plan(&dl, w, h, fx_on, framegen, &hs); const char *hdr_copy = NULL; /* why this scene's HDR frames (if any) took the copy path */ - if (banner_color_hdr_open()) { + if (droiddeck_color_hdr_open()) { hdr_note_route(hdr_route, &hs); hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen); } @@ -2059,12 +2059,12 @@ static void render_scene(void) { sc_layer_hide_overlay(); if (sc_layer_present_hdr_scene(dl.d, dl.n, &hs.hf, w, h, hs.color) == 0) { rendered = vkp_base_black(w, h) == 0; /* black under the composed picture, presented only when it is not already */ - if (fell_back) { fell_back = 0; banner_log("color", "the composed picture is on the game's display layer again"); } + if (fell_back) { fell_back = 0; droiddeck_log("color", "the composed picture is on the game's display layer again"); } } else { hdr_route = 2; /* no layer this frame: the picture goes through the swapchain instead */ if (!fell_back) { fell_back = 1; - banner_log("color", "the composed picture could not go on the game's display layer (no layer, or the " + droiddeck_log("color", "the composed picture could not go on the game's display layer (no layer, or the " "10-bit pass failed): it goes through the screen swapchain instead until that changes"); } } @@ -2074,8 +2074,8 @@ static void render_scene(void) { sc_layer_hide(); rendered = vkp_render_hdr(w, h, dl.d, dl.n, &hs.hf, &how) == 0; copy = 1; - if (rendered && how == 1) banner_color_frame_shown(hs.color, BANNER_HDR_SWAPCHAIN, 0); - else if (rendered && how == 2) banner_color_frame_shown(hs.color, BANNER_HDR_TONEMAPPED, 0); + if (rendered && how == 1) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_SWAPCHAIN, 0); + else if (rendered && how == 2) droiddeck_color_frame_shown(hs.color, DROIDDECK_HDR_TONEMAPPED, 0); else if (rendered) hdr_copy = "the HDR composition pass is unavailable on this driver"; } if (hdr_route == 0 || hdr_route == 3) { @@ -2104,14 +2104,14 @@ static void render_scene(void) { if (g_zero_copy && blocked != g_zero_copy_paused) { g_zero_copy_paused = blocked; if (blocked == 1) - banner_log("framegen", "zero-copy paused: frame generation needs the compositor pass"); + droiddeck_log("framegen", "zero-copy paused: frame generation needs the compositor pass"); else if (blocked == 2) - banner_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene"); + droiddeck_log("effects", "zero-copy paused: a window above the game needs the compositor pass for the whole scene"); else if (blocked == 3) - banner_log("layer", "zero-copy paused: a window above the game would need a second display layer, " + droiddeck_log("layer", "zero-copy paused: a window above the game would need a second display layer, " "which this display cannot compose in hardware - whole scene on the copy path"); else - banner_log("effects", "zero-copy resumed: the game is back on its own display layer"); + droiddeck_log("effects", "zero-copy resumed: the game is back on its own display layer"); } struct surface *ls = li >= 0 ? surface_for_image(dl.d[li].img) : NULL; /* A frame this renderer could not import can only be shown on the layer, effects or not. */ @@ -2121,7 +2121,7 @@ static void render_scene(void) { if (li < 0 && !ls) ls = hdr_unimported_fullscreen(&dl, w, h); if (ls && pass_on && li < 0 && !g_zero_copy_fx_skip_said) { g_zero_copy_fx_skip_said = 1; - banner_log(framegen ? "framegen" : "effects", + droiddeck_log(framegen ? "framegen" : "effects", "the game's frames cannot be imported by the compositor: shown zero-copy, %s skipped", framegen ? "frame generation" : "effects"); } @@ -2148,7 +2148,7 @@ static void render_scene(void) { r = ahb_swapchain_present(ls->dmabuf_buf, ls, w, h); if (r != 0 && li >= 0) r = fx_on ? sc_layer_present_pass(&dl.d[li], 1, w, h) - : sc_layer_present(dl.d[li].img, w, h, ls ? banner_surface_color(ls) : NULL); + : sc_layer_present(dl.d[li].img, w, h, ls ? droiddeck_surface_color(ls) : NULL); if (r == 0) { if (ls) ls->drawn = 1; /* The one window above the game keeps the game off the copy path entirely: it goes on @@ -2188,7 +2188,7 @@ static void render_scene(void) { } } /* routes 0 and 3 */ free(hs.is_hdr); - if (hdr_copy && rendered && banner_color_hdr_open()) hdr_count_copied(&dl, hdr_copy); + if (hdr_copy && rendered && droiddeck_color_hdr_open()) hdr_count_copied(&dl, hdr_copy); if (rendered) { int64_t t = now_ns(); g_stat_frames++; @@ -2370,7 +2370,7 @@ static void bind_seat(struct wl_client *c, void *data, uint32_t ver, uint32_t id struct wl_resource *r = wl_resource_create(c, &wl_seat_interface, ver, id); wl_resource_set_implementation(r, &seat_impl, NULL, NULL); wl_seat_send_capabilities(r, WL_SEAT_CAPABILITY_POINTER | WL_SEAT_CAPABILITY_KEYBOARD | WL_SEAT_CAPABILITY_TOUCH); - if (ver >= 2) wl_seat_send_name(r, "bannerlator-seat"); + if (ver >= 2) wl_seat_send_name(r, "droiddeck-seat"); } static struct seat_pointer *pointer_for(struct wl_client *client) { @@ -2449,7 +2449,7 @@ static void keyboard_focus(struct wl_resource *target) { g_mods_depressed, 0, g_mods_locked, 0); } wl_array_release(&keys); - banner_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */ + droiddeck_clipboard_keyboard_focus(client); /* wl_data_device selection follows focus */ } /* ------------------------------------------------------------------ pointer constraints @@ -2502,14 +2502,14 @@ static int g_raw_valid; /* JNI: the app switches its touch/mouse path to deltas while a lock holds and re-syncs its * pointer to x,y when it ends. */ -extern void banner_on_pointer_lock(int locked, int x, int y); +extern void droiddeck_on_pointer_lock(int locked, int x, int y); static void sync_lock_notify(void) { static int last; int locked = g_active_constraint && g_active_constraint->is_lock; if (locked == last) return; last = locked; - banner_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y); + droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y); } static void constraint_describe(const struct constraint *k, char *out, size_t size) { @@ -2570,7 +2570,7 @@ static void constraint_end(struct constraint *k, int tell_client, const char *wh g_raw_valid = 0; if (tell_client) constraint_send_state(k, 0); constraint_describe(k, name, sizeof(name)); - banner_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined", + droiddeck_log("pointer", "%s: %s (%s), pointer at %d,%d", k->is_lock ? "unlocked" : "unconfined", name, why, (int)g_ptr_x, (int)g_ptr_y); sync_lock_notify(); } @@ -2590,15 +2590,15 @@ static void constraint_activate(struct constraint *k) { constraint_send_state(k, 1); constraint_describe(k, name, sizeof(name)); if (k->is_lock) - banner_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name, + droiddeck_log("pointer", "locked: %s (%s), pointer frozen at %d,%d", name, k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent", (int)g_ptr_x, (int)g_ptr_y); else if (k->region.set) - banner_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name, + droiddeck_log("pointer", "confined: %s (%s) to %dx%d at %d,%d of the window", name, k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent", k->region.w, k->region.h, k->region.x, k->region.y); else - banner_log("pointer", "confined: %s (%s) to the whole window", name, + droiddeck_log("pointer", "confined: %s (%s) to the whole window", name, k->lifetime == ZWP_POINTER_CONSTRAINTS_V1_LIFETIME_ONESHOT ? "oneshot" : "persistent"); sync_lock_notify(); wl_display_flush_clients(g_display); @@ -2652,7 +2652,7 @@ static void constraints_surface_commit(struct surface *s) { g_ptr_x = ox + k->hint_x; g_ptr_y = oy + k->hint_y; if (log_budget()) - banner_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y); + droiddeck_log("pointer", "position hint: pointer moved to %d,%d", (int)g_ptr_x, (int)g_ptr_y); } } if (k->pending_region_set) { @@ -2733,7 +2733,7 @@ static void constraint_create(struct wl_client *c, struct wl_resource *r, uint32 wl_list_insert(g_constraints.prev, &k->link); if (!s) { k->defunct = 1; return; } constraint_describe(k, name, sizeof(name)); - banner_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name); + droiddeck_log("pointer", "%s requested by %s for %s", is_lock ? "lock" : "confine", client_name(c), name); constraint_activate(k); } @@ -2764,7 +2764,7 @@ static void bind_pointer_constraints(struct wl_client *c, void *data, uint32_t v static void relative_pointer_resource_destroy(struct wl_resource *r) { struct relative_pointer *rp = wl_resource_get_user_data(r); if (!rp) return; - banner_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r))); + droiddeck_log("pointer", "relative pointer released by %s", client_name(wl_resource_get_client(r))); wl_list_remove(&rp->link); free(rp); } @@ -2783,7 +2783,7 @@ static void relative_pointer_manager_get(struct wl_client *c, struct wl_resource rp->pointer = pointer; wl_resource_set_implementation(res, &relative_pointer_impl, rp, relative_pointer_resource_destroy); wl_list_insert(g_relative_pointers.prev, &rp->link); - banner_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c)); + droiddeck_log("pointer", "relative pointer created for %s (motion arrives as deltas)", client_name(c)); } static const struct zwp_relative_pointer_manager_v1_interface relative_pointer_manager_impl = { .destroy = relative_pointer_manager_destroy, @@ -2890,7 +2890,7 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int if (button && pressed && !(k && k->is_lock)) { struct surface *clicked = toplevel_at(g_ptr_x, g_ptr_y); if (clicked) g_ime_click = clicked; - banner_text_input_refocus(); + droiddeck_text_input_refocus(); } wl_display_flush_clients(g_display); } @@ -3034,7 +3034,7 @@ static void touch_down(int id, double x, double y) { if (!any_active) { char name[160]; describe(target, name, sizeof(name)); - banner_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)"); + droiddeck_log("touch", "%s gets %s", name, has_touch ? "touch" : "the pointer (it takes no touch)"); } if (!has_touch) { if (any_active) return; /* one finger drives the pointer; a second one is ignored */ @@ -3113,7 +3113,7 @@ static void deliver_touch(const struct input_msg *m) { } static void key_event(uint32_t evdev, int pressed); -struct wl_resource *banner_ime_target(void); +struct wl_resource *droiddeck_ime_target(void); static void deliver_key(const struct input_msg *m) { key_event((uint32_t)m->p1, m->p2); } @@ -3177,7 +3177,7 @@ static void key_event(uint32_t evdev, int pressed) { * thread, not to the foreground program (X11 delivers keys to the focused app window, so * this mirrors it). The desktop itself is the last resort. */ struct surface *target = NULL; - struct wl_resource *ime = banner_ime_target(); + struct wl_resource *ime = droiddeck_ime_target(); if (ime) target = wl_resource_get_user_data(ime); if (!target && g_key_target && g_key_target->mapped && g_key_target != g_desktop) target = g_key_target; if (!target) target = g_desktop; @@ -3220,14 +3220,14 @@ static int on_input_readable(int fd, uint32_t mask, void *data) { /* Called from JNI (Android UI thread). Queues a pointer event; the compositor thread * dispatches it. x/y are in INPUT_SPACE (0..1919, 0..1079) over the whole output. */ -void banner_wayland_send_pointer(int action, int x, int y) { +void droiddeck_wayland_send_pointer(int action, int x, int y) { if (g_input_pipe[1] < 0) return; struct input_msg m = { 0, action, x, y, 0 }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); (void)n; } -void banner_wayland_send_touch(int action, int pointer_id, int x, int y) { +void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y) { if (g_input_pipe[1] < 0) return; struct input_msg m = { 7, action, pointer_id, x, y }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); @@ -3235,7 +3235,7 @@ void banner_wayland_send_touch(int action, int pointer_id, int x, int y) { } /* Called from JNI. Queues a key event. evdev = Linux input keycode (e.g. KEY_A=30); state 1=down 0=up. */ -void banner_wayland_send_key(int evdev, int state) { +void droiddeck_wayland_send_key(int evdev, int state) { if (g_input_pipe[1] < 0) return; struct input_msg m = { 1, evdev, state, 0, 0 }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); @@ -3243,7 +3243,7 @@ void banner_wayland_send_key(int evdev, int state) { } /* Called from JNI on every screen refresh (Choreographer). */ -void banner_wayland_vsync(int64_t frame_time_ns) { +void droiddeck_wayland_vsync(int64_t frame_time_ns) { if (g_input_pipe[1] < 0) return; struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); @@ -3253,7 +3253,7 @@ void banner_wayland_vsync(int64_t frame_time_ns) { /* Called from JNI with the app's X-server input (on-screen controls, mouse): type 2 = motion * to scene x,y; 3 = evdev button a pressed/released (b); 4 = a wheel steps (negative = up); * 6 = relative motion by a,b in 1/256 pixel (the app's Relative Mouse / captured-mouse path). */ -void banner_wayland_send_scene_input(int type, int a, int b) { +void droiddeck_wayland_send_scene_input(int type, int a, int b) { if (g_input_pipe[1] < 0) return; struct input_msg m = { type, a, b, 0, 0 }; ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); @@ -3261,15 +3261,15 @@ void banner_wayland_send_scene_input(int type, int a, int b) { } /* ------------------------------------------------------------------ extension module hooks - * What wl_clipboard.c / wl_text_input.c need from the scene (see banner_ext.h). */ + * What wl_clipboard.c / wl_text_input.c need from the scene (see droiddeck_ext.h). */ -const char *banner_client_name(struct wl_client *client) { return client_name(client); } -struct wl_display *banner_get_display(void) { return g_display; } -void banner_request_redraw(void) { schedule_render(); } +const char *droiddeck_client_name(struct wl_client *client) { return client_name(client); } +struct wl_display *droiddeck_get_display(void) { return g_display; } +void droiddeck_request_redraw(void) { schedule_render(); } /* The surface text input follows: the last clicked mapped program window, else the topmost * mapped window not owned by the desktop's process (explorer). NULL when there is none. */ -struct wl_resource *banner_ime_target(void) { +struct wl_resource *droiddeck_ime_target(void) { struct surface *s; if (g_ime_click && g_ime_click->mapped) return g_ime_click->resource; struct wl_client *shell = g_desktop ? wl_resource_get_client(g_desktop->resource) : NULL; @@ -3278,13 +3278,13 @@ struct wl_resource *banner_ime_target(void) { return NULL; } -void banner_surface_scene_origin(struct wl_resource *surface, int *x, int *y) { +void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y) { struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL; *x = 0; *y = 0; if (s && s->placed) { *x = s->x; *y = s->y; } } -void banner_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); } +void droiddeck_inject_key(uint32_t evdev, int pressed) { key_event(evdev, pressed); } /* ------------------------------------------------------------------ 10 s summary */ @@ -3311,7 +3311,7 @@ static int on_stats_timer(void *data) { * zero-copy, so they are counted apart from the line above. */ if (layer_frames) off += snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u layer frames", layer_frames); if (generated) snprintf(extra + off, sizeof(extra) - (size_t)off, " | %u generated frames", generated); - banner_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s", + droiddeck_log("stats", "last 10 s: %u frames on screen (%.1f fps) | %u GPU frames from games | %u window redraws | %d windows open%s", g_stat_frames + generated, (g_stat_frames + generated) / 10.0, g_stat_dmabuf, g_stat_shm, windows, extra); } /* The perf line, next to the stats line and only when something happened: the compositor thread's @@ -3323,7 +3323,7 @@ static int on_stats_timer(void *data) { const unsigned drops = sc_layer_drops_take(); #define PERF_MS(sum, n) ((n) ? (double)(sum) / 1e6 / (double)(n) : 0.0) if (g_perf.scenes || vp.presents || g_perf.releases || drops) - banner_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | " + droiddeck_log("perf", "last 10 s: %u ticks, %u scenes, %u on screen (copy %u, zero-copy %u, layer copy %u) | " "render_scene %.2f/%.2f ms | base %u black kept, %u presented | acquire %.2f/%.2f ms | " "present %.2f/%.2f ms (%u) | fence wait %.2f/%.2f ms (%u, %u GPU release waits) | " "release %.2f/%.2f ms (%u, %u held) | %u pool drops", @@ -3339,7 +3339,7 @@ static int on_stats_timer(void *data) { memset(&g_perf, 0, sizeof(g_perf)); } g_stat_frames = g_stat_dmabuf = g_stat_shm = 0; - banner_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */ + droiddeck_color_stats_tick(); /* HDR evidence for the same window (nothing when the HDR gate is closed) */ /* A program that asked for dma-buf feedback (EGL's GPU path does, first thing) but has drawn * only wl_shm frames since is an OpenGL program whose EGL gave up on the GPU. The software * path it fell to has no rasteriser in the Wayland layers, so what it commits is black. */ @@ -3347,7 +3347,7 @@ static int on_stats_timer(void *data) { if (!ci->asked_feedback || ci->dmabuf_buffers || ci->shm_gl_said || ci->shm_frames < 150) continue; ci->shm_gl_said = 1; - banner_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) " + droiddeck_log("opengl", "%s asked for GPU buffers but has drawn only software (shared-memory) " "frames: its OpenGL fell back to software rendering, which the Wayland layer cannot " "draw - expect a black picture (main device %u:%u)", ci->name, (unsigned)major(g_main_device), (unsigned)minor(g_main_device)); @@ -3373,7 +3373,7 @@ static void test_input_line(const char *line) { int n = sscanf(line, "%15s %lf %lf", cmd, &x, &y); if (n < 2) return; - banner_log("test", "input: %s", line); + droiddeck_log("test", "input: %s", line); if (!strcmp(cmd, "key") || !strcmp(cmd, "keydown") || !strcmp(cmd, "keyup")) { code = (unsigned)x; if (strcmp(cmd, "keyup")) key_event(code, 1); @@ -3436,7 +3436,7 @@ static void start_test_input(struct wl_event_loop *loop) { static struct wl_listener g_client_created = { .notify = on_client_created }; -int banner_wayland_run(void) { +int droiddeck_wayland_run(void) { wl_list_init(&g_surfaces); wl_list_init(&g_toplevels); wl_list_init(&g_constraints); @@ -3468,7 +3468,7 @@ int banner_wayland_run(void) { rt ? rt : "(null)"); return 1; } - banner_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?"); + droiddeck_log("display", "Wayland compositor listening on %s/wayland-0", rt ? rt : "?"); wl_display_add_client_created_listener(display, &g_client_created); wl_global_create(display, &wl_compositor_interface, 6, NULL, bind_compositor); @@ -3485,13 +3485,13 @@ int banner_wayland_run(void) { wl_global_create(display, &wp_presentation_interface, 2, NULL, bind_presentation); wl_global_create(display, &zwp_pointer_constraints_v1_interface, 1, NULL, bind_pointer_constraints); wl_global_create(display, &zwp_relative_pointer_manager_v1_interface, 1, NULL, bind_relative_pointer_manager); - banner_ext_init(display); /* clipboard, text input, toplevel icons (own files, see banner_ext.h) */ + droiddeck_ext_init(display); /* clipboard, text input, toplevel icons (own files, see droiddeck_ext.h) */ ahb_swapchain_init(display); /* zero-copy layers: banner_ahb_v1, advertised whenever a display * layer is possible; the mode event carries the live switch * (ahb_swapchain.h) */ - banner_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the + droiddeck_color_init(display); /* HDR10 (opt-in): decides the gate from the app's request + the * display + the layer path above; creates wp_color_manager_v1 - * only when it is open (banner_color.h). Before any client can + * only when it is open (droiddeck_color.h). Before any client can * bind zwp_linux_dmabuf_v1, whose table it widens. */ wl_list_init(&g_pending_releases); g_release_timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC); diff --git a/app/src/main/cpp/waylandcomp/src/compositor_internal.h b/app/src/main/cpp/waylandcomp/src/compositor_internal.h index db739132a..0468f007f 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor_internal.h +++ b/app/src/main/cpp/waylandcomp/src/compositor_internal.h @@ -1,7 +1,7 @@ /* * What compositor.c shares with the protocol modules split out of it (wl_dmabuf.c): the dma-buf * buffer records, the per-client bookkeeping and the few functions and globals both sides use. - * Everything declared here is hidden: it is internal to libbannerwayland, not part of its ABI. + * Everything declared here is hidden: it is internal to libdroiddeckwayland, not part of its ABI. */ #pragma once #include <stdint.h> @@ -13,7 +13,7 @@ #define COMPOSITOR_INTERNAL __attribute__((visibility("hidden"))) #ifndef WLOGE -#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", __VA_ARGS__) +#define WLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", __VA_ARGS__) #endif /* ------------------------------------------------------------------ dmabuf buffers */ @@ -71,8 +71,8 @@ struct client_info { COMPOSITOR_INTERNAL struct client_info *client_info_of(struct wl_client *client); COMPOSITOR_INTERNAL void dmabuf_buffer_unref(struct dmabuf_buffer *b); COMPOSITOR_INTERNAL extern const struct wl_buffer_interface dbuf_buffer_impl; -extern volatile int g_ubwc; /* BANNER_WAYLAND_UBWC (waylandcomp_jni.c sets it) */ -extern volatile int g_no_render_node; /* BANNER_WAYLAND_NO_RENDER_NODE */ +extern volatile int g_ubwc; /* DROIDDECK_WAYLAND_UBWC (waylandcomp_jni.c sets it) */ +extern volatile int g_no_render_node; /* DROIDDECK_WAYLAND_NO_RENDER_NODE */ /* ---- wl_dmabuf.c */ COMPOSITOR_INTERNAL extern dev_t g_main_device; /* the render node clients are told to allocate on */ diff --git a/app/src/main/cpp/waylandcomp/src/banner_color.h b/app/src/main/cpp/waylandcomp/src/droiddeck_color.h similarity index 70% rename from app/src/main/cpp/waylandcomp/src/banner_color.h rename to app/src/main/cpp/waylandcomp/src/droiddeck_color.h index e39a81bfb..9591870a1 100644 --- a/app/src/main/cpp/waylandcomp/src/banner_color.h +++ b/app/src/main/cpp/waylandcomp/src/droiddeck_color.h @@ -1,5 +1,5 @@ -#ifndef BANNER_COLOR_H -#define BANNER_COLOR_H +#ifndef DROIDDECK_COLOR_H +#define DROIDDECK_COLOR_H /* * HDR10 output on the Wayland backend (opt-in) - the compositor half of HDR_RECON.md Phase A (round 1) * plus HDR-aware composition (round 2, hdr_compose.h), implemented in wl_color_mgmt.c. The game half @@ -8,18 +8,18 @@ * with DXVK_HDR=1 (the app exports it when the setting is on). * * THE GATE. Nothing here exists for a session unless all of these hold when the compositor starts: - * - the container's / game's "HDR output" setting is on, or BANNER_WAYLAND_HDR=1 in their + * - the container's / game's "HDR output" setting is on, or DROIDDECK_WAYLAND_HDR=1 in their * environment (which overrides the setting either way); * - the display the game is on lists HDR10 among its supported HDR types (read by the app from * android.view.Display - a property of the connector, not of the device); * - display layers with dataspace control (ASurfaceTransaction_setBufferDataSpace, Android 10+) * and the zero-copy global (banner_ahb_v1): the HDR frame (or the composed HDR picture) is shown * on the game's own display layer, tagged BT2020_PQ. - * In game, the drawer's HDR output switch (banner_color_set_output) flips between that and the same + * In game, the drawer's HDR output switch (droiddeck_color_set_output) flips between that and the same * frames tone-mapped to SDR, live; the gate itself never changes during a session. * A closed gate advertises nothing - no colour-management global, no 10-bit dma-buf formats - so the * session is byte-for-byte what it was before this file existed, and the session log says why. - * BANNER_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel; + * DROIDDECK_WAYLAND_HDR=force skips the display check only (testing the negotiation on an SDR panel; * SurfaceFlinger then tone-maps the layer, and says so in its composition type). * * WHAT AN OPEN GATE OFFERS (exactly the subset Mesa binds, HDR_RECON.md §2.2): wp_color_manager_v1 @@ -35,13 +35,13 @@ #include <wayland-server.h> /* ADataSpace values (NDK <android/data_space.h>) the layer path emits. */ -#define BANNER_ADATASPACE_UNKNOWN 0 -#define BANNER_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */ -#define BANNER_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */ +#define DROIDDECK_ADATASPACE_UNKNOWN 0 +#define DROIDDECK_ADATASPACE_BT2020_PQ 163971072 /* STANDARD_BT2020 | TRANSFER_ST2084 | RANGE_FULL = 0x09c60000 */ +#define DROIDDECK_ADATASPACE_BT2020_HLG 168165376 /* STANDARD_BT2020 | TRANSFER_HLG | RANGE_FULL */ /* One image description as the program made it, and what it becomes on a display layer. Immutable: * a changed description is a new record with a new identity. */ -struct banner_color { +struct droiddeck_color { uint32_t identity; /* the id the `ready` event carried (never 0) */ uint32_t primaries, tf; /* wp_color_manager_v1 named values */ int32_t dataspace; /* ADataSpace on a display layer; 0 = none (treated as sRGB) */ @@ -54,97 +54,97 @@ struct banner_color { }; /* ---- app -> compositor (JNI, any thread) */ -/* mode 0 = off, 1 = BANNER_WAYLAND_HDR=1, 2 = BANNER_WAYLAND_HDR=force (testing). source names where +/* mode 0 = off, 1 = DROIDDECK_WAYLAND_HDR=1, 2 = DROIDDECK_WAYLAND_HDR=force (testing). source names where * the switch came from; dxvk_hdr = DXVK_HDR=1 is in the game's environment; zero_copy_forced = the * app turned zero-copy presentation on for this session because HDR needs it. Before the start. */ -void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced); +void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced); /* The display the game is on, as android.view.Display reports it. Before the start (it feeds the * gate) and again whenever it changes (logged; the gate is decided once per session). */ -void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum, +void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum, float max_avg, float min_lum, int ratio_available, float ratio, int api); /* One reading of Display.getHdrSdrRatio() (API 34+; < 0 = not available). listener = it came from * the display's ratio listener rather than the app's periodic sampler. */ -void banner_color_ratio_sample(float ratio, int listener); +void droiddeck_color_ratio_sample(float ratio, int listener); /* Milliseconds since an HDR frame last went onto a display layer; -1 = none this session. */ -int banner_color_last_frame_age_ms(void); +int droiddeck_color_last_frame_age_ms(void); /* -1 = the compositor has not decided yet, 0 = closed, 1 = open. */ -int banner_color_gate_state(void); +int droiddeck_color_gate_state(void); /* The session is ending: write the summary line ("HDR on screen: …"). */ -void banner_color_session_end(void); +void droiddeck_color_session_end(void); /* 1 while HDR frames are really on screen: a frame tagged BT2020_PQ reached a display layer or the HDR * swapchain in the last 1.5 s AND, where the display reports an HDR/SDR ratio, its last reading is * above 1.01 (the HUD badge). */ -int banner_color_hdr_on_screen(void); +int droiddeck_color_hdr_on_screen(void); /* The HUD / drawer state: 0 = no HDR frames on screen (or not confirmed yet), 1 = HDR frames on screen - * with headroom (as banner_color_hdr_on_screen), 2 = HDR frames on screen but the display has given + * with headroom (as droiddeck_color_hdr_on_screen), 2 = HDR frames on screen but the display has given * them NO headroom (ratio <= 1.01) for 5 s or more - the brightness slider at maximum, or a screen * recording (Android turns HDR headroom off while the screen is recorded). */ -int banner_color_hdr_state(void); +int droiddeck_color_hdr_state(void); /* Nits SDR content is placed at inside an HDR picture (default 203, BT.2408); any thread. */ -void banner_color_set_sdr_white(float nits); -float banner_color_sdr_white(void); -/* The HDR session was asked for (BANNER_WAYLAND_HDR / the setting resolved on or force) - known before +void droiddeck_color_set_sdr_white(float nits); +float droiddeck_color_sdr_white(void); +/* The HDR session was asked for (DROIDDECK_WAYLAND_HDR / the setting resolved on or force) - known before * the compositor starts, so the Vulkan instance can enable VK_EXT_swapchain_colorspace for it. */ -int banner_color_requested(void); +int droiddeck_color_requested(void); /* The drawer's live "HDR output" switch, per session, starting ON. On = HDR frames go to the display as * HDR; off = the SAME frames are composed tone-mapped to SDR (hdr_compose.h) - the game is told nothing * and keeps rendering HDR (DXVK_HDR and the colour-manager offer were decided at launch). Compositor - * thread (the app posts it through banner_host_hdr_output); logs the flip and redraws. */ -void banner_color_set_output(int on); + * thread (the app posts it through droiddeck_host_hdr_output); logs the flip and redraws. */ +void droiddeck_color_set_output(int on); /* 1 = HDR output on (any thread). */ -int banner_color_output(void); +int droiddeck_color_output(void); /* 1 while an HDR game's frames are being shown tone-mapped to SDR (the last one < 1.5 s ago); any thread. */ -int banner_color_tonemapped_on_screen(void); +int droiddeck_color_tonemapped_on_screen(void); /* Device evidence beside the HDR/SDR headroom (the app, non-root APIs; any thread; HDR sessions only): * PowerManager thermal status (0..6, -1 unknown) + getThermalHeadroom(10) (< 0 = not available), * Settings.System SCREEN_BRIGHTNESS (0..255, -1 unknown) + SCREEN_BRIGHTNESS_MODE (1 auto, 0 manual). * Changes of status and brightness are logged; every no-headroom line, the 10 s line and the verdict * carry the last values. */ -void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode); +void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode); /* ---- the explicit HDR headroom request (Android 15+: ASurfaceTransaction_setDesiredHdrHeadroom on the game * layer, SurfaceView.setDesiredHdrHeadroom on the screen surface). Some phones only boost HDR when asked. */ /* The ratio to ask for an HDR frame of `c`: content peak (max CLL, else mastering max, else the display's * peak) / SDR white (203), capped at the display's highest ratio when it reports one; 0 = ask nothing * (not HDR, or the display reports it cannot boost). `why` gets the one-line reason. Any thread. */ -float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n); +float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n); /* What was last asked for (> 0 the ratio, 0 nothing, -1 the API is missing): for the no-headroom lines. */ -void banner_color_note_headroom_request(float ratio); +void droiddeck_color_note_headroom_request(float ratio); /* Display.getHighestHdrSdrRatio() (Android 16+; <= 0 = not reported). Any thread; logged when it arrives. */ -void banner_color_set_highest_ratio(float ratio); +void droiddeck_color_set_highest_ratio(float ratio); /* The headroom the SCREEN surface should ask for (frames through the HDR10 swapchain in the last 1.5 s), * 0 = none; `why` gets the reason. The app polls it and applies it. Any thread. */ -float banner_color_screen_headroom(char *why, size_t n); +float droiddeck_color_screen_headroom(char *why, size_t n); /* ---- compositor.c -> here */ /* Decide the gate (call after ahb_swapchain_init) and create the global when it is open. */ -void banner_color_init(struct wl_display *display); +void droiddeck_color_init(struct wl_display *display); /* The gate is open: the 10-bit dma-buf formats may be advertised, descriptions may exist. */ -int banner_color_hdr_open(void); +int droiddeck_color_hdr_open(void); /* wl_surface.commit: the pending image description (if any request touched it) becomes current. */ -void banner_color_commit(struct wl_resource *surface); +void droiddeck_color_commit(struct wl_resource *surface); /* The surface's current image description; NULL = none (sRGB, today's default). */ -const struct banner_color *banner_color_of(struct wl_resource *surface); +const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface); /* A program disconnected: if it presented HDR, its part of the summary is written now. */ -void banner_color_client_gone(struct wl_client *client); +void droiddeck_color_client_gone(struct wl_client *client); /* The 10 s summary tick (one `color` line when anything HDR happened in the window). */ -void banner_color_stats_tick(void); +void droiddeck_color_stats_tick(void); /* A frame of an HDR-described surface went through the compositor's own 8-bit sRGB pass this scene * (who = the window, reason = why it did not get the display layer). Shown untone-mapped. */ -void banner_color_frame_copied(const char *who, const char *reason); +void droiddeck_color_frame_copied(const char *who, const char *reason); /* ---- sc_layer.c / compositor.c -> here: a NEW frame of an HDR-described surface was shown, and how */ -enum banner_hdr_path { - BANNER_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */ - BANNER_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */ - BANNER_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */ - BANNER_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */ - BANNER_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that +enum droiddeck_hdr_path { + DROIDDECK_HDR_ZERO_COPY = 0, /* the game's own gralloc buffer on its display layer, tagged */ + DROIDDECK_HDR_LAYER_COPY, /* one 8-bit copy of the game's frame on its layer, tagged */ + DROIDDECK_HDR_COMPOSED, /* the whole scene composed into one PQ picture on the game layer (hdr_compose.h) */ + DROIDDECK_HDR_SWAPCHAIN, /* composed into PQ and presented through an HDR10 swapchain (frame generation) */ + DROIDDECK_HDR_TONEMAPPED, /* composed and tone-mapped to SDR (HDR output switched off, or a present that * cannot carry HDR) - NOT HDR */ }; /* ahb_format = the buffer's AHARDWAREBUFFER_FORMAT_* where one exists (0 otherwise). */ -void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format); +void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format); /* "RGBA1010102 (10-bit)" etc. for an AHARDWAREBUFFER_FORMAT_* value (static buffer). */ -const char *banner_ahb_format_name(uint32_t format); +const char *droiddeck_ahb_format_name(uint32_t format); #endif diff --git a/app/src/main/cpp/waylandcomp/src/banner_ext.c b/app/src/main/cpp/waylandcomp/src/droiddeck_ext.c similarity index 82% rename from app/src/main/cpp/waylandcomp/src/banner_ext.c rename to app/src/main/cpp/waylandcomp/src/droiddeck_ext.c index 0b9c8c411..74ddc6ada 100644 --- a/app/src/main/cpp/waylandcomp/src/banner_ext.c +++ b/app/src/main/cpp/waylandcomp/src/droiddeck_ext.c @@ -2,7 +2,7 @@ * Host → compositor message queue plus the init of the extension modules. * * The app's threads (UI thread, clipboard listener) hand text to the compositor through - * banner_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event + * droiddeck_host_* ; the messages are queued under a mutex and a pipe byte wakes the wl event * loop, which drains them on the compositor thread - the only thread that may touch * wl_resources. */ @@ -13,9 +13,9 @@ #include <fcntl.h> #include <pthread.h> #include <android/log.h> -#include "banner_ext.h" +#include "droiddeck_ext.h" #include "ahb_swapchain.h" -#include "banner_color.h" +#include "droiddeck_color.h" enum host_kind { HOST_CLIPBOARD = 1, HOST_TEXT_COMMIT, HOST_TEXT_PREEDIT, HOST_TEXT_DELETE, HOST_ZERO_COPY, HOST_HDR_OUTPUT }; @@ -53,22 +53,22 @@ static void host_post(enum host_kind kind, const char *text, size_t len, int a, } } -void banner_host_clipboard_text(const char *utf8, int len) { +void droiddeck_host_clipboard_text(const char *utf8, int len) { host_post(HOST_CLIPBOARD, utf8, len > 0 ? (size_t)len : 0, 0, 0); } -void banner_host_text_commit(const char *utf8, int len) { +void droiddeck_host_text_commit(const char *utf8, int len) { host_post(HOST_TEXT_COMMIT, utf8, len > 0 ? (size_t)len : 0, 0, 0); } -void banner_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) { +void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end) { host_post(HOST_TEXT_PREEDIT, utf8, len > 0 ? (size_t)len : 0, cursor_begin, cursor_end); } -void banner_host_text_delete(int before, int after) { +void droiddeck_host_text_delete(int before, int after) { host_post(HOST_TEXT_DELETE, NULL, 0, before, after); } -void banner_host_zero_copy(int on, int live) { +void droiddeck_host_zero_copy(int on, int live) { host_post(HOST_ZERO_COPY, NULL, 0, on, live); } -void banner_host_hdr_output(int on) { +void droiddeck_host_hdr_output(int on) { host_post(HOST_HDR_OUTPUT, NULL, 0, on, 0); } @@ -99,17 +99,17 @@ static int on_wake(int fd, uint32_t mask, void *data) { ahb_swapchain_set_mode(m->a, m->b); break; case HOST_HDR_OUTPUT: - banner_color_set_output(m->a); + droiddeck_color_set_output(m->a); break; } free(m->text); free(m); } - wl_display_flush_clients(banner_get_display()); + wl_display_flush_clients(droiddeck_get_display()); return 0; } -void banner_ext_init(struct wl_display *display) { +void droiddeck_ext_init(struct wl_display *display) { struct wl_event_loop *loop = wl_display_get_event_loop(display); if (pipe2(g_wake, O_CLOEXEC | O_NONBLOCK) == 0) { wl_event_loop_add_fd(loop, g_wake[0], WL_EVENT_READABLE, on_wake, NULL); @@ -118,7 +118,7 @@ void banner_ext_init(struct wl_display *display) { pthread_mutex_unlock(&g_mu); if (queued) { char c = 1; ssize_t n = write(g_wake[1], &c, 1); (void)n; } } else - __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "host queue pipe failed"); + __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "host queue pipe failed"); clipboard_init(display); text_input_init(display); toplevel_icon_init(display); diff --git a/app/src/main/cpp/waylandcomp/src/droiddeck_ext.h b/app/src/main/cpp/waylandcomp/src/droiddeck_ext.h new file mode 100644 index 000000000..e21014fbf --- /dev/null +++ b/app/src/main/cpp/waylandcomp/src/droiddeck_ext.h @@ -0,0 +1,50 @@ +/* + * Interface between compositor.c and the extension modules that live in their own files: + * wl_clipboard.c wl_data_device_manager + zwlr_data_control_manager_v1 (selection only) + * wl_text_input.c zwp_text_input_manager_v3 (host IME / soft keyboard) + * wl_toplevel_icon.c xdg_toplevel_icon_manager_v1 (accepted, not rendered) + * droiddeck_ext.c the host→compositor message queue shared by the modules + * Everything here runs on the compositor (dispatch) thread unless noted. + */ +#pragma once +#include <stdint.h> +#include <wayland-server.h> + +void droiddeck_log(const char *tag, const char *fmt, ...); + +/* ---- compositor.c → modules */ +void droiddeck_ext_init(struct wl_display *display); /* creates the globals + host queue */ +void droiddeck_clipboard_keyboard_focus(struct wl_client *client); /* keyboard focus moved; NULL = none */ +void droiddeck_text_input_refocus(void); /* the text-input target may have changed */ +void droiddeck_text_input_surface_gone(struct wl_resource *surface); + +/* ---- modules → compositor.c */ +const char *droiddeck_client_name(struct wl_client *client); +struct wl_display *droiddeck_get_display(void); +/* The surface text input follows (the last clicked window, else the topmost program window). */ +struct wl_resource *droiddeck_ime_target(void); +/* Scene (virtual desktop) origin of a toplevel surface; 0,0 when unknown. */ +void droiddeck_surface_scene_origin(struct wl_resource *surface, int *x, int *y); +/* Deliver a key press/release through the wl_keyboard path as if typed. */ +void droiddeck_inject_key(uint32_t evdev, int pressed); + +/* ---- modules → app (JNI upcalls; waylandcomp_jni.c) */ +void droiddeck_on_clipboard_text(const char *utf8, int len); +void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h); + +/* ---- app → modules (callable from any thread; queued to the compositor thread) */ +void droiddeck_host_clipboard_text(const char *utf8, int len); /* len 0 = clear */ +void droiddeck_host_text_commit(const char *utf8, int len); +void droiddeck_host_text_preedit(const char *utf8, int len, int cursor_begin, int cursor_end); +void droiddeck_host_text_delete(int before, int after); +void droiddeck_host_zero_copy(int on, int live); /* the drawer's zero-copy switch (ahb_swapchain_set_mode) */ +void droiddeck_host_hdr_output(int on); /* the drawer's HDR output switch (droiddeck_color_set_output) */ + +/* ---- module internals reached through the queue (droiddeck_ext.c → modules) */ +void clipboard_init(struct wl_display *display); +void clipboard_host_text(char *utf8, size_t len); /* takes ownership of utf8 */ +void text_input_init(struct wl_display *display); +void text_input_host_commit(const char *utf8, size_t len); +void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end); +void text_input_host_delete(int before, int after); +void toplevel_icon_init(struct wl_display *display); diff --git a/app/src/main/cpp/waylandcomp/src/effects_chain.c b/app/src/main/cpp/waylandcomp/src/effects_chain.c index a4227102d..0ab39af65 100644 --- a/app/src/main/cpp/waylandcomp/src/effects_chain.c +++ b/app/src/main/cpp/waylandcomp/src/effects_chain.c @@ -2,7 +2,7 @@ #define _POSIX_C_SOURCE 200809L #include "effects_chain.h" #include "vk_loader.h" -#include "vk_present.h" /* banner_log */ +#include "vk_present.h" /* droiddeck_log */ #include <pthread.h> #include <stdio.h> #include <stdlib.h> @@ -115,7 +115,7 @@ static void log_settings(const struct fx_settings *s) { char line[400]; int n; if (!spatial_mode(s->scaling) && !effect_count(s)) { - banner_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling)); + droiddeck_log("effects", "all off: scaling=%s (plain blit)", scaling_name(s->scaling)); return; } n = snprintf(line, sizeof(line), "scaling=%s", scaling_name(s->scaling)); @@ -133,7 +133,7 @@ static void log_settings(const struct fx_settings *s) { if (s->crt) n += snprintf(line + n, sizeof(line) - n, ", CRT on"); if (s->deband_on) n += snprintf(line + n, sizeof(line) - n, ", Deband on %d%%", s->deband_pct); (void)n; - banner_log("effects", "%s", line); + droiddeck_log("effects", "%s", line); } int vkp_effects_sync(void) { @@ -262,7 +262,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) { VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED}; VkResult r = g_vk.CreateImage(g_dev, &ii, NULL, &t->img); - if (r != VK_SUCCESS) { banner_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; } + if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s image %dx%d: vkCreateImage %d", what, w, h, (int)r); return -1; } VkMemoryRequirements req; g_vk.GetImageMemoryRequirements(g_dev, t->img, &req); int idx = memtype(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT); @@ -271,7 +271,7 @@ static int target_ensure(struct fx_target *t, int w, int h, const char *what) { .memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)}; r = g_vk.AllocateMemory(g_dev, &mai, NULL, &t->mem); if (r != VK_SUCCESS) { - banner_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h, + droiddeck_log("error", "effects: %s image %dx%d (%llu bytes): vkAllocateMemory %d", what, w, h, (unsigned long long)req.size, (int)r); target_destroy(t); return -1; } @@ -312,7 +312,7 @@ static VkShaderModule shader(const uint32_t *code, size_t size) { * viewport/scissor - the X11 renderer's createPostPipeline. */ static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code, size_t frag_size, const char *name) { VkShaderModule frag = shader(frag_code, frag_size); - if (!frag) { banner_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; } + if (!frag) { droiddeck_log("error", "effects: %s: vkCreateShaderModule failed", name); return VK_NULL_HANDLE; } VkPipelineShaderStageCreateInfo st[2] = { {.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT, .module = vert, .pName = "main"}, {.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT, .module = frag, .pName = "main"}}; @@ -340,7 +340,7 @@ static VkPipeline post_pipeline(VkShaderModule vert, const uint32_t *frag_code, VkPipeline p = VK_NULL_HANDLE; VkResult r = g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p); g_vk.DestroyShaderModule(g_dev, frag, NULL); - if (r != VK_SUCCESS) { banner_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; } + if (r != VK_SUCCESS) { droiddeck_log("error", "effects: %s: vkCreateGraphicsPipelines %d", name, (int)r); return VK_NULL_HANDLE; } return p; } @@ -367,7 +367,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) { g_fx_fmt = target_fmt; g_scene_fmt = scene_fmt; if (g_dev) - banner_log("effects", "chain now works in %s (%s)", + droiddeck_log("effects", "chain now works in %s (%s)", target_fmt == FX_FORMAT ? "8-bit RGBA" : target_fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? "FP16 RGBA" : "10-bit RGB", target_fmt == FX_FORMAT ? "SDR frames" @@ -383,7 +383,7 @@ void vkp_effects_set_formats(VkFormat scene_fmt, VkFormat target_fmt) { * unavailable for this session (logged once; frames fall back to the plain blit). */ static int objects_ensure(void) { if (g_ready) return g_ready == 1 ? 0 : -1; - if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; banner_log("error", "effects: no device for the chain"); return -1; } + if (!g_dev || !g_vk.CreateGraphicsPipelines) { g_ready = -1; droiddeck_log("error", "effects: no device for the chain"); return -1; } /* Render pass: one colour target, overwritten whole (DONT_CARE), left SHADER_READ_ONLY for the * next pass. The dependencies order this pass after whoever read the target last (previous * pass / previous frame's blit) and before whoever samples or blits it next. */ @@ -448,12 +448,12 @@ static int objects_ensure(void) { #undef MK g_vk.DestroyShaderModule(g_dev, vert, NULL); g_ready = 1; - banner_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name()); + droiddeck_log("effects", "chain ready: 13 passes (SGSR, SGSR HQ, NIS, FSR EASU+RCAS, CAS, colour, FXAA, Toon, HDR, NTSC, CRT, deband) on %s", vkp_gpu_name()); return 0; fail: objects_destroy(); g_ready = -1; - banner_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session"); + droiddeck_log("error", "effects: the screen-effect chain could not be built on this driver; effects are off for this session"); return -1; } diff --git a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c index 47a819bfd..ac2100365 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_bridge.c +++ b/app/src/main/cpp/waylandcomp/src/framegen_bridge.c @@ -11,9 +11,9 @@ #include <time.h> #include <android/log.h> -void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3))); -#define FGLOG(...) banner_log("framegen", __VA_ARGS__) -#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "BannerWayland", __VA_ARGS__) +void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +#define FGLOG(...) droiddeck_log("framegen", __VA_ARGS__) +#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, "DroidDeckWayland", __VA_ARGS__) /* ---- app controls (written from any thread, read on the compositor thread) ------------- */ static _Atomic int g_kind = VKP_FG_ENGINE_LSFG; diff --git a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp index 35e160edc..12674e533 100644 --- a/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp +++ b/app/src/main/cpp/waylandcomp/src/framegen_engine.cpp @@ -20,8 +20,8 @@ #include "lsfg/lsfg_vkd.h" #include "winfg/winfg_engine.h" -#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "BannerWayland", "framegen: " __VA_ARGS__) -#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "BannerWayland", "framegen: " __VA_ARGS__) +#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__) +#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__) namespace { diff --git a/app/src/main/cpp/waylandcomp/src/hdr_compose.c b/app/src/main/cpp/waylandcomp/src/hdr_compose.c index a927df908..6f54d4e0c 100644 --- a/app/src/main/cpp/waylandcomp/src/hdr_compose.c +++ b/app/src/main/cpp/waylandcomp/src/hdr_compose.c @@ -2,7 +2,7 @@ #define _POSIX_C_SOURCE 200809L #include "hdr_compose.h" #include "vk_loader.h" -#include "vk_present.h" /* banner_log, vkp_gpu_name */ +#include "vk_present.h" /* droiddeck_log, vkp_gpu_name */ #include <stdio.h> #include <string.h> @@ -190,7 +190,7 @@ static int objects_ensure(void) { if (!(g_out[i].pipe = make_pipe(g_out[i].rp))) goto fail; } g_ready = 1; - banner_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one " + droiddeck_log("color", "HDR composition ready on %s: scenes that mix HDR and SDR content are put into one " "encoding (PQ BT.2020, or tone-mapped sRGB) instead of being shown washed out", vkp_gpu_name()); return 0; fail: @@ -200,7 +200,7 @@ fail: g_dev = keep; } g_ready = -1; - banner_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot " + droiddeck_log("error", "color: the HDR composition pass could not be built on this driver; HDR scenes that cannot " "stay on the game's own layer are shown the old way (washed out) this session"); return -1; } diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.c b/app/src/main/cpp/waylandcomp/src/sc_layer.c index e8a651c42..3f44a6be8 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.c +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.c @@ -5,7 +5,7 @@ #define _GNU_SOURCE #include "sc_layer.h" #include "ahb_swapchain.h" -#include "banner_color.h" +#include "droiddeck_color.h" #include "vk_present.h" #include <android/hardware_buffer.h> #include <android/native_window.h> @@ -34,7 +34,7 @@ /* The gralloc buffer handle behind an AHardwareBuffer (AOSP cutils/native_handle.h, which the NDK * does not ship; the layout is a stable ABI: fds first, then ints). */ -struct banner_native_handle { int version; int numFds; int numInts; int data[]; }; +struct droiddeck_native_handle { int version; int numFds; int numInts; int data[]; }; /* ---- libandroid SurfaceControl API (dlsym) ---------------------------------------------------- */ @@ -44,12 +44,12 @@ typedef struct ASurfaceTransactionStats ASurfaceTransactionStats; typedef void (*sc_complete_fn)(void *context, ASurfaceTransactionStats *stats); /* <android/hdr_metadata.h> (NDK, API 29), declared here like the rest of this table: stable ABI. */ -struct banner_color_xy { float x, y; }; -struct banner_hdr_smpte2086 { - struct banner_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint; +struct droiddeck_color_xy { float x, y; }; +struct droiddeck_hdr_smpte2086 { + struct droiddeck_color_xy displayPrimaryRed, displayPrimaryGreen, displayPrimaryBlue, whitePoint; float maxLuminance, minLuminance; }; -struct banner_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; }; +struct droiddeck_hdr_cta861_3 { float maxContentLightLevel, maxFrameAverageLightLevel; }; static struct { ASurfaceControl *(*createFromWindow)(ANativeWindow *, const char *); @@ -69,11 +69,11 @@ static struct { * the layer simply carries no vote and the app's surface vote is all there is. */ void (*setFrameRate)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t); void (*setFrameRateStrategy)(ASurfaceTransaction *, ASurfaceControl *, float, int8_t, int8_t); - /* Colour (API 29), optional: only an HDR session ever calls them (banner_color.h's gate needs + /* Colour (API 29), optional: only an HDR session ever calls them (droiddeck_color.h's gate needs * setBufferDataSpace; the metadata calls are sent when present). */ void (*setBufferDataSpace)(ASurfaceTransaction *, ASurfaceControl *, int32_t); - void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_smpte2086 *); - void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct banner_hdr_cta861_3 *); + void (*setHdrMetadata_smpte2086)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_smpte2086 *); + void (*setHdrMetadata_cta861_3)(ASurfaceTransaction *, ASurfaceControl *, const struct droiddeck_hdr_cta861_3 *); /* Not in the public NDK headers (vndk/hardware_buffer.h) but exported by libnativewindow.so on * every device; Mesa's Android WSI calls it for every gralloc buffer it imports. */ const void *(*getNativeHandle)(const AHardwareBuffer *); @@ -95,12 +95,12 @@ static int load_api(void) { if (!nw) nw = dlopen("libnativewindow.so", RTLD_NOW); api.getNativeHandle = nw ? dlsym(nw, "AHardwareBuffer_getNativeHandle") : NULL; if (!api.getNativeHandle) { - banner_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle"); + droiddeck_log("layer", "unavailable: libnativewindow.so has no AHardwareBuffer_getNativeHandle"); return -1; } void *lib = dlopen("libandroid.so", RTLD_NOW | RTLD_NOLOAD); if (!lib) lib = dlopen("libandroid.so", RTLD_NOW); - if (!lib) { banner_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; } + if (!lib) { droiddeck_log("layer", "unavailable: dlopen(libandroid.so): %s", dlerror()); return -1; } #define SYM(field, name) api.field = dlsym(lib, name) SYM(createFromWindow, "ASurfaceControl_createFromWindow"); SYM(release, "ASurfaceControl_release"); @@ -125,7 +125,7 @@ static int load_api(void) { if (!api.createFromWindow || !api.release || !api.txCreate || !api.txDelete || !api.txApply || !api.setBuffer || !api.setZOrder || !api.setVisibility || !api.setGeometry || !api.setBufferTransparency || !api.reparent || !api.setOnComplete || !api.prevReleaseFence) { - banner_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)"); + droiddeck_log("layer", "unavailable: libandroid.so lacks part of the ASurfaceControl API (Android 10+)"); return -1; } api.state = 1; @@ -214,10 +214,10 @@ static AHardwareBuffer *g_blank; static void layers_init(void) { if (g_layers_ready) return; g_layers_ready = 1; - g_layers[SC_LAYER_GAME] = (struct layer){.name = "banner_wayland_game", .z = 1, .pool_n = POOL_MAX, + g_layers[SC_LAYER_GAME] = (struct layer){.name = "droiddeck_wayland_game", .z = 1, .pool_n = POOL_MAX, .cur_slot = -1, .votes_rate = 1, .fps_applied = -1.0f, .hr_applied = -1.0f, .ds_applied = -1}; - g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "banner_wayland_overlay", .z = 2, .pool_n = 3, + g_layers[SC_LAYER_OVERLAY] = (struct layer){.name = "droiddeck_wayland_overlay", .z = 2, .pool_n = 3, .cur_slot = -1, .votes_rate = 0, .fps_applied = -1.0f, .hr_applied = -1.0f, .ds_applied = -1}; for (int i = 0; i < SC_LAYER_COUNT; i++) @@ -330,7 +330,7 @@ static void retire_sc(struct layer *l) { if (l->cur_token) ahb_swapchain_layer_released(l->cur_token, -1); api.release(l->sc); } - banner_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win); + droiddeck_log("layer", "%s: SurfaceControl retired (window %p)", l->name, (void *)l->win); l->sc = NULL; l->win = NULL; l->shown = 0; l->cur_slot = -1; l->cur_token = NULL; l->geo_valid = 0; l->fps_applied = -1.0f; /* the next SurfaceControl carries no vote until it is re-applied */ @@ -368,7 +368,7 @@ static void drain_and_free_pools(void) { /* Native-handle sniff, the same one Mesa's u_gralloc fallback uses (u_gralloc_fallback.c): a QTI * gralloc private_handle_t carries the magic 'gmsm' as its first int and the UBWC flag * (PRIV_FLAGS_UBWC_ALIGNED, 0x08000000) in the next one. Returns 0 when the layout is unknown. */ -static int sniff_modifier(const struct banner_native_handle *h, uint64_t *mod) { +static int sniff_modifier(const struct droiddeck_native_handle *h, uint64_t *mod) { const uint32_t gmsm = ('g' << 24) | ('m' << 16) | ('s' << 8) | 'm'; if (!h || h->numFds < 1 || h->numInts < 2) return 0; if ((uint32_t)h->data[h->numFds] != gmsm) return 0; @@ -387,7 +387,7 @@ static int g_alloc_failed; /* set by alloc_slot when gralloc or the import refus * TIER_PLAIN the same without the vendor bit (a gralloc that refuses unknown bits; what we asked before). * TIER_LINEAR plus a CPU bit, so the layout is known to be linear even when the handle is unreadable. */ enum { TIER_UBWC, TIER_PLAIN, TIER_LINEAR }; -#define BANNER_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */ +#define DROIDDECK_AHB_USAGE_VENDOR_UBWC (1ULL << 28) /* AHARDWAREBUFFER_USAGE_VENDOR_0 */ static const char *tier_name(int tier) { return tier == TIER_UBWC ? "asked for UBWC" : tier == TIER_PLAIN ? "no UBWC request" : "linear by request (CPU bit)"; @@ -404,22 +404,22 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm * composer overlay (the display scans it out). See the tiers above for the rest. */ .usage = AHARDWAREBUFFER_USAGE_GPU_COLOR_OUTPUT | AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE | AHARDWAREBUFFER_USAGE_COMPOSER_OVERLAY | - (tier == TIER_UBWC ? BANNER_AHB_USAGE_VENDOR_UBWC : 0) | + (tier == TIER_UBWC ? DROIDDECK_AHB_USAGE_VENDOR_UBWC : 0) | (tier == TIER_LINEAR ? AHARDWAREBUFFER_USAGE_CPU_READ_RARELY : 0)}; AHardwareBuffer *ahb = NULL; if (AHardwareBuffer_allocate(&d, &ahb) != 0 || !ahb) { if (tier < TIER_LINEAR) { - banner_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h, + droiddeck_log("layer", "%s: gralloc refused a %dx%d pool buffer (%s): trying %s", l->name, w, h, tier_name(tier), tier_name(tier + 1)); l->alloc_tier = tier + 1; continue; } - banner_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt); + droiddeck_log("error", "layer: %s: AHardwareBuffer_allocate %dx%d (format %#x) failed", l->name, w, h, fmt); g_alloc_failed = 1; return -1; } AHardwareBuffer_Desc got; AHardwareBuffer_describe(ahb, &got); - const struct banner_native_handle *nh = api.getNativeHandle(ahb); + const struct droiddeck_native_handle *nh = api.getNativeHandle(ahb); uint64_t mod = MOD_LINEAR; if (!sniff_modifier(nh, &mod)) { /* Not a handle we can read: the only layout we can assume is linear, and only if the buffer @@ -427,7 +427,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm if (tier < TIER_LINEAR) { AHardwareBuffer_release(ahb); l->alloc_tier = TIER_LINEAR; - banner_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers", + droiddeck_log("layer", "%s: gralloc handle layout unknown (%d fds, %d ints): using linear pool buffers", l->name, nh ? nh->numFds : -1, nh ? nh->numInts : -1); continue; } @@ -439,7 +439,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm struct vkp_image *img = fd >= 0 ? vkp_image_import_dmabuf(fd, fmt == AHB_RGB10A2 ? DRM_ABGR2101010 : DRM_ABGR8888, mod, w, h, got.stride * 4, 0, 1) : NULL; if (!img) { - banner_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed", + droiddeck_log("layer", "%s: import of a %s %dx%d pool buffer (stride %u px) into the compositor's Turnip failed", l->name, mod == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", w, h, got.stride); AHardwareBuffer_release(ahb); if (tier < TIER_LINEAR) { l->alloc_tier = tier + 1; continue; } @@ -451,7 +451,7 @@ static int alloc_slot(struct layer *l, struct slot *s, int w, int h, uint32_t fm if (l->logged_w != w || l->logged_h != h || l->logged_fmt != fmt || l->logged_mod != mod || l->logged_tier != tier) { l->logged_w = w; l->logged_h = h; l->logged_fmt = fmt; l->logged_mod = mod; l->logged_tier = tier; - banner_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)", + droiddeck_log("layer", "%s: pool buffers %dx%d%s: %s (%s), stride %u px, up to %d buffers (gralloc handle %d fds / %d ints)", l->name, w, h, fmt == AHB_RGB10A2 ? " 10-bit RGBA1010102" : "", mod == MOD_QCOM_COMPRESSED ? "UBWC (QCOM_COMPRESSED)" : "linear", tier_name(tier), got.stride, l->pool_n, nh ? nh->numFds : -1, nh ? nh->numInts : -1); @@ -532,10 +532,10 @@ void sc_layer_probe_dmabuf_fd(int fd) { struct { uint32_t flags; int32_t fd; } exp = {.flags = 1u /* DMA_BUF_SYNC_READ */, .fd = -1}; /* DMA_BUF_IOCTL_EXPORT_SYNC_FILE = _IOWR('b', 2, struct dma_buf_export_sync_file) */ if (ioctl(fd, _IOWR('b', 2, exp), &exp) == 0) { - banner_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself"); + droiddeck_log("layer", "kernel exports sync_file fences from the game's dma-buf: zero-copy acquire fences can come from the buffer itself"); if (exp.fd >= 0) close(exp.fd); } else { - banner_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead", + droiddeck_log("layer", "DMA_BUF_IOCTL_EXPORT_SYNC_FILE on the game's dma-buf failed (%s): acquire fences must be exported by the game's driver (sync_fd) instead", strerror(errno)); } } @@ -549,7 +549,7 @@ static int ensure_sc(struct layer *l) { if (l->sc && l->win != win) retire_sc(l); if (!l->sc) { l->sc = api.createFromWindow(win, l->name); - if (!l->sc) { banner_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; } + if (!l->sc) { droiddeck_log("error", "layer: ASurfaceControl_createFromWindow(%s) failed", l->name); return -1; } l->win = win; l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f; ASurfaceTransaction *tx = api.txCreate(); @@ -559,7 +559,7 @@ static int ensure_sc(struct layer *l) { apply_frame_rate(tx, l); api.txApply(tx); api.txDelete(tx); } - banner_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z); + droiddeck_log("layer", "SurfaceControl \"%s\" created as a child of the screen surface (z=%d)", l->name, (int)l->z); } return 0; } @@ -585,7 +585,7 @@ static void apply_geometry(ASurfaceTransaction *tx, struct layer *l, const int r l->geo_src = srcR; l->geo_dst = dstR; l->geo_valid = 1; char cov[96] = ""; if (l->ds_applied > 0) coverage_text(l, cov, sizeof(cov)); - banner_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name, + droiddeck_log("layer", "%s geometry: buffer %d,%d-%d,%d -> screen %d,%d-%d,%d%s%s%s", l->name, r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], cov[0] ? " (HDR layer " : "", cov, cov[0] ? ")" : ""); } } @@ -606,11 +606,11 @@ static void apply_frame_rate(ASurfaceTransaction *tx, struct layer *l) { } int first = l->fps_applied < 0.0f; l->fps_applied = want; - if (want > 0.0f) banner_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want); - else if (!first) banner_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name); + if (want > 0.0f) droiddeck_log("layer", "display frame-rate vote on %s: %.2f Hz", l->name, want); + else if (!first) droiddeck_log("layer", "display frame-rate vote on %s cleared (panel runs free)", l->name); } -/* ---- colour (HDR, banner_color.h) ------------------------------------------------------------- +/* ---- colour (HDR, droiddeck_color.h) ------------------------------------------------------------- * The layer is told what its buffer's pixels MEAN - the dataspace (BT2020_PQ for an HDR10 frame) and * the game's mastering / content-light metadata - in the same transaction as the buffer, so the * display never shows an HDR frame decoded as sRGB or the other way round. A layer that has never been @@ -630,67 +630,67 @@ static void coverage_text(const struct layer *l, char *out, size_t n) { /* API 35: ask the display for HDR headroom explicitly while the layer carries an HDR frame, and clear the * request (0 = no preference) when it stops. Only on change; a layer never tagged is never touched. */ -static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) { +static void apply_headroom(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) { static int said_missing; if (!api.setDesiredHdrHeadroom) { if (c && c->dataspace && !said_missing) { said_missing = 1; - banner_color_note_headroom_request(-1.0f); - banner_log("color", "HDR headroom request not available on %s (Android < 15 has no " + droiddeck_color_note_headroom_request(-1.0f); + droiddeck_log("color", "HDR headroom request not available on %s (Android < 15 has no " "ASurfaceTransaction_setDesiredHdrHeadroom): the layer relies on Android's default", l->name); } return; } - const float want = (c && c->dataspace) ? banner_color_desired_headroom(c, NULL, 0) : 0.0f; + const float want = (c && c->dataspace) ? droiddeck_color_desired_headroom(c, NULL, 0) : 0.0f; if (l->hr_applied < 0.0f && want == 0.0f && !(c && c->dataspace)) return; /* never asked: leave it be */ if (l->hr_applied >= 0.0f && fabsf(want - l->hr_applied) < 0.005f) return; char why[200] = ""; - if (c && c->dataspace) banner_color_desired_headroom(c, why, sizeof(why)); + if (c && c->dataspace) droiddeck_color_desired_headroom(c, why, sizeof(why)); api.setDesiredHdrHeadroom(tx, l->sc, want); const int first = l->hr_applied < 0.0f; l->hr_applied = want; - banner_color_note_headroom_request(want); + droiddeck_color_note_headroom_request(want); if (want > 0.0f) - banner_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why); + droiddeck_log("color", "requested HDR headroom %.1fx on %s (%s)", want, l->name, why); else if (c && c->dataspace) - banner_log("color", "no HDR headroom requested on %s: %s", l->name, why); + droiddeck_log("color", "no HDR headroom requested on %s: %s", l->name, why); else if (!first) - banner_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name); + droiddeck_log("color", "HDR headroom request on %s cleared (no preference): the frame on the layer is not HDR", l->name); } -static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct banner_color *c) { - int32_t want = c ? c->dataspace : BANNER_ADATASPACE_UNKNOWN; +static void apply_colour(ASurfaceTransaction *tx, struct layer *l, const struct droiddeck_color *c) { + int32_t want = c ? c->dataspace : DROIDDECK_ADATASPACE_UNKNOWN; uint32_t md = (c && c->dataspace) ? c->identity : 0; if (!api.setBufferDataSpace) return; - if (l->ds_applied < 0 && want == BANNER_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */ + if (l->ds_applied < 0 && want == DROIDDECK_ADATASPACE_UNKNOWN) return; /* never tagged: leave it be */ apply_headroom(tx, l, want ? c : NULL); if (want == l->ds_applied && md == l->md_applied) return; api.setBufferDataSpace(tx, l->sc, want); - struct banner_hdr_smpte2086 st; - struct banner_hdr_cta861_3 cta; + struct droiddeck_hdr_smpte2086 st; + struct droiddeck_hdr_cta861_3 cta; int st_on = c && c->dataspace && c->has_st2086, cta_on = c && c->dataspace && c->has_cta861; if (st_on) { - st = (struct banner_hdr_smpte2086){ + st = (struct droiddeck_hdr_smpte2086){ .displayPrimaryRed = {c->red[0], c->red[1]}, .displayPrimaryGreen = {c->green[0], c->green[1]}, .displayPrimaryBlue = {c->blue[0], c->blue[1]}, .whitePoint = {c->white[0], c->white[1]}, .maxLuminance = c->max_lum, .minLuminance = c->min_lum}; } - if (cta_on) cta = (struct banner_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall}; + if (cta_on) cta = (struct droiddeck_hdr_cta861_3){.maxContentLightLevel = c->max_cll, .maxFrameAverageLightLevel = c->max_fall}; /* NULL clears what a previous description set: metadata never outlives the frame it belongs to. */ if (api.setHdrMetadata_smpte2086) api.setHdrMetadata_smpte2086(tx, l->sc, st_on ? &st : NULL); if (api.setHdrMetadata_cta861_3) api.setHdrMetadata_cta861_3(tx, l->sc, cta_on ? &cta : NULL); if (want) { char cov[96]; coverage_text(l, cov, sizeof(cov)); - banner_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 " - "%s, CTA-861.3 %s [%s]", l->name, want == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR", + droiddeck_log("color", "%s: dataspace %s (%#x) set on the display layer for image description #%u%s%s; SMPTE 2086 " + "%s, CTA-861.3 %s [%s]", l->name, want == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR", (unsigned)want, c->identity, cov[0] ? ", " : "", cov, st_on ? (api.setHdrMetadata_smpte2086 ? "sent" : "not supported by this Android") : "none given", cta_on ? (api.setHdrMetadata_cta861_3 ? "sent" : "not supported by this Android") : "none given", c->text); } else - banner_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is " + droiddeck_log("color", "%s: dataspace back to UNKNOWN (sRGB), HDR metadata cleared - the frame on the layer is " "not an HDR frame", l->name); l->ds_applied = want; l->md_applied = md; @@ -711,7 +711,7 @@ void sc_layer_hdr_symbols(char *out, size_t size) { static void log_layer_count(void) { if (g_two_logged) return; g_two_logged = 1; - banner_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the " + droiddeck_log("layer", "%d display layers in use: \"%s\" (z=%d) and \"%s\" (z=%d) above it, both children of the " "screen surface; the app's own views (drawer, HUD, pointer) stay above both. %d is the cap - more " "would push SurfaceFlinger into GPU client composition", SC_LAYER_COUNT, g_layers[SC_LAYER_GAME].name, (int)g_layers[SC_LAYER_GAME].z, @@ -748,7 +748,7 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) { if (!win || win != l->win) return NULL; /* the window changed: ensure_sc re-creates it anyway */ ASurfaceControl *fresh = api.createFromWindow(win, l->name); if (!fresh) { - banner_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name); + droiddeck_log("error", "layer: %s: composition recovery could not create a new SurfaceControl", l->name); return NULL; } ASurfaceControl *old = l->sc; @@ -760,14 +760,14 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) { l->geo_valid = 0; l->fps_applied = -1.0f; l->ds_applied = -1; l->md_applied = 0; l->hr_applied = -1.0f; - banner_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above " + droiddeck_log("layer", "composition recovery: %s got a fresh SurfaceControl now that nothing is above " "the game (measured on this panel: hardware composition does NOT return from this alone)", l->name); return old; } /* The transaction that puts pool slot `idx` of layer `l` on screen at `r`, as `color` (NULL = no * description). 0 = applied. */ -static int present_slot(struct layer *l, int idx, const int r[8], const struct banner_color *color, +static int present_slot(struct layer *l, int idx, const int r[8], const struct droiddeck_color *color, int translucent) { struct slot *s = &l->slots[idx]; ASurfaceTransaction *tx = api.txCreate(); @@ -792,7 +792,7 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b api.txDelete(tx); l->cur_slot = idx; l->cur_token = NULL; - if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); } + if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); } g_stat_layer_frames++; return 0; } @@ -805,7 +805,7 @@ static void log_drop(struct layer *l) { l->drops_unlogged++; int64_t t = now_ns(); if (!l->drop_logged_ns || t - l->drop_logged_ns > 30000000000LL) { - banner_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name, + droiddeck_log("layer", "%s: no free layer buffer (display still holds all %d): %u frame%s dropped%s", l->name, l->pool_n, l->drops_unlogged, l->drops_unlogged == 1 ? "" : "s", l->drop_logged_ns ? " since the last such line" : ""); l->drop_logged_ns = t; @@ -820,7 +820,7 @@ unsigned sc_layer_drops_take(void) { } int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token, int scene_w, int scene_h, - const struct banner_color *color, uint32_t ahb_format) { + const struct droiddeck_color *color, uint32_t ahb_format) { struct layer *l = layer_of(SC_LAYER_GAME); int r[8]; if (!ahb || !token || ensure_sc(l) != 0) goto unavailable; @@ -863,19 +863,19 @@ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, voi api.txDelete(tx); l->cur_slot = -1; l->cur_token = token; - if (!l->shown) { l->shown = 1; banner_log("layer", "%s: layer shown", l->name); } + if (!l->shown) { l->shown = 1; droiddeck_log("layer", "%s: layer shown", l->name); } /* The notification is re-armed for each session; the layer and its logging flag can * outlive it. Let the notification's own one-shot decide whether to fire. */ if (!l->first_logged) l->first_logged = 1; vkp_signal_first_frame(); - if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_ZERO_COPY, ahb_format); + if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_ZERO_COPY, ahb_format); return 0; unavailable: if (acquire_fd >= 0) close(acquire_fd); return -1; } -int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color) { +int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color) { struct layer *l = layer_of(SC_LAYER_GAME); if (!src || ensure_sc(l) != 0) return -1; int sw = vkp_image_width(src), sh = vkp_image_height(src); @@ -889,10 +889,10 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru if (idx < 0) { log_drop(l); return 0; } if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1; if (present_slot(l, idx, r, color, 0) != 0) return -1; - if (color && color->dataspace) banner_color_frame_shown(color, BANNER_HDR_LAYER_COPY, AHB_RGBA8); + if (color && color->dataspace) droiddeck_color_frame_shown(color, DROIDDECK_HDR_LAYER_COPY, AHB_RGBA8); if (!l->first_logged) { l->first_logged = 1; - banner_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); " + droiddeck_log("layer", "presenting %dx%d game frames on their own SurfaceControl layer (%s pool, %d buffers); " "HUD and pointer stay Android views above it", sw, sh, l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n); } @@ -916,7 +916,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int if (present_slot(l, idx, r, NULL, 0) != 0) return -1; /* the effects chain's result is 8-bit sRGB */ if (!l->first_logged) { l->first_logged = 1; - banner_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); " + droiddeck_log("layer", "presenting %dx%d frames on their own SurfaceControl layer (%s pool, %d buffers); " "HUD and pointer stay Android views above it", rw, rh, l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", l->pool_n); } @@ -935,7 +935,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int static uint32_t g_hdr_pool_fmt = AHB_RGB10A2; int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf, - int scene_w, int scene_h, const struct banner_color *color) { + int scene_w, int scene_h, const struct droiddeck_color *color) { struct layer *l = layer_of(SC_LAYER_GAME); int rw = 0, rh = 0, r[8]; if (!draws || n <= 0 || !hf || ensure_sc(l) != 0) return -1; @@ -949,7 +949,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct int idx = take_free_slot(l, rw, rh, fmt, &wait_fd); if (idx < 0 && g_alloc_failed && fmt == AHB_RGB10A2) { g_hdr_pool_fmt = AHB_RGBA8; - banner_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes " + droiddeck_log("color", "%s: this device will not make a 10-bit layer buffer (RGBA1010102): the HDR picture goes " "on 8-bit buffers instead (still tagged BT2020_PQ; less precision)", l->name); idx = take_free_slot(l, rw, rh, g_hdr_pool_fmt, &wait_fd); } @@ -957,21 +957,21 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1; if (present_slot(l, idx, r, tm ? NULL : color, 0) != 0) return -1; /* tone-mapped = plain sRGB: no tag */ if (color && color->dataspace) - banner_color_frame_shown(color, tm ? BANNER_HDR_TONEMAPPED : BANNER_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt); + droiddeck_color_frame_shown(color, tm ? DROIDDECK_HDR_TONEMAPPED : DROIDDECK_HDR_COMPOSED, tm ? AHB_RGBA8 : g_hdr_pool_fmt); static int said = -1; if (said != tm) { said = tm; if (tm) - banner_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged " + droiddeck_log("color", "tone-mapped picture on the game's display layer: %dx%d, %s 8-bit buffers, untagged " "(sRGB) - HDR output is switched off", rw, rh, l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear"); else { char cov[96]; coverage_text(l, cov, sizeof(cov)); - banner_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh, + droiddeck_log("color", "HDR picture on its own display layer: %dx%d, %s %s buffers, tagged %s%s%s", rw, rh, l->pool_modifier == MOD_QCOM_COMPRESSED ? "UBWC" : "linear", g_hdr_pool_fmt == AHB_RGB10A2 ? "10-bit" : "8-bit", - color && color->dataspace == BANNER_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR", + color && color->dataspace == DROIDDECK_ADATASPACE_BT2020_PQ ? "BT2020_PQ" : "HDR", cov[0] ? ", " : "", cov); } } @@ -1025,13 +1025,13 @@ int sc_layer_overlay_affordable(void) { int first = g_overlay_declined < 0; g_overlay_declined = want; if (want) - banner_log("layer", "overlay layer declined: this display rotates every layer %d° and the game " + droiddeck_log("layer", "overlay layer declined: this display rotates every layer %d° and the game " "layer is scaled %dx%d -> %dx%d, and on that combination a second layer drops the " "whole frame to GPU composition for the rest of the session (measured). The window " "above the game goes on the copy path instead - same picture, one blit.", deg, sr[0], sr[1], ds[0], ds[1]); else if (!first) - banner_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and " + droiddeck_log("layer", "overlay layer allowed again: the game layer is no longer both rotated and " "scaled, so a second display layer costs nothing here"); } return ok; @@ -1082,7 +1082,7 @@ void sc_layer_hide(void) { * SurfaceControl, since it goes up and down with every effects/frame-generation toggle. */ if (g_layers[SC_LAYER_OVERLAY].sc) sc_layer_hide_overlay(); if (g_layers[SC_LAYER_GAME].shown) hide_layer(&g_layers[SC_LAYER_GAME]); - if (said) banner_log("layer", "layers hidden (scene is not a single fullscreen window)"); + if (said) droiddeck_log("layer", "layers hidden (scene is not a single fullscreen window)"); } void sc_layer_hide_overlay(void) { @@ -1098,7 +1098,7 @@ void sc_layer_hide_overlay(void) { * buffers stay allocated for the next window. */ if (l->shown) hide_layer(l); retire_sc(l); - banner_log("layer", "%s: gone (nothing is above the game any more)", l->name); + droiddeck_log("layer", "%s: gone (nothing is above the game any more)", l->name); /* ...and that is the half the measurement said is not enough: arm the game layer's * SurfaceControl swap, which the next frame performs (swap_sc_begin). */ if (g_layers[SC_LAYER_GAME].sc) g_layers[SC_LAYER_GAME].recreate_pending = 1; diff --git a/app/src/main/cpp/waylandcomp/src/sc_layer.h b/app/src/main/cpp/waylandcomp/src/sc_layer.h index f2f12a704..1d8e6a11e 100644 --- a/app/src/main/cpp/waylandcomp/src/sc_layer.h +++ b/app/src/main/cpp/waylandcomp/src/sc_layer.h @@ -1,7 +1,7 @@ #ifndef SC_LAYER_H #define SC_LAYER_H /* - * Layer mode (BANNER_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small, + * Layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1): the scene is handed to SurfaceFlinger as a small, * deliberately ordered SET of Android display layers - ASurfaceControl children of the * compositor's SurfaceView - instead of being blitted into the compositor's own swapchain. * @@ -12,8 +12,8 @@ * an ASurfaceControl has no input channel, so touch and mouse keep * reaching the SurfaceView exactly as they did. * +-- SurfaceView ..... the compositor's Vulkan swapchain; black while layer mode is up - * +-- z=1 "banner_wayland_game" ...... the one fullscreen game window - * +-- z=2 "banner_wayland_overlay" ... at most one window drawn ABOVE the game + * +-- z=1 "droiddeck_wayland_game" ...... the one fullscreen game window + * +-- z=2 "droiddeck_wayland_overlay" ... at most one window drawn ABOVE the game * * TWO layers is the hard cap (SC_LAYER_COUNT), and deliberately so: HWC composes only a few * layers before SurfaceFlinger falls back to GPU client composition, which would throw away the @@ -71,7 +71,7 @@ struct vkp_image; struct vkp_draw; -struct banner_color; +struct droiddeck_color; /* The layers, bottom first. The z-order is the array order (z = id + 1). */ enum sc_layer_id { SC_LAYER_GAME = 0, SC_LAYER_OVERLAY = 1, SC_LAYER_COUNT = 2 }; @@ -84,7 +84,7 @@ int sc_layer_available(void); * Decides where a zero-copy acquire fence can come from; result goes to the session log. */ void sc_layer_probe_dmabuf_fd(int fd); -/* HDR (banner_color.h): can a display layer be told its buffer's colour encoding here +/* HDR (droiddeck_color.h): can a display layer be told its buffer's colour encoding here * (ASurfaceTransaction_setBufferDataSpace, Android 10+)? Part of the HDR gate. */ int sc_layer_can_tag_hdr(void); /* Which of the colour calls this libandroid has, for the gate's log line. */ @@ -100,7 +100,7 @@ void sc_layer_hdr_symbols(char *out, size_t size); * free buffer), -1 = the layer path is unavailable this frame and the caller must draw the old way. * The copy into the layer buffer is 8-bit: an HDR frame keeps its tag (its colours stay right), and * loses precision. */ -int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct banner_color *color); +int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const struct droiddeck_color *color); /* GAME layer, screen effects on: run the compositor pass (composite `draws` + the effects chain, * vkp_pass_begin) and put its result on the layer. Same return values as sc_layer_present. */ @@ -113,7 +113,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int * Same return values as sc_layer_present. */ struct vkp_hdr_frame; int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct vkp_hdr_frame *hf, - int scene_w, int scene_h, const struct banner_color *color); + int scene_w, int scene_h, const struct droiddeck_color *color); /* GAME layer, zero-copy (ahb_swapchain.c): show the game's own w x h AHardwareBuffer, gated by * acquire_fd (a sync_file the layer waits on before reading; owned by the callee, -1 = none). @@ -124,7 +124,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct * frame (the caller draws the old way). `color` / `ahb_format`: the frame's image description (NULL = * none) and the buffer's AHARDWAREBUFFER_FORMAT_*, for the dataspace and the HDR evidence. */ int sc_layer_present_ahb(AHardwareBuffer *ahb, int w, int h, int acquire_fd, void *token, - int scene_w, int scene_h, const struct banner_color *color, uint32_t ahb_format); + int scene_w, int scene_h, const struct droiddeck_color *color, uint32_t ahb_format); /* Vote a panel refresh rate for the layer the game presents on (VRR / refresh-rate matching), the * same rate and compatibility the app votes on its own surface with Surface.setFrameRate; 0 = no diff --git a/app/src/main/cpp/waylandcomp/src/vk_loader.c b/app/src/main/cpp/waylandcomp/src/vk_loader.c index 3038d79ae..ef939911c 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_loader.c +++ b/app/src/main/cpp/waylandcomp/src/vk_loader.c @@ -8,7 +8,7 @@ #include <sys/stat.h> #include <android/log.h> -#define TAG "BannerWayland" +#define TAG "DroidDeckWayland" struct vk_api g_vk; static void *g_handle; diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c index 7a2ecfd48..3af9a8c25 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.c +++ b/app/src/main/cpp/waylandcomp/src/vk_present.c @@ -8,7 +8,7 @@ #include "framegen_bridge.h" #include "hdr_compose.h" #include "blend_pass.h" -#include "banner_color.h" +#include "droiddeck_color.h" #include <stdio.h> #include <stdlib.h> #include <string.h> @@ -19,9 +19,9 @@ #include <pthread.h> #include <android/log.h> -#define TAG "BannerWayland" +#define TAG "DroidDeckWayland" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__) -#define LOGE(...) banner_log("error", __VA_ARGS__) +#define LOGE(...) droiddeck_log("error", __VA_ARGS__) #define MOD_INVALID VKP_MOD_INVALID struct vkp_image { @@ -73,7 +73,7 @@ static VkImage *g_images; static uint32_t g_nimg; static VkExtent2D g_extent; -static int g_first_frame_done; /* one-shot: fire banner_on_first_frame() on first present */ +static int g_first_frame_done; /* one-shot: fire droiddeck_on_first_frame() on first present */ /* The base surface under the display layers (vkp_base_black): 1 once a plain black frame has been * presented on the CURRENT swapchain and nothing else since. Every other present clears it, and so does @@ -179,7 +179,7 @@ static int64_t g_swap_retry_at_ns; static int g_swap_fail_logged; /* Implemented in waylandcomp_jni.c - notifies Java (dismiss launch overlay). */ -extern void banner_on_first_frame(void); +extern void droiddeck_on_first_frame(void); static char g_gpu_name[VK_MAX_PHYSICAL_DEVICE_NAME_SIZE]; const char *vkp_gpu_name(void) { return g_gpu_name; } @@ -276,7 +276,7 @@ int vkp_apply_window_request(void) { g_window = w; g_swap_retry_at_ns = 0; g_swap_fail_logged = 0; - banner_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused"); + droiddeck_log("gpu", w ? "screen surface attached" : "screen surface gone: presenting paused"); return 1; } @@ -353,7 +353,7 @@ static void update_map(int scene_w, int scene_h) { g_map.off_y = (float)(g_map.ry + (int)((g_map.rh - scene_h * aspect) * 0.5f)); } g_map.valid = 1; - banner_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output", + droiddeck_log("screen", "%s, %s: %dx%d scene shown %dx%d at %d,%d on the %dx%d output", mode_name(mode), align_name(align), scene_w, scene_h, (int)(scene_w * g_map.kx + 0.5f), (int)(scene_h * g_map.ky + 0.5f), (int)g_map.off_x, (int)g_map.off_y, W, H); @@ -391,10 +391,10 @@ static int dev_init(void) { const char *inst_exts[3] = {VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_ANDROID_SURFACE_EXTENSION_NAME, NULL}; uint32_t n_inst_exts = 2; - /* HDR sessions only (banner_color.h): the colour-space extension lets the swapchain carry HDR10 for + /* HDR sessions only (droiddeck_color.h): the colour-space extension lets the swapchain carry HDR10 for * frame generation. Asked for only when the loader has it, and only when HDR was asked for, so * every other session creates exactly the instance it always did. */ - if (banner_color_requested() && g_vk.EnumerateInstanceExtensionProperties) { + if (droiddeck_color_requested() && g_vk.EnumerateInstanceExtensionProperties) { uint32_t ne = 0; g_vk.EnumerateInstanceExtensionProperties(NULL, &ne, NULL); VkExtensionProperties *ie = ne ? calloc(ne, sizeof(*ie)) : NULL; @@ -407,14 +407,14 @@ static int dev_init(void) { } } free(ie); - banner_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame " + droiddeck_log("color", "compositor instance %s VK_EXT_swapchain_colorspace (an HDR10 swapchain for frame " "generation %s)", g_colorspace_ext ? "enables" : "has no", g_colorspace_ext ? "is possible where the screen surface offers one" : "is not possible: tone-mapped instead"); } /* 1.3 like the X11 renderer's instance: the frame-generation probe (framegen_engine.cpp) * queries VkPhysicalDeviceVulkan12Features, and a 1.1 instance may be answered as 1.1. */ VkApplicationInfo app = {.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO, - .pApplicationName = "banner-wayland-present", + .pApplicationName = "droiddeck-wayland-present", .apiVersion = VK_API_VERSION_1_3}; VkInstanceCreateInfo ici = {.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, .pApplicationInfo = &app, @@ -444,9 +444,9 @@ static int dev_init(void) { VkPhysicalDeviceProperties props; g_vk.GetPhysicalDeviceProperties(g_pd, &props); snprintf(g_gpu_name, sizeof(g_gpu_name), "%s", props.deviceName); - banner_log("gpu", "compositor renders on %s with %s", props.deviceName, + droiddeck_log("gpu", "compositor renders on %s with %s", props.deviceName, g_library_name ? g_library_name : "the system Vulkan driver"); - if (g_driver_path) banner_log("gpu", "driver folder %s", g_driver_path); + if (g_driver_path) droiddeck_log("gpu", "driver folder %s", g_driver_path); } g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops); @@ -464,7 +464,7 @@ static int dev_init(void) { LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]); /* HDR sessions only: the HDR10 swapchain (frame generation) can carry the game's metadata. */ int want_hdr_md = 0; - if (banner_color_requested()) { + if (droiddeck_color_requested()) { want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME); if (want_hdr_md) dev_exts[n_dev_exts++] = VK_EXT_HDR_METADATA_EXTENSION_NAME; } @@ -487,7 +487,7 @@ static int dev_init(void) { .enabledExtensionCount = n_dev_exts, .ppEnabledExtensionNames = dev_exts}; VkResult dr = g_vk.CreateDevice(g_pd, &dci, NULL, &g_dev); if (dr != VK_SUCCESS && fg_features) { - banner_log("framegen", "the driver refused the LSFG feature set (%s); device created without it", + droiddeck_log("framegen", "the driver refused the LSFG feature set (%s); device created without it", vk_result_name(dr)); dci.pNext = NULL; fg_features = NULL; @@ -506,13 +506,13 @@ static int dev_init(void) { g_wait_sem = VK_NULL_HANDLE; } } - banner_log("perf", "layer buffers: the display's release fences are waited for %s", + droiddeck_log("perf", "layer buffers: the display's release fences are waited for %s", g_import_sem_fd ? "on the GPU (VK_KHR_external_semaphore_fd)" : "on the CPU (this driver has no VK_KHR_external_semaphore_fd)"); if (want_hdr_md) g_set_hdr_metadata = (PFN_vkSetHdrMetadataEXT)g_vk.GetDeviceProcAddr(g_dev, "vkSetHdrMetadataEXT"); - if (banner_color_requested()) - banner_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)", + if (droiddeck_color_requested()) + droiddeck_log("color", "compositor device %s VK_EXT_hdr_metadata (the HDR10 swapchain for frame generation %s)", g_set_hdr_metadata ? "enables" : "has no", g_set_hdr_metadata ? "carries the game's mastering metadata" : "goes without the game's metadata"); @@ -641,7 +641,7 @@ static void log_surface_formats(const VkSurfaceFormatKHR *f, uint32_t n) { vk_colorspace_short(f[i].colorSpace)); else hdr_cut++; } - banner_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per, + droiddeck_log("color", "screen surface lists %u format/colour-space pairs (%s); HDR-capable: %s%s", n, per, hdr_n ? hdr : "none", hdr_cut ? " (+more)" : ""); } @@ -678,7 +678,7 @@ static int swap_init_locked(void) { } VkSurfaceFormatKHR chosen = fmts[0]; const VkSurfaceFormatKHR sdr_choice = fmts[0]; - if (banner_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) { + if (droiddeck_color_requested() && (!g_surface_formats_said || g_swap_hdr_want)) { g_surface_formats_said = 1; log_surface_formats(fmts, nfmt); } @@ -702,13 +702,13 @@ static int swap_init_locked(void) { g_swap_is_hdr = 1; char fname[24]; vk_format_short(chosen.format, fname, sizeof(fname)); - banner_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR " + droiddeck_log("color", "screen swapchain built as HDR10 (format %d %s, HDR10_ST2084): frame-generated HDR " "frames reach the display as PQ BT.2020%s", (int)chosen.format, fname, vk_format_bits(chosen.format) <= 8 ? " - an 8-bit swapchain: banding possible in smooth gradients" : ""); } else { g_swap_hdr_unavailable = 1; - banner_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space " + droiddeck_log("color", "the screen surface lists no HDR10 swapchain format among its %u format/colour-space " "pairs (see the line above): frames with frame generation are tone-mapped to SDR instead", nfmt); } @@ -748,7 +748,7 @@ static int swap_init_locked(void) { if (cr != VK_SUCCESS && g_swap_is_hdr) { /* The surface listed the HDR10 pair but refused the swapchain: say so, never ask again this * surface, and build the ordinary one now (frames tone-mapped) - never a black screen. */ - banner_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are " + droiddeck_log("color", "the driver refused the HDR10 swapchain (%s): frames with frame generation are " "tone-mapped to SDR instead", vk_result_name(cr)); g_swap_is_hdr = 0; g_swap_hdr_unavailable = 1; @@ -767,7 +767,7 @@ static int swap_init_locked(void) { g_images = calloc(g_nimg, sizeof(VkImage)); g_vk.GetSwapchainImagesKHR(g_dev, g_swapchain, &g_nimg, g_images); - banner_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg, + droiddeck_log("gpu", "screen output %ux%u, %u buffers, vsync%s", g_extent.width, g_extent.height, g_nimg, g_swap_extra ? " (frame generation: several presents per frame)" : ""); return 0; } @@ -833,7 +833,7 @@ static void init_black_image(void) { g_vk.WaitForFences(g_dev, 1, &g_fence, VK_TRUE, 1000000000ULL) == VK_SUCCESS) g_black_state = 1; else - banner_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame"); + droiddeck_log("gpu", "letterbox black image unavailable: the whole output is cleared every copy-path frame"); } /* Blit the black image over everything of swapchain image `img` (in TRANSFER_DST_OPTIMAL) OUTSIDE the @@ -971,7 +971,7 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t LOGE("%s: vkCreateImage(%s, %dx%d, pitch %u, offset %u) -> %s%s", as_blit_dst ? "layer" : "dmabuf", vkp_modifier_name(modifier), w, h, stride, offset, vk_result_name(cr), (!as_blit_dst && modifier == VKP_MOD_QCOM_COMPRESSED) - ? ": the game's UBWC layout was refused; BANNER_WAYLAND_UBWC=0 forces linear buffers" : ""); + ? ": the game's UBWC layout was refused; DROIDDECK_WAYLAND_UBWC=0 forces linear buffers" : ""); free(img); return NULL; } @@ -1182,7 +1182,7 @@ static int ensure_scene_image(int w, int h) { if (r != VK_SUCCESS) { LOGE("effects: scene image %dx%d: vkAllocateMemory %s", w, h, vk_result_name(r)); destroy_scene_image(); return -1; } g_vk.BindImageMemory(g_dev, g_scene.img, g_scene.mem, 0); g_scene.w = w; g_scene.h = h; - banner_log("effects", "scene image %dx%d for the effect chain", w, h); + droiddeck_log("effects", "scene image %dx%d for the effect chain", w, h); return 0; } @@ -1218,7 +1218,7 @@ static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImag s->w = w; s->h = h; s->fmt = fmt; char fname[24]; vk_format_short(fmt, fname, sizeof(fname)); - banner_log("color", "%s image %dx%d (%s)", what, w, h, fname); + droiddeck_log("color", "%s image %dx%d (%s)", what, w, h, fname); return 0; } @@ -1249,7 +1249,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n, .dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT}; g_vk.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &mb, 0, NULL, 0, NULL); - struct hdrc_params p = {.mode = mode, .sdr_white_nits = banner_color_sdr_white(), + struct hdrc_params p = {.mode = mode, .sdr_white_nits = droiddeck_color_sdr_white(), .peak_nits = hf->peak_nits > 0.0f ? hf->peak_nits : 1000.0f}; int lowest_hdr = -1; for (int i = 0; i < n; i++) { @@ -1272,7 +1272,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n, } if (i >= lowest_hdr && !truncated_said) { truncated_said = 1; - banner_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest " + droiddeck_log("color", "HDR composition: more than %d windows are stacked over the HDR game - the lowest " "ones are treated as SDR", HDRC_MAX_RECTS); } } @@ -1287,7 +1287,7 @@ static void device_lost(const char *where) { g_dev_state = -2; g_base_black = 0; vkp_framegen_device_lost(); - banner_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; " + droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; " "restart the session", where); for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]); if (g_swapchain) g_vk.DestroySwapchainKHR(g_dev, g_swapchain, NULL); @@ -1385,12 +1385,12 @@ static int acquire_image(int k, int first, uint32_t *img, VkResult *ar_out) { if (ar == VK_SUCCESS || ar == VK_SUBOPTIMAL_KHR) { *ar_out = ar; return 0; } if (ar == VK_ERROR_DEVICE_LOST) { device_lost("acquire"); return -1; } if (ar == VK_ERROR_OUT_OF_DATE_KHR || ar == VK_ERROR_SURFACE_LOST_KHR) { - banner_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar)); + droiddeck_log("gpu", "screen surface %s on acquire: rebuilding the swapchain", vk_result_name(ar)); destroy_swapchain(); if (first && attempt == 0) continue; return -1; } - banner_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar); + droiddeck_log("error", "present: acquire %d failed (%s %d)", k, vk_result_name(ar), (int)ar); if (ar == VK_TIMEOUT || ar == VK_NOT_READY) return -1; destroy_swapchain(); /* anything else: start over next frame */ return -1; @@ -1422,7 +1422,7 @@ static void check_surface_changed(void) { if (caps.currentExtent.width == g_caps_extent.width && caps.currentExtent.height == g_caps_extent.height && caps.currentTransform == g_surface_transform) return; - banner_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain", + droiddeck_log("gpu", "screen surface changed (%ux%u -> %ux%u, transform %d -> %d): rebuilding the swapchain", g_caps_extent.width, g_caps_extent.height, caps.currentExtent.width, caps.currentExtent.height, (int)g_surface_transform, (int)caps.currentTransform); destroy_swapchain(); @@ -1464,7 +1464,7 @@ static int take_wait_fd(int fd) { static int said; if (!said) { said = 1; - banner_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU"); + droiddeck_log("perf", "layer buffers: importing a release fence as a GPU wait failed; waiting for it on the CPU"); } } struct pollfd p = {.fd = fd, .events = POLLIN}; @@ -1476,18 +1476,18 @@ static int take_wait_fd(int fd) { /* The HDR10 swapchain's metadata: the game's SMPTE 2086 / CTA-861.3 values (its image description) via * VK_EXT_hdr_metadata, once per swapchain and description. A game that gave none gets none. */ -static void send_hdr_metadata(const struct banner_color *c) { +static void send_hdr_metadata(const struct droiddeck_color *c) { static int said_none, said_nodesc; if (!c) return; if (!g_set_hdr_metadata) { - if (!said_none) { said_none = 1; banner_log("color", "HDR10 swapchain without the game's metadata: this device " + if (!said_none) { said_none = 1; droiddeck_log("color", "HDR10 swapchain without the game's metadata: this device " "has no VK_EXT_hdr_metadata (the display uses its defaults)"); } return; } if (!c->has_st2086 && !c->has_cta861) { if (said_nodesc != (int)c->identity) { said_nodesc = (int)c->identity; - banner_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), " + droiddeck_log("color", "HDR10 swapchain: image description #%u carries no HDR metadata (the game sent none), " "so none is set - the display uses its defaults", c->identity); } return; @@ -1506,7 +1506,7 @@ static void send_hdr_metadata(const struct banner_color *c) { md.maxFrameAverageLightLevel = c->max_fall; } g_set_hdr_metadata(g_dev, 1, &g_swapchain, &md); - banner_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)", + droiddeck_log("color", "HDR10 swapchain: the game's metadata set via VK_EXT_hdr_metadata (image description #%u: %s)", c->identity, c->text); } @@ -1522,7 +1522,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i /* Frame generation queues several presents per scene frame and needs swapchain images for * them: rebuild when that changes (armed, disarmed, another multiplier). */ if (g_swapchain && vkp_framegen_extra_images() != g_swap_extra) { - banner_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain", + droiddeck_log("gpu", "frame generation now wants %d present%s per frame: rebuilding the swapchain", 1 + vkp_framegen_extra_images(), vkp_framegen_extra_images() ? "s" : ""); destroy_swapchain(); } @@ -1531,7 +1531,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i * swapchain (only in HDR sessions: g_colorspace_ext). */ const int want_hdr = hf && !hf->tonemap && g_colorspace_ext && !g_swap_hdr_unavailable; if (g_swapchain && want_hdr != g_swap_is_hdr) { - banner_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10" + droiddeck_log("color", want_hdr ? "HDR frames with frame generation: rebuilding the screen swapchain as HDR10" : hf ? "HDR output switched off: rebuilding the screen swapchain as SDR (frames tone-mapped)" : "no HDR frames through the screen swapchain any more: rebuilding it as SDR"); destroy_swapchain(); @@ -1570,10 +1570,10 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i char sw[24]; vk_format_short(g_swap_fmt, sw, sizeof(sw)); if (deep) - banner_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ " + droiddeck_log("color", "frame generation runs on the HDR picture in FP16 (RGBA16F): more than 8 bits of PQ " "through interpolation, into the HDR10 swapchain (%s)", sw); else - banner_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), " + droiddeck_log("color", "frame generation runs on the HDR picture in 8 bits (the engine cannot take FP16 here), " "into the HDR10 swapchain (%s): banding possible in smooth gradients", sw); } } @@ -1746,7 +1746,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i if (k > 0) { VkResult ark; if (acquire_image(k, 0, &img, &ark) != 0) { - banner_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents); + droiddeck_log("framegen", "present %d/%d: no swapchain image; the frame ends early", k + 1, presents); pr = VK_ERROR_OUT_OF_DATE_KHR; break; } @@ -1791,7 +1791,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i if (fr == VK_ERROR_DEVICE_LOST || pr == VK_ERROR_DEVICE_LOST) { device_lost("present"); return -1; } if (pr == VK_ERROR_OUT_OF_DATE_KHR || pr == VK_ERROR_SURFACE_LOST_KHR) { /* The surface changed or went away under this frame: rebuild before the next one. */ - banner_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr)); + droiddeck_log("gpu", "screen surface %s on present: rebuilding the swapchain", vk_result_name(pr)); destroy_swapchain(); return -1; } else if (pr == VK_SUBOPTIMAL_KHR || ar == VK_SUBOPTIMAL_KHR) { @@ -1799,9 +1799,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i * swap_init), so a rotated panel reports SUBOPTIMAL on every frame. Rebuilding would change * nothing (and a rebuild per frame would be far worse), so present as is; say so once. */ static int said; - if (!said) { said = 1; banner_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); } + if (!said) { said = 1; droiddeck_log("gpu", "surface reports SUBOPTIMAL (panel rotation); presenting as is"); } } else if (pr != VK_SUCCESS) { - banner_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr); + droiddeck_log("error", "present: vkQueuePresentKHR failed (%s %d)", vk_result_name(pr), (int)pr); destroy_swapchain(); return -1; } @@ -1815,7 +1815,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i * launch/preloader overlay can dismiss (wayland has no XServer window-content hook). */ if (drawn && !g_first_frame_done) { g_first_frame_done = 1; - banner_on_first_frame(); + droiddeck_on_first_frame(); } if (how) *how = hdr_how; return 0; @@ -1856,7 +1856,7 @@ void vkp_reset_first_frame(void) { void vkp_signal_first_frame(void) { if (g_first_frame_done) return; g_first_frame_done = 1; - banner_on_first_frame(); + droiddeck_on_first_frame(); } int vkp_update_map(int scene_w, int scene_h) { diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h index eef9c80cc..2a752c343 100644 --- a/app/src/main/cpp/waylandcomp/src/vk_present.h +++ b/app/src/main/cpp/waylandcomp/src/vk_present.h @@ -122,12 +122,12 @@ int vkp_can_wait_sync_fd(void); /* ---- HDR composition (hdr_compose.h) ---- * An HDR game that cannot be alone on its display layer: the scene is composed into ONE encoding. */ -struct banner_color; +struct droiddeck_color; struct vkp_hdr_frame { const unsigned char *is_hdr; /* per draw: 1 = its image holds PQ BT.2020 (the surface's description) */ float peak_nits; /* the HDR content's peak, for a tone-map (max CLL / mastering max; 0 = 1000) */ int tonemap; /* 1 = the drawer's HDR output switch is off: tone-map to SDR, never PQ */ - const struct banner_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */ + const struct droiddeck_color *color; /* the topmost HDR draw's description: an HDR10 swapchain's metadata */ }; /* vkp_render for a scene with HDR draws (frame generation): composed into PQ and presented through an * HDR10 swapchain where the surface offers one (and hf->tonemap is 0), else tone-mapped into the ordinary @@ -184,6 +184,6 @@ int vkp_pass_copy_to(struct vkp_image *dst, int wait_fd); void vkp_pass_abort(void); // Session log (compositor.c): one line to Download/Wayland-logs and logcat. -void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +void droiddeck_log(const char *tag, const char *fmt, ...) __attribute__((format(printf, 2, 3))); #endif diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index 414ca45b9..4d11a5419 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -16,20 +16,20 @@ #include <android/log.h> #include <android/native_window_jni.h> #include "vk_present.h" -#include "banner_ext.h" +#include "droiddeck_ext.h" #include "ahb_swapchain.h" #include "sc_layer.h" #include "effects_chain.h" -#include "banner_color.h" +#include "droiddeck_color.h" -extern int banner_wayland_run(void); -extern void banner_wayland_send_pointer(int action, int x, int y); -extern void banner_wayland_send_touch(int action, int pointer_id, int x, int y); -extern int banner_cursor_snapshot(int *out, int cap); -extern int banner_cursor_serial(void); -extern void banner_wayland_send_key(int evdev, int state); -extern void banner_wayland_send_scene_input(int type, int a, int b); -extern void banner_wayland_vsync(int64_t frame_time_ns); +extern int droiddeck_wayland_run(void); +extern void droiddeck_wayland_send_pointer(int action, int x, int y); +extern void droiddeck_wayland_send_touch(int action, int pointer_id, int x, int y); +extern int droiddeck_cursor_snapshot(int *out, int cap); +extern int droiddeck_cursor_serial(void); +extern void droiddeck_wayland_send_key(int evdev, int state); +extern void droiddeck_wayland_send_scene_input(int type, int a, int b); +extern void droiddeck_wayland_vsync(int64_t frame_time_ns); extern volatile int g_fps_limit; extern volatile int g_hide_shell; extern volatile int g_zero_copy; @@ -39,7 +39,7 @@ extern volatile int g_no_render_node; extern volatile int g_output_refresh_mhz; extern volatile int g_output_w, g_output_h; -#define TAG "BannerWayland" +#define TAG "DroidDeckWayland" static JavaVM *g_jvm; static volatile pid_t g_comp_tid; /* the compositor thread, for the app's ADPF hint session */ @@ -80,7 +80,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { /* Called from vk_present.c on the compositor thread when the first client frame is * presented. Attaches to the JVM (this thread is a bare pthread) and calls back into * Java so the launch overlay can dismiss. Fires exactly once. */ -void banner_on_first_frame(void) { +void droiddeck_on_first_frame(void) { if (!g_jvm || !g_compositor_cls || !g_on_first_frame) return; JNIEnv *env = NULL; int attached = 0; @@ -111,7 +111,7 @@ static JNIEnv *thread_env(void) { } /* A window started presenting GPU frames (window = its description), or NULL when it closed. */ -void banner_on_game_surface(const char *window, const char *gpu) { +void droiddeck_on_game_surface(const char *window, const char *gpu) { JNIEnv *env; if (!g_compositor_cls || !g_on_game_surface || !(env = thread_env())) return; jstring jw = window ? (*env)->NewStringUTF(env, window) : NULL; @@ -124,7 +124,7 @@ void banner_on_game_surface(const char *window, const char *gpu) { /* The program behind the game window that just started presenting: its Linux pid and executable name * ("" = unknown). The app arms its launch-time CPU affinity on it. Compositor thread. */ -void banner_on_game_program(int pid, const char *program) { +void droiddeck_on_game_program(int pid, const char *program) { JNIEnv *env; if (!g_compositor_cls || !g_on_game_program || !(env = thread_env())) return; jstring jp = (*env)->NewStringUTF(env, program ? program : ""); @@ -134,7 +134,7 @@ void banner_on_game_program(int pid, const char *program) { } /* One GPU frame from that window. */ -void banner_on_game_frame(void) { +void droiddeck_on_game_frame(void) { JNIEnv *env; if (!g_compositor_cls || !g_on_game_frame || !(env = thread_env())) return; (*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_game_frame); @@ -144,7 +144,7 @@ void banner_on_game_frame(void) { /* A program locked the pointer (locked = 1; the app's input path switches to deltas) or the lock * ended (locked = 0; x,y = where the pointer is now, in scene coordinates, for the app to re-sync * its own pointer to). Compositor thread. */ -void banner_on_pointer_lock(int locked, int x, int y) { +void droiddeck_on_pointer_lock(int locked, int x, int y) { JNIEnv *env; if (!g_compositor_cls || !g_on_pointer_lock || !(env = thread_env())) return; (*env)->CallStaticVoidMethod(env, g_compositor_cls, g_on_pointer_lock, (jboolean)(locked != 0), (jint)x, (jint)y); @@ -153,7 +153,7 @@ void banner_on_pointer_lock(int locked, int x, int y) { /* A program copied text (UTF-8, not NUL-terminated for the app's sake - a byte[] so emoji and * NULs survive JNI). Compositor thread. */ -void banner_on_clipboard_text(const char *utf8, int len) { +void droiddeck_on_clipboard_text(const char *utf8, int len) { JNIEnv *env; if (!g_compositor_cls || !g_on_clipboard || !(env = thread_env())) return; jbyteArray arr = (*env)->NewByteArray(env, len); @@ -166,7 +166,7 @@ void banner_on_clipboard_text(const char *utf8, int len) { /* A program started (enabled) or stopped accepting IME text; x,y,w,h = its caret rectangle in * scene pixels (all 0 = unknown). Compositor thread. */ -void banner_on_text_input(int enabled, const char *program, int x, int y, int w, int h) { +void droiddeck_on_text_input(int enabled, const char *program, int x, int y, int w, int h) { JNIEnv *env; if (!g_compositor_cls || !g_on_text_input || !(env = thread_env())) return; jstring jp = (*env)->NewStringUTF(env, program ? program : ""); @@ -195,11 +195,11 @@ static void *comp_thread(void *arg) { if (before > COMPOSITOR_NICE && setpriority(PRIO_PROCESS, tid, COMPOSITOR_NICE) != 0) refused = errno ? errno : -1; const int after = getpriority(PRIO_PROCESS, tid); if (refused) - banner_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)", + droiddeck_log("perf", "compositor thread %d \"wl-compositor\": stays at nice %d, a higher priority was refused (%s)", (int)tid, after, refused > 0 ? strerror(refused) : "?"); else - banner_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after); - banner_wayland_run(); + droiddeck_log("perf", "compositor thread %d \"wl-compositor\": nice %d -> %d", (int)tid, before, after); + droiddeck_wayland_run(); __android_log_print(ANDROID_LOG_INFO, TAG, "compositor thread exited"); if (t_attached) (*g_jvm)->DetachCurrentThread(g_jvm); t_env = NULL; t_attached = 0; @@ -272,13 +272,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStartWithSurface( JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendPointer( JNIEnv *env, jclass clazz, jint action, jint x, jint y) { - banner_wayland_send_pointer(action, x, y); + droiddeck_wayland_send_pointer(action, x, y); } JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendTouch( JNIEnv *env, jclass clazz, jint action, jint pointer_id, jint x, jint y) { - banner_wayland_send_touch(action, pointer_id, x, y); + droiddeck_wayland_send_touch(action, pointer_id, x, y); } /* The client's pointer image and whether it wants one at all (wl_pointer.set_cursor). @@ -291,7 +291,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( jint cap = (*env)->GetArrayLength(env, out); jint *buf = (*env)->GetIntArrayElements(env, out, NULL); if (!buf) return 0; - int n = banner_cursor_snapshot((int *)buf, (int)cap); + int n = droiddeck_cursor_snapshot((int *)buf, (int)cap); (*env)->ReleaseIntArrayElements(env, out, buf, 0); return n; } @@ -300,27 +300,27 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot( JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) { (void)env; (void)clazz; - return banner_cursor_serial(); + return droiddeck_cursor_serial(); } /* Inject a key event. evdev = Linux input keycode (KEY_A=30…); state 1=down 0=up. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendKey( JNIEnv *env, jclass clazz, jint evdev, jint state) { - banner_wayland_send_key(evdev, state); + droiddeck_wayland_send_key(evdev, state); } -/* App X-server input in scene (virtual desktop) coordinates; see banner_wayland_send_scene_input. */ +/* App X-server input in scene (virtual desktop) coordinates; see droiddeck_wayland_send_scene_input. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSendSceneInput( JNIEnv *env, jclass clazz, jint type, jint a, jint b) { - banner_wayland_send_scene_input(type, a, b); + droiddeck_wayland_send_scene_input(type, a, b); } /* One screen refresh (Choreographer frame callback, UI thread): the compositor draws once. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeVsync(JNIEnv *env, jclass clazz, jlong frameTimeNanos) { - banner_wayland_vsync((int64_t)frameTimeNanos); + droiddeck_wayland_vsync((int64_t)frameTimeNanos); } /* Shortcut launches: don't draw explorer's windows (desktop, taskbar), like X11's unviewable classes. */ @@ -329,15 +329,15 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHideShell(JNIEnv g_hide_shell = hide ? 1 : 0; } -/* Zero-copy layer mode (BANNER_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen +/* Zero-copy layer mode (DROIDDECK_WAYLAND_ZERO_COPY=1 at launch, the drawer's switch live): one fullscreen * window on its own Android layer instead of the swapchain blit (sc_layer.c / ahb_swapchain.c). Any * thread, any time: before the compositor starts it is the launch default; afterwards the compositor * thread flips the state, tells the bound games to rebuild their swapchains for it and redraws. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetZeroCopy(JNIEnv *env, jclass clazz, jboolean on) { - int live = banner_get_display() != NULL; + int live = droiddeck_get_display() != NULL; if (!live) g_zero_copy = on ? 1 : 0; /* read by ahb_swapchain_init before the queue drains */ - banner_host_zero_copy(on ? 1 : 0, live); + droiddeck_host_zero_copy(on ? 1 : 0, live); __android_log_print(ANDROID_LOG_INFO, TAG, "zero-copy layer mode %s%s", on ? "on" : "off", live ? " (live)" : ""); } @@ -355,7 +355,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeZeroCopyFrames(JNIEn } /* Compressed (UBWC) game buffers: advertise DRM_FORMAT_MOD_QCOM_COMPRESSED on zwp_linux_dmabuf_v1 when - * the renderer's driver imports it (default on; BANNER_WAYLAND_UBWC=0 = off). Set before the compositor + * the renderer's driver imports it (default on; DROIDDECK_WAYLAND_UBWC=0 = off). Set before the compositor * starts. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, jclass clazz, jboolean on) { @@ -363,7 +363,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetUbwc(JNIEnv *env, __android_log_print(ANDROID_LOG_INFO, TAG, "compressed (UBWC) game buffers %s", on ? "on" : "off"); } -/* Debug: name no DRM device in the dma-buf feedback (BANNER_WAYLAND_NO_RENDER_NODE=1), the way a +/* Debug: name no DRM device in the dma-buf feedback (DROIDDECK_WAYLAND_NO_RENDER_NODE=1), the way a * phone that exposes no /dev/dri node to apps does. Set before the compositor starts. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetNoRenderNode(JNIEnv *env, jclass clazz, jboolean on) { @@ -452,12 +452,12 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI } /* One "display" line in the session log, written from Java: facts that live in the Android - * framework (the panel's HDR capability) rather than in the compositor. banner_log() mirrors to + * framework (the panel's HDR capability) rather than in the compositor. droiddeck_log() mirrors to * logcat and appends to the session file when one is open, so this is safe at any point. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *env, jclass clazz, jstring message) { char *s = dup_jstr(env, message); - if (s) banner_log("display", "%s", s); + if (s) droiddeck_log("display", "%s", s); free(s); } @@ -466,18 +466,18 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogDisplay(JNIEnv *e JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogPerf(JNIEnv *env, jclass clazz, jstring message) { char *s = dup_jstr(env, message); - if (s) banner_log("perf", "%s", s); + if (s) droiddeck_log("perf", "%s", s); free(s); } -/* ---- HDR10 output, round 1 (banner_color.h / wl_color_mgmt.c) ---- */ +/* ---- HDR10 output, round 1 (droiddeck_color.h / wl_color_mgmt.c) ---- */ -/* The opt-in: mode 0 off, 1 BANNER_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */ +/* The opt-in: mode 0 off, 1 DROIDDECK_WAYLAND_HDR=1, 2 =force (testing). Before the compositor starts. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrRequest(JNIEnv *env, jclass clazz, jint mode, jstring source, jboolean dxvkHdr, jboolean zeroCopyForced) { char *s = dup_jstr(env, source); - banner_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0); + droiddeck_color_set_request((int)mode, s, dxvkHdr ? 1 : 0, zeroCopyForced ? 1 : 0); free(s); } @@ -487,7 +487,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv jstring formats, jboolean hdr10, jfloat maxLum, jfloat maxAvg, jfloat minLum, jboolean ratioAvailable, jfloat ratio, jint api) { char *n = dup_jstr(env, name), *f = dup_jstr(env, formats); - banner_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum, + droiddeck_color_set_display((int)id, n, f, hdr10 ? 1 : 0, (float)maxLum, (float)maxAvg, (float)minLum, ratioAvailable ? 1 : 0, (float)ratio, (int)api); free(n); free(f); } @@ -496,38 +496,38 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrDisplay(JNIEnv JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSdrRatioSample(JNIEnv *env, jclass clazz, jfloat ratio, jboolean listener) { - banner_color_ratio_sample((float)ratio, listener ? 1 : 0); + droiddeck_color_ratio_sample((float)ratio, listener ? 1 : 0); } /* ms since an HDR frame last reached a display layer, -1 = never this session. Any thread. */ JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrLastFrameAgeMs(JNIEnv *env, jclass clazz) { - return (jint)banner_color_last_frame_age_ms(); + return (jint)droiddeck_color_last_frame_age_ms(); } /* -1 = not decided yet, 0 = closed, 1 = open. Any thread. */ JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrGateState(JNIEnv *env, jclass clazz) { - return (jint)banner_color_gate_state(); + return (jint)droiddeck_color_gate_state(); } /* The session is ending: the "HDR on screen: ..." summary line. Any thread, once. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrSessionEnd(JNIEnv *env, jclass clazz) { - banner_color_session_end(); + droiddeck_color_session_end(); } /* HDR frames really on screen right now (the HUD badge). Any thread. */ JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOnScreen(JNIEnv *env, jclass clazz) { - return banner_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE; + return droiddeck_color_hdr_on_screen() ? JNI_TRUE : JNI_FALSE; } /* One "color" line in the session log from Java. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env, jclass clazz, jstring message) { char *s = dup_jstr(env, message); - if (s) banner_log("color", "%s", s); + if (s) droiddeck_log("color", "%s", s); free(s); } @@ -536,7 +536,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLogColor(JNIEnv *env JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jclass clazz, jstring area, jstring message) { char *a = dup_jstr(env, area), *s = dup_jstr(env, message); - if (s) banner_log(a && a[0] ? a : "app", "%s", s); + if (s) droiddeck_log(a && a[0] ? a : "app", "%s", s); free(a); free(s); } @@ -544,65 +544,65 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeLog(JNIEnv *env, jcl /* SDR content's level inside an HDR picture, in nits (default 203). */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrSdrWhite(JNIEnv *env, jclass clazz, jfloat nits) { - banner_color_set_sdr_white((float)nits); + droiddeck_color_set_sdr_white((float)nits); } /* The drawer's live HDR output switch: on = HDR frames as HDR, off = the same frames tone-mapped to SDR. * Applied on the compositor thread (logged there, with a redraw). */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrOutput(JNIEnv *env, jclass clazz, jboolean on) { - if (banner_get_display()) banner_host_hdr_output(on ? 1 : 0); - else banner_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */ + if (droiddeck_get_display()) droiddeck_host_hdr_output(on ? 1 : 0); + else droiddeck_color_set_output(on ? 1 : 0); /* no compositor thread yet: nothing is drawing either */ } JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrOutput(JNIEnv *env, jclass clazz) { - return banner_color_output() ? JNI_TRUE : JNI_FALSE; + return droiddeck_color_output() ? JNI_TRUE : JNI_FALSE; } /* Device evidence for the HDR lines: thermal status + headroom, brightness + mode. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrEnvSample(JNIEnv *env, jclass clazz, jint thermal, jfloat headroom, jint brightness, jint mode) { - banner_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom, + droiddeck_color_env_sample((int)thermal, (float)headroom != (float)headroom ? -1.0f : (float)headroom, (int)brightness, (int)mode); } /* Display.getHighestHdrSdrRatio() (Android 16+), <= 0 = not reported. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetHdrHighestRatio(JNIEnv *env, jclass clazz, jfloat ratio) { - banner_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f); + droiddeck_color_set_highest_ratio((float)ratio == (float)ratio ? (float)ratio : -1.0f); } /* The HDR headroom the screen surface should ask for (HDR10 swapchain frames in the last 1.5 s), 0 = none. */ JNIEXPORT jfloat JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroom(JNIEnv *env, jclass clazz) { - return (jfloat)banner_color_screen_headroom(NULL, 0); + return (jfloat)droiddeck_color_screen_headroom(NULL, 0); } JNIEXPORT jstring JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrScreenHeadroomWhy(JNIEnv *env, jclass clazz) { char why[200]; - banner_color_screen_headroom(why, sizeof(why)); + droiddeck_color_screen_headroom(why, sizeof(why)); return (*env)->NewStringUTF(env, why); } /* The app's screen-surface request, for the no-headroom lines: > 0 asked, 0 cleared, -1 not possible. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrNoteHeadroomRequest(JNIEnv *env, jclass clazz, jfloat ratio) { - banner_color_note_headroom_request((float)ratio); + droiddeck_color_note_headroom_request((float)ratio); } /* 0 none, 1 HDR frames on screen with headroom, 2 HDR frames on screen without headroom for 5 s+. */ JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrState(JNIEnv *env, jclass clazz) { - return (jint)banner_color_hdr_state(); + return (jint)droiddeck_color_hdr_state(); } /* An HDR game's frames were shown tone-mapped to SDR in the last 1.5 s (the drawer's status line). */ JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeHdrToneMappedOnScreen(JNIEnv *env, jclass clazz) { - return banner_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE; + return droiddeck_color_tonemapped_on_screen() ? JNI_TRUE : JNI_FALSE; } /* The Look the controls currently match (null = Custom) - only named in the session log. */ @@ -651,7 +651,7 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetClipboardText(JNIEnv *env, jclass clazz, jbyteArray utf8) { int len; char *buf = dup_bytes(env, utf8, &len); - banner_host_clipboard_text(buf ? buf : "", len); + droiddeck_host_clipboard_text(buf ? buf : "", len); free(buf); } @@ -660,7 +660,7 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputCommit(JNIEnv *env, jclass clazz, jbyteArray utf8) { int len; char *buf = dup_bytes(env, utf8, &len); - if (buf && len) banner_host_text_commit(buf, len); + if (buf && len) droiddeck_host_text_commit(buf, len); free(buf); } @@ -670,14 +670,14 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputPreedit(JNI jint cursorBegin, jint cursorEnd) { int len; char *buf = dup_bytes(env, utf8, &len); - banner_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd); + droiddeck_host_text_preedit(buf ? buf : "", len, cursorBegin, cursorEnd); free(buf); } /* The IME deleted characters around the caret. */ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) { - banner_host_text_delete(before, after); + droiddeck_host_text_delete(before, after); } /* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the diff --git a/app/src/main/cpp/waylandcomp/src/wl_clipboard.c b/app/src/main/cpp/waylandcomp/src/wl_clipboard.c index 0c6c57304..7668090f7 100644 --- a/app/src/main/cpp/waylandcomp/src/wl_clipboard.c +++ b/app/src/main/cpp/waylandcomp/src/wl_clipboard.c @@ -16,7 +16,7 @@ * * Guest → Android: when a program sets a selection with a text mime, the compositor asks for it * once over a pipe on the event loop (non-blocking, 1 MiB cap, 3 s timeout) and hands the text - * to the app (banner_on_clipboard_text → ClipboardManager). Android → guest: the app's text + * to the app (droiddeck_on_clipboard_text → ClipboardManager). Android → guest: the app's text * becomes the HOST selection; receive requests are answered with a non-blocking writer. */ #define _GNU_SOURCE 1 @@ -25,7 +25,7 @@ #include <unistd.h> #include <fcntl.h> #include <errno.h> -#include "banner_ext.h" +#include "droiddeck_ext.h" #include "wlr-data-control-unstable-v1-server-protocol.h" #define CLIP_MAX_BYTES (1024 * 1024) @@ -130,7 +130,7 @@ static int on_writer_writable(int fd, uint32_t mask, void *data) { static int on_writer_timeout(void *data) { struct writer *w = data; - banner_log("clipboard", "a program never read the clipboard text it asked for; giving up"); + droiddeck_log("clipboard", "a program never read the clipboard text it asked for; giving up"); writer_free(w); return 0; } @@ -179,11 +179,11 @@ static void reader_finish(struct reader *r) { free(g_last_guest_text); g_last_guest_text = copy; g_last_guest_len = r->len; - banner_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime); - banner_on_clipboard_text(copy, (int)r->len); + droiddeck_log("clipboard", "guest copied %zu bytes (%s)", r->len, r->mime); + droiddeck_on_clipboard_text(copy, (int)r->len); } } else { - banner_log("clipboard", "guest copied an empty text; Android clipboard left alone"); + droiddeck_log("clipboard", "guest copied an empty text; Android clipboard left alone"); } reader_free(r); } @@ -192,7 +192,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) { struct reader *r = data; for (;;) { if (r->len >= CLIP_MAX_BYTES) { - banner_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES); + droiddeck_log("clipboard", "guest text exceeds %d bytes; dropped", CLIP_MAX_BYTES); reader_free(r); return 0; } @@ -215,7 +215,7 @@ static int on_reader_readable(int fd, uint32_t mask, void *data) { static int on_reader_timeout(void *data) { struct reader *r = data; - banner_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len); + droiddeck_log("clipboard", "timed out reading the guest's clipboard text (%zu bytes so far)", r->len); reader_free(r); return 0; } @@ -251,7 +251,7 @@ static void offer_receive(struct wl_client *c, struct wl_resource *r, const char if (!o || o->gen != g_gen) { close(fd); return; } /* stale offer */ if (g_owner == OWNER_HOST) { if (is_text_mime(mime)) { - banner_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, banner_client_name(c)); + droiddeck_log("clipboard", "Android clipboard → guest (%zu bytes) for %s", g_host_len, droiddeck_client_name(c)); writer_start(fd, g_host_text, g_host_len); } else { close(fd); @@ -327,7 +327,7 @@ static void broadcast_selection(void) { wl_display_flush_clients(g_disp); } -void banner_clipboard_keyboard_focus(struct wl_client *client) { +void droiddeck_clipboard_keyboard_focus(struct wl_client *client) { struct device *d; if (client == g_kb_client) return; g_kb_client = client; @@ -345,7 +345,7 @@ static void source_res_destroy(struct wl_resource *r) { g_src = NULL; g_owner = OWNER_NONE; if (g_reader) reader_free(g_reader); - banner_log("clipboard", "%s withdrew its clipboard contents", banner_client_name(wl_resource_get_client(r))); + droiddeck_log("clipboard", "%s withdrew its clipboard contents", droiddeck_client_name(wl_resource_get_client(r))); broadcast_selection(); } for (int i = 0; i < s->nmimes; i++) free(s->mimes[i]); @@ -396,9 +396,9 @@ static void set_selection(struct wl_client *c, struct wl_resource *source_res) { if (*list) strncat(list, ", ", sizeof(list) - strlen(list) - 1); strncat(list, s->mimes[i], sizeof(list) - strlen(list) - 1); } - banner_log("clipboard", "selection set by %s (%s)", banner_client_name(c), *list ? list : "no mime types"); + droiddeck_log("clipboard", "selection set by %s (%s)", droiddeck_client_name(c), *list ? list : "no mime types"); } else { - banner_log("clipboard", "%s cleared the clipboard", banner_client_name(c)); + droiddeck_log("clipboard", "%s cleared the clipboard", droiddeck_client_name(c)); } broadcast_selection(); read_client_text(); @@ -415,7 +415,7 @@ static void device_res_destroy(struct wl_resource *r) { static void device_start_drag(struct wl_client *c, struct wl_resource *r, struct wl_resource *source, struct wl_resource *origin, struct wl_resource *icon, uint32_t serial) { - banner_log("clipboard", "%s started a drag and drop; not supported, cancelled", banner_client_name(c)); + droiddeck_log("clipboard", "%s started a drag and drop; not supported, cancelled", droiddeck_client_name(c)); if (source) { struct source *s = wl_resource_get_user_data(source); if (s && s != g_src) source_send_cancelled(s); @@ -512,7 +512,7 @@ void clipboard_host_text(char *utf8, size_t len) { if (!len) free(utf8); g_host_len = len; g_owner = len ? OWNER_HOST : OWNER_NONE; - banner_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len); + droiddeck_log("clipboard", len ? "Android clipboard → guest (%zu bytes)" : "Android clipboard cleared (%zu bytes)", len); broadcast_selection(); } diff --git a/app/src/main/cpp/waylandcomp/src/wl_color_mgmt.c b/app/src/main/cpp/waylandcomp/src/wl_color_mgmt.c index be4885fd5..f18aab9f4 100644 --- a/app/src/main/cpp/waylandcomp/src/wl_color_mgmt.c +++ b/app/src/main/cpp/waylandcomp/src/wl_color_mgmt.c @@ -1,7 +1,7 @@ /* * wp_color_manager_v1 (wayland-protocols staging color-management-v1, version 1) - the subset Mesa's * Wayland WSI binds - plus the HDR gate, the per-surface image descriptions and the session log's - * HDR evidence. See banner_color.h for the design and HDR_RECON.md for why it is shaped this way. + * HDR evidence. See droiddeck_color.h for the design and HDR_RECON.md for why it is shaped this way. * * Strictness policy. A protocol error disconnects the client, and the client here is a GAME, so * errors are only raised where the protocol leaves no choice and a conforming client can never hit @@ -12,8 +12,8 @@ * for the same surface replaces the first, a request on an inert object is ignored. */ #define _GNU_SOURCE -#include "banner_color.h" -#include "banner_ext.h" +#include "droiddeck_color.h" +#include "droiddeck_ext.h" #include "ahb_swapchain.h" #include "sc_layer.h" #include "color-management-v1-server-protocol.h" @@ -30,7 +30,7 @@ extern volatile int g_zero_copy; #define TAG "color" /* AHARDWAREBUFFER_FORMAT_* (android/hardware_buffer.h), for the log only. */ -const char *banner_ahb_format_name(uint32_t f) { +const char *droiddeck_ahb_format_name(uint32_t f) { static char other[32]; switch (f) { case 1: return "RGBA8888 (8-bit)"; @@ -73,9 +73,9 @@ static const char *tf_name(uint32_t tf) { static const char *dataspace_name(int32_t ds) { switch (ds) { - case BANNER_ADATASPACE_BT2020_PQ: return "BT2020_PQ"; - case BANNER_ADATASPACE_BT2020_HLG: return "BT2020_HLG"; - case BANNER_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)"; + case DROIDDECK_ADATASPACE_BT2020_PQ: return "BT2020_PQ"; + case DROIDDECK_ADATASPACE_BT2020_HLG: return "BT2020_HLG"; + case DROIDDECK_ADATASPACE_UNKNOWN: return "UNKNOWN (sRGB)"; default: return "other"; } } @@ -99,7 +99,7 @@ static _Atomic int g_gate = -1; /* -1 undecided, 0 closed, 1 open */ static char g_gate_why[320]; /* the closed gate's reason (for the summary) */ static _Atomic int g_output_on = 1; /* the drawer's HDR output switch (per session, starts on) */ -void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) { +void droiddeck_color_set_request(int mode, const char *source, int dxvk_hdr, int zero_copy_forced) { pthread_mutex_lock(&g_mu); g_req.mode = mode < 0 ? 0 : mode > 2 ? 2 : mode; snprintf(g_req.source, sizeof(g_req.source), "%s", source ? source : "environment"); @@ -109,7 +109,7 @@ void banner_color_set_request(int mode, const char *source, int dxvk_hdr, int ze atomic_store(&g_output_on, 1); /* the drawer's switch is per session and starts on */ } -void banner_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum, +void droiddeck_color_set_display(int id, const char *name, const char *formats, int hdr10, float max_lum, float max_avg, float min_lum, int ratio_available, float ratio, int api) { pthread_mutex_lock(&g_mu); int was_known = g_req.display_known, was_hdr10 = g_req.hdr10, was_id = g_req.display_id; @@ -127,17 +127,17 @@ void banner_color_set_display(int id, const char *name, const char *formats, int * session is recorded, and what it means is said out loud. */ if (was_known && atomic_load(&g_gate) >= 0 && (was_hdr10 != (hdr10 ? 1 : 0) || was_id != id)) { if (atomic_load(&g_gate) == 1 && !hdr10) - banner_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged " + droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports no HDR10: HDR frames stay tagged " "BT2020_PQ and SurfaceFlinger tone-maps them for this display (the offer to games is fixed " "for the session)", name ? name : "?", id); else if (atomic_load(&g_gate) == 0 && hdr10) - banner_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this " + droiddeck_log(TAG, "the game is now on \"%s\" (display %d), which reports HDR10 - HDR stays off for this " "session (the gate is decided when the compositor starts); relaunch to use it", name ? name : "?", id); } } -int banner_color_gate_state(void) { return atomic_load(&g_gate); } -int banner_color_hdr_open(void) { return atomic_load(&g_gate) == 1; } +int droiddeck_color_gate_state(void) { return atomic_load(&g_gate); } +int droiddeck_color_hdr_open(void) { return atomic_load(&g_gate) == 1; } /* ---------------------------------------------------------------- HDR evidence (summary) */ @@ -170,7 +170,7 @@ static struct { static _Atomic int64_t g_last_frame_ns; static _Atomic int64_t g_last_tm_ns; /* the last HDR frame shown tone-mapped to SDR */ -/* What the device says beside the headroom (the app, non-root APIs; banner_color_env_*). The Fold showed +/* What the device says beside the headroom (the app, non-root APIs; droiddeck_color_env_*). The Fold showed * three ways to lose HDR headroom with HDR frames on screen - a screen recording, heat under load, and * (plausibly) the brightness slider at maximum - so every no-headroom line carries this. Under g_mu. */ static struct { @@ -241,20 +241,20 @@ static _Atomic int g_screen_hr_x1000; /* the screen surface's wanted headr static _Atomic int64_t g_screen_hr_ns; /* the last HDR10-swapchain frame */ static char g_screen_hr_why[200]; /* under g_mu */ -static float peak_of(const struct banner_color *c, float disp_max, const char **src) { +static float peak_of(const struct droiddeck_color *c, float disp_max, const char **src) { if (c->max_cll > 0.0f) { *src = "max CLL"; return c->max_cll; } if (c->has_st2086 && c->max_lum > 0.0f) { *src = "mastering max"; return c->max_lum; } if (disp_max > 0.0f) { *src = "the display's peak"; return disp_max; } *src = "assumed"; return 1000.0f; } -float banner_color_desired_headroom(const struct banner_color *c, char *why, size_t n) { +float droiddeck_color_desired_headroom(const struct droiddeck_color *c, char *why, size_t n) { if (!c || !c->dataspace) { if (why && n) why[0] = 0; return 0.0f; } pthread_mutex_lock(&g_mu); const float disp_max = g_req.max_lum, highest = g_req.highest_ratio; pthread_mutex_unlock(&g_mu); const char *src; - const float peak = peak_of(c, disp_max, &src), white = banner_color_sdr_white(); + const float peak = peak_of(c, disp_max, &src), white = droiddeck_color_sdr_white(); if (highest > 0.0f && highest <= 1.01f) { if (why && n) snprintf(why, n, "the display reports its highest HDR/SDR ratio as %.2f: no app can get a " "boost here, so nothing is asked", highest); @@ -273,24 +273,24 @@ float banner_color_desired_headroom(const struct banner_color *c, char *why, siz return r; } -void banner_color_note_headroom_request(float ratio) { +void droiddeck_color_note_headroom_request(float ratio) { pthread_mutex_lock(&g_mu); g_env.requested = ratio; pthread_mutex_unlock(&g_mu); } -void banner_color_set_highest_ratio(float ratio) { +void droiddeck_color_set_highest_ratio(float ratio) { pthread_mutex_lock(&g_mu); const float was = g_req.highest_ratio; g_req.highest_ratio = ratio > 0.0f ? ratio : -1.0f; pthread_mutex_unlock(&g_mu); if (ratio > 0.0f && (was < 0.0f || was != ratio) && atomic_load(&g_gate) == 1) - banner_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio, + droiddeck_log(TAG, "the display's highest HDR/SDR ratio is %.2f%s", ratio, ratio <= 1.01f ? " - it reports no HDR boost at all: no app can raise HDR highlights above SDR white here" : " (the most HDR headroom it can give)"); } -float banner_color_screen_headroom(char *why, size_t n) { +float droiddeck_color_screen_headroom(char *why, size_t n) { const int64_t t = atomic_load(&g_screen_hr_ns); if (!t || now_ns() - t > 1500000000LL) { if (why && n) why[0] = 0; return 0.0f; } if (why && n) { @@ -301,7 +301,7 @@ float banner_color_screen_headroom(char *why, size_t n) { return atomic_load(&g_screen_hr_x1000) / 1000.0f; } -int banner_color_last_frame_age_ms(void) { +int droiddeck_color_last_frame_age_ms(void) { int64_t t = atomic_load(&g_last_frame_ns); if (!t) return -1; int64_t age = (now_ns() - t) / 1000000LL; @@ -408,34 +408,34 @@ static void log_verdict(int force) { int changed = strcmp(v, g_hdr.verdict) != 0; if (changed) snprintf(g_hdr.verdict, sizeof(g_hdr.verdict), "%s", v); pthread_mutex_unlock(&g_mu); - if (changed || force) banner_log(TAG, "HDR on screen: %s", v); + if (changed || force) droiddeck_log(TAG, "HDR on screen: %s", v); } -void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t ahb_format) { +void droiddeck_color_frame_shown(const struct droiddeck_color *c, int path, uint32_t ahb_format) { if (!c || !c->dataspace) return; int first8 = 0; pthread_mutex_lock(&g_mu); switch (path) { - case BANNER_HDR_ZERO_COPY: + case DROIDDECK_HDR_ZERO_COPY: if (ahb_format == 0x2b) g_hdr.layer_10bit++; g_hdr.win_zc++; - snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", banner_ahb_format_name(ahb_format)); + snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "%s", droiddeck_ahb_format_name(ahb_format)); break; - case BANNER_HDR_LAYER_COPY: + case DROIDDECK_HDR_LAYER_COPY: if (!g_hdr.layer_copy8) first8 = 1; g_hdr.layer_copy8++; g_hdr.win_copy8++; snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "RGBA8888 layer copy"); break; - case BANNER_HDR_COMPOSED: + case DROIDDECK_HDR_COMPOSED: g_hdr.composed++; g_hdr.win_composed++; - snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", banner_ahb_format_name(ahb_format)); + snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "composed %s", droiddeck_ahb_format_name(ahb_format)); break; - case BANNER_HDR_SWAPCHAIN: + case DROIDDECK_HDR_SWAPCHAIN: g_hdr.swapchain++; g_hdr.win_swapchain++; snprintf(g_hdr.layer_fmt, sizeof(g_hdr.layer_fmt), "HDR10 swapchain"); atomic_store(&g_screen_hr_ns, now_ns()); break; - default: /* BANNER_HDR_TONEMAPPED: shown, but not as HDR */ + default: /* DROIDDECK_HDR_TONEMAPPED: shown, but not as HDR */ g_hdr.tonemapped++; g_hdr.win_tonemapped++; if (!atomic_load(&g_output_on)) g_hdr.tm_off++; pthread_mutex_unlock(&g_mu); @@ -446,7 +446,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a g_hdr.win_layer++; pthread_mutex_unlock(&g_mu); atomic_store(&g_last_frame_ns, now_ns()); - if (path == BANNER_HDR_SWAPCHAIN) { + if (path == DROIDDECK_HDR_SWAPCHAIN) { /* The screen surface's headroom request (the app applies it): worked out again when the * description changes, else once a second. */ static uint32_t ident; @@ -454,7 +454,7 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a const int64_t t = now_ns(); if (c->identity != ident || t - calc_ns > 1000000000LL) { char why[200]; - const float r = banner_color_desired_headroom(c, why, sizeof(why)); + const float r = droiddeck_color_desired_headroom(c, why, sizeof(why)); ident = c->identity; calc_ns = t; pthread_mutex_lock(&g_mu); snprintf(g_screen_hr_why, sizeof(g_screen_hr_why), "%s", why); @@ -463,12 +463,12 @@ void banner_color_frame_shown(const struct banner_color *c, int path, uint32_t a } } if (first8) - banner_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's " + droiddeck_log(TAG, "HDR frames are reaching the display layer through the compositor's 8-bit layer copy (the game's " "buffer is not a gralloc buffer this frame): colours stay correct (the frame keeps its BT2020_PQ tag), " "precision drops to 8 bits (banding possible)"); } -void banner_color_frame_copied(const char *who, const char *reason) { +void droiddeck_color_frame_copied(const char *who, const char *reason) { int first = 0; pthread_mutex_lock(&g_mu); g_hdr.copy_frames++; @@ -479,14 +479,14 @@ void banner_color_frame_copied(const char *who, const char *reason) { } pthread_mutex_unlock(&g_mu); if (first) - banner_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT " + droiddeck_log(TAG, "HDR frames of %s go through the compositor's 8-bit SDR copy now, because %s: shown WITHOUT " "tone mapping (washed out) until that changes", who ? who : "a window", reason ? reason : "?"); } -void banner_color_ratio_sample(float ratio, int listener) { +void droiddeck_color_ratio_sample(float ratio, int listener) { if (atomic_load(&g_gate) != 1) return; int64_t t = now_ns(); - int age = banner_color_last_frame_age_ms(); + int age = droiddeck_color_last_frame_age_ms(); int live = age >= 0 && age < 1500; int log_it = 0, periodic = 0, nohead_now = 0, head_back = 0; float was; @@ -536,28 +536,28 @@ void banner_color_ratio_sample(float ratio, int listener) { } pthread_mutex_unlock(&g_mu); if (nohead_now) - banner_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android " + droiddeck_log(TAG, "no HDR headroom for 5 s while HDR frames are on screen (display HDR/SDR ratio %.2f): %s - Android " "drops HDR headroom while the screen is recorded and when the device is hot, and some phones never " "boost HDR for apps; HDR highlights look no brighter until that ends [%s]", ratio, causes, env); if (head_back) - banner_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio); + droiddeck_log(TAG, "HDR headroom is back: display HDR/SDR ratio %.2f with HDR frames on screen", ratio); if (!log_it) return; char when[64]; if (age < 0) snprintf(when, sizeof(when), "none yet this session"); else snprintf(when, sizeof(when), "%s, last one %d ms ago", live ? "yes" : "no", age); if (periodic) - banner_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when, + droiddeck_log(TAG, "display HDR/SDR ratio %.2f (steady; HDR frames on screen: %s)%s%s%s%s%s", ratio, when, live && ratio <= 1.01f ? " - no HDR headroom: " : "", live && ratio <= 1.01f ? causes : "", env[0] ? " [" : "", env, env[0] ? "]" : ""); else if (was > 0.0f) - banner_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when, + droiddeck_log(TAG, "display HDR/SDR ratio %.2f (was %.2f; HDR frames on screen: %s) [%s]", ratio, was, when, listener ? "display listener" : "sampler"); else - banner_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when, + droiddeck_log(TAG, "display HDR/SDR ratio %.2f (first reading; HDR frames on screen: %s) [%s]", ratio, when, listener ? "display listener" : "sampler"); } -void banner_color_stats_tick(void) { +void droiddeck_color_stats_tick(void) { if (atomic_load(&g_gate) != 1) return; char line[512]; int any; @@ -582,13 +582,13 @@ void banner_color_stats_tick(void) { g_hdr.win_zc = g_hdr.win_composed = g_hdr.win_swapchain = g_hdr.win_tonemapped = 0; g_hdr.win_ratio_n = 0; pthread_mutex_unlock(&g_mu); - if (any) banner_log(TAG, "%s", line); + if (any) droiddeck_log(TAG, "%s", line); log_verdict(0); } -int banner_color_hdr_state(void) { +int droiddeck_color_hdr_state(void) { if (atomic_load(&g_gate) != 1) return 0; - int age = banner_color_last_frame_age_ms(); + int age = droiddeck_color_last_frame_age_ms(); if (age < 0 || age >= 1500) return 0; pthread_mutex_lock(&g_mu); int confirmed = !g_hdr.ratio_n || g_hdr.ratio_last > 1.01f; /* no ratio on this display: the tag is all there is */ @@ -597,26 +597,26 @@ int banner_color_hdr_state(void) { return confirmed ? 1 : nohead ? 2 : 0; } -int banner_color_hdr_on_screen(void) { return banner_color_hdr_state() == 1; } +int droiddeck_color_hdr_on_screen(void) { return droiddeck_color_hdr_state() == 1; } static _Atomic int g_sdr_white_x100 = 20300; -void banner_color_set_sdr_white(float nits) { +void droiddeck_color_set_sdr_white(float nits) { if (!(nits >= 10.0f && nits <= 2000.0f)) return; atomic_store(&g_sdr_white_x100, (int)(nits * 100.0f + 0.5f)); - banner_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (BANNER_WAYLAND_HDR_SDR_NITS)", nits); + droiddeck_log(TAG, "SDR content inside an HDR picture is placed at %.0f nits (DROIDDECK_WAYLAND_HDR_SDR_NITS)", nits); } -float banner_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; } +float droiddeck_color_sdr_white(void) { return atomic_load(&g_sdr_white_x100) / 100.0f; } -int banner_color_requested(void) { +int droiddeck_color_requested(void) { pthread_mutex_lock(&g_mu); int m = g_req.mode; pthread_mutex_unlock(&g_mu); return m != 0; } -int banner_color_output(void) { return atomic_load(&g_output_on); } +int droiddeck_color_output(void) { return atomic_load(&g_output_on); } -void banner_color_env_sample(int thermal, float headroom, int brightness, int bmode) { +void droiddeck_color_env_sample(int thermal, float headroom, int brightness, int bmode) { if (atomic_load(&g_gate) != 1) return; /* evidence for the HDR lines only */ char msg[2][200]; int nmsg = 0; @@ -644,42 +644,42 @@ void banner_color_env_sample(int thermal, float headroom, int brightness, int bm "brightness changes are logged as they happen)", e); } pthread_mutex_unlock(&g_mu); - for (int i = 0; i < nmsg; i++) banner_log(TAG, "%s", msg[i]); + for (int i = 0; i < nmsg; i++) droiddeck_log(TAG, "%s", msg[i]); } -int banner_color_tonemapped_on_screen(void) { +int droiddeck_color_tonemapped_on_screen(void) { if (atomic_load(&g_gate) != 1) return 0; int64_t t = atomic_load(&g_last_tm_ns); return t && now_ns() - t < 1500000000LL; } -void banner_color_set_output(int on) { +void droiddeck_color_set_output(int on) { on = on ? 1 : 0; if (atomic_load(&g_gate) != 1) { /* The drawer only shows the switch in sessions whose gate is open; say so if it ever gets here. */ - banner_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off"); + droiddeck_log(TAG, "HDR output switch (%s) ignored: HDR is not open in this session", on ? "on" : "off"); return; } if (atomic_exchange(&g_output_on, on) == on) return; char who[160]; - int age = banner_color_last_frame_age_ms(), tm = banner_color_tonemapped_on_screen(); + int age = droiddeck_color_last_frame_age_ms(), tm = droiddeck_color_tonemapped_on_screen(); pthread_mutex_lock(&g_mu); if (!on) g_hdr.output_offs++; snprintf(who, sizeof(who), "%s", g_hdr.applied_who[0] ? g_hdr.applied_who : "no HDR program yet"); pthread_mutex_unlock(&g_mu); if (on) - banner_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next " + droiddeck_log(TAG, "HDR output switched ON in the drawer: HDR frames go to the display as HDR again from the next " "frame (%s; %s)", who, tm ? "they were being tone-mapped to SDR until now" : "none were on screen just now"); else - banner_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame " + droiddeck_log(TAG, "HDR output switched OFF in the drawer: HDR frames are tone-mapped to SDR from the next frame " "(%s; %s). The game is not told - it keeps rendering HDR; its own HDR setting and DXVK_HDR are " "untouched", who, age >= 0 && age < 1500 ? "HDR frames were on screen" : "no HDR frames on screen just now"); - banner_request_redraw(); /* a paused game commits nothing: show the change now */ + droiddeck_request_redraw(); /* a paused game commits nothing: show the change now */ } static _Atomic int g_session_ended; -void banner_color_session_end(void) { +void droiddeck_color_session_end(void) { if (atomic_exchange(&g_session_ended, 1)) return; if (atomic_load(&g_gate) < 0) return; pthread_mutex_lock(&g_mu); @@ -692,7 +692,7 @@ void banner_color_session_end(void) { struct cm_desc { int refs; /* protocol objects + surfaces (pending / current) */ - struct banner_color c; + struct droiddeck_color c; /* Set on the output / preferred descriptions the compositor serves (never on one a game built * from parameters): get_information is answered rather than refused, with these as the target. */ int informative; @@ -727,7 +727,7 @@ static void image_description_get_information(struct wl_client *c, struct wl_res wl_resource_get_version(r), id); if (!info) { wl_client_post_no_memory(c); return; } wl_resource_set_implementation(info, NULL, NULL, NULL); /* events only */ - const struct banner_color *col = &d->c; + const struct droiddeck_color *col = &d->c; int32_t xy[8] = { (int32_t)(col->red[0] * 1e6f + 0.5f), (int32_t)(col->red[1] * 1e6f + 0.5f), (int32_t)(col->green[0] * 1e6f + 0.5f), (int32_t)(col->green[1] * 1e6f + 0.5f), (int32_t)(col->blue[0] * 1e6f + 0.5f), (int32_t)(col->blue[1] * 1e6f + 0.5f), @@ -765,12 +765,12 @@ static void image_description_resource_destroy(struct wl_resource *r) { static void make_output_description(struct wl_client *c, struct wl_resource *parent, uint32_t id, const char *what) { struct cm_desc *d = calloc(1, sizeof(*d)); if (!d) { wl_client_post_no_memory(c); return; } - struct banner_color *col = &d->c; + struct droiddeck_color *col = &d->c; col->identity = g_next_identity++; if (!g_next_identity) g_next_identity = 1; col->primaries = WP_COLOR_MANAGER_V1_PRIMARIES_BT2020; col->tf = WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ; - col->dataspace = BANNER_ADATASPACE_BT2020_PQ; + col->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ; col->has_st2086 = 1; col->red[0] = 0.708f; col->red[1] = 0.292f; col->green[0] = 0.170f; col->green[1] = 0.797f; col->blue[0] = 0.131f; col->blue[1] = 0.046f; col->white[0] = 0.3127f; col->white[1] = 0.3290f; @@ -788,8 +788,8 @@ static void make_output_description(struct wl_client *c, struct wl_resource *par d->refs = 1; wl_resource_set_implementation(r, &image_description_impl, d, image_description_resource_destroy); wp_image_description_v1_send_ready(r, col->identity); - banner_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information", - banner_client_name(c), what, col->text); + droiddeck_log(TAG, "%s asked for %s: answered the display's HDR10 volume (%s), ready and open to get_information", + droiddeck_client_name(c), what, col->text); } static void params_create(struct wl_client *client, struct wl_resource *r, uint32_t id) { @@ -801,15 +801,15 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3 } struct cm_desc *d = calloc(1, sizeof(*d)); if (!d) { wl_client_post_no_memory(client); return; } - struct banner_color *c = &d->c; + struct droiddeck_color *c = &d->c; c->identity = g_next_identity++; if (!g_next_identity) g_next_identity = 1; c->primaries = p->primaries; c->tf = p->tf; if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ) - c->dataspace = BANNER_ADATASPACE_BT2020_PQ; + c->dataspace = DROIDDECK_ADATASPACE_BT2020_PQ; else if (p->primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020 && p->tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_HLG) - c->dataspace = BANNER_ADATASPACE_BT2020_HLG; + c->dataspace = DROIDDECK_ADATASPACE_BT2020_HLG; /* HDR metadata, kept only when it makes sense. The PQ defaults stand in for what was not given * (0.005 - 10000 cd/m², the target volume = the primary one); CTA-861.3 treats 0 as unknown. */ @@ -870,12 +870,12 @@ static void params_create(struct wl_client *client, struct wl_resource *r, uint3 g_hdr.descs++; pthread_mutex_unlock(&g_mu); } - banner_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity, - banner_client_name(client), c->text, dataspace_name(c->dataspace)); + droiddeck_log(TAG, "image description #%u from %s: %s -> ready (on a display layer: dataspace %s)", c->identity, + droiddeck_client_name(client), c->text, dataspace_name(c->dataspace)); if (dropped[0]) { size_t l = strlen(dropped); if (l && dropped[l - 1] == ',') dropped[l - 1] = 0; - banner_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s " + droiddeck_log(TAG, "image description #%u: dropped HDR metadata that breaks the protocol's rules:%s " "(Android gets the rest)", c->identity, dropped); } wl_resource_destroy(r); /* create is the creator's destructor */ @@ -982,7 +982,7 @@ static struct cm_surf *surf_of(struct wl_resource *surface) { static void describe_surface(struct wl_resource *surface, char *out, size_t size) { struct surface *s = surface ? wl_resource_get_user_data(surface) : NULL; - if (s) banner_surface_describe(s, out, size); + if (s) droiddeck_surface_describe(s, out, size); else snprintf(out, size, "a closed window"); } @@ -1002,8 +1002,8 @@ static void cm_surface_set_image_description(struct wl_client *c, struct wl_reso struct cm_surf *cs = wl_resource_get_user_data(r); struct cm_desc *d = desc_res ? wl_resource_get_user_data(desc_res) : NULL; if (!cs) { - banner_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored", - banner_client_name(c)); + droiddeck_log(TAG, "%s set an image description on a colour-management object whose surface is gone: ignored", + droiddeck_client_name(c)); return; } if (!d) { @@ -1045,7 +1045,7 @@ static void cm_surface_resource_destroy(struct wl_resource *r) { cs->pending_dirty = 1; } -void banner_color_commit(struct wl_resource *surface) { +void droiddeck_color_commit(struct wl_resource *surface) { if (atomic_load(&g_gate) != 1) return; struct cm_surf *cs = surf_of(surface); if (!cs || !cs->pending_dirty) return; @@ -1056,7 +1056,7 @@ void banner_color_commit(struct wl_resource *surface) { char who[160]; describe_surface(surface, who, sizeof(who)); if (cs->current) { - banner_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who, + droiddeck_log(TAG, "%s: image description #%u now applies to its frames (%s; perceptual intent)", who, cs->current->c.identity, cs->current->c.text); if (cs->current->c.dataspace) { pthread_mutex_lock(&g_mu); @@ -1064,16 +1064,16 @@ void banner_color_commit(struct wl_resource *surface) { snprintf(g_hdr.applied_who, sizeof(g_hdr.applied_who), "%s", who); pthread_mutex_unlock(&g_mu); if (!g_zero_copy) - banner_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture " + droiddeck_log(TAG, "%s: zero-copy presentation is OFF, so its HDR frames are composed into the HDR picture " "(one extra pass per frame) - switch Zero-copy presentation on for the direct path", who); } } else if (old) { - banner_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity); + droiddeck_log(TAG, "%s: image description #%u removed - its frames are sRGB again", who, old->c.identity); } desc_unref(old); } -const struct banner_color *banner_color_of(struct wl_resource *surface) { +const struct droiddeck_color *droiddeck_color_of(struct wl_resource *surface) { if (atomic_load(&g_gate) != 1) return NULL; struct cm_surf *cs = surf_of(surface); return cs && cs->current ? &cs->current->c : NULL; @@ -1125,14 +1125,14 @@ static void cm_get_surface(struct wl_client *c, struct wl_resource *r, uint32_t describe_surface(surface, who, sizeof(who)); if (cs->owner) { /* The protocol says surface_exists; a game would be disconnected for it. The newer object wins. */ - banner_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over " + droiddeck_log(TAG, "%s: a second colour-management object for the same surface - the newer one takes over " "(protocol error surface_exists not raised)", who); wl_resource_set_user_data(cs->owner, NULL); } cs->owner = o; wl_resource_set_implementation(o, &cm_surface_impl, cs, cm_surface_resource_destroy); - banner_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space " - "other than sRGB)", who, banner_client_name(c)); + droiddeck_log(TAG, "%s: colour-management surface created by %s (its Vulkan swapchain asks for a colour space " + "other than sRGB)", who, droiddeck_client_name(c)); } static void cm_get_surface_feedback(struct wl_client *c, struct wl_resource *r, uint32_t id, struct wl_resource *surface) { @@ -1186,16 +1186,16 @@ static void bind_cm(struct wl_client *c, void *data, uint32_t ver, uint32_t id) /* One line per program: Mesa binds again for every surface-format query. */ if (last_named != c) { last_named = c; - banner_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions " + droiddeck_log(TAG, "%s bound wp_color_manager_v1 version %u (offered: perceptual intent; parametric descriptions " "with mastering metadata; BT.2020 primaries; ST 2084 PQ) - its Vulkan driver can now list " - "VK_COLOR_SPACE_HDR10_ST2084_EXT", banner_client_name(c), ver > 1 ? 1u : ver); + "VK_COLOR_SPACE_HDR10_ST2084_EXT", droiddeck_client_name(c), ver > 1 ? 1u : ver); } } -void banner_color_client_gone(struct wl_client *client) { +void droiddeck_color_client_gone(struct wl_client *client) { if (atomic_load(&g_gate) != 1 || atomic_load(&g_session_ended)) return; /* A program that presented HDR has left: say where the session stands now, while it is fresh. */ - const char *name = banner_client_name(client); + const char *name = droiddeck_client_name(client); int mine; pthread_mutex_lock(&g_mu); mine = g_hdr.applied_who[0] && name && strstr(g_hdr.applied_who, name) != NULL; @@ -1205,7 +1205,7 @@ void banner_color_client_gone(struct wl_client *client) { /* ---------------------------------------------------------------- the gate */ -void banner_color_init(struct wl_display *display) { +void droiddeck_color_init(struct wl_display *display) { char why[320] = ""; int mode, dxvk_hdr, zc_forced, known, hdr10, id, ratio_avail, api; char name[96], formats[96], source[64]; @@ -1229,14 +1229,14 @@ void banner_color_init(struct wl_display *display) { /* What asked for HDR, in the words the user knows it by: the editors' setting, or the env override. */ char asked[128]; - if (mode == 2) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=force (%s)", source); - else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "BANNER_WAYLAND_HDR=1 (%s)", source); + if (mode == 2) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=force (%s)", source); + else if (strstr(source, "env")) snprintf(asked, sizeof(asked), "DROIDDECK_WAYLAND_HDR=1 (%s)", source); else snprintf(asked, sizeof(asked), "HDR output is on (%s)", source); int layer_ok = sc_layer_can_tag_hdr(); int ahb_ok = ahb_swapchain_advertised(); char off_why[128]; - if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "BANNER_WAYLAND_HDR=0 in the %s overrides the setting", source); + if (strstr(source, "env")) snprintf(off_why, sizeof(off_why), "DROIDDECK_WAYLAND_HDR=0 in the %s overrides the setting", source); else snprintf(off_why, sizeof(off_why), "the HDR output setting is off"); if (mode == 0) snprintf(why, sizeof(why), "HDR output is off (%s)", off_why); @@ -1255,16 +1255,16 @@ void banner_color_init(struct wl_display *display) { snprintf(g_gate_why, sizeof(g_gate_why), "%s", why); atomic_store(&g_gate, 0); if (mode) { - banner_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf " + droiddeck_log(TAG, "HDR gate CLOSED: %s. Nothing is advertised (no wp_color_manager_v1, no 10-bit dma-buf " "formats): games see an SDR display, exactly as without the switch", why); if (dxvk_hdr) - banner_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here " + droiddeck_log(TAG, "DXVK_HDR=1 is set: DXGI will still claim an HDR display that no swapchain can get here " "(games that check fall back to SDR; some show washed-out colours) - remove it on this display"); log_verdict(1); } else if (dxvk_hdr || (known && hdr10)) { /* Off, and silent unless it is worth a line: a display that could show HDR10, or a DXVK * switch that promises games an HDR display they cannot get. */ - banner_log(TAG, "HDR output off for this session (%s)%s%s", off_why, + droiddeck_log(TAG, "HDR output off for this session (%s)%s%s", off_why, (known && hdr10) ? " - this display lists HDR10, so switching HDR output on for this game would " "offer it to games" : "", dxvk_hdr ? " - but DXVK_HDR=1 is set, so DXGI claims an HDR display the game cannot get a " @@ -1276,25 +1276,25 @@ void banner_color_init(struct wl_display *display) { if (!wl_global_create(display, &wp_color_manager_v1_interface, 1, NULL, bind_cm)) { snprintf(g_gate_why, sizeof(g_gate_why), "creating the wp_color_manager_v1 global failed"); atomic_store(&g_gate, 0); - banner_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed"); + droiddeck_log("error", "color: wp_color_manager_v1 global creation failed - HDR gate closed"); log_verdict(1); return; } atomic_store(&g_gate, 1); char syms[160]; sc_layer_hdr_symbols(syms, sizeof(syms)); - banner_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries " + droiddeck_log(TAG, "HDR gate %s: %s and %s. Offering games HDR10: wp_color_manager_v1 version 1 (BT.2020 primaries " "+ ST 2084 PQ, parametric descriptions with mastering metadata) and 10-bit AB30/XB30 dma-buf formats; HDR " "frames go on the game's own display layer as BT2020_PQ (%s)", mode == 2 ? "FORCED OPEN (testing)" : "OPEN", asked, disp, syms); if (mode == 2 && !hdr10) - banner_log(TAG, "BANNER_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map " + droiddeck_log(TAG, "DROIDDECK_WAYLAND_HDR=force on a display without HDR10 - testing only: SurfaceFlinger will tone-map " "the game layer for this panel (expect GPU/CLIENT composition for it), nothing looks HDR"); if (zc_forced) - banner_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight " + droiddeck_log(TAG, "zero-copy presentation turned on for this session: the HDR game's own 10-bit frames go straight " "to its display layer (the best path; anything that needs the compositor gets the composed HDR picture)"); if (!dxvk_hdr) - banner_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI " + droiddeck_log(TAG, "DXVK_HDR=1 is not in the game's environment: DXVK games will not see an HDR display in DXGI " "(dxgi.enableHDR in a dxvk.conf does the same) - add DXVK_HDR=1 to the container's or shortcut's " "environment variables"); (void)max_avg; (void)min_lum; diff --git a/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c index bdda9d75e..2f8552f1e 100644 --- a/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c +++ b/app/src/main/cpp/waylandcomp/src/wl_dmabuf.c @@ -16,8 +16,8 @@ #include "linux-dmabuf-v1-server-protocol.h" #include "vk_present.h" -#include "banner_ext.h" -#include "banner_color.h" +#include "droiddeck_ext.h" +#include "droiddeck_color.h" #include "compositor_internal.h" /* ------------------------------------------------------------ zwp_linux_dmabuf_v1 */ @@ -112,7 +112,7 @@ static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = { /* The advertised format/modifier table, built at the first bind from what the renderer's driver * can import (vkp_dmabuf_modifiers). INVALID is always offered too (Mesa drops it; other clients * may use it for the implicit path). Without a renderer the list is LINEAR + INVALID, as before. */ -/* The last two rows are HDR10's (banner_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa +/* The last two rows are HDR10's (droiddeck_color.h): A2B10G10R10 as AB30 (alpha) + XB30 (opaque) - Mesa * lists a VkFormat only when both are advertised, and it is the one 10-bit layout our zero-copy WSI * can put in a gralloc buffer (AHARDWAREBUFFER_FORMAT_R10G10B10A2_UNORM). They are advertised ONLY * while the HDR gate is open, and after the 8-bit rows, so every other session - and every client that @@ -132,7 +132,7 @@ static void dmabuf_build_formats(void) { char line[320]; int pos = 0, compressed = 0; g_dmabuf_fmts_ready = 1; - g_dmabuf_nfmt = banner_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR; + g_dmabuf_nfmt = droiddeck_color_hdr_open() ? DMABUF_NFMT_MAX : DMABUF_NFMT_SDR; for (int f = 0; f < g_dmabuf_nfmt; f++) { uint64_t got[DMABUF_NMOD]; int n = vkp_dmabuf_modifiers(g_dmabuf_fmts[f].fmt, got, DMABUF_NMOD), k = 0; @@ -155,10 +155,10 @@ static void dmabuf_build_formats(void) { vkp_modifier_name(g_dmabuf_fmts[f].mods[i])); if (pos >= (int)sizeof(line)) pos = (int)sizeof(line) - 1; } - banner_log("dmabuf", "formats: %s%s", line, - !g_ubwc ? " (BANNER_WAYLAND_UBWC=0: qcom_compressed not advertised)" : ""); + droiddeck_log("dmabuf", "formats: %s%s", line, + !g_ubwc ? " (DROIDDECK_WAYLAND_UBWC=0: qcom_compressed not advertised)" : ""); if (g_ubwc && !compressed) - banner_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: " + droiddeck_log("dmabuf", "the compositor's driver (%s) reports no importable qcom_compressed layout: " "game swapchains stay linear", vkp_gpu_name()); if (g_dmabuf_nfmt > DMABUF_NFMT_SDR) { /* The rows above always carry LINEAR; say what the compositor's own driver can really import @@ -166,7 +166,7 @@ static void dmabuf_build_formats(void) { uint64_t got[DMABUF_NMOD]; int n = vkp_dmabuf_modifiers(DRM_XBGR2101010, got, DMABUF_NMOD), ubwc10 = 0; for (int i = 0; i < n; i++) if (got[i] == VKP_MOD_QCOM_COMPRESSED) ubwc10 = 1; - banner_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) " + droiddeck_log("color", "10-bit dma-buf formats AB30/XB30 advertised for HDR10; the compositor's driver (%s) " "imports XB30 %s", vkp_gpu_name(), n == 0 ? "with no layout it reports (copy path unlikely; display layer only)" : ubwc10 ? "linear and UBWC" : "linear only (UBWC 10-bit frames: display layer only)"); @@ -237,7 +237,7 @@ static void dmabuf_build_feedback(void) { } if (!n) return; - int fd = (int)syscall(__NR_memfd_create, "banner-dmabuf-formats", + int fd = (int)syscall(__NR_memfd_create, "droiddeck-dmabuf-formats", MFD_CLOEXEC | MFD_ALLOW_SEALING); if (fd < 0) { WLOGE("dmabuf feedback: memfd_create failed (%s)", strerror(errno)); return; } size_t size = (size_t)n * sizeof(entries[0]); @@ -251,14 +251,14 @@ static void dmabuf_build_feedback(void) { g_fmt_table_fd = fd; g_fmt_table_size = size; g_fmt_table_n = (uint16_t)n; - banner_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u", + droiddeck_log("dmabuf", "feedback ready: %d format/modifier pairs, main device %u:%u", n, (unsigned)major(g_main_device), (unsigned)minor(g_main_device)); /* Vulkan games never need the node. Mesa's EGL did until Wayland layer versionCode 9: without * one it fell back to a software path that draws nothing here (black window, sound plays). */ if (!g_main_device) - banner_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, " + droiddeck_log("dmabuf", "%s: OpenGL games need Wayland layer versionCode 9 or newer, " "which runs OpenGL on the GPU without a DRM node; older layers show a black window", - g_no_render_node ? "no DRM device named (forced by BANNER_WAYLAND_NO_RENDER_NODE=1)" + g_no_render_node ? "no DRM device named (forced by DROIDDECK_WAYLAND_NO_RENDER_NODE=1)" : "this device gives apps no display (DRM) device (/dev/dri)"); } diff --git a/app/src/main/cpp/waylandcomp/src/wl_text_input.c b/app/src/main/cpp/waylandcomp/src/wl_text_input.c index 1c424c83c..631167e91 100644 --- a/app/src/main/cpp/waylandcomp/src/wl_text_input.c +++ b/app/src/main/cpp/waylandcomp/src/wl_text_input.c @@ -5,7 +5,7 @@ * Focus model. winewayland enables its text input as soon as our `enter` names a surface and * routes every IME update to that surface's window - inside that window's process (the update * list is per process). Keyboard focus in this compositor sits on the desktop surface (the - * desktop owner's process), so text input can't follow it; it follows banner_ime_target(): + * desktop owner's process), so text input can't follow it; it follows droiddeck_ime_target(): * the program window last clicked, else the topmost program window. Clicking into notepad * moves the text-input focus to notepad's process, which is where the edit control lives. * @@ -13,7 +13,7 @@ * them (enable resets the state as the protocol says). We answer with `done(serial)` only when * we sent events (preedit/commit strings), never for every commit. * - * Host side. banner_on_text_input(enabled, program, cursor rect in scene pixels) tells the app + * Host side. droiddeck_on_text_input(enabled, program, cursor rect in scene pixels) tells the app * when a program starts/stops accepting IME text or moves its caret (Wine reports the rect only * when an edit control positions its IME composition window, via ImmSetCompositionWindow). * Typed text arrives through text_input_host_*: preedit_string / commit_string + done. @@ -23,7 +23,7 @@ #define _GNU_SOURCE 1 #include <stdlib.h> #include <string.h> -#include "banner_ext.h" +#include "droiddeck_ext.h" #include "text-input-unstable-v3-server-protocol.h" #ifndef KEY_BACKSPACE @@ -58,7 +58,7 @@ static struct wl_resource *g_target; /* surface text input is focused static int g_told_enabled, g_told_rect[4]; static struct text_input *g_told_ti; -static const char *ti_program(struct text_input *ti) { return banner_client_name(ti->client); } +static const char *ti_program(struct text_input *ti) { return droiddeck_client_name(ti->client); } /* The enabled text input focused on the current target, if any. */ static struct text_input *active_input(void) { @@ -74,22 +74,22 @@ static void tell_app(void) { int enabled = ti != NULL, rect[4] = {0, 0, 0, 0}; if (ti) { int ox = 0, oy = 0; - banner_surface_scene_origin(ti->entered, &ox, &oy); + droiddeck_surface_scene_origin(ti->entered, &ox, &oy); rect[0] = ti->rect[0] + ox; rect[1] = ti->rect[1] + oy; rect[2] = ti->rect[2]; rect[3] = ti->rect[3]; } if (enabled == g_told_enabled && ti == g_told_ti && !memcmp(rect, g_told_rect, sizeof(rect))) return; if (enabled && (!g_told_enabled || ti != g_told_ti)) - banner_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti), + droiddeck_log("text-input", "text input enabled by %s (cursor rect %d,%d %dx%d)", ti_program(ti), rect[0], rect[1], rect[2], rect[3]); else if (enabled) - banner_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]); + droiddeck_log("text-input", "cursor rect from %s: %d,%d %dx%d", ti_program(ti), rect[0], rect[1], rect[2], rect[3]); else - banner_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : ""); + droiddeck_log("text-input", "text input disabled%s%s", g_told_ti ? " by " : "", g_told_ti ? ti_program(g_told_ti) : ""); g_told_enabled = enabled; g_told_ti = ti; memcpy(g_told_rect, rect, sizeof(rect)); - banner_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]); + droiddeck_on_text_input(enabled, enabled ? ti_program(ti) : "", rect[0], rect[1], rect[2], rect[3]); } /* ------------------------------------------------------------------ focus */ @@ -102,8 +102,8 @@ static void send_leave(struct text_input *ti) { ti->has_preedit = 0; } -void banner_text_input_refocus(void) { - struct wl_resource *t = banner_ime_target(); +void droiddeck_text_input_refocus(void) { + struct wl_resource *t = droiddeck_ime_target(); struct text_input *ti; if (t == g_target) return; wl_list_for_each(ti, &g_inputs, link) @@ -121,7 +121,7 @@ void banner_text_input_refocus(void) { wl_display_flush_clients(g_disp); } -void banner_text_input_surface_gone(struct wl_resource *surface) { +void droiddeck_text_input_surface_gone(struct wl_resource *surface) { struct text_input *ti; wl_list_for_each(ti, &g_inputs, link) if (ti->entered == surface) { ti->entered = NULL; ti->enabled = 0; ti->has_preedit = 0; } @@ -136,7 +136,7 @@ static void ti_res_destroy(struct wl_resource *r) { if (!ti) return; wl_list_remove(&ti->link); if (g_told_ti == ti) { g_told_ti = NULL; g_told_enabled = 0; memset(g_told_rect, 0, sizeof(g_told_rect)); - banner_on_text_input(0, "", 0, 0, 0, 0); } + droiddeck_on_text_input(0, "", 0, 0, 0, 0); } free(ti); } @@ -235,13 +235,13 @@ static int32_t utf8_offset(const char *s, size_t len, int chars) { void text_input_host_commit(const char *utf8, size_t len) { struct text_input *ti = active_input(); if (!ti) { - banner_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len)); + droiddeck_log("text-input", "typed text (%zu chars) dropped: no program accepts text input", utf8_chars(utf8, len)); return; } if (ti->has_preedit) { zwp_text_input_v3_send_preedit_string(ti->res, "", 0, 0); ti->has_preedit = 0; } zwp_text_input_v3_send_commit_string(ti->res, utf8); zwp_text_input_v3_send_done(ti->res, ti->serial); - banner_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti)); + droiddeck_log("text-input", "committed %zu chars to %s", utf8_chars(utf8, len), ti_program(ti)); } void text_input_host_preedit(const char *utf8, size_t len, int cursor_begin, int cursor_end) { @@ -264,9 +264,9 @@ void text_input_host_delete(int before, int after) { zwp_text_input_v3_send_done(ti->res, ti->serial); ti->has_preedit = 0; } - for (int i = 0; i < before; i++) { banner_inject_key(KEY_BACKSPACE, 1); banner_inject_key(KEY_BACKSPACE, 0); } - for (int i = 0; i < after; i++) { banner_inject_key(KEY_DELETE, 1); banner_inject_key(KEY_DELETE, 0); } - banner_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after); + for (int i = 0; i < before; i++) { droiddeck_inject_key(KEY_BACKSPACE, 1); droiddeck_inject_key(KEY_BACKSPACE, 0); } + for (int i = 0; i < after; i++) { droiddeck_inject_key(KEY_DELETE, 1); droiddeck_inject_key(KEY_DELETE, 0); } + droiddeck_log("text-input", "deleted %d before / %d after the caret (as key presses)", before, after); } void text_input_init(struct wl_display *display) { diff --git a/app/src/main/cpp/waylandcomp/src/wl_toplevel_icon.c b/app/src/main/cpp/waylandcomp/src/wl_toplevel_icon.c index 17a9f59e0..ce1cd0e5f 100644 --- a/app/src/main/cpp/waylandcomp/src/wl_toplevel_icon.c +++ b/app/src/main/cpp/waylandcomp/src/wl_toplevel_icon.c @@ -4,7 +4,7 @@ * missing. We send no icon_size (any size is fine) and `done` at bind, as the protocol asks. */ #include <stdlib.h> -#include "banner_ext.h" +#include "droiddeck_ext.h" #include "xdg-toplevel-icon-v1-server-protocol.h" static void icon_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); } diff --git a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt index 87c3f2790..5857101ea 100644 --- a/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt +++ b/app/src/main/java/com/droiddeck/launcher/ComponentsMenu.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.droiddeck.launcher.session.ComponentsManager +import com.droiddeck.launcher.session.ProtonDefault /** * The Components page's state and work: the Protons and their DXVK/VKD3D sets, the Nightlies @@ -20,6 +21,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat var compChecking by mutableStateOf(false) var compBusy by mutableStateOf<String?>(null) var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap()) + private var shownDefault: String? = null /** Reads the Protons (and on first open saves their originals) off the UI thread; the Nightlies list comes from its cache. */ fun refreshComponents(snapshotFirst: Boolean = false) { @@ -28,14 +30,35 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat runCatching { ComponentsManager.applyQueued(activity) } val snap = runCatching { ComponentsManager.snapshot(activity) }.onFailure { Log.w(TAG, "components", it) }.getOrNull() val cat = runCatching { ComponentsManager.catalog(activity, false) }.getOrNull() + val chosen = snap?.let { s -> runCatching { ProtonDefault.selectedId(activity, s.protons.map { it.proton }) }.getOrNull() } ui.post { compSnapshot = snap ?: ComponentsManager.Snapshot(emptyList(), emptyList()) if (cat != null && cat.fetchedAt > 0) { compCatalog = cat.items; compCatalogAt = cat.fetchedAt } - if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) compProton = snap?.protons?.firstOrNull()?.proton?.id + if (chosen != null && chosen != shownDefault) { + shownDefault = chosen + compProton = chosen + } else if (compProton == null || snap?.protons?.none { it.proton.id == compProton } == true) { + compProton = chosen ?: snap?.protons?.firstOrNull()?.proton?.id + } } }, "components").start() } + fun chooseProton(id: String) { + val proton = compSnapshot?.protons?.firstOrNull { it.proton.id == id }?.proton ?: return + compProton = id + Thread({ + val message = runCatching { + when (ProtonDefault.request(activity, proton)) { + ProtonDefault.Outcome.LIVE -> activity.getString(R.string.comp_default_live, proton.name) + ProtonDefault.Outcome.NEXT_START -> activity.getString(R.string.comp_default_next, proton.name) + ProtonDefault.Outcome.NOT_RUNNABLE -> activity.getString(R.string.comp_default_not_runnable, proton.name) + } + }.getOrElse { e -> activity.getString(R.string.comp_default_failed, e.message ?: e.javaClass.simpleName) } + ui.post { android.widget.Toast.makeText(activity, message, android.widget.Toast.LENGTH_LONG).show() } + }, "components-default").start() + } + fun componentAction(label: String, work: () -> String) { if (compBusy != null) return compBusy = label diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 673d41ab4..fb765383d 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -137,6 +137,9 @@ class MainActivity : ComponentActivity() { private var tuSysmem by mutableStateOf(false) private var zinkLazy by mutableStateOf(false) private var noXalia by mutableStateOf(true) + private var fastSync by mutableStateOf(false) + private var syncFallback by mutableStateOf(true) + private var fsyncFirst by mutableStateOf(true) private var gamescopeRealtime by mutableStateOf(false) private var gpuClockPin by mutableStateOf(false) private var prootNoSeccomp by mutableStateOf(false) @@ -863,9 +866,10 @@ class MainActivity : ComponentActivity() { val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList()) if (applied.isNotEmpty()) ui.post { android.widget.Toast.makeText(this, "Applied: " + applied.joinToString(", "), android.widget.Toast.LENGTH_LONG).show() - if (showComponents) components.refreshComponents() } + ui.post { if (showComponents) components.refreshComponents() } }, "components-queue").start() + else if (showComponents) components.refreshComponents() oscMode = SessionPrefs.oscMode(this) refreshController() refreshHomeAppState() @@ -1018,7 +1022,7 @@ class MainActivity : ComponentActivity() { busy = if (busy) stage else components.compBusy, downloads = components.compDownloads, requestInitialFocus = focusComponentsContent, - onProton = { components.compProton = it }, + onProton = { components.chooseProton(it) }, onComp = { components.compComp = it }, onSwap = { file -> components.compProton?.let { pid -> components.componentAction("Swapping") { ComponentsManager.swap(this, pid, file) } } }, onRestore = { version -> components.compProton?.let { pid -> components.componentAction("Restoring") { ComponentsManager.restore(this, pid, components.compComp, version) } } }, @@ -1130,6 +1134,7 @@ class MainActivity : ComponentActivity() { storageOptions = storageOptions, storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics, fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, + syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null, steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null, steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode, mangoapp = mangoapp, @@ -1203,6 +1208,12 @@ class MainActivity : ComponentActivity() { storageDiagnostics = on }, onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id }, + onSyncBackend = { id -> + SessionPrefs.setSyncBackend(this, id) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) + }, onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id }, onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on) @@ -1242,6 +1253,9 @@ class MainActivity : ComponentActivity() { cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) }, clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia, + fastSync = fastSync, + syncFallback = syncFallback, + fsyncFirst = fsyncFirst, gamescopeRealtime = gamescopeRealtime, gpuClockPin = gpuClockPin, prootNoSeccomp = prootNoSeccomp, prootFastPath = prootFastPath, guestHostname = guestHostname, phantomWarning = phantomWarning, @@ -1251,6 +1265,9 @@ class MainActivity : ComponentActivity() { onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on }, onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, + onFastSync = { on -> SessionPrefs.setFastSync(this, on); fastSync = on }, + onSyncFallback = { on -> SessionPrefs.setSyncFallback(this, on); syncFallback = on }, + onFsyncFirst = { on -> SessionPrefs.setFsyncFirst(this, on); fsyncFirst = on }, onGamescopeRealtime = { on -> SessionPrefs.setGamescopeRealtime(this, on); gamescopeRealtime = on }, onGpuClockPin = { on -> SessionPrefs.setGpuClockPin(this, on); gpuClockPin = on }, onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, @@ -1309,6 +1326,9 @@ class MainActivity : ComponentActivity() { resolutionCap = SessionPrefs.resolutionCap(this, mode) customResolution = SessionPrefs.customResolution(this, mode) fexPreset = SessionPrefs.fexPreset(this) + fastSync = SessionPrefs.fastSync(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) + syncFallback = SessionPrefs.syncFallback(this) steamChannel = SessionPrefs.steamChannel(this) steamDeckMode = SessionPrefs.steamDeckMode(this) mangoapp = SessionPrefs.mangoapp(this) @@ -1380,6 +1400,9 @@ class MainActivity : ComponentActivity() { tuSysmem = SessionPrefs.tuSysmem(this) zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) + fastSync = SessionPrefs.fastSync(this) + syncFallback = SessionPrefs.syncFallback(this) + fsyncFirst = SessionPrefs.fsyncFirst(this) gamescopeRealtime = SessionPrefs.gamescopeRealtime(this) gpuClockPin = SessionPrefs.gpuClockPin(this) prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index efa24f7e2..9d7fa640e 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -945,7 +945,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val on = wanted && probe.reason == null SessionState.hdr = on if (on) { - try { android.system.Os.setenv("BANNER_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "BANNER_WAYLAND_HDR", e) } + try { android.system.Os.setenv("DROIDDECK_WAYLAND_HDR", "1", true) } catch (e: Exception) { Log.w(TAG, "DROIDDECK_WAYLAND_HDR", e) } WaylandCompositor.nativeSetZeroCopy(true) } WaylandCompositor.nativeSetHdrRequest( @@ -1156,8 +1156,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, "session-end-hint").start() } - private fun showEnded(status: Int, hint: String?) { - if (status == 0) { + private fun showEnded(status: Int, hint: EndHint?) { + if (status == 0 && hint?.evenOnSuccess != true) { finish() return } @@ -1168,7 +1168,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { else -> "Steam" } loading.showEnded( - hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.", + hint?.text ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.", "Exit status $status · ${SessionState.logFile?.path ?: "no log"}", ) focusEndedScreen() @@ -1196,10 +1196,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { * FEX) on some devices (a Fold 5, twice). The switch that answers it is in Performance, and a * user who never opens the log would not know. */ - private fun sessionEndHint(log: File?): String? { + private class EndHint(val text: String, val evenOnSuccess: Boolean = false) + + private fun sessionEndHint(log: File?): EndHint? { if (log == null || !log.isFile) return null return try { var enosys = 0 + var fexMissing = false // The tail is where a dying session says why; 512 KB covers the storm without reading a 1 GB log. val size = log.length() java.io.RandomAccessFile(log, "r").use { f -> @@ -1209,11 +1212,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { f.readFully(bytes) String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line -> if (line.contains("Function not implemented")) enosys++ + if (line.contains(FEX_MISSING)) fexMissing = true } } - if (enosys >= 8) { - "The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " + - "Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again." + if (fexMissing) { + val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program) + EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true) + } else if (enosys >= 8) { + EndHint("The log shows $enosys \"Function not implemented\" errors: proot's seccomp acceleration is failing a helper on this device. " + + "Try Performance \u2192 \"Run proot without seccomp\" (or \"Skip Steam's xalia helper\") and start again.") } else null } catch (e: Exception) { null @@ -1998,6 +2005,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { /** How long the leaving flood may take before the session closes without it. */ private const val LEAVE_TIMEOUT_MS = 1_500L private const val TAG = "SessionActivity" + private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION private const val CURSOR_PAD_HOLD_MS = 1200L private const val DRAWER_HAT_THRESHOLD = 0.5f 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 5a22a7f3b..f95c53df8 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt @@ -12,6 +12,7 @@ import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.runtime.DesktopCatalog import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller +import com.droiddeck.launcher.session.EsyncPacks import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState @@ -154,6 +155,9 @@ object DeviceReport { " (sharpness ${SessionPrefs.upscaleSharpness(context)}%)") k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" }) k("Skip xalia", SessionPrefs.noXalia(context)) + k("Wine sync", SessionPrefs.syncBackend(context)) + EsyncPacks.status(LinuxRuntime.rootDir(context)).let { k("droiddeck-esync packs", "${it.installed} installed, ${it.wanted} wanted") } + k("Linux x86 (FEX)", if (com.droiddeck.launcher.runtime.LinuxFex.ready(context)) "ready" else "not set up") k("gamescope realtime", SessionPrefs.gamescopeRealtime(context)) k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) k("proot fast path", SessionPrefs.prootFastPath(context)) diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt index 7a9e48d23..645898e64 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironment.kt @@ -14,7 +14,7 @@ object GameEnvironment { private val namePattern = Regex("[A-Za-z_][A-Za-z0-9_]*") private val unused = setOf( - "WINEESYNC", "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER", + "WINENTSYNC", "WINE_FAST_YIELD", "WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER", "WINE_NEW_MEDIASOURCE", "WRAPPER_MAX_IMAGE_COUNT", "WRAPPER_DMAHEAP_CACHED", "ALSA_LATENCY_MS", "ALSA_VOLUME", "ALSA_BASS_BOOST", "ALSA_PERFORMANCE_MODE", "DXVK_ASYNC", "DXVK_GPLASYNCCACHE", diff --git a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt index 0904c5b2a..078ae8165 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/GameEnvironmentOptions.kt @@ -15,6 +15,10 @@ object GameEnvironmentOptions { Option("PROTON_LOG", "1", R.string.game_env_proton_log, Type.TOGGLE, listOf("0", "1")), Option("PROTON_USE_WINED3D", "1", R.string.game_env_wined3d, Type.TOGGLE, listOf("0", "1")), Option("PROTON_USE_XALIA", "0", R.string.game_env_xalia, Type.TOGGLE, listOf("0", "1")), + Option("PROTON_NO_NTSYNC", "1", R.string.game_env_no_ntsync, Type.TOGGLE, listOf("0", "1")), + Option("PROTON_NO_ESYNC", "1", R.string.game_env_no_esync, Type.TOGGLE, listOf("0", "1")), + Option("PROTON_NO_FSYNC", "1", R.string.game_env_no_fsync, Type.TOGGLE, listOf("0", "1")), + Option("BL_FSYNC_FIRST", "1", R.string.game_env_fsync_first, Type.TOGGLE, listOf("0", "1")), Option("DXVK_HUD", "fps", R.string.game_env_hud, Type.MULTIPLE, listOf("scale=0.5", "scale=0.7", "opacity=0.5", "opacity=0.7", "devinfo", "fps", "frametimes", "submissions", "drawcalls", "pipelines", "descriptors", "memory", "gpuload", "version", "api", "cs", "compiler", "samplers")), Option("DXVK_CONFIG", "dxvk.maxFrameRate = 60", R.string.game_env_dxvk_config, Type.TEXT), Option("VKD3D_FRAME_RATE", "60", R.string.game_env_vkd3d_limit, Type.NUMBER), diff --git a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt index 4712f65e4..d7d6aea06 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/LogRedactor.kt @@ -18,7 +18,7 @@ package com.droiddeck.launcher.core * * The device's OWN addresses are not kept: the client's IPv6 check logs "external address <ours>" * about twenty times a session, and a public IPv6 address is the user's. [learnOwnAddresses] reads - * the link the session was given (etc/bannerlator-net) and every address on the same /64 - privacy + * the link the session was given (etc/droiddeck-net) and every address on the same /64 - privacy * addresses rotate inside it - or an own public IPv4 is replaced wherever it appears. Likewise every * Steam account and persona name on the device ([learnAccounts]), for users who sign in with an * account name rather than an email. @@ -95,7 +95,7 @@ object LogRedactor { /** Everything the session's runtime can tell about whose logs these are: addresses and accounts. */ fun learnFromRuntime(root: java.io.File) { - learnOwnAddresses(java.io.File(root, "etc/bannerlator-net")) + learnOwnAddresses(java.io.File(root, "etc/droiddeck-net")) learnAccounts(java.io.File(root, "root/.local/share/Steam/config/loginusers.vdf")) } diff --git a/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt b/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt index af8f71303..b8a0e5dae 100644 --- a/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt +++ b/app/src/main/java/com/droiddeck/launcher/core/NetworkReport.kt @@ -91,9 +91,9 @@ object NetworkReport { } else { append("etc/resolv.conf does not exist yet - written when the session's link is published.\n") } - val netdev = File(LinuxRuntime.rootDir(context), "etc/bannerlator-net") + val netdev = File(LinuxRuntime.rootDir(context), "etc/droiddeck-net") if (netdev.isFile) { - append("\netc/bannerlator-net (the link as the session sees it):\n") + append("\netc/droiddeck-net (the link as the session sees it):\n") FileUtils.readString(netdev)?.lines()?.forEach { append(" ").append(it).append('\n') } } append("\nproot makes no network namespace, so the session uses the phone's connection\n") diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index 886cb872a..9ccc0ff3b 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -204,7 +204,7 @@ object Library { * Whether a program from the rail runs at the panel's own resolution rather than the session's * 720p default. melonDS draws two 256x192 screens on the CPU: the panel's size costs it * nothing, and 720p scaled up to the panel blurs the sharp pixels its screen layout is set - * up for (bannerlator-pad-defaults). + * up for (droiddeck-pad-defaults). */ fun drawsAtPanel(program: String?): Boolean = specs.any { it.program == program && it.atPanel } @@ -341,7 +341,7 @@ object Library { // Dolphin: full screen, drawn inside its own main window, without its "stop the emulation?" // question; no warning boxes either, which wait for a click a controller cannot give // (they still go to Dolphin's log). The guide button is Dolphin's Toggle Fullscreen hotkey - // (bannerlator-pad-defaults), which shows Dolphin's window and its settings - so no -b, which + // (droiddeck-pad-defaults), which shows Dolphin's window and its settings - so no -b, which // hides that window; closing Dolphin ends the session. Under gamescope Dolphin does not // always see its window as focused, and by default both its hotkeys and the game's // controller then stop: HotkeysRequireFocus off, BackgroundInput on. -C sets a Dolphin.ini @@ -353,10 +353,10 @@ object Library { "-e", guestPath, ) // Full screen (LaunchSettings.cpp -f); the settings seed keeps the Getting Started - // dialog away (bannerlator-pad-defaults). + // dialog away (droiddeck-pad-defaults). "cemu" -> listOf("-f", "-g", guestPath) // Full screen (CLI.cpp --fullscreen); the guide button leaves it for melonDS's menus and - // comes back (HK_FullscreenToggle, bannerlator-pad-defaults). + // comes back (HK_FullscreenToggle, droiddeck-pad-defaults). "melonds" -> listOf("-f", guestPath) else -> listOf(guestPath) } diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java index 29c7dbd17..6eb3c3441 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java +++ b/app/src/main/java/com/droiddeck/launcher/gpu/LsfgNative.java @@ -6,7 +6,7 @@ package com.droiddeck.launcher.gpu; * DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block. */ final class LsfgNative { - static { System.loadLibrary("bannerwayland"); } + static { System.loadLibrary("droiddeckwayland"); } private LsfgNative() {} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt index 7afe4a3bc..50e851db1 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/AppImageManager.kt @@ -13,16 +13,14 @@ import java.io.RandomAccessFile * AppImages the user brings: picked from storage, checked, and extracted once into the runtime at * /opt/appimages/user/<id>/app. proot has no FUSE to mount an image, and extracting it at every * start would cost its whole size in /tmp each time. Each one gets a menu entry on the Linux - * desktop, and the front end starts it under gamescope through bannerlator-appimage-run. + * desktop, and the front end starts it under gamescope through droiddeck-appimage-run. * * <id>/name holds the name to show, <id>/icon.png the icon when the image has a PNG one. */ object AppImageManager { private const val TAG = "AppImageManager" const val GUEST_DIR = "/opt/appimages/user" - const val LAUNCHER = "/usr/local/bin/bannerlator-appimage-run" - private const val ELF_AARCH64 = 183 - private const val ELF_X86_64 = 62 + const val LAUNCHER = "/usr/local/bin/droiddeck-appimage-run" private const val SHARP_ICON = 128 class Item(val id: String, val name: String, val icon: File?, val comment: String?) { @@ -59,9 +57,8 @@ object AppImageManager { } val machine = (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8) return when { - machine == ELF_X86_64 -> "This AppImage is built for x86_64 PCs. DroidDeck runs ARM64 (aarch64) AppImages - look for an \"aarch64\" or \"arm64\" download." - machine != ELF_AARCH64 -> "This AppImage is not built for ARM64 (aarch64)" - head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is an ARM64 program but not an AppImage" + machine != LinuxFex.ELF_AARCH64 && !LinuxFex.isX86(machine) -> "This AppImage is built for neither ARM64 (aarch64) nor x86 PCs" + head[8] != 'A'.code.toByte() || head[9] != 'I'.code.toByte() -> "This is a Linux program but not an AppImage" head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})" else -> null } @@ -96,6 +93,7 @@ object AppImageManager { val dir = File(base, idFor(name ?: file.name, base.list()?.toSet() ?: emptySet())) try { extract(context, dir, file, onProgress)?.let { FileUtils.delete(dir); return it } + LinuxFex.writeArch(dir, LinuxFex.elfMachine(file)) describe(dir, file) if (name != null) FileUtils.writeString(File(dir, "name"), name) if (icon != null && !UserApps.saveIcon(icon, File(dir, "icon.png"))) Log.w(TAG, "icon ${icon.path} could not be read") @@ -119,6 +117,7 @@ object AppImageManager { } return try { extract(context, dir, file, onProgress) ?: run { + LinuxFex.writeArch(dir, LinuxFex.elfMachine(file)) meta.forEach { (k, v) -> FileUtils.writeString(File(dir, k), v) } null } @@ -140,12 +139,14 @@ object AppImageManager { file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } } image.setExecutable(true, false) onProgress("Extracting ${file.name}") + val unpack = if (LinuxFex.isX86(LinuxFex.elfMachine(image))) "${LinuxFex.TOOL} extract ./image.AppImage" + else "./image.AppImage --appimage-extract || { rm -rf squashfs-root AppDir; ${LinuxFex.TOOL} extract ./image.AppImage; }" val out = StringBuilder() // uruntime's DwarFS images unpack into AppDir with squashfs-root a link to it, and keep // one program under several names as hard links (sharun), which Android denies apps. val status = GuestCommand.run(context, listOf( "/bin/bash", "-c", - "cd \"$1\" && rm -rf squashfs-root AppDir && ./image.AppImage --appimage-extract >/dev/null && " + + "cd \"$1\" && rm -rf squashfs-root AppDir && { $unpack; } >/dev/null && " + "rm -rf app && mv \"$(readlink -f squashfs-root)\" app && rm -f squashfs-root", "extract", "$GUEST_DIR/${dir.name}", ), logName = "appimage-import", linkDir = links) { line -> if (out.length < 2000) out.appendLine(line) } diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt index 6ec25ddbf..6e20ff89d 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/BwrapSpawner.kt @@ -19,7 +19,7 @@ import java.nio.file.Files import java.util.concurrent.atomic.AtomicInteger /** - * Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-bwrap). + * Flatpak's sandboxes, started for the runtime's bwrap stand-in (droiddeck-bwrap). * * A Flatpak app runs with its runtime as /usr and itself as /app, which bwrap arranges with mount * namespaces Android gives no app. proot can show the same picture, and started here - beside the diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt index 601527623..ebe1b3dd0 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DeckyManager.kt @@ -74,8 +74,7 @@ object DeckyManager { val assets = release.optJSONArray("assets") ?: continue val rows = (0 until assets.length()).map { assets.getJSONObject(it) } val arm = rows.firstOrNull { it.optString("name") == "PluginLoader-arm64" } - val guest = LinuxRuntime.rootDir(context) - val fexReady = File(guest, "usr/share/guestos/fex-mesa").isDirectory && File(guest, "usr/bin/FEX").canExecute() + val fexReady = LinuxFex.ready(context) val binary = arm ?: if (fexReady) rows.firstOrNull { it.optString("name") == "PluginLoader" } else null val name = binary?.optString("name") ?: continue val url = binary.optString("browser_download_url").takeIf { it.startsWith("https://") } ?: continue diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt index 41a89f4d5..6d7be215e 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/FlatpakManager.kt @@ -7,8 +7,8 @@ import org.json.JSONObject import java.io.File /** - * Flatpak in the Linux runtime: putting it there (bannerlator-flatpak-setup), the apps installed - * from Flathub, and installing, removing and updating them (bannerlator-flatpak). One per-user + * Flatpak in the Linux runtime: putting it there (droiddeck-flatpak-setup), the apps installed + * from Flathub, and installing, removing and updating them (droiddeck-flatpak). One per-user * installation at /root/.local/share/flatpak, so nothing needs the system helper or polkit. * * Every command runs in a proot of its own, outside any session: the store works whether or not @@ -17,11 +17,11 @@ import java.io.File object FlatpakManager { private const val TAG = "FlatpakManager" const val FLATPAK = "/usr/bin/flatpak" - const val BWRAP = "/usr/local/bin/bannerlator-bwrap" + const val BWRAP = "/usr/local/bin/droiddeck-bwrap" /** The program the front end starts a Flatpak app through, in a session of its own. */ - const val LAUNCHER = "/usr/local/bin/bannerlator-flatpak-run" - private const val HELPER = "/usr/local/bin/bannerlator-flatpak" - private const val SETUP = "/usr/local/bin/bannerlator-flatpak-setup" + const val LAUNCHER = "/usr/local/bin/droiddeck-flatpak-run" + private const val HELPER = "/usr/local/bin/droiddeck-flatpak" + private const val SETUP = "/usr/local/bin/droiddeck-flatpak-setup" private const val USER_DIR = "root/.local/share/flatpak" /** One store operation at a time: Flatpak locks its installation per transaction anyway. */ diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LegacyNames.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LegacyNames.kt new file mode 100644 index 000000000..eb01d3dc5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LegacyNames.kt @@ -0,0 +1,109 @@ +package com.droiddeck.launcher.runtime + +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import java.io.File +import java.nio.file.Files +import java.nio.file.Paths + +object LegacyNames { + private const val TAG = "LegacyNames" + const val SESSION_SCRIPT = "/usr/local/bin/bannerlator-session" + val SCRIPTS = listOf( + "appimage-run", "bwrap", "desktop-games", "flatpak", "flatpak-run", "flatpak-setup", + "game-env", "login1", "netmanager", "pad-defaults", "proton-extra", "script-run", + "seed-redists", "session", "steam-games", "steam-install", "steam-launch", + "steam-library", "steam-shim", "steam-shortcuts", + ) + private val LINKED_DIRS = listOf("etc/bannerlator" to "etc/droiddeck", "usr/local/share/bannerlator" to "usr/local/share/droiddeck") + private val MOVED_FILES = listOf( + "etc/bannerlator-net" to "etc/droiddeck-net", + "etc/bannerlator-network.json" to "etc/droiddeck-network.json", + "etc/bannerlator-wifi-scan-request" to "etc/droiddeck-wifi-scan-request", + ) + private const val MENU_DIR = "usr/local/share/applications" + private const val OLD_BIN = "/usr/local/bin/bannerlator-" + private const val NEW_BIN = "/usr/local/bin/droiddeck-" + + @JvmStatic + @Synchronized + fun migrateEtc(root: File) { + for ((old, new) in LINKED_DIRS) moveAndLink(File(root, old), File(root, new)) + File(root, "etc/droiddeck/empty").mkdirs() + } + + @Synchronized + fun migrateRootfs(root: File) { + migrateEtc(root) + for ((old, new) in MOVED_FILES) move(File(root, old), File(root, new)) + linkScripts(root) + rewriteMenuEntries(root) + } + + private fun isLink(f: File) = Files.isSymbolicLink(f.toPath()) + + private fun move(legacy: File, target: File) { + if (isLink(legacy) || !legacy.exists()) return + if (legacy.isDirectory && !isLink(target) && (target.isDirectory || !target.exists())) { + target.mkdirs() + legacy.listFiles()?.forEach { move(it, File(target, it.name)) } + if (!legacy.delete()) Log.w(TAG, "could not empty $legacy") + return + } + if (target.exists() || isLink(target)) { + FileUtils.delete(legacy) + } else if (!legacy.renameTo(target)) { + Log.w(TAG, "could not move $legacy to $target") + } + } + + private fun moveAndLink(legacy: File, target: File) { + if (isLink(legacy) || !legacy.exists()) return + move(legacy, target) + if (!legacy.exists()) replaceWithLink(legacy, target) + } + + private fun replaceWithLink(legacy: File, target: File) { + val link = File(legacy.parentFile, legacy.name + ".link") + FileUtils.delete(link) + try { + Files.createSymbolicLink(link.toPath(), Paths.get(target.name)) + if (!link.renameTo(legacy)) { + FileUtils.delete(link) + Log.w(TAG, "could not link $legacy to ${target.name}") + } + } catch (e: Exception) { + FileUtils.delete(link) + Log.w(TAG, "could not link $legacy to ${target.name}", e) + } + } + + private fun linkScripts(root: File) { + val bin = File(root, "usr/local/bin") + for (name in SCRIPTS) { + val target = File(bin, "droiddeck-$name") + val legacy = File(bin, "bannerlator-$name") + if (!target.isFile || isLink(target)) continue + if (isLink(legacy)) { + val points = runCatching { Files.readSymbolicLink(legacy.toPath()).toString() }.getOrNull() + if (points == target.name) continue + } else if (!legacy.exists()) { + continue + } + replaceWithLink(legacy, target) + } + } + + private fun rewriteMenuEntries(root: File) { + val entries = File(root, MENU_DIR).listFiles { f -> f.isFile && f.name.startsWith("droiddeck-") && f.name.endsWith(".desktop") } ?: return + for (entry in entries) { + val text = FileUtils.readString(entry) ?: continue + if (!text.contains(OLD_BIN)) continue + val staged = File(entry.parentFile, entry.name + ".staged") + if (!FileUtils.writeString(staged, text.replace(OLD_BIN, NEW_BIN)) || !staged.renameTo(entry)) { + staged.delete() + Log.w(TAG, "could not rewrite $entry") + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxFex.kt b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxFex.kt new file mode 100644 index 000000000..b79d3c6ae --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxFex.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.runtime + +import android.content.Context +import com.droiddeck.launcher.core.FileUtils +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile + +object LinuxFex { + const val TOOL = "/usr/local/bin/droiddeck-fex" + const val AUTO = "auto" + const val ON = "on" + const val OFF = "off" + val MODES = listOf(AUTO, ON, OFF) + const val ELF_I386 = 3 + const val ELF_X86_64 = 62 + const val ELF_AARCH64 = 183 + private const val MODE_FILE = "fex" + private const val ARCH_FILE = "arch" + private const val STATUS = "root/.local/share/droiddeck-fex/status.json" + + fun ready(context: Context): Boolean = + FileUtils.readString(File(LinuxRuntime.rootDir(context), STATUS)) + ?.let { runCatching { JSONObject(it).optBoolean("ready") }.getOrNull() } == true + + fun mode(dir: File): String = FileUtils.readString(File(dir, MODE_FILE))?.trim()?.takeIf { it in MODES } ?: AUTO + + fun setMode(dir: File, mode: String): Boolean { + val file = File(dir, MODE_FILE) + return when (mode) { + AUTO -> !file.exists() || file.delete() + ON, OFF -> FileUtils.writeString(file, mode) + else -> false + } + } + + fun elfMachine(file: File): Int? = try { + RandomAccessFile(file, "r").use { f -> + val head = ByteArray(20) + if (f.read(head) < head.size) return null + if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) return null + if (head[5].toInt() == 2) ((head[18].toInt() and 0xff) shl 8) or (head[19].toInt() and 0xff) + else (head[18].toInt() and 0xff) or ((head[19].toInt() and 0xff) shl 8) + } + } catch (e: Exception) { + null + } + + fun isX86(machine: Int?): Boolean = machine == ELF_X86_64 || machine == ELF_I386 + + fun archName(machine: Int?): String? = when (machine) { + ELF_X86_64 -> "x86_64" + ELF_I386 -> "i386" + ELF_AARCH64 -> "arm64" + else -> null + } + + fun writeArch(dir: File, machine: Int?) { + val name = archName(machine) + if (name == null) File(dir, ARCH_FILE).delete() else FileUtils.writeString(File(dir, ARCH_FILE), name) + } +} 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 a47ca9330..5b5585c22 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxNetworkLinkComponent.kt @@ -62,7 +62,7 @@ class LinuxNetworkLinkComponent( synchronized(lock) { writeNetworkState(currentProperties, currentCapabilities) } requestWifiScan() } - val stamp = File(rootDir, "etc/bannerlator-wifi-scan-request").lastModified() + val stamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified() if (stamp != 0L && stamp != scanRequestStamp) { scanRequestStamp = stamp requestWifiScan() @@ -126,7 +126,7 @@ class LinuxNetworkLinkComponent( addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) addAction(LocationManager.MODE_CHANGED_ACTION) }) - scanRequestStamp = File(rootDir, "etc/bannerlator-wifi-scan-request").lastModified() + scanRequestStamp = File(rootDir, "etc/droiddeck-wifi-scan-request").lastModified() scanHandler.post(scanRequests) requestWifiScan() } @@ -253,7 +253,7 @@ class LinuxNetworkLinkComponent( } } try { - val file = File(rootDir, "etc/bannerlator-network.json") + val file = File(rootDir, "etc/droiddeck-network.json") val staged = File(file.path + ".staged") staged.writeText(state.toString()) if (!staged.renameTo(file)) throw IOException("Could not replace network state") @@ -335,7 +335,7 @@ class LinuxNetworkLinkComponent( companion object { private const val TAG = "LinuxNetworkLink" - private const val LINK_FILE = "etc/bannerlator-net" + private const val LINK_FILE = "etc/droiddeck-net" private const val LINK_INDEX = 2 private const val DEFAULT_MTU = 1500 private const val OFFLINE_NAME = "eth0" diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java index de3f45d23..35a73971f 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java +++ b/app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java @@ -27,7 +27,7 @@ import java.util.List; */ public final class LinuxRuntime { public static final String DIR = "linuxfs"; - public static final String SESSION_SCRIPT = "/usr/local/bin/bannerlator-session"; + public static final String SESSION_SCRIPT = "/usr/local/bin/droiddeck-session"; public static final String MODE_DESKTOP = "desktop"; public static final String MODE_STEAM = "steam"; public static final String MODE_RUN = "run"; @@ -91,7 +91,8 @@ public final class LinuxRuntime { if (LinuxRuntimeInstaller.isRemoving() || LinuxRuntimeInstaller.hasRemovalPending(context)) return false; File root = rootDir(context); return new File(root, "usr/bin/gamescope").isFile() - && new File(root, SESSION_SCRIPT.substring(1)).isFile() + && (new File(root, SESSION_SCRIPT.substring(1)).isFile() + || new File(root, LegacyNames.SESSION_SCRIPT.substring(1)).isFile()) && prootBinary(context).isFile() && prootLoader(context).isFile(); } @@ -182,6 +183,7 @@ public final class LinuxRuntime { public static List<String> binds(Context context, File sessionRoot, File runtimeDir, File externalStorage, List<String> extraBinds) { File root = rootDir(context); + LegacyNames.migrateEtc(root); List<String> cmd = new ArrayList<>(); bind(cmd, "/dev"); bind(cmd, "/proc"); @@ -191,7 +193,7 @@ public final class LinuxRuntime { bind(cmd, "/proc/self/fd/0:/dev/stdin"); bind(cmd, "/proc/self/fd/1:/dev/stdout"); bind(cmd, "/proc/self/fd/2:/dev/stderr"); - bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux"); + bind(cmd, new File(root, "etc/droiddeck/empty").getPath() + ":/sys/fs/selinux"); bind(cmd, context.getFilesDir().getPath()); bind(cmd, context.getCacheDir().getPath()); if (runtimeDir != null) { @@ -207,7 +209,7 @@ public final class LinuxRuntime { bind(cmd, shm.getPath() + ":/dev/shm"); // Android denies apps these; glibc, Steam and libcap read them at startup. - File fakeProc = new File(root, "etc/bannerlator/proc"); + File fakeProc = new File(root, "etc/droiddeck/proc"); // libpci picks its procfs backend on whether it can read the /proc/bus/pci directory, which // the app can, then die()s - exit(1) on the calling process - on the devices file inside // it, which the app cannot. Chromium loads libpci in its GPU process to name the video @@ -418,7 +420,7 @@ public final class LinuxRuntime { * under the mainline name. */ private static void bindCpuTemps(List<String> cmd, File root) { - File name = new File(root, "etc/bannerlator/cpu-thermal-type"); + File name = new File(root, "etc/droiddeck/cpu-thermal-type"); try { if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII)); } catch (IOException e) { diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt index 8f4798ef0..43eb5001b 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/UserApps.kt @@ -31,7 +31,7 @@ import java.io.RandomAccessFile object UserApps { private const val TAG = "UserApps" const val GUEST_DIR = "/opt/droiddeck-apps" - const val SCRIPT_LAUNCHER = "/usr/local/bin/bannerlator-script-run" + const val SCRIPT_LAUNCHER = "/usr/local/bin/droiddeck-script-run" private const val FLATPAK_OVERRIDES = ".flatpak" private const val ICON_SIZE = 256 private const val ELF_SCAN_LIMIT = 4000 @@ -53,6 +53,7 @@ object UserApps { val key: String, val kind: Kind, val name: String, val icon: Any?, val detail: String?, val program: String, val args: List<String>, val copied: Boolean = false, val repo: String? = null, val version: String? = null, + val fex: String = LinuxFex.AUTO, val arch: String? = null, ) class Release(val tag: String, val published: String, val asset: String, val url: String) @@ -89,6 +90,7 @@ object UserApps { "script:${dir.name}", Kind.SCRIPT, readName(dir) ?: dir.name, File(dir, "icon.png").takeIf { it.isFile }, FileUtils.readString(File(dir, "source"))?.trim(), SCRIPT_LAUNCHER, listOf("$GUEST_DIR/${dir.name}"), copied = !java.nio.file.Files.isSymbolicLink(File(dir, "files").toPath()), + fex = LinuxFex.mode(dir), ) }.orEmpty() val images = AppImageManager.list(context).map { item -> @@ -96,6 +98,7 @@ object UserApps { App( "appimage:${item.id}", Kind.APPIMAGE, item.name, item.icon, item.comment, AppImageManager.LAUNCHER, listOf(item.guestDir), repo = FileUtils.readString(File(dir, GITHUB_REPO))?.trim(), version = FileUtils.readString(File(dir, GITHUB_TAG))?.trim(), + fex = LinuxFex.mode(dir), arch = FileUtils.readString(File(dir, "arch"))?.trim()?.takeIf { it.isNotEmpty() }, ) } val flatpaks = FlatpakManager.installedApps(context).map { app -> @@ -140,7 +143,7 @@ object UserApps { * Renames [app] and sets its icon: [icon] a path or URL, "" for its default, null to keep it. * The Linux desktop's menu entry follows. */ - fun edit(context: Context, app: App, name: String, icon: String?): String? { + fun edit(context: Context, app: App, name: String, icon: String?, fex: String? = null): String? { val id = app.key.substringAfter(':') if (id.isEmpty() || '/' in id || id.startsWith(".")) return null val dir = when (app.kind) { @@ -155,6 +158,7 @@ object UserApps { icon.isEmpty() -> if (app.kind == Kind.APPIMAGE) AppImageManager.restoreIcon(dir) else target.delete() else -> withIcon(context, icon) { file -> if (file == null || !saveIcon(file, target)) return context.getString(R.string.user_apps_icon_failed) } } + if (fex != null && app.kind != Kind.FLATPAK && !LinuxFex.setMode(dir, fex)) return context.getString(R.string.user_apps_failed) when (app.kind) { Kind.SCRIPT -> writeScriptEntry(context, dir) Kind.APPIMAGE -> AppImageManager.writeMenuEntry(context, dir) @@ -310,14 +314,18 @@ object UserApps { return if (repo.isEmpty() || repo == "." || repo == "..") null else "${m.groupValues[1]}/$repo" } - /** The ARM64 AppImage among a release's files: named for aarch64/arm64, else for no other CPU. */ + /** + * The AppImage among a release's files: named for aarch64/arm64, else for no other CPU, else the + * x86_64 one, which runs through FEX. + */ internal fun pickAsset(names: List<String>): String? { val images = names.filter { it.endsWith(".appimage", ignoreCase = true) } return images.firstOrNull { Regex("aarch64|arm64", RegexOption.IGNORE_CASE).containsMatchIn(it) } ?: images.firstOrNull { !Regex("x86_64|x86-64|amd64|x64|i[36]86|armhf|armv7", RegexOption.IGNORE_CASE).containsMatchIn(it) } + ?: images.firstOrNull { Regex("x86_64|x86-64|amd64|x64", RegexOption.IGNORE_CASE).containsMatchIn(it) } } - /** The newest release of [repo] with an ARM64 AppImage, preferring full releases; else what went wrong. */ + /** The newest release of [repo] with an AppImage this device runs, preferring full releases; else what went wrong. */ private fun latestRelease(context: Context, repo: String): Pair<Release?, String?> { val body = Downloader.downloadString("https://api.github.com/repos/$repo/releases?per_page=15") val releases = body?.let { runCatching { JSONArray(it) }.getOrNull() } 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 da00b1b39..1f51f666d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ComponentsManager.kt @@ -4,6 +4,7 @@ import com.droiddeck.launcher.core.Hashes import android.content.Context import android.util.Log import com.droiddeck.launcher.core.Downloader +import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.runtime.LinuxRuntime import com.github.luben.zstd.ZstdInputStream import com.github.luben.zstd.ZstdOutputStream @@ -84,6 +85,7 @@ object ComponentsManager { val proton: Proton, val components: Map<String, Component>, val originals: List<Original>, val inUseByGame: Boolean, /** When a launch last had to put this Proton's chosen files back (epoch seconds), or 0. */ val reappliedAt: Long = 0, + val sync: EsyncPacks.ToolState? = null, ) data class Snapshot(val protons: List<ProtonView>, val packages: List<Package>) data class Catalog(val items: List<CatalogItem>, val fetchedAt: Long) @@ -101,11 +103,25 @@ object ComponentsManager { private fun stateFile(context: Context) = File(dataDir(context), "state.json") private fun catalogFile(context: Context) = File(dataDir(context), "catalog.json") /** - * Inside the Linux runtime, read by the Proton launch wrappers (bannerlator-steam-compat's + * Inside the Linux runtime, read by the Proton launch wrappers (steam-compatibility's * bl_components) before every game launch: desired.tsv + an unpacked copy of each package in use. */ - private const val LAUNCH_DIR = "root/.local/share/bannerlator-components" - private fun launchDir(context: Context) = File(root(context), LAUNCH_DIR) + private const val LAUNCH_DIR = "root/.local/share/droiddeck-components" + private const val LEGACY_LAUNCH_DIR = "root/.local/share/bannerlator-components" + private fun launchDir(context: Context): File { + migrateLaunchDir(context) + return File(root(context), LAUNCH_DIR) + } + + fun migrateLaunchDir(context: Context) = synchronized(lock) { + val legacy = File(root(context), LEGACY_LAUNCH_DIR) + val dir = File(root(context), LAUNCH_DIR) + if (!legacy.isDirectory || dir.exists() || !legacy.renameTo(dir)) return@synchronized + val desired = File(dir, "desired.tsv") + val text = FileUtils.readString(desired) ?: return@synchronized + val staged = File(dir, "desired.tsv.staged") + if (!FileUtils.writeString(staged, text.replace("/$LEGACY_LAUNCH_DIR/", "/$LAUNCH_DIR/")) || !staged.renameTo(desired)) staged.delete() + } fun safeName(s: String): String = s.replace(Regex("[^A-Za-z0-9._+-]+"), "_").trim('_').ifEmpty { "unnamed" } @@ -202,22 +218,24 @@ object ComponentsManager { * Is anything running on this Proton right now - a game, or the client's own start-up runs, * whose Wine processes can outlive them? Swaps wait while it is. Read from the command lines. */ - fun inUse(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") } + fun inUse(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton ") || text.contains(needle + "files/bin") } /** * Is a game running on this Proton? Only a launch runs proton with the verb waitforexitandrun * (Proton's own script stays up until the game exits); the client's start-up runs do not. */ - fun gameRunning(proton: Proton): Boolean = anyProcess(proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") } + fun gameRunning(context: Context, proton: Proton): Boolean = anyProcess(context, proton) { needle, text -> text.contains(needle + "proton waitforexitandrun") } - private fun anyProcess(proton: Proton, match: (String, String) -> Boolean): Boolean { - val needle = proton.guestPath.trimEnd('/') + "/" + private fun anyProcess(context: Context, proton: Proton, match: (String, String) -> Boolean): Boolean { + val needles = (listOf(proton.guestPath) + EsyncPacks.distPathsFor(root(context), proton.guestPath)) + .map { it.trimEnd('/') + "/" } val procs = File("/proc").listFiles() ?: return false for (p in procs) { if (!p.name.all(Char::isDigit)) continue val cmd = runCatching { File(p, "cmdline").readBytes() }.getOrNull() ?: continue if (cmd.isEmpty()) continue - if (match(needle, String(cmd).replace('\u0000', ' '))) return true + val text = String(cmd).replace('\u0000', ' ') + if (needles.any { match(it, text) }) return true } return false } @@ -290,6 +308,7 @@ object ComponentsManager { } } require(File(staged, "files").isDirectory) { "${wcp.name} has no files/" } + if (comp == "fex") FEX_FILES.filter { it.endsWith(".so") && !File(staged, it).isFile }.forEach { File(dir, it).delete() } COMP_DIR[comp]?.let { compDir -> for (arch in PE_ARCHES) { val target = File(dir, "$compDir/$arch") @@ -392,6 +411,7 @@ object ComponentsManager { if (parts.size >= 2) parts[0].toLongOrNull()?.let { reapplied[parts[1]] = maxOf(reapplied[parts[1]] ?: 0, it) } } } + val syncPacks = EsyncPacks.status(root(context)) val views = protons(context).map { p -> val comps = COMPONENTS.associateWith { comp -> val detected = if (comp == "fex") fexVersion(p.dir) else readVersionFile(File(p.dir, "${COMP_DIR.getValue(comp)}/version")) @@ -414,7 +434,7 @@ object ComponentsManager { q?.optString("label"), ) } - ProtonView(p, comps, originals(context, p.id), gameRunning(p), reapplied[p.dir.name] ?: 0) + ProtonView(p, comps, originals(context, p.id), gameRunning(context, p), reapplied[p.dir.name] ?: 0, syncPacks.tool(p.guestPath)) } Snapshot(views, packages(context)) } @@ -489,7 +509,7 @@ object ComponentsManager { val wcp = File(packagesDir(context), safeName(file)) val info = packageInfo(wcp) val state = loadState(context) - if (inUse(p)) { + if (inUse(context, p)) { state.sub("queued").sub(p.id).put(info.comp, JSONObject().put("kind", "package").put("file", wcp.name).put("label", info.version)) saveState(context, state) return "${LABEL[info.comp]} ${info.version} goes into ${p.name} when the game running on it closes." @@ -515,7 +535,7 @@ object ComponentsManager { val wcp = File(originalsDir(context), "$protonId/${safeName(protonVersion)}/$comp.wcp") check(wcp.isFile) { "That original bundle is not stored" } val state = loadState(context) - if (inUse(p)) { + if (inUse(context, p)) { state.sub("queued").sub(p.id).put(comp, JSONObject().put("kind", "original").put("protonVersion", protonVersion).put("label", "Original $protonVersion")) saveState(context, state) return "${LABEL[comp]} of ${p.name} goes back to its original when the game running on it closes." @@ -545,7 +565,7 @@ object ComponentsManager { val all = protons(context) for (pid in queued.keys().asSequence().toList()) { val p = all.firstOrNull { it.id == pid } ?: run { queued.remove(pid); null } ?: continue - if (inUse(p)) continue + if (inUse(context, p)) continue val comps = queued.optJSONObject(pid) ?: continue for (comp in comps.keys().asSequence().toList()) { val q = comps.optJSONObject(comp) ?: continue diff --git a/app/src/main/java/com/droiddeck/launcher/session/EsyncPacks.kt b/app/src/main/java/com/droiddeck/launcher/session/EsyncPacks.kt new file mode 100644 index 000000000..7c1664d34 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/EsyncPacks.kt @@ -0,0 +1,941 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.core.Hashes +import com.github.luben.zstd.ZstdInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarConstants +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Files +import java.security.KeyFactory +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Signature +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +object EsyncPacks { + private const val TAG = "DroidDeckSync" + const val REPO = "Droid-Deck/DroidDeck-Components" + const val INDEX_URL = "https://github.com/$REPO/releases/download/droiddeck-esync-index/index.json" + const val SIG_URL = "$INDEX_URL.sig" + const val ASSET_PREFIX = "https://github.com/$REPO/releases/download/droiddeck-esync-" + const val PUBLIC_KEY = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFyqZQAMh3g3nsEGLU+kBGVPBEkXrDv5F3kPtuQ+FV1DkTCGmYUQ1LS9wJtHGt0Ht4wToHGGEp9yzDibYZ4/zhg==" + const val NTDLL = "files/lib/wine/aarch64-unix/ntdll.so" + const val WINESERVER = "files/bin-arm64/wineserver" + const val GUEST_STORE = "/root/.local/share/droiddeck-esync" + private const val STORE = "root/.local/share/droiddeck-esync" + private const val PACK_JSON = "pack.json" + private const val COMPLETE = ".complete" + private const val IGNORED = ".ignored" + private const val DIST_JSON = ".droiddeck-esync.json" + private const val WANTED = "wanted.tsv" + private const val TOOLS = "tools.tsv" + private const val FLOOR = "floor" + private const val PACK_FAILURES = "packFailures" + private const val PROGRESS_LABEL = "Fetching droiddeck-esync pack" + private const val INDEX_LIMIT = 4L shl 20 + private const val SIG_LIMIT = 4096L + private const val ASSET_LIMIT = 256L shl 20 + private const val PACK_JSON_LIMIT = 1L shl 20 + private const val MAX_AGE_MS = 12L * 60 * 60 * 1000 + private const val WANTED_AGE_MS = 30L * 60 * 1000 + private const val FAILURE_BACKOFF_MS = 10L * 60 * 1000 + private const val STALE_MS = 60L * 60 * 1000 + private const val PRUNE_AGE_MS = 30L * 24 * 60 * 60 * 1000 + private const val TIMEOUT_MS = 30_000 + private val ID = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,127}") + private val SHA256 = Regex("[0-9a-f]{64}") + private val ASSET_PATH = Regex("[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*") + private val TEMP = Regex("\\..+\\.tmp-[0-9a-f]{16}") + private val RETIRED = Regex("\\..+\\.del-[0-9a-f]{16}") + private val BINARIES = listOf(NTDLL, WINESERVER) + private val EXPECTED = setOf(PACK_JSON, NTDLL, WINESERVER) + private val REAL_DIRS = setOf(".", "files", "files/bin-arm64", "files/lib", "files/lib/wine", "files/lib/wine/aarch64-unix") + private val DIRECTORIES = REAL_DIRS - "." + "" + private val STOCK_LINKS = setOf("dist.lock", "files/steampipe_fixups_mtime") + + data class Asset(val url: String, val sha256: String, val size: Long) + + data class Entry( + val id: String, + val flavor: String, + val rev: Int, + val version: String, + val versionLine: String, + val stock: Map<String, String>, + val exports: String, + val sourceMatch: Boolean, + val files: Map<String, String>, + val asset: Asset, + val revoked: Boolean, + ) + + data class Index(val schema: Int, val generated: Long, val packs: List<Entry>) + + private class Signed(val bytes: ByteArray, val sig: ByteArray, val index: Index) + + data class Wanted( + val ntdll: String, + val wineserver: String, + val version: String, + val exports: String, + val toolDir: String, + val firstSeen: Long, + val inUse: Rank? = null, + val epoch: Long = 0L, + ) { + val key: String get() = "$ntdll:$wineserver" + } + + data class Rank(val kind: Int, val sameVersion: Int, val rev: Int) : Comparable<Rank> { + override fun compareTo(other: Rank): Int = compareValuesBy(this, other, { it.kind }, { it.sameVersion }, { it.rev }) + } + + data class ToolState( + val toolDir: String, + val versionLine: String, + val ntdll: String, + val wineserver: String, + val state: String, + val packId: String?, + ) + + data class Status(val installed: Int, val wanted: Int, val tools: List<ToolState>) { + fun tool(guestPath: String): ToolState? { + val path = guestPath.trimEnd('/') + return tools.firstOrNull { it.toolDir.trimEnd('/') == path } + } + } + + private val fetchLock = Any() + private val cacheLock = Any() + private val installLock = Any() + private val pending = AtomicBoolean(false) + private val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "droiddeck-esync-fetch").apply { isDaemon = true } + } + private val random = SecureRandom() + private val shippedKey: PublicKey by lazy { key(PUBLIC_KEY) } + internal var keyOverride: PublicKey? = null + private val publicKey: PublicKey get() = keyOverride ?: shippedKey + internal var fetcher: (String, Long, OutputStream, ((Long) -> Unit)?) -> Boolean = { url, limit, out, progress -> + httpFetch(url, limit, out, progress) + } + + fun store(root: File): File = File(root, STORE) + + private fun cacheDir(context: Context) = File(context.filesDir, "droiddeck-esync") + + internal fun key(base64: String): PublicKey = + KeyFactory.getInstance("EC").generatePublic(X509EncodedKeySpec(Base64.getDecoder().decode(base64))) + + fun allowedUrl(url: String): Boolean = + url.startsWith(ASSET_PREFIX) && ASSET_PATH.matches(url.substring(ASSET_PREFIX.length)) + + fun parseIndex(bytes: ByteArray): Index { + val root = JSONObject(String(bytes, Charsets.UTF_8)) + val schema = root.getInt("schema") + require(schema == 1) { "sync index schema $schema is not supported" } + val generated = root.getLong("generated") + val array = root.getJSONArray("packs") + val packs = ArrayList<Entry>() + val seen = HashSet<String>() + for (i in 0 until array.length()) { + val entry = try { + parseEntry(array.getJSONObject(i)) + } catch (e: Exception) { + Log.w(TAG, "sync index entry $i skipped: ${e.message}") + continue + } + if (seen.add(entry.id)) packs.add(entry) + } + return Index(schema, generated, packs) + } + + private fun parseEntry(o: JSONObject): Entry { + val id = o.getString("id") + require(ID.matches(id)) { "invalid id $id" } + val asset = o.getJSONObject("asset") + val url = asset.getString("url") + require(allowedUrl(url)) { "$id: asset $url is outside $ASSET_PREFIX" } + val sha256 = asset.getString("sha256").lowercase() + require(SHA256.matches(sha256)) { "$id: invalid asset sha256" } + val size = asset.getLong("size") + require(size in 1..ASSET_LIMIT) { "$id: invalid asset size $size" } + val flavor = o.getString("flavor") + require(flavor.isNotEmpty()) { "$id: no flavor" } + return Entry( + id, flavor, o.getInt("rev"), o.optString("version"), o.optString("version_line"), + hashes(o.getJSONObject("stock")), o.optString("exports").lowercase(), o.opt("source_match") == true, + hashes(o.getJSONObject("files")), Asset(url, sha256, size), o.optBoolean("revoked", false), + ) + } + + private fun hashes(o: JSONObject): Map<String, String> = BINARIES.associateWith { rel -> + o.getString(rel).lowercase().also { require(SHA256.matches(it)) { "invalid sha256 for $rel" } } + } + + fun verify(indexBytes: ByteArray, sigBytes: ByteArray): Boolean = try { + verify(indexBytes, sigBytes, publicKey) + } catch (e: Exception) { + false + } + + internal fun verify(indexBytes: ByteArray, sigBytes: ByteArray, key: PublicKey): Boolean = try { + Signature.getInstance("SHA256withECDSA").run { + initVerify(key) + update(indexBytes) + verify(sigBytes) + } + } catch (e: Exception) { + false + } + + internal fun accept(indexBytes: ByteArray, sigBytes: ByteArray, cached: Index?, key: PublicKey = publicKey, floor: Long = 0L): Index? { + if (!verify(indexBytes, sigBytes, key)) { + Log.w(TAG, "sync index signature does not verify") + return null + } + val index = try { + parseIndex(indexBytes) + } catch (e: Exception) { + Log.w(TAG, "sync index unreadable: ${e.message}") + return null + } + val minimum = maxOf(cached?.generated ?: Long.MIN_VALUE, floor) + if (index.generated < minimum) { + Log.w(TAG, "sync index ${index.generated} is older than $minimum, one already accepted; kept the cached one") + return null + } + return index + } + + internal fun kind(entry: Entry, row: Wanted): Int = when { + entry.stock[NTDLL] == row.ntdll && entry.stock[WINESERVER] == row.wineserver -> + if (entry.version != row.version && row.epoch > 0 && epochOf(entry.versionLine) > row.epoch) 0 else 2 + entry.sourceMatch && entry.version.isNotEmpty() && entry.version == row.version && + entry.exports.isNotEmpty() && entry.exports == row.exports -> 1 + else -> 0 + } + + fun matches(entry: Entry, row: Wanted): Boolean = !entry.revoked && kind(entry, row) > 0 + + internal fun epochOf(versionLine: String): Long = + versionLine.trim().split(Regex("\\s+")).firstOrNull()?.toLongOrNull()?.takeIf { it > 0 } ?: 0L + + internal fun withEpochs(wanted: List<Wanted>, tools: List<ToolState>): List<Wanted> = wanted.map { row -> + tools.firstOrNull { + it.toolDir == row.toolDir && it.ntdll.trim().lowercase() == row.ntdll && it.wineserver.trim().lowercase() == row.wineserver + }?.let { row.copy(epoch = epochOf(it.versionLine)) } ?: row + } + + internal fun rank(entry: Entry, row: Wanted): Rank = + Rank(kind(entry, row), if (entry.version.isNotEmpty() && entry.version == row.version) 1 else 0, entry.rev) + + fun best(entries: List<Entry>, row: Wanted): Entry? = entries.filter { matches(it, row) } + .maxWithOrNull(compareBy<Entry>({ rank(it, row) }, { it.id })) + + internal fun plan(index: Index, wanted: List<Wanted>, installed: Set<String>): List<Entry> = + wanted.mapNotNull { row -> best(index.packs, row)?.takeIf { row.inUse == null || rank(it, row) > row.inUse } } + .distinctBy { it.id }.filter { it.id !in installed } + + internal fun upgradeRows(store: File, tools: List<ToolState>): List<Wanted> = tools.mapNotNull { tool -> + val ntdll = tool.ntdll.trim().lowercase() + val wineserver = tool.wineserver.trim().lowercase() + if (tool.state != "pack" || !SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null + val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty() + val pack = tool.packId?.takeIf { ID.matches(it) }?.let { installedPack(store, it) } + val exports = if (pack != null && pack.opt("source_match") == true && token.isNotEmpty() && pack.opt("version") == token) + (pack.opt("exports") as? String).orEmpty() else "" + val epoch = epochOf(tool.versionLine) + val inUse = pack?.let { packRank(it, ntdll, wineserver, token, exports, epoch) }?.takeIf { it.kind > 0 } + Wanted(ntdll, wineserver, token, exports, tool.toolDir, 0L, inUse, epoch) + } + + private fun packRank(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Rank = + Rank(covers(pack, ntdll, wineserver, token, exports, epoch), if (token.isNotEmpty() && pack.opt("version") == token) 1 else 0, + pack.optInt("rev")) + + private fun installedPack(store: File, id: String): JSONObject? { + val dir = File(store, "packs/$id") + if (!File(dir, COMPLETE).isFile) return null + return readFile(File(dir, PACK_JSON), PACK_JSON_LIMIT) + ?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() } + } + + fun parseWanted(text: String): List<Wanted> = text.lineSequence().mapNotNull { line -> + val fields = line.trimEnd('\r').split('\t') + if (fields.size < 5) return@mapNotNull null + val ntdll = fields[0].trim().lowercase() + val wineserver = fields[1].trim().lowercase() + if (!SHA256.matches(ntdll) || !SHA256.matches(wineserver)) return@mapNotNull null + Wanted(ntdll, wineserver, field(fields[2]), field(fields[3]).lowercase(), fields[4], + fields.getOrNull(5)?.trim()?.toLongOrNull() ?: 0L) + }.distinctBy { it.key }.toList() + + private fun field(value: String): String = value.trim().takeIf { it != "-" }.orEmpty() + + fun parseTools(text: String): List<ToolState> = text.lineSequence().mapNotNull { line -> + val fields = line.trimEnd('\r').split('\t') + if (fields.size < 6 || fields[0].isEmpty()) return@mapNotNull null + ToolState(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5].takeIf { it.isNotEmpty() && it != "-" }) + }.toList() + + fun status(root: File): Status = try { + val store = store(root) + if (!store.isDirectory) Status(0, 0, emptyList()) + else { + val wanted = readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty() + val tools = readText(File(store, TOOLS))?.let { parseTools(it) }.orEmpty() + Status(installedIds(store).size, wanted.size, settled(store, tools, wanted)) + } + } catch (t: Throwable) { + Log.w(TAG, "sync status", t) + Status(0, 0, emptyList()) + } + + internal fun settled(store: File, tools: List<ToolState>, wanted: List<Wanted>): List<ToolState> { + if (tools.none { it.state == "wanted" }) return tools + val packs = installedIds(store).sorted() + .filter { !File(store, "packs/$it/$IGNORED").exists() } + .mapNotNull { id -> installedPack(store, id)?.let { id to it } } + val rows = wanted.associateBy { it.key } + return tools.map { tool -> + if (tool.state != "wanted") return@map tool + val ntdll = tool.ntdll.trim().lowercase() + val wineserver = tool.wineserver.trim().lowercase() + val token = tool.versionLine.trim().split(Regex("\\s+")).getOrNull(1).orEmpty() + val exports = rows["$ntdll:$wineserver"]?.exports.orEmpty() + val epoch = epochOf(tool.versionLine) + val match = packs.map { (id, pack) -> Triple(id, pack, packRank(pack, ntdll, wineserver, token, exports, epoch)) } + .filter { it.third.kind > 0 } + .maxWithOrNull(compareBy({ it.third }, { it.first })) + if (match == null) tool else tool.copy(state = "pack", packId = match.first) + } + } + + private fun covers(pack: JSONObject, ntdll: String, wineserver: String, token: String, exports: String, epoch: Long): Int { + val stock = pack.optJSONObject("stock") + val version = pack.opt("version") as? String + if (stock != null && stock.optString(NTDLL) == ntdll && stock.optString(WINESERVER) == wineserver) { + return if (version != token && epoch > 0 && epochOf(pack.optString("version_line")) > epoch) 0 else 2 + } + val packExports = pack.opt("exports") as? String + return if (pack.opt("source_match") == true && token.isNotEmpty() && version == token && + exports.isNotEmpty() && packExports == exports) 1 else 0 + } + + fun distPathsFor(root: File, stockGuestPath: String): List<String> = try { + val wanted = stockGuestPath.trimEnd('/') + if (wanted.isEmpty()) emptyList() + else File(store(root), "dist").listFiles().orEmpty() + .filter { !it.name.startsWith(".") && it.isDirectory } + .mapNotNull { dir -> + val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() } + ?: return@mapNotNull null + val stock = record.optString("stock").trimEnd('/') + val tool = record.optString("tool").trimEnd('/') + if (stock == wanted || tool == wanted) "$GUEST_STORE/dist/${dir.name}" else null + } + .sorted() + } catch (t: Throwable) { + Log.w(TAG, "sync dists", t) + emptyList() + } + + fun stageBundled(context: Context, root: File) { + try { + val store = store(root) + if (!store.isDirectory && !store.mkdirs()) { + Log.w(TAG, "could not create $store") + return + } + val bundled = bundledIndex(context) + val trusted = if (bundled == null) cachedIndex(context) + else storeIndex(context, bundled.bytes, bundled.sig, bundled.index, newerOnly = true) + if (trusted != null) removeRevoked(store, trusted) + if (bundled == null) return + if (trusted == null) { + Log.w(TAG, "the bundled sync index is older than one already accepted; bundled packs skipped") + return + } + val revoked = (bundled.index.packs + trusted.packs).filter { it.revoked }.map { it.id }.toSet() + val shipped = context.assets.list("droiddeck-esync/packs")?.toSet().orEmpty() + val missing = bundled.index.packs.filter { + it.id !in revoked && "${it.id}.tzst" in shipped && !File(store, "packs/${it.id}/$COMPLETE").isFile + } + if (missing.isEmpty()) return + sweep(context, store) + for (entry in missing) installBundled(context, store, entry) + } catch (t: Throwable) { + Log.w(TAG, "bundled sync packs", t) + } + } + + private fun bundledIndex(context: Context): Signed? { + val bundle = context.assets.list("droiddeck-esync")?.toSet().orEmpty() + if ("index.json" !in bundle || "index.json.sig" !in bundle) return null + val bytes = context.assets.open("droiddeck-esync/index.json").use { readBounded(it, INDEX_LIMIT) } + val sig = context.assets.open("droiddeck-esync/index.json.sig").use { readBounded(it, SIG_LIMIT) } + if (!verify(bytes, sig)) { + Log.w(TAG, "the bundled sync index does not verify; bundled packs skipped") + return null + } + return Signed(bytes, sig, parseIndex(bytes)) + } + + private fun installBundled(context: Context, store: File, entry: Entry) { + val copy = try { + tempFile(context, entry.id) + } catch (e: IOException) { + Log.w(TAG, "bundled sync pack ${entry.id}", e) + return + } + try { + context.assets.open("droiddeck-esync/packs/${entry.id}.tzst").use { input -> + FileOutputStream(copy).use { output -> copyBounded(input, output, entry.asset.size) } + } + if (install(store, entry, copy)) { + Log.i(TAG, "sync pack ${entry.id} installed from the app") + SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "installed")) + } + } catch (t: Throwable) { + Log.w(TAG, "bundled sync pack ${entry.id}", t) + SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "bundled", "result" to "failed", "reason" to t.message)) + } finally { + copy.delete() + } + } + + fun fetchInBackground(context: Context, root: File) { + val app = context.applicationContext ?: context + if (!pending.compareAndSet(false, true)) return + try { + executor.execute { + pending.set(false) + fetchWanted(app, root) + } + } catch (t: Throwable) { + pending.set(false) + Log.w(TAG, "could not schedule a sync fetch", t) + } + } + + fun enabled(context: Context): Boolean = SessionPrefs.syncFallback(context) || try { + GameEnvironmentStore.read(context).let { config -> + (listOf(config.shared) + config.games.values).any { it["BL_SYNC_FALLBACK"] == "1" } + } + } catch (t: Throwable) { + false + } + + fun fetchWanted(context: Context, root: File, onProgress: ((String, Int) -> Unit)? = null): Int = synchronized(fetchLock) { + try { + val store = store(root) + if (!store.isDirectory) return 0 + cachedIndex(context)?.let { removeRevoked(store, it) } + if (!enabled(context)) return 0 + sweep(context, store) + val toolsText = readText(File(store, TOOLS)) + val tools = toolsText?.let { parseTools(it) }.orEmpty() + val wanted = withEpochs(readText(File(store, WANTED))?.let { parseWanted(it) }.orEmpty(), tools) + val index = refreshIndex(context, wanted) ?: return 0 + removeRevoked(store, index) + val rows = wanted + upgradeRows(store, tools) + if (toolsText != null) prune(store, keep(store, index, rows, tools, bundledIds(context)), System.currentTimeMillis()) + val planned = plan(index, rows, installedIds(store)) + val state = loadState(context) + val previous = state.optJSONObject(PACK_FAILURES) + val failures = pruneFailures(previous, index) + val missing = planned.filter { !failedRecently(failures, it, System.currentTimeMillis()) } + var added = 0 + for (entry in missing) { + if (download(context, store, entry, onProgress)) { + added++ + failures.remove(entry.id) + } else { + failures.put(entry.id, failure(failures, entry, System.currentTimeMillis())) + } + } + val kept = failures.takeIf { it.length() > 0 } + if (kept?.toString() != previous?.toString()) { + if (kept != null) state.put(PACK_FAILURES, kept) else state.remove(PACK_FAILURES) + saveState(context, state) + } + if (planned.size > missing.size) Log.i(TAG, "sync: ${planned.size - missing.size} pack(s) wait after failed downloads") + if (missing.isNotEmpty()) Log.i(TAG, "sync: $added of ${missing.size} wanted pack(s) installed") + added + } catch (t: Throwable) { + Log.w(TAG, "fetching sync packs", t) + 0 + } + } + + fun refreshIndex(context: Context, wanted: List<Wanted> = emptyList()): Index? = synchronized(fetchLock) { + val cached = cachedIndex(context) + val state = loadState(context) + val now = System.currentTimeMillis() + if (!due(cached, state, wanted, now)) return cached + val bytes = fetchBytes(INDEX_URL, INDEX_LIMIT) + val sig = if (bytes != null) fetchBytes(SIG_URL, SIG_LIMIT) else null + val fresh = if (bytes != null && sig != null) accept(bytes, sig, cached, floor = floor(context)) else null + if (bytes == null || sig == null || fresh == null) { + state.put("failedAt", now) + saveState(context, state) + return cached + } + val trusted = storeIndex(context, bytes, sig, fresh, newerOnly = false) ?: return cached + state.remove("failedAt") + state.put("checkedAt", now) + state.put("misses", misses(state.optJSONObject("misses"), trusted, wanted, now)) + saveState(context, state) + Log.i(TAG, "sync index ${trusted.generated}: ${trusted.packs.size} pack(s)") + trusted + } + + internal fun due(cached: Index?, state: JSONObject, wanted: List<Wanted>, now: Long): Boolean { + val failed = state.optLong("failedAt", 0L) + if (failed > 0 && now - failed in 0 until FAILURE_BACKOFF_MS) return false + val checked = state.optLong("checkedAt", 0L) + if (cached == null || checked <= 0 || now < checked || now - checked >= MAX_AGE_MS) return true + val misses = state.optJSONObject("misses") + return wanted.any { row -> + if (cached.packs.any { matches(it, row) }) return@any false + val miss = misses?.optJSONObject(row.key) + val count = miss?.optInt("n", 0) ?: 0 + if (count <= 0) now - checked >= WANTED_AGE_MS + else now - (miss?.optLong("at", 0L) ?: 0L) >= backoff(count) + } + } + + private fun backoff(misses: Int): Long = minOf(WANTED_AGE_MS shl minOf(misses - 1, 5), MAX_AGE_MS) + + private fun packBackoff(failures: Int): Long = minOf(FAILURE_BACKOFF_MS shl minOf(failures - 1, 7), MAX_AGE_MS) + + internal fun pruneFailures(previous: JSONObject?, index: Index): JSONObject { + val out = JSONObject() + if (previous == null) return out + val assets = index.packs.associate { it.id to it.asset.sha256 } + for (id in previous.keys()) { + val record = previous.optJSONObject(id) ?: continue + if (assets[id] == record.optString("sha")) out.put(id, record) + } + return out + } + + internal fun failedRecently(failures: JSONObject?, entry: Entry, now: Long): Boolean { + val record = failures?.optJSONObject(entry.id) ?: return false + if (record.optString("sha") != entry.asset.sha256) return false + val count = record.optInt("n", 0) + return count > 0 && now - record.optLong("at", 0L) in 0 until packBackoff(count) + } + + internal fun failure(failures: JSONObject, entry: Entry, now: Long): JSONObject { + val record = failures.optJSONObject(entry.id)?.takeIf { it.optString("sha") == entry.asset.sha256 } + return JSONObject().put("sha", entry.asset.sha256).put("n", (record?.optInt("n", 0) ?: 0) + 1).put("at", now) + } + + private fun misses(previous: JSONObject?, index: Index, wanted: List<Wanted>, now: Long): JSONObject { + val out = JSONObject() + for (row in wanted) { + if (index.packs.any { matches(it, row) }) continue + val count = previous?.optJSONObject(row.key)?.optInt("n", 0) ?: 0 + out.put(row.key, JSONObject().put("n", count + 1).put("at", now)) + } + return out + } + + private fun loadState(context: Context): JSONObject = synchronized(cacheLock) { + readFile(File(cacheDir(context), "state.json"), PACK_JSON_LIMIT) + ?.let { runCatching { JSONObject(String(it, Charsets.UTF_8)) }.getOrNull() } ?: JSONObject() + } + + private fun saveState(context: Context, state: JSONObject) = synchronized(cacheLock) { + try { + writeAtomic(File(cacheDir(context).apply { mkdirs() }, "state.json"), state.toString().toByteArray(Charsets.UTF_8)) + } catch (e: IOException) { + Log.w(TAG, "sync state", e) + } + } + + private fun cachedIndex(context: Context): Index? = synchronized(cacheLock) { + val dir = cacheDir(context) + val bytes = readFile(File(dir, "index.json"), INDEX_LIMIT) ?: return null + val sig = readFile(File(dir, "index.json.sig"), SIG_LIMIT) ?: return null + if (!verify(bytes, sig)) { + Log.w(TAG, "the cached sync index does not verify; ignoring it") + return null + } + runCatching { parseIndex(bytes) }.getOrNull() + } + + private fun floor(context: Context): Long = synchronized(cacheLock) { + readFile(File(cacheDir(context), FLOOR), 64)?.toString(Charsets.UTF_8)?.trim()?.toLongOrNull() ?: 0L + } + + private fun storeIndex(context: Context, bytes: ByteArray, sig: ByteArray, index: Index, newerOnly: Boolean): Index? = synchronized(cacheLock) { + val cached = cachedIndex(context) + val floor = floor(context) + if (index.generated < floor) return cached + if (cached != null && (cached.generated > index.generated || newerOnly && cached.generated >= index.generated)) return cached + try { + val dir = cacheDir(context).apply { mkdirs() } + if (index.generated > floor) writeAtomic(File(dir, FLOOR), index.generated.toString().toByteArray(Charsets.UTF_8)) + writeAtomic(File(dir, "index.json.sig"), sig) + writeAtomic(File(dir, "index.json"), bytes) + } catch (e: IOException) { + Log.w(TAG, "could not cache the sync index", e) + } + index + } + + private fun download(context: Context, store: File, entry: Entry, onProgress: ((String, Int) -> Unit)?): Boolean { + if (!allowedUrl(entry.asset.url)) { + Log.w(TAG, "sync pack ${entry.id}: ${entry.asset.url} is outside $ASSET_PREFIX") + return false + } + val part = try { + tempFile(context, entry.id) + } catch (e: IOException) { + Log.w(TAG, "sync pack ${entry.id}", e) + return false + } + try { + onProgress?.invoke(PROGRESS_LABEL, -1) + var reported = -1 + val fetched = FileOutputStream(part).use { output -> + fetch(entry.asset.url, entry.asset.size, output) { done -> + val percent = (done * 100 / entry.asset.size).toInt().coerceIn(0, 100) + if (percent != reported) { + reported = percent + onProgress?.invoke(PROGRESS_LABEL, percent) + } + } + } + if (!fetched) { + SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to "download")) + return false + } + if (install(store, entry, part)) { + Log.i(TAG, "sync pack ${entry.id} downloaded and installed") + SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "installed")) + } + return true + } catch (t: Throwable) { + Log.w(TAG, "sync pack ${entry.id}", t) + SessionEvents.record("sync.pack", mapOf("id" to entry.id, "source" to "download", "result" to "failed", "reason" to t.message)) + return false + } finally { + part.delete() + } + } + + internal fun install(store: File, entry: Entry, archive: File): Boolean { + require(archive.length() == entry.asset.size) { "${entry.id}: ${archive.length()} bytes, the index says ${entry.asset.size}" } + require(Hashes.sha256(archive) == entry.asset.sha256) { "${entry.id}: the archive does not match the index checksum" } + return TarArchiveInputStream(ZstdInputStream(BufferedInputStream(FileInputStream(archive), 1 shl 16))).use { + place(store, entry, it) + } + } + + internal fun place(store: File, entry: Entry, tar: TarArchiveInputStream): Boolean { + val packs = File(store, "packs") + if (!packs.isDirectory && !packs.mkdirs()) throw IOException("could not create $packs") + val temp = File(packs, ".${entry.id}.tmp-${token()}") + try { + if (!temp.mkdir()) throw IOException("could not create $temp") + unpack(tar, temp) + val manifest = File(temp, PACK_JSON) + checkPack(JSONObject(manifest.readText()), entry) + for (rel in BINARIES) { + val file = File(temp, rel) + require(Hashes.sha256(file) == entry.files[rel]) { "${entry.id}: $rel does not match the index" } + if (!file.setReadable(true, false) || !file.setExecutable(true, false)) throw IOException("could not mark $rel executable") + } + manifest.setReadable(true, false) + if (!File(temp, COMPLETE).createNewFile()) throw IOException("could not mark ${entry.id} complete") + return commit(packs, temp, entry.id) + } finally { + if (temp.exists()) FileUtils.delete(temp) + } + } + + private fun commit(packs: File, temp: File, id: String): Boolean = synchronized(installLock) { + val target = File(packs, id) + if (File(target, COMPLETE).isFile) return false + if (target.exists() || isLink(target)) retire(packs, target) + if (!temp.renameTo(target)) throw IOException("could not move sync pack $id into place") + true + } + + internal fun checkPack(pack: JSONObject, entry: Entry) { + require(pack.opt("format") == 1) { "${entry.id}: pack.json format ${pack.opt("format")}" } + require(pack.opt("id") == entry.id) { "pack.json names ${pack.opt("id")}, the index ${entry.id}" } + require(pack.opt("flavor") == entry.flavor) { "${entry.id}: flavor differs from the index" } + require(pack.opt("rev") == entry.rev) { "${entry.id}: rev differs from the index" } + require(pack.opt("version") == entry.version) { "${entry.id}: version differs from the index" } + require(!pack.has("version_line") || pack.opt("version_line") is String) { "${entry.id}: version_line is not a string" } + require(strictHashes(pack.opt("stock")) == entry.stock) { "${entry.id}: stock hashes differ from the index" } + require(pack.opt("exports") == entry.exports) { "${entry.id}: exports differ from the index" } + require(pack.opt("source_match") == entry.sourceMatch) { "${entry.id}: source_match differs from the index" } + require(strictHashes(pack.opt("files")) == entry.files) { "${entry.id}: file hashes differ from the index" } + require(!pack.has("copy") || validCopy(pack.opt("copy"))) { "${entry.id}: invalid copy list" } + require(!truthy(pack.opt("revoked")) && !truthy(pack.opt("ignored"))) { "${entry.id}: pack.json is revoked" } + } + + private fun strictHashes(value: Any?): Map<String, String>? { + if (value !is JSONObject) return null + return BINARIES.associateWith { rel -> (value.opt(rel) as? String)?.takeIf { SHA256.matches(it) } ?: return null } + } + + private fun validCopy(value: Any?): Boolean = + value is JSONArray && (0 until value.length()).all { copyPath(value.opt(it)) } + + internal fun copyPath(value: Any?): Boolean { + if (value !is String || value.isEmpty() || value.startsWith("/") || '\u0000' in value) return false + if (value.split('/').any { it.isEmpty() || it == "." || it == ".." }) return false + return value.substringBeforeLast('/', ".") in REAL_DIRS && value !in BINARIES && value !in REAL_DIRS && + value !in STOCK_LINKS && value != DIST_JSON + } + + private fun truthy(value: Any?): Boolean = when (value) { + null, JSONObject.NULL -> false + is Boolean -> value + is Number -> value.toDouble() != 0.0 + is String -> value.isNotEmpty() + is JSONArray -> value.length() > 0 + is JSONObject -> value.length() > 0 + else -> true + } + + internal fun unpack(tar: TarArchiveInputStream, dest: File) { + val seen = HashSet<String>() + while (true) { + val entry = tar.nextEntry ?: break + val rel = entryPath(entry) ?: continue + require(seen.add(rel)) { "$rel appears twice" } + val limit = if (rel == PACK_JSON) PACK_JSON_LIMIT else ASSET_LIMIT + require(entry.size in 0..limit) { "$rel is too large" } + val out = File(dest, rel) + out.parentFile?.let { if (!it.isDirectory && !it.mkdirs()) throw IOException("could not create $it") } + FileOutputStream(out).use { + copyBounded(tar, it, limit) + it.fd.sync() + } + } + require(seen == EXPECTED) { "the pack lacks ${(EXPECTED - seen).joinToString()}" } + } + + internal fun entryPath(entry: TarArchiveEntry): String? { + val name = entry.name + require(!name.startsWith("/")) { "absolute path $name" } + val parts = name.split('/').filter { it.isNotEmpty() }.let { if (it.firstOrNull() == ".") it.drop(1) else it } + require(parts.none { it == "." || it == ".." }) { "unsafe path $name" } + require(!entry.isSymbolicLink && !entry.isLink) { "link entry $name" } + require(!entry.isCharacterDevice && !entry.isBlockDevice && !entry.isFIFO) { "device entry $name" } + val rel = parts.joinToString("/") + if (entry.isDirectory) { + require(rel in DIRECTORIES) { "unexpected directory $name" } + return null + } + require(entry.linkFlag == TarConstants.LF_NORMAL || entry.linkFlag == TarConstants.LF_OLDNORM) { "unsupported entry type for $name" } + require(!entry.isSparse) { "sparse entry $name" } + require(rel in EXPECTED) { "unexpected entry $name" } + return rel + } + + internal fun installedIds(store: File): Set<String> = File(store, "packs").listFiles().orEmpty() + .filter { ID.matches(it.name) && File(it, COMPLETE).isFile } + .map { it.name } + .toSet() + + internal fun removeRevoked(store: File, index: Index): List<String> { + val revoked = index.packs.filter { it.revoked }.map { it.id }.toSet() + if (revoked.isEmpty()) return emptyList() + val packs = File(store, "packs") + val removed = ArrayList<String>() + synchronized(installLock) { + for (id in revoked) { + val dir = File(packs, id) + if (!dir.exists() && !isLink(dir)) continue + retire(packs, dir) + removed.add(id) + } + } + for (id in removed) { + Log.i(TAG, "sync pack $id was revoked; removed") + SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "revoked")) + } + return removed + } + + private fun bundledIds(context: Context): Set<String> = + context.assets.list("droiddeck-esync/packs").orEmpty().filter { it.endsWith(".tzst") }.map { it.removeSuffix(".tzst") }.toSet() + + internal fun keep(store: File, index: Index, rows: List<Wanted>, tools: List<ToolState>, bundled: Set<String>): Set<String> { + val ids = HashSet(bundled) + tools.mapNotNullTo(ids) { it.packId } + rows.mapNotNullTo(ids) { best(index.packs, it)?.id } + File(store, "dist").listFiles().orEmpty().filter { it.isDirectory }.forEach { dir -> + val record = readText(File(dir, DIST_JSON))?.let { runCatching { JSONObject(it) }.getOrNull() } + (record?.opt("pack") as? String)?.let { ids.add(it) } + } + return ids + } + + internal fun prune(store: File, keep: Set<String>, now: Long): List<String> { + val packs = File(store, "packs") + val removed = ArrayList<String>() + synchronized(installLock) { + for (id in installedIds(store).sorted()) { + val dir = File(packs, id) + if (id in keep || now - dir.lastModified() < PRUNE_AGE_MS) continue + retire(packs, dir) + removed.add(id) + } + } + for (id in removed) { + Log.i(TAG, "sync pack $id is no longer used; removed") + SessionEvents.record("sync.pack", mapOf("id" to id, "result" to "pruned")) + } + return removed + } + + private fun retire(packs: File, dir: File) { + val gone = File(packs, ".${dir.name}.del-${token()}") + if (dir.renameTo(gone)) FileUtils.delete(gone) else FileUtils.delete(dir) + } + + private fun sweep(context: Context, store: File) { + val now = System.currentTimeMillis() + synchronized(installLock) { + File(store, "packs").listFiles()?.forEach { file -> + if (RETIRED.matches(file.name) || TEMP.matches(file.name) && now - file.lastModified() > STALE_MS) FileUtils.delete(file) + } + } + File(cacheDir(context), "tmp").listFiles()?.forEach { file -> + if (now - file.lastModified() > STALE_MS) FileUtils.delete(file) + } + } + + private fun fetchBytes(url: String, limit: Long): ByteArray? { + val out = ByteArrayOutputStream() + return if (fetch(url, limit, out, null)) out.toByteArray() else null + } + + private fun fetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean = + fetcher(url, limit, out, progress) + + private fun httpFetch(url: String, limit: Long, out: OutputStream, progress: ((Long) -> Unit)?): Boolean { + var connection: HttpURLConnection? = null + try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + instanceFollowRedirects = true + setRequestProperty("User-Agent", "DroidDeck-Android") + } + val code = connection.responseCode + if (code / 100 != 2) { + Log.w(TAG, "$url -> HTTP $code") + return false + } + if (connection.contentLengthLong > limit) { + Log.w(TAG, "$url is ${connection.contentLengthLong} bytes, more than $limit") + return false + } + connection.inputStream.use { input -> + val buffer = ByteArray(1 shl 16) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + if (total > limit) { + Log.w(TAG, "$url sent more than $limit bytes") + return false + } + out.write(buffer, 0, n) + progress?.invoke(total) + } + } + return true + } catch (e: Exception) { + Log.w(TAG, "GET $url: ${e.message}") + return false + } finally { + connection?.disconnect() + } + } + + private fun copyBounded(input: InputStream, out: OutputStream, limit: Long): Long { + val buffer = ByteArray(1 shl 16) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) return total + total += n + if (total > limit) throw IOException("more than $limit bytes") + out.write(buffer, 0, n) + } + } + + private fun readBounded(input: InputStream, limit: Long): ByteArray { + val out = ByteArrayOutputStream() + copyBounded(input, out, limit) + return out.toByteArray() + } + + private fun readFile(file: File, limit: Long): ByteArray? = try { + if (!file.isFile || file.length() > limit) null else file.readBytes() + } catch (e: IOException) { + null + } + + private fun readText(file: File): String? = readFile(file, INDEX_LIMIT)?.toString(Charsets.UTF_8) + + private fun writeAtomic(file: File, bytes: ByteArray) { + val temp = File(file.parentFile, ".${file.name}.tmp-${token()}") + try { + temp.writeBytes(bytes) + if (!temp.renameTo(file)) throw IOException("could not replace $file") + } finally { + if (temp.exists()) temp.delete() + } + } + + private fun tempFile(context: Context, id: String): File { + val dir = File(cacheDir(context), "tmp") + if (!dir.isDirectory && !dir.mkdirs()) throw IOException("could not create $dir") + return File(dir, "$id.${token()}.tzst") + } + + private fun isLink(file: File): Boolean = try { + Files.isSymbolicLink(file.toPath()) + } catch (e: Exception) { + false + } + + private fun token(): String { + val bytes = ByteArray(8) + synchronized(random) { random.nextBytes(bytes) } + return bytes.joinToString("") { "%02x".format(it.toInt() and 0xff) } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt index 12c7bba36..e69fa1b6c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameEnvironmentStore.kt @@ -11,7 +11,7 @@ import java.io.File object GameEnvironmentStore { private const val SETTINGS = "game-environment.json" private const val GUEST_FILE = "root/.config/droiddeck/game-environment.json" - /** The published file's extra DXVK options (bannerlator-game-env reads it; the app's own file has none). */ + /** The published file's extra DXVK options (droiddeck-game-env reads it; the app's own file has none). */ const val DXVK_CONFIG = "dxvkConfig" @Synchronized diff --git a/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt b/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt index 6c1c44700..028091e44 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/OverlayShown.kt @@ -4,7 +4,7 @@ import java.io.File /** * Whether Deck mode's performance overlay is on screen. The client sets its level by rewriting - * mangoapp's config (bannerlator-session makes it, `/tmp/mangohud.*`), and `no_display` is the + * mangoapp's config (droiddeck-session makes it, `/tmp/mangohud.*`), and `no_display` is the * level that hides it. The feeds whose reads cost something - the fan's tachometer busy-waits in * the kernel, GPU memory walks every process's mappings - only run while it shows. */ diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt new file mode 100644 index 000000000..12a517e08 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonDefault.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.session + +import android.content.Context +import android.util.Log +import com.droiddeck.launcher.runtime.LinuxRuntime +import org.json.JSONObject +import java.io.File + +object ProtonDefault { + private const val TAG = "ProtonDefault" + private const val DIR = "root/.local/share/droiddeck-compat" + + data class Tool(val name: String, val display: String, val dir: String, val valve: Boolean) + data class Request(val seq: Long, val valve: Boolean, val dir: String) + data class State(val default: String?, val applied: Long, val live: Boolean, val pending: String?, val tools: List<Tool>) + + enum class Outcome { LIVE, NEXT_START, NOT_RUNNABLE } + + private fun dir(context: Context) = File(LinuxRuntime.rootDir(context), DIR) + + internal fun parseState(text: String?): State { + val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return State(null, 0, false, null, emptyList()) + val tools = o.optJSONArray("tools") + return State( + default = o.optString("default").ifEmpty { null }, + applied = o.optLong("applied", 0), + live = o.optBoolean("live", false), + pending = o.optString("pending").ifEmpty { null }, + tools = (0 until (tools?.length() ?: 0)).mapNotNull { i -> + tools?.optJSONObject(i)?.let { t -> + Tool(t.optString("name"), t.optString("display"), t.optString("dir"), t.optBoolean("valve")) + }?.takeIf { it.name.isNotEmpty() } + }, + ) + } + + internal fun parseRequest(text: String?): Request? { + val o = runCatching { JSONObject(text ?: "") }.getOrNull() ?: return null + val seq = o.optLong("seq", 0) + val dir = o.optString("dir") + return if (seq > 0 && dir.isNotEmpty()) Request(seq, o.optBoolean("valve"), dir) else null + } + + internal fun chosen(state: State, request: Request?): Pair<Boolean, String>? { + if (request != null && request.seq > state.applied) return request.valve to request.dir + val tool = state.tools.firstOrNull { it.name == state.default } ?: return null + return tool.valve to tool.dir + } + + internal fun runnable(state: State, proton: ComponentsManager.Proton): Boolean = + state.tools.isEmpty() || state.tools.any { it.valve == proton.valve && it.dir == proton.dir.name } + + private fun read(file: File): String? = runCatching { file.readText() }.getOrNull() + + fun state(context: Context): State = parseState(read(File(dir(context), "state.json"))) + + fun selectedId(context: Context, protons: List<ComponentsManager.Proton>): String? { + val d = dir(context) + val pick = chosen(parseState(read(File(d, "state.json"))), parseRequest(read(File(d, "request.json")))) ?: return null + return protons.firstOrNull { it.valve == pick.first && it.dir.name == pick.second }?.id + } + + internal fun nextSeq(now: Long, state: State, previous: Request?): Long = + maxOf(now, state.applied + 1, (previous?.seq ?: 0) + 1) + + @Synchronized + fun request(context: Context, proton: ComponentsManager.Proton): Outcome { + val state = state(context) + if (!runnable(state, proton)) return Outcome.NOT_RUNNABLE + val d = dir(context) + val seq = nextSeq(System.currentTimeMillis(), state, parseRequest(read(File(d, "request.json")))) + val body = JSONObject().put("seq", seq).put("valve", proton.valve).put("dir", proton.dir.name).toString(1) + "\n" + val staged = File(d, "request.json.staged") + try { + d.mkdirs() + staged.writeText(body) + if (!staged.renameTo(File(d, "request.json"))) throw java.io.IOException("rename failed") + } catch (e: Exception) { + staged.delete() + Log.w(TAG, "could not write the Proton request", e) + throw e + } + val tool = state.tools.firstOrNull { it.valve == proton.valve && it.dir == proton.dir.name } + return if (SessionState.running && state.live && (tool == null || state.pending != tool.name)) Outcome.LIVE else Outcome.NEXT_START + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 2a94134b8..7340d2048 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -131,7 +131,7 @@ object ProtonExtras { val guest = listOf( "/usr/bin/env", "-i", "HOME=/root", "USER=root", "PATH=/usr/local/bin:/usr/bin:/bin", "LANG=C.UTF-8", "XDG_DATA_HOME=/root/.local/share", - "/usr/local/bin/bannerlator-proton-extra", "/root/.local/share/Steam", archive.absolutePath, + "/usr/local/bin/droiddeck-proton-extra", "/root/.local/share/Steam", archive.absolutePath, ) val command = LinuxRuntime.command(context, null, runtimeDir, null, guest) val process = ProcessBuilder(command) @@ -155,6 +155,14 @@ object ProtonExtras { else { archive.delete() unqueue(context, tool) + if (EsyncPacks.enabled(context)) { + onProgress("Fetching droiddeck-esync pack", -1) + try { + EsyncPacks.fetchWanted(context, root, onProgress) + } catch (t: Throwable) { + Log.w(TAG, "sync pack for ${tool.name}", t) + } + } null } } catch (e: Exception) { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt index 78760a5fb..74b7448ff 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SecondaryLibrary.kt @@ -13,6 +13,8 @@ object SecondaryLibrary { "4185400" to "SteamLinuxRuntime_4-arm64", "4427310" to "Proton Experimental (ARM64)", "4628740" to "Proton 11.0 (ARM64)", + "1628350" to "SteamLinuxRuntime_sniper", + "3127680" to "FEX-Emu", ) fun privateRoot(files: File, library: File): File { @@ -32,7 +34,7 @@ object SecondaryLibrary { val id = Regex("\"appid\"\\s+\"(\\d+)\"").find(text)?.groupValues?.get(1) val dir = Regex("\"installdir\"\\s+\"([^\"]+)\"").find(text)?.groupValues?.get(1) if (id != null && dir != null && File(dir).name == dir && - (dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime"))) tools[id] = dir + (dir.startsWith("Proton ") || dir.startsWith("SteamLinuxRuntime") || id in bootstrapTools)) tools[id] = dir } val overrides = ArrayList<Pair<File, String>>() for (name in listOf("compatdata", "shadercache")) { 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 20da7e67f..1a0d935cd 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -3,7 +3,9 @@ package com.droiddeck.launcher.session import android.content.Context import android.os.Environment import android.util.Log +import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils +import com.droiddeck.launcher.runtime.LegacyNames import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.ProotFastPath import java.io.File @@ -27,35 +29,38 @@ object SessionFiles { fun stage(context: Context, root: File) { GameEnvironmentStore.publish(context) val files = arrayOf( - "usr/local/bin/bannerlator-game-env" to "usr/local/bin/bannerlator-game-env", + "usr/local/bin/droiddeck-game-env" to "usr/local/bin/droiddeck-game-env", + "usr/local/bin/droiddeck-esync" to "usr/local/bin/droiddeck-esync", + "usr/local/bin/droiddeck-steam-compat" to "usr/local/bin/droiddeck-steam-compat", + "usr/local/bin/droiddeck-fex" to "usr/local/bin/droiddeck-fex", "libblsession.so" to "usr/local/lib/libblsession.so", "libfakeinput.so" to "usr/local/lib/libfakeinput.so", "libblfastpath.so" to "usr/local/lib/libblfastpath.so", - "usr/local/bin/bannerlator-session" to "usr/local/bin/bannerlator-session", + "usr/local/bin/droiddeck-session" to "usr/local/bin/droiddeck-session", + "usr/local/bin/steam-compatibility" to "usr/local/bin/steam-compatibility", "usr/local/bin/droiddeck-clipboard" to "usr/local/bin/droiddeck-clipboard", - "usr/local/bin/bannerlator-steam-compat" to "usr/local/bin/bannerlator-steam-compat", - "usr/local/bin/bannerlator-steam-install" to "usr/local/bin/bannerlator-steam-install", - "usr/local/bin/bannerlator-steam-ui-scale" to "usr/local/bin/bannerlator-steam-ui-scale", - "usr/local/bin/bannerlator-steam-library" to "usr/local/bin/bannerlator-steam-library", - "usr/local/bin/bannerlator-seed-redists" to "usr/local/bin/bannerlator-seed-redists", - "usr/local/bin/bannerlator-proton-extra" to "usr/local/bin/bannerlator-proton-extra", - "usr/local/bin/bannerlator-netmanager" to "usr/local/bin/bannerlator-netmanager", - "usr/local/bin/bannerlator-login1" to "usr/local/bin/bannerlator-login1", - "usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch", - "usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games", - "usr/local/bin/bannerlator-desktop-bookmarks" to "usr/local/bin/bannerlator-desktop-bookmarks", - "usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim", - "usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts", - "usr/local/bin/bannerlator-steam-games" to "usr/local/bin/bannerlator-steam-games", - "usr/local/bin/bannerlator-pad-defaults" to "usr/local/bin/bannerlator-pad-defaults", + "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-library" to "usr/local/bin/droiddeck-steam-library", + "usr/local/bin/droiddeck-seed-redists" to "usr/local/bin/droiddeck-seed-redists", + "usr/local/bin/droiddeck-proton-extra" to "usr/local/bin/droiddeck-proton-extra", + "usr/local/bin/droiddeck-netmanager" to "usr/local/bin/droiddeck-netmanager", + "usr/local/bin/droiddeck-login1" to "usr/local/bin/droiddeck-login1", + "usr/local/bin/droiddeck-steam-launch" to "usr/local/bin/droiddeck-steam-launch", + "usr/local/bin/droiddeck-desktop-games" to "usr/local/bin/droiddeck-desktop-games", + "usr/local/bin/droiddeck-desktop-bookmarks" to "usr/local/bin/droiddeck-desktop-bookmarks", + "usr/local/bin/droiddeck-steam-shim" to "usr/local/bin/droiddeck-steam-shim", + "usr/local/bin/droiddeck-steam-shortcuts" to "usr/local/bin/droiddeck-steam-shortcuts", + "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", // Flatpak: the bwrap stand-in, the store's helper and setup, and the front end's launcher. - "usr/local/bin/bannerlator-bwrap" to "usr/local/bin/bannerlator-bwrap", - "usr/local/bin/bannerlator-flatpak" to "usr/local/bin/bannerlator-flatpak", - "usr/local/bin/bannerlator-flatpak-setup" to "usr/local/bin/bannerlator-flatpak-setup", - "usr/local/bin/bannerlator-flatpak-run" to "usr/local/bin/bannerlator-flatpak-run", + "usr/local/bin/droiddeck-bwrap" to "usr/local/bin/droiddeck-bwrap", + "usr/local/bin/droiddeck-flatpak" to "usr/local/bin/droiddeck-flatpak", + "usr/local/bin/droiddeck-flatpak-setup" to "usr/local/bin/droiddeck-flatpak-setup", + "usr/local/bin/droiddeck-flatpak-run" to "usr/local/bin/droiddeck-flatpak-run", // The user's own AppImages, extracted at import (AppImageManager). - "usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-run", - "usr/local/bin/bannerlator-script-run" to "usr/local/bin/bannerlator-script-run", + "usr/local/bin/droiddeck-appimage-run" to "usr/local/bin/droiddeck-appimage-run", + "usr/local/bin/droiddeck-script-run" to "usr/local/bin/droiddeck-script-run", // The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus // the three under /usr/bin, all no-ops that answer "nothing to do" (see each file). "usr/bin/steamos-update" to "usr/bin/steamos-update", @@ -102,9 +107,14 @@ object SessionFiles { val wlroots = if (File(root, "usr/bin/labwc").isFile) { arrayOf("usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" to "usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so") } else emptyArray() + val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> + listOf("libblsession.so", "libfakeinput.so").map { "$arch/$it" to "usr/local/lib/droiddeck-fex/$arch/$it" } + } + listOf("libfaultreport.so", "libthunkaudit.so", "libvulkan-thunk.so").map { "x86_64/$it" to "usr/local/lib/droiddeck-fex/x86_64/$it" } val optional = (arrayOf( "usr/local/bin/gamescope" to "usr/local/bin/gamescope", - ) + wlroots + mangoapp).filter { (asset, _) -> + "usr/local/lib/droiddeck/uruntime" to "usr/local/lib/droiddeck/uruntime", + "usr/local/share/licenses/uruntime/LICENSE" to "usr/local/share/licenses/uruntime/LICENSE", + ) + wlroots + mangoapp + fexPreloads).filter { (asset, _) -> val dir = asset.substringBeforeLast('/') runCatching { context.assets.list("linuxfs/$dir")?.contains(asset.substringAfterLast('/')) == true }.getOrDefault(false) } @@ -126,6 +136,10 @@ object SessionFiles { } if (!installed) Log.e(TAG, "$relative NOT staged") } + File(root, "usr/local/bin/bannerlator-steam-compat").delete() + LegacyNames.migrateRootfs(root) + ComponentsManager.migrateLaunchDir(context) + EsyncPacks.stageBundled(context, root) // The DirectAudio driver for games under Proton: the glibc build of winedirectaudio, which // the Proton wrappers add to WINEDLLPATH when the session asks for it (BL_DIRECTAUDIO). // Staged like the scripts, so a driver fix reaches an installed runtime without re-hosting. @@ -168,6 +182,9 @@ object SessionFiles { staged.delete() Log.e(TAG, "could not write ld.so.preload") } + if (!FileUtils.writeString(File(etc, "steam-compatibility-label"), context.getString(R.string.steam_compat_label))) { + Log.e(TAG, "could not write steam-compatibility-label") + } val startupMovieDir = File(root, "root/.local/share/Steam/config/uioverrides/movies") // Steam looks up these conventional names in its user override directory. Keep both 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 1f698d6fe..61cbb649d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -285,6 +285,52 @@ object SessionPrefs { prefs(context).edit().putBoolean("noXalia", on).apply() } + fun fastSync(context: Context): Boolean = prefs(context).getBoolean("fastSync", false) + + fun setFastSync(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("fastSync", on).apply() + } + + fun fsyncFirst(context: Context): Boolean = prefs(context).getBoolean("fsyncFirst", true) + + fun setFsyncFirst(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("fsyncFirst", on).apply() + } + + const val SYNC_ESYNC = "esync" + const val SYNC_NTSYNC = "ntsync" + const val SYNC_FSYNC = "fsync" + const val SYNC_WINESERVER = "wineserver" + + /** + * The sync Proton games use, as the three switches above decide it: droiddeck-ntsync wins + * while it is on, then droiddeck-fsync first, then droiddeck-esync, and wineserver alone while all three are off. + */ + fun syncBackend(context: Context): String = syncBackendOf(fastSync(context), fsyncFirst(context), syncFallback(context)) + + fun syncBackendOf(fastSync: Boolean, fsyncFirst: Boolean, syncFallback: Boolean): String = when { + fastSync -> SYNC_NTSYNC + fsyncFirst -> SYNC_FSYNC + syncFallback -> SYNC_ESYNC + else -> SYNC_WINESERVER + } + + /** Picks one sync for Proton games; the switches change together, in one write. */ + fun setSyncBackend(context: Context, backend: String) { + require(backend == SYNC_ESYNC || backend == SYNC_NTSYNC || backend == SYNC_FSYNC || backend == SYNC_WINESERVER) { "unknown sync $backend" } + prefs(context).edit() + .putBoolean("fastSync", backend == SYNC_NTSYNC) + .putBoolean("fsyncFirst", backend == SYNC_FSYNC) + .putBoolean("syncFallback", backend != SYNC_WINESERVER) + .apply() + } + + fun syncFallback(context: Context): Boolean = prefs(context).getBoolean("syncFallback", true) + + fun setSyncFallback(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("syncFallback", on).apply() + } + /** * Whether gamescope asks for realtime-priority Vulkan queues (GAMESCOPE_FORCE_VULKAN_REALTIME=1, * which the app's gamescope build honours without CAP_SYS_NICE). Off by default, as in 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 4e837efc2..584c2c380 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -212,7 +212,7 @@ class SessionService : Service() { } // Another Steam client on the device signs ours out seconds after every login; the one that // does it here runs from boot without being opened. Only the Steam session signs in. - // The desktop too: its Steam launchers run the client right there (bannerlator-steam-launch). + // The desktop too: its Steam launchers run the client right there (droiddeck-steam-launch). if (SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP) RivalClients.stopBeforeSession(this) SessionState.firstFrameSeen = false SessionState.guestPid = -1 @@ -300,7 +300,7 @@ class SessionService : Service() { val size = SessionState.outputSize val guest = ArrayList<String>() - // The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the + // The desktop's Steam launchers start the client there (droiddeck-steam-launch), through the // same set-up as a Steam session: it gets what the client and its games are started with. val steamHere = SessionState.mode == MODE_STEAM || SessionState.mode == MODE_DESKTOP addClientEnvironment(guest, steamHere) @@ -339,7 +339,7 @@ class SessionService : Service() { guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this))) } // The user's own games, for the runtime's shortcuts writer to put in the client's library - // before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts). + // before the client starts (see frontend/AddedGames and droiddeck-steam-shortcuts). if (steamHere) { val added = com.droiddeck.launcher.frontend.AddedGames.scan(this) val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added) @@ -350,7 +350,7 @@ class SessionService : Service() { } // Where the guest leaves a request for another session (the desktop's Steam launchers). guest.add("BL_LAUNCH_DIR=" + sessionRoot.path) - // The second library's name, for bannerlator-steam-library; the bind itself is made below. + // The second library's name, for droiddeck-steam-library; the bind itself is made below. GameStorage.effective(this)?.let { guest.add("BL_LIBRARY_LABEL=" + it.label.replace('"', ' ')) } if (SessionState.mode == MODE_STEAM && SessionState.steamUi == "desktop") guest.add("BL_STEAM_UI=desktop") // The desktop asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's @@ -455,6 +455,8 @@ class SessionService : Service() { it.replace("\\", "\\\\").replace(" ", "\\ ") } watchLaunchRequests(sessionRoot) + watchSyncWanted(root) + EsyncPacks.fetchInBackground(this, root) // One session replacing another (the desktop's Steam launchers): the old proot is killed // by the teardown a second after the new one has started, and its exit used to arrive // here as "session ended: 137" and end the NEW session. An exit belongs to the session @@ -473,6 +475,8 @@ class SessionService : Service() { if (gen == sessionGen) { launchWatcher?.stopWatching() launchWatcher = null + syncWatcher?.stopWatching() + syncWatcher = null components.reversed().forEach { runCatching { it.stop() } } components.clear() } @@ -594,6 +598,9 @@ class SessionService : Service() { // XALIA_SUPPORTED_ONLY itself otherwise). Skipped by default: under FEX it costs every game // a slice of a core for gamepad navigation the session already has. if (SessionPrefs.noXalia(this)) guest.add("PROTON_USE_XALIA=0") + guest.add("BL_SYNC=" + (if (SessionPrefs.fastSync(this)) "1" else "0")) + guest.add("BL_SYNC_FALLBACK=" + (if (SessionPrefs.syncFallback(this)) "1" else "0")) + guest.add("BL_FSYNC_FIRST=" + (if (SessionPrefs.fsyncFirst(this)) "1" else "0")) // gamescope's realtime Vulkan queues (the session script turns this into // GAMESCOPE_FORCE_VULKAN_REALTIME); off unless the user turns it on. guest.add("BL_GAMESCOPE_REALTIME=" + (if (SessionPrefs.gamescopeRealtime(this)) "1" else "0")) @@ -781,7 +788,7 @@ class SessionService : Service() { File(home, "Storage").mkdirs() binds.add(Environment.getExternalStorageDirectory().path + ":/root/Storage") // A second Steam library: the storage chosen in the Steam cog, at the path the runtime's - // bannerlator-steam-library registers with the client. Nothing bound = the script removes + // droiddeck-steam-library registers with the client. Nothing bound = the script removes // the entry, so the client never offers a place that is not there. val library = GameStorage.effective(this) var storageDiagnosticLibrary: File? = null @@ -1112,6 +1119,24 @@ class SessionService : Service() { launchWatcher = watcher } + @Volatile private var syncWatcher: android.os.FileObserver? = null + + private fun watchSyncWanted(root: File) { + syncWatcher?.stopWatching() + syncWatcher = null + val store = EsyncPacks.store(root) + if (!store.isDirectory) return + @Suppress("DEPRECATION") + val watcher = object : android.os.FileObserver(store.path, CLOSE_WRITE or MOVED_TO) { + override fun onEvent(event: Int, path: String?) { + if (path != "wanted.tsv" && path != "tools.tsv") return + EsyncPacks.fetchInBackground(this@SessionService, root) + } + } + watcher.startWatching() + syncWatcher = watcher + } + private fun updateSuspendPolicy() { if (!SessionState.running) return if (suspendPolicy == SessionPrefs.SUSPEND_MANUAL && (!activityVisible || !screenOn)) { @@ -1253,6 +1278,8 @@ class SessionService : Service() { suspendOperationPending = false launchWatcher?.stopWatching() launchWatcher = null + syncWatcher?.stopWatching() + syncWatcher = null // proot's --kill-on-exit takes the guest tree down only if proot gets to run it, and // SIGKILL never lets it. A SIGKILLed proot left its tracees alive with no tracer: every // seccomp-trapped syscall then failed with ENOSYS, they spun on retries at a full core @@ -1465,7 +1492,7 @@ class SessionService : Service() { private const val ACTION_AUXILIARY_EXITED = "com.droiddeck.launcher.AUXILIARY_EXITED" private const val EXTRA_AUXILIARY_PID = "auxiliaryPid" /** Command lines that can only belong to a session of ours. */ - private val STRAGGLERS = listOf("bannerlator-session", "gamescope", "Xwayland", "steamrtarm64", + private val STRAGGLERS = listOf("droiddeck-session", "gamescope", "Xwayland", "steamrtarm64", "steamwebhelper", "linuxfs/opt/android-host/proot", "/libproot.so", "pulseaudio/libpulseaudio.so") /** How long proot gets to run its own cleanup before it is killed outright. */ private const val GRACE_MS = 1200L diff --git a/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt b/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt index c47733dd9..340eabfbf 100644 --- a/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt +++ b/app/src/main/java/com/droiddeck/launcher/store/UserAppsState.kt @@ -54,8 +54,8 @@ object UserAppsState { run(context, target.name, R.string.user_apps_removed) { app, progress -> UserApps.remove(app, target, progress) } } - fun edit(context: Context, target: UserApps.App, name: String, icon: String?) = - run(context, name, R.string.user_apps_saved) { app, _ -> UserApps.edit(app, target, name, icon) } + fun edit(context: Context, target: UserApps.App, name: String, icon: String?, fex: String? = null) = + run(context, name, R.string.user_apps_saved) { app, _ -> UserApps.edit(app, target, name, icon, fex) } fun checkUpdate(context: Context, target: UserApps.App) { if (checking != null) return 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 8d151774a..82e6936ab 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/AddAppDialog.kt @@ -91,6 +91,7 @@ import coil.request.ImageRequest import com.droiddeck.launcher.R import com.droiddeck.launcher.core.FileUtils import com.droiddeck.launcher.files.InAppFilePicker +import com.droiddeck.launcher.runtime.LinuxFex import com.droiddeck.launcher.runtime.UserApps import com.droiddeck.launcher.store.UserAppsState import kotlinx.coroutines.Dispatchers @@ -230,12 +231,14 @@ internal fun AddAppDialog(runtimeReady: Boolean, onDismiss: () -> Unit, onAdd: ( * back to the default, else a picked file or a suggestion. */ @Composable -internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (String, String?) -> Unit) { +internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (String, String?, String?) -> Unit) { val ctx = LocalContext.current val shown = rememberShown(onDismiss) val close = { shown.targetState = false } var name by rememberSaveable(app.key) { mutableStateOf(app.name) } var icon by rememberSaveable(app.key) { mutableStateOf<String?>(null) } + var fex by rememberSaveable(app.key) { mutableStateOf(app.fex) } + val fexChoice = app.kind != UserApps.Kind.FLATPAK val pickIcon = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> if (r.resultCode == Activity.RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { icon = it.path } } @@ -245,7 +248,7 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St else -> iconModel(i) } val busy = UserAppsState.working - val changed = name.trim() != app.name || icon != null + val changed = name.trim() != app.name || icon != null || fex != app.fex AppDialog(shown, close, "editApp", wide = false) { DialogHeader(stringResource(app.kind.label()), stringResource(R.string.edit_app_title, app.name)) Rise(1) { @@ -260,13 +263,25 @@ internal fun EditAppDialog(app: UserApps.App, onDismiss: () -> Unit, onSave: (St clear = if (preview == null) null else R.string.add_app_icon_reset to { icon = "" }, ) app.repo?.let { repo -> IconSuggestions(repo, icon) { icon = it } } + if (fexChoice) { + Text(stringResource(R.string.app_fex_title).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + SegmentedTabs( + listOf( + LinuxFex.AUTO to stringResource(R.string.app_fex_auto), + LinuxFex.ON to stringResource(R.string.app_fex_on), + LinuxFex.OFF to stringResource(R.string.app_fex_off), + ), + fex, + ) { fex = it } + Text(stringResource(R.string.app_fex_hint), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } } DialogFooter( busy?.let { stringResource(R.string.add_app_busy, it) }, stringResource(R.string.edit_app_save), name.isNotBlank() && changed && busy == null, onCancel = close, - ) { onSave(name.trim(), icon); close() } + ) { onSave(name.trim(), icon, fex.takeIf { fexChoice && it != app.fex }); close() } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt index 0d19c376e..47e38d76d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt @@ -187,6 +187,14 @@ fun ComponentsPage( MenuItem( v.proton.name, checked = v.proton.id == view.proton.id, detail = (if (swaps > 0) stringResource(R.string.comp_swapped, v.proton.version, swaps) else stringResource(R.string.comp_all_original, v.proton.version)) + .let { + when (v.sync?.state) { + "pack" -> stringResource(R.string.comp_esync_pack, it) + "wanted" -> stringResource(R.string.comp_esync_wanted, it) + "builtin" -> stringResource(R.string.comp_esync_builtin, it) + else -> it + } + } .let { if (v.inUseByGame) stringResource(R.string.comp_game_running_suffix, it) else it }, focusRequester = if (i == 0) first else null, ) { onProton(v.proton.id); protonMenu = false } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt index b6028777c..e2b79a198 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt @@ -57,6 +57,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Terminal import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.produceState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalContext @@ -575,6 +576,10 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, val narrow = LocalNarrowPane.current var confirm by remember(app.key) { mutableStateOf(false) } var editing by rememberSaveable(app.key) { mutableStateOf(false) } + val x86 = (app.arch == "x86_64" || app.arch == "i386") && app.fex != com.droiddeck.launcher.runtime.LinuxFex.OFF + val fexReady by produceState<Boolean?>(null, app.key, x86, s.sessionRunning) { + value = if (x86) kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.runtime.LinuxFex.ready(ctx) } else true + } LaunchedEffect(confirm) { if (confirm) { kotlinx.coroutines.delay(4000); confirm = false } } Rise(0) { BackLink(stringResource(R.string.user_apps_back)) { onSelect("desktop") } } Rise(1) { @@ -586,7 +591,8 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, Column { Text(app.name, fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) Text( - app.repo?.let { stringResource(R.string.user_apps_github_version, it, app.version.orEmpty()) } ?: stringResource(app.kind.label()), + (app.repo?.let { stringResource(R.string.user_apps_github_version, it, app.version.orEmpty()) } ?: stringResource(app.kind.label())) + + (if (app.arch == "x86_64" || app.arch == "i386") " · " + stringResource(R.string.app_fex_x86, app.arch) else ""), fontSize = 14.sp, color = colors.onSurfaceVariant, ) } @@ -609,7 +615,10 @@ private fun UserAppPage(app: UserApps.App, s: FrontEndState, a: FrontEndActions, } } if (UserAppsState.working != null || UserAppsState.lastError != null) Rise(4) { UserAppsProgress() } - if (editing) EditAppDialog(app, onDismiss = { editing = false }) { name, icon -> UserAppsState.edit(ctx, app, name, icon) } + if (fexReady == false) Rise(4) { + Box(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Note(stringResource(R.string.app_fex_not_ready, app.arch.orEmpty())) } + } + if (editing) EditAppDialog(app, onDismiss = { editing = false }) { name, icon, fex -> UserAppsState.edit(ctx, app, name, icon, fex) } val detail = app.detail if (detail != null) Rise(4) { Box(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { 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 d260c069f..09bcfa0ec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ModeSettingsDialog.kt @@ -73,6 +73,8 @@ class ModeSettings( /** Steam only: record allocation and sampled storage timings in the next session's Share logs. */ val storageDiagnostics: Boolean = false, val fexPreset: String? = null, + /** Steam only: SessionPrefs.SYNC_* chosen for Proton games; null outside Steam. */ + val syncBackend: String? = null, /** Steam only: games are stretched to fill the screen (null = not a Steam page). */ val forceFullscreen: Boolean? = null, val stretch16x9: Boolean? = null, @@ -134,6 +136,7 @@ class ModeSettingsActions( val onPickGameStorageFolder: () -> Unit = {}, val onStorageDiagnostics: (Boolean) -> Unit = {}, val onFexPreset: (String) -> Unit = {}, + val onSyncBackend: (String) -> Unit = {}, val onForceFullscreen: (Boolean) -> Unit = {}, val onStretch16x9: (Boolean) -> Unit = {}, val onSteamChannel: (String) -> Unit = {}, @@ -404,6 +407,20 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) { ) } if (steam && s.fexPreset != null) SettingsGroup(stringResource(R.string.game_settings_title)) { + if (s.syncBackend != null) SettingsRow( + stringResource(R.string.sync_backend_title), + stringResource(R.string.sync_backend_hint), + ) { + SegmentedTabs( + listOf( + SessionPrefs.SYNC_NTSYNC to stringResource(R.string.sync_backend_ntsync), + SessionPrefs.SYNC_FSYNC to stringResource(R.string.sync_backend_fsync), + SessionPrefs.SYNC_ESYNC to stringResource(R.string.sync_backend_esync), + SessionPrefs.SYNC_WINESERVER to stringResource(R.string.sync_backend_wineserver), + ), + s.syncBackend, + ) { id -> host.open = null; a.onSyncBackend(id) } + } ChoiceRow( host, "fex", stringResource(R.string.fex_preset_title), stringResource(R.string.fex_next_launch), FexPreset.all.map { it.id to stringResource(it.label) }, s.fexPreset, 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 fccd4e7cb..b46133463 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/PerformanceDialog.kt @@ -39,6 +39,9 @@ fun PerformancePage( glThread: Boolean, noGlError: Boolean, noXalia: Boolean, + fastSync: Boolean, + syncFallback: Boolean, + fsyncFirst: Boolean, gamescopeRealtime: Boolean, gpuClockPin: Boolean, prootNoSeccomp: Boolean, @@ -51,6 +54,9 @@ fun PerformancePage( onGlThread: (Boolean) -> Unit, onNoGlError: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, + onFastSync: (Boolean) -> Unit, + onSyncFallback: (Boolean) -> Unit, + onFsyncFirst: (Boolean) -> Unit, onGamescopeRealtime: (Boolean) -> Unit, onGpuClockPin: (Boolean) -> Unit, onProotNoSeccomp: (Boolean) -> Unit, @@ -126,6 +132,21 @@ fun PerformancePage( stringResource(R.string.perf_xalia_hint), noXalia, onChange = onNoXalia, ) + ToggleRow( + host, "syncfallback", stringResource(R.string.perf_esync), + stringResource(R.string.perf_esync_hint), + syncFallback, onChange = onSyncFallback, + ) + ToggleRow( + host, "fsyncfirst", stringResource(R.string.perf_fsync), + stringResource(R.string.perf_fsync_hint), + fsyncFirst, onChange = onFsyncFirst, + ) + ToggleRow( + host, "fastsync", stringResource(R.string.perf_fastsync), + stringResource(R.string.perf_fastsync_hint), + fastSync, onChange = onFastSync, + ) ToggleRow( host, "seccomp", stringResource(R.string.perf_seccomp), stringResource(R.string.perf_seccomp_hint), diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt index 28e235800..3515f2180 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ProtonPage.kt @@ -48,12 +48,17 @@ fun ProtonPage( stringResource(R.string.proton_stop_first), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), ) + val compatible = stringResource(R.string.steam_compat_label) + Text( + stringResource(R.string.proton_compatible_note, compatible), + fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 6.dp), + ) SettingsGroup(stringResource(R.string.proton_available)) { for (row in rows) { SettingsRow( row.name, when { - row.installed != null -> stringResource(R.string.proton_installed, row.installed) + row.installed != null -> stringResource(R.string.proton_installed_compatible, row.installed, compatible) row.queued -> stringResource(R.string.proton_queued) else -> stringResource(R.string.proton_not_installed) }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt index 323cdb080..dc8a345f1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SettingsWidgets.kt @@ -8,6 +8,11 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -33,6 +38,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -661,3 +667,62 @@ fun SettingsPage( } } } + +/** + * A short row of tabs with one chosen: a pill in the signal blue slides under the choice and its + * label turns to sit on it. Left and right move between tabs; a tap or A picks one. [selected] + * null leaves no tab chosen. + */ +@Composable +fun <T> SegmentedTabs(options: List<Pair<T, String>>, selected: T?, modifier: Modifier = Modifier, onPick: (T) -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val index = options.indexOfFirst { it.first == selected } + val shape = RoundedCornerShape(12.dp) + val pillShape = RoundedCornerShape(9.dp) + BoxWithConstraints(modifier) { + val segment = minOf(92.dp, (maxWidth - 8.dp) / options.size) + val slide by animateDpAsState(segment * index.coerceAtLeast(0), Motion.sp(0.75f), label = "tabPill") + val shown by animateFloatAsState(if (index >= 0) 1f else 0f, Motion.tw(180), label = "tabPillShown") + Box( + modifier = Modifier + .height(48.dp).width(segment * options.size + 8.dp) + .clip(shape).background(colors.surfaceVariant).border(1.dp, pal.line2, shape) + .padding(4.dp), + ) { + Box( + Modifier.offset(x = slide).width(segment).fillMaxHeight() + .graphicsLayer { alpha = shown } + .clip(pillShape).background(pal.signal), + ) + Row { + options.forEachIndexed { i, (value, label) -> + val src = remember { MutableInteractionSource() } + val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value + val chosen = i == index + val ink by animateColorAsState( + when { + chosen -> pal.onSignal + hot -> colors.onBackground + else -> colors.onSurfaceVariant + }, + Motion.tw(220), label = "tabInk", + ) + val pick = { onPick(value) } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.width(segment).fillMaxHeight() + .clip(pillShape) + .glideBorder(hot, pillShape, if (chosen) pal.onSignal else pal.signal) + .semantics { role = Role.Tab; this.selected = chosen } + .hoverable(src) + .clickable(interactionSource = src, indication = LocalIndication.current, onClick = pick) + .controllerConfirm(onClick = pick), + ) { + Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = ink, maxLines = 1, softWrap = false) + } + } + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt index 6df5ec7f2..02df141e5 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt @@ -7,7 +7,7 @@ import android.view.WindowManager /** * What the panel says about HDR, read from android.view.Display: the gate the compositor's HDR10 - * output sits behind (banner_color.h). A property of the display, not the device - and the + * output sits behind (droiddeck_color.h). A property of the display, not the device - and the * settings screen greys the HDR switch out with the reason when it is not there, rather than * offering a switch that a session would then silently ignore. */ diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index e49394194..73e3c9a9e 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -13,7 +13,7 @@ import android.view.Surface; */ public final class WaylandCompositor { static { - System.loadLibrary("bannerwayland"); + System.loadLibrary("droiddeckwayland"); } private WaylandCompositor() {} @@ -30,7 +30,7 @@ public final class WaylandCompositor { if (firstFrameListener == r) firstFrameListener = null; } - /** Invoked from native (banner_on_first_frame) on the first present. */ + /** Invoked from native (droiddeck_on_first_frame) on the first present. */ @SuppressWarnings("unused") static void onFirstFramePresented() { Runnable r = firstFrameListener; @@ -60,7 +60,7 @@ public final class WaylandCompositor { if (gameListener == l) gameListener = null; } - /** Invoked from native (banner_on_game_surface). */ + /** Invoked from native (droiddeck_on_game_surface). */ @SuppressWarnings("unused") static void onGameSurface(String window, String gpuName) { GameListener l = gameListener; @@ -80,7 +80,7 @@ public final class WaylandCompositor { return Math.max(frameIntervalNanos, System.nanoTime() - last) / 1_000_000; } - /** Invoked from native (banner_on_game_frame) for every frame of the HUD's window. */ + /** Invoked from native (droiddeck_on_game_frame) for every frame of the HUD's window. */ @SuppressWarnings("unused") static void onGameFrame() { long now = System.nanoTime(); @@ -95,7 +95,7 @@ public final class WaylandCompositor { if (l != null) l.onGameFrame(); } - /** Invoked from native (banner_on_game_program) once per game window that starts presenting. */ + /** Invoked from native (droiddeck_on_game_program) once per game window that starts presenting. */ @SuppressWarnings("unused") static void onGameProgram(int pid, String program) { GameListener l = gameListener; @@ -115,7 +115,7 @@ public final class WaylandCompositor { public static void setPointerLockListener(PointerLockListener l) { pointerLockListener = l; } - /** Invoked from native (banner_on_pointer_lock). */ + /** Invoked from native (droiddeck_on_pointer_lock). */ @SuppressWarnings("unused") static void onPointerLock(boolean locked, int x, int y) { PointerLockListener l = pointerLockListener; @@ -139,7 +139,7 @@ public final class WaylandCompositor { if (clipboardListener == l) clipboardListener = null; } - /** Invoked from native (banner_on_clipboard_text) with UTF-8 bytes. */ + /** Invoked from native (droiddeck_on_clipboard_text) with UTF-8 bytes. */ @SuppressWarnings("unused") static void onClipboardText(byte[] utf8) { ClipboardListener l = clipboardListener; @@ -158,7 +158,7 @@ public final class WaylandCompositor { public static void setTextInputListener(TextInputListener l) { textInputListener = l; } - /** Invoked from native (banner_on_text_input). */ + /** Invoked from native (droiddeck_on_text_input). */ @SuppressWarnings("unused") static void onTextInput(boolean enabled, String program, int x, int y, int w, int h) { TextInputListener l = textInputListener; @@ -240,7 +240,7 @@ public final class WaylandCompositor { public static native void nativeSetOutputRefreshRate(float hz); /** Show a single fullscreen window on its own Android layer (SurfaceControl) instead of blitting - * it into the compositor's swapchain. BANNER_WAYLAND_ZERO_COPY=1 in the container's environment + * it into the compositor's swapchain. DROIDDECK_WAYLAND_ZERO_COPY=1 in the container's environment * variables is the launch default; see waylandcomp/ZERO_COPY_SPIKE.md. * * Live and thread-safe: before the compositor starts this is the initial state, and afterwards @@ -264,12 +264,12 @@ public final class WaylandCompositor { /** Compressed (UBWC) game buffers: the compositor advertises DRM_FORMAT_MOD_QCOM_COMPRESSED next to * LINEAR on zwp_linux_dmabuf_v1 for every format its driver can import that way, so the game's * Turnip allocates compressed swapchain images instead of resolving every frame to a linear copy. - * Default on; BANNER_WAYLAND_UBWC=0 in the container's environment variables turns it off (A/B). + * Default on; DROIDDECK_WAYLAND_UBWC=0 in the container's environment variables turns it off (A/B). * Set before the compositor starts. */ public static native void nativeSetUbwc(boolean on); /** Debug: advertise no DRM device (main device 0:0) in the dma-buf feedback, as a phone that - * exposes no /dev/dri node to apps does. BANNER_WAYLAND_NO_RENDER_NODE=1 in the container's or + * exposes no /dev/dri node to apps does. DROIDDECK_WAYLAND_NO_RENDER_NODE=1 in the container's or * shortcut's environment variables. Set before the compositor starts. */ public static native void nativeSetNoRenderNode(boolean on); @@ -311,13 +311,13 @@ public final class WaylandCompositor { * already presented a previous one. */ public static native void nativeResetFirstFrame(); - // ── HDR10 output, round 1 (waylandcomp/src/banner_color.h, wl_color_mgmt.c) ───────────────── - // Opt-in: BANNER_WAYLAND_HDR=1 in the container's or shortcut's env vars. The compositor offers + // ── HDR10 output, round 1 (waylandcomp/src/droiddeck_color.h, wl_color_mgmt.c) ───────────────── + // Opt-in: DROIDDECK_WAYLAND_HDR=1 in the container's or shortcut's env vars. The compositor offers // games HDR10 (wp_color_manager_v1 + 10-bit buffers, frames tagged BT2020_PQ on the game's own // display layer) only when the game's display lists HDR10 as well; otherwise nothing changes and // the session log says why. Not the drawer's "HDR" effect, which is an SDR bloom/contrast filter. - /** HDR_MODE_OFF / HDR_MODE_ON (BANNER_WAYLAND_HDR=1) / HDR_MODE_FORCE (=force: skip the display + /** HDR_MODE_OFF / HDR_MODE_ON (DROIDDECK_WAYLAND_HDR=1) / HDR_MODE_FORCE (=force: skip the display * check, for testing the negotiation on an SDR panel). */ public static final int HDR_MODE_OFF = 0, HDR_MODE_ON = 1, HDR_MODE_FORCE = 2; diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 347916beb..f0eff20e5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -73,11 +73,21 @@ <string name="game_env_vkd3d_config">Opciones de VKD3D para D3D12. nodxr desactiva el trazado de rayos; úsalo solo en un juego que lo necesite.</string> <string name="game_env_proton_log">Pon 1 para obtener un registro de diagnóstico de Proton. El registro puede reducir el rendimiento y usar almacenamiento.</string> <string name="game_env_wined3d">Pon 1 para usar WineD3D basado en OpenGL en lugar de DXVK, para pruebas de compatibilidad con D3D9–11.</string> + <string name="game_env_no_ntsync">Pon 1 para que este juego no use ntsync (droiddeck-ntsync o un controlador del kernel); usa droiddeck-esync si hay un paquete y, si no, droiddeck-fsync.</string> + <string name="game_env_no_esync">Pon 1 para desactivar droiddeck-esync en este juego; usa droiddeck-fsync en su lugar, o droiddeck-ntsync si fsync también está desactivado.</string> + <string name="game_env_no_fsync">Pon 1 para que este juego no use droiddeck-fsync; usa droiddeck-esync si hay un paquete y, si no, droiddeck-ntsync.</string> + <string name="game_env_fsync_first">Pon 1 para que este juego use droiddeck-fsync, o 0 para dar prioridad a droiddeck-esync cuando haya un paquete para su Proton.</string> <string name="game_env_xalia">Pon 0 para desactivar el asistente de navegación con mando de Proton en este juego.</string> <string name="game_env_hud">Overlay de DXVK para D3D9–11. fps muestra la tasa de fotogramas; sin definir la oculta.</string> <string name="game_env_dxvk_config">Ajustes de DXVK separados por punto y coma. Las versiones recientes usan dxvk.maxFrameRate para limitar fotogramas; para el uso normal prefiere el límite de fotogramas de la sesión.</string> <string name="game_env_vkd3d_limit">Límite de fotogramas de D3D12. 0 desactiva el límite.</string> <string name="game_env_glthread">Subprocesos de OpenGL de Mesa. true puede ayudar a juegos OpenGL limitados por la CPU; pruébalo en cada juego.</string> + <string name="sync_backend_title">Sincronización de Wine</string> + <string name="sync_backend_hint">Cómo los juegos coordinan sus hilos. Se aplica en la próxima sesión.</string> + <string name="sync_backend_esync">esync</string> + <string name="sync_backend_ntsync">ntsync</string> + <string name="sync_backend_fsync">fsync</string> + <string name="sync_backend_wineserver">wineserver</string> <string name="fex_preset_title">Ajuste preestablecido de FEX</string> <string name="fex_next_launch">Se aplica al iniciar el próximo juego.</string> <string name="game_settings_title">Juegos</string> @@ -124,7 +134,7 @@ <string name="user_apps_failed">No se pudo añadir la app</string> <string name="user_apps_github_checking">Buscando una versión de %1$s</string> <string name="user_apps_github_unreachable">No se pudieron leer las versiones de %1$s</string> - <string name="user_apps_github_no_appimage">Ninguna versión de %1$s tiene un AppImage ARM64</string> + <string name="user_apps_github_no_appimage">Ninguna versión de %1$s tiene un AppImage ARM64 o x86_64</string> <string name="user_apps_downloading">Descargando %1$s</string> <string name="user_apps_download_failed">La descarga falló</string> <string name="user_apps_update_failed">No se pudo actualizar la app</string> @@ -138,8 +148,17 @@ <string name="user_apps_updated">%1$s actualizada</string> <string name="add_app_title">Añadir una app</string> <string name="add_app_script_hint">Un archivo .sh. Se ejecuta desde su propia carpeta, a pantalla completa con gamescope.</string> - <string name="add_app_appimage_hint">Un AppImage ARM64 (aarch64). Se descomprime una vez en el runtime de Linux.</string> - <string name="add_app_github_hint">Instala el AppImage ARM64 de la última versión del repositorio.</string> + <string name="add_app_appimage_hint">Un AppImage ARM64 (aarch64), o uno x86_64 que se ejecuta con FEX. Se descomprime una vez en el runtime de Linux.</string> + <string name="add_app_github_hint">Instala el AppImage ARM64 de la última versión del repositorio, o el x86_64 con FEX.</string> + <string name="app_fex_title">Usar FEX</string> + <string name="app_fex_auto">Auto</string> + <string name="app_fex_on">Sí</string> + <string name="app_fex_off">No</string> + <string name="app_fex_hint">FEX ejecuta programas Linux x86 en este dispositivo ARM64. Auto lo usa cuando el programa es para x86.</string> + <string name="app_fex_x86">%1$s con FEX</string> + <string name="app_fex_not_ready">Este programa está hecho para PC %1$s y se ejecuta con FEX, que aún no está listo. Abre Steam una vez con tu sesión iniciada: DroidDeck hará que descargue FEX y el Steam Linux Runtime que necesita, y después se abrirá.</string> + <string name="session_end_program">Este programa</string> + <string name="session_end_fex_missing">%1$s está hecho para PC x86 y se ejecuta con FEX, que aún no está instalado. Abre Steam una vez con tu sesión iniciada y deja que descargue FEX y el Steam Linux Runtime; después vuelve a iniciarlo.</string> <string name="add_app_flatpak_hint">Un ID de app de Flathub o su página en Flathub. Flatpak se configura primero si aún no lo está.</string> <string name="add_app_file">Archivo</string> <string name="add_app_no_file">Ningún archivo elegido</string> @@ -315,10 +334,17 @@ <string name="comp_about">Acerca de Componentes</string> <string name="comp_check_drivers">Buscar controladores nuevos</string> <string name="comp_check_nightlies">Buscar paquetes nuevos en las Nightlies</string> - <string name="comp_proton_note">Steam reemplaza el Proton de Valve al actualizarse; los originales se conservan por versión.</string> + <string name="comp_proton_note">El Proton elegido aquí ejecuta todos los juegos que no tengan uno propio en Steam, y un cambio hecho en Steam también se ve aquí. Steam reemplaza el Proton de Valve al actualizarse; los originales se conservan por versión.</string> + <string name="comp_default_live">%1$s ahora ejecuta todos los juegos que no tengan su propio Proton en Steam.</string> + <string name="comp_default_next">%1$s ejecutará todos los juegos que no tengan su propio Proton a partir del próximo inicio de Steam.</string> + <string name="comp_default_not_runnable">%1$s no puede iniciar juegos aquí, así que no puede ser el predeterminado.</string> + <string name="comp_default_failed">No se pudo guardar el Proton predeterminado: %1$s</string> <string name="comp_swapped">%1$s · %2$d reemplazados</string> <string name="comp_all_original">%1$s · todos originales</string> <string name="comp_game_running_suffix">%1$s · juego en ejecución</string> + <string name="comp_esync_pack">%1$s · paquete droiddeck-esync</string> + <string name="comp_esync_wanted">%1$s · aún sin paquete droiddeck-esync</string> + <string name="comp_esync_builtin">%1$s · esync integrado, no necesita paquete</string> <string name="comp_gpu_drivers">Controladores de GPU</string> <string name="comp_reapplied">%1$s · reaplicado al iniciar %2$s</string> <string name="comp_next_queued">Siguiente: %1$s · cuando se cierre el juego</string> @@ -380,6 +406,12 @@ <string name="perf_sysmem">Turnip: renderizado sysmem</string> <string name="perf_sysmem_hint">Más rápido para Steam y la mayoría de juegos. Obligatorio en Adreno 710/720/722.</string> <string name="perf_xalia">Omitir el asistente xalia de Steam</string> + <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> + <string name="perf_fastsync_hint">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.</string> + <string name="perf_esync">droiddeck-esync</string> + <string name="perf_fsync">droiddeck-fsync primero</string> + <string name="perf_fsync_hint">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.</string> + <string name="perf_esync_hint">Los hilos de Windows de los juegos se esperan entre sí mediante eventfd y memoria compartida, sin pasar por wineserver. Usa un paquete droiddeck-esync hecho para esa versión exacta de Proton; los paquetes se descargan automáticamente. Mientras no exista uno para una versión nueva de Proton, droiddeck-fsync lo sustituye. Se aplica en la próxima sesión.</string> <string name="perf_xalia_hint">Desactiva el asistente de navegación con mando de Proton, que consume CPU en cada juego.</string> <string name="perf_seccomp">Ejecutar proot sin seccomp</string> <string name="perf_seccomp_hint">Puede corregir errores de syscalls ausentes en algunos kernels. Puede reducir el rendimiento.</string> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 273eaac32..11c794fc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -74,11 +74,21 @@ <string name="game_env_vkd3d_config">VKD3D options for D3D12. nodxr disables ray tracing; use only for a game that needs it.</string> <string name="game_env_proton_log">Set to 1 for a Proton diagnostic log. Logging can reduce performance and use storage.</string> <string name="game_env_wined3d">Set to 1 to use OpenGL-based WineD3D instead of DXVK for D3D9–11 compatibility testing.</string> + <string name="game_env_no_ntsync">Set to 1 to keep this game off ntsync (droiddeck-ntsync or a kernel driver); it uses droiddeck-esync where a pack exists, otherwise droiddeck-fsync.</string> + <string name="game_env_no_esync">Set to 1 to turn droiddeck-esync off for this game; it uses droiddeck-fsync instead, or droiddeck-ntsync where fsync is off too.</string> + <string name="game_env_no_fsync">Set to 1 to keep this game off droiddeck-fsync; it uses droiddeck-esync where a pack exists, otherwise droiddeck-ntsync.</string> + <string name="game_env_fsync_first">Set to 1 to run this game on droiddeck-fsync, or 0 to put droiddeck-esync first where a pack fits its Proton.</string> <string name="game_env_xalia">Set to 0 to disable Proton’s gamepad-navigation helper for this game.</string> <string name="game_env_hud">DXVK overlay for D3D9–11. fps displays frame rate; unset hides it.</string> <string name="game_env_dxvk_config">DXVK settings separated by semicolons. Recent builds use dxvk.maxFrameRate for frame limiting; prefer the session frame limiter for ordinary play.</string> <string name="game_env_vkd3d_limit">D3D12 frame-rate limit. 0 disables the limit.</string> <string name="game_env_glthread">Mesa OpenGL threading. true can help CPU-limited OpenGL games; test per game.</string> + <string name="sync_backend_title">Wine sync</string> + <string name="sync_backend_hint">How games keep their threads in step. Applies next session.</string> + <string name="sync_backend_esync">esync</string> + <string name="sync_backend_ntsync">ntsync</string> + <string name="sync_backend_fsync">fsync</string> + <string name="sync_backend_wineserver">wineserver</string> <string name="fex_preset_title">FEX preset</string> <string name="fex_next_launch">Applies on the next game launch.</string> <string name="game_settings_title">Games</string> @@ -130,7 +140,7 @@ <string name="user_apps_failed">Adding the app failed</string> <string name="user_apps_github_checking">Looking for a release of %1$s</string> <string name="user_apps_github_unreachable">Could not read the releases of %1$s</string> - <string name="user_apps_github_no_appimage">No release of %1$s has an ARM64 AppImage</string> + <string name="user_apps_github_no_appimage">No release of %1$s has an ARM64 or x86_64 AppImage</string> <string name="user_apps_downloading">Downloading %1$s</string> <string name="user_apps_download_failed">Download failed</string> <string name="user_apps_update_failed">Updating the app failed</string> @@ -149,8 +159,17 @@ <string name="add_app_tab_github">GitHub</string> <string name="add_app_tab_flatpak">Flatpak</string> <string name="add_app_script_hint">A .sh file. It runs from its own folder, full screen under gamescope.</string> - <string name="add_app_appimage_hint">An ARM64 (aarch64) AppImage. It is unpacked into the Linux runtime once.</string> - <string name="add_app_github_hint">Installs the ARM64 AppImage from the repository\'s newest release.</string> + <string name="add_app_appimage_hint">An ARM64 (aarch64) AppImage, or an x86_64 one that runs through FEX. It is unpacked into the Linux runtime once.</string> + <string name="add_app_github_hint">Installs the ARM64 AppImage from the repository\'s newest release, or its x86_64 one through FEX.</string> + <string name="app_fex_title">Use FEX</string> + <string name="app_fex_auto">Auto</string> + <string name="app_fex_on">On</string> + <string name="app_fex_off">Off</string> + <string name="app_fex_hint">FEX runs x86 Linux programs on this ARM64 device. Auto uses it when the program is built for x86.</string> + <string name="app_fex_x86">%1$s through FEX</string> + <string name="app_fex_not_ready">This program is built for %1$s PCs and runs through FEX, which is not set up yet. Open Steam once while signed in: DroidDeck has it download FEX and the Steam Linux Runtime it needs, then this opens.</string> + <string name="session_end_program">This program</string> + <string name="session_end_fex_missing">%1$s is built for x86 PCs and runs through FEX, which is not installed yet. Start Steam once while signed in and let it download FEX and the Steam Linux Runtime, then start this again.</string> <string name="add_app_flatpak_hint">A Flathub app ID or its Flathub page. Flatpak is set up first if it is not yet.</string> <string name="add_app_file">File</string> <string name="add_app_no_file">No file chosen</string> @@ -186,6 +205,9 @@ <string name="add_app_cancel">Cancel</string> <string name="add_app_runtime_required">Install the Linux runtime from Setup first.</string> <string name="add_app_busy">Wait for %1$s to finish.</string> + <string name="steam_compat_label">Compatible</string> + <string name="proton_compatible_note">Builds marked “%1$s” in Steam’s Compatibility menu have DroidDeck’s prefix fixes and start games here. The default is Proton Experimental ARM64 (%1$s), or Proton 11.0 ARM64 (%1$s) once that newer build is installed. Valve’s own Proton entries without the mark cannot start a game on this device.</string> + <string name="proton_installed_compatible">Installed %1$s · %2$s</string> <string name="common_cancel">Cancel</string> <string name="common_auto">Auto</string> <string name="common_never">Never</string> @@ -346,10 +368,17 @@ <string name="comp_check_drivers">Check for new drivers</string> <string name="comp_check_nightlies">Check the Nightlies for new packages</string> <string name="comp_proton">Proton</string> - <string name="comp_proton_note">Steam replaces Valve\'s Proton when it updates; originals are kept per build.</string> + <string name="comp_proton_note">The Proton chosen here runs every game that has not picked its own in Steam, and a change made in Steam shows here too. Steam replaces Valve\'s Proton when it updates; originals are kept per build.</string> + <string name="comp_default_live">%1$s now runs every game that has not picked its own Proton in Steam.</string> + <string name="comp_default_next">%1$s will run every game that has not picked its own Proton, from the next time Steam starts.</string> + <string name="comp_default_not_runnable">%1$s cannot start games here, so it cannot be the default.</string> + <string name="comp_default_failed">The default Proton could not be saved: %1$s</string> <string name="comp_swapped">%1$s · %2$d swapped</string> <string name="comp_all_original">%1$s · all original</string> <string name="comp_game_running_suffix">%1$s · game running</string> + <string name="comp_esync_pack">%1$s · droiddeck-esync pack</string> + <string name="comp_esync_wanted">%1$s · no droiddeck-esync pack yet</string> + <string name="comp_esync_builtin">%1$s · esync built in, no pack needed</string> <string name="comp_gpu_drivers">GPU drivers</string> <string name="comp_reapplied">%1$s · re-applied at launch %2$s</string> <string name="comp_next_queued">Next: %1$s · after the game closes</string> @@ -412,6 +441,12 @@ <string name="perf_sysmem">Turnip: sysmem rendering</string> <string name="perf_sysmem_hint">Faster for Steam and most games. Required on Adreno 710/720/722.</string> <string name="perf_xalia">Skip Steam\'s xalia helper</string> + <string name="perf_fastsync">droiddeck-ntsync (experimental)</string> + <string name="perf_fastsync_hint">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.</string> + <string name="perf_esync">droiddeck-esync</string> + <string name="perf_fsync">droiddeck-fsync first</string> + <string name="perf_fsync_hint">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.</string> + <string name="perf_esync_hint">Games\' Windows threads wait on each other through eventfd and shared memory instead of round trips through wineserver. Uses a droiddeck-esync pack made for that exact Proton build; packs are fetched automatically. Until one exists for a new Proton build, droiddeck-fsync stands in. Applies next session.</string> <string name="perf_xalia_hint">Disables Proton\'s gamepad navigation helper, which costs CPU in every game.</string> <string name="perf_seccomp">Run proot without seccomp</string> <string name="perf_seccomp_hint">May fix missing-syscall errors on some kernels. Can reduce performance.</string> diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt index 1cd246ab4..49ff62126 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/AppImageManagerTest.kt @@ -26,7 +26,11 @@ class AppImageManagerTest { @Test fun acceptsAnArm64TypeTwoImage() = assertNull(AppImageManager.problem(image(183))) - @Test fun refusesX86WithAHint() = assertTrue(AppImageManager.problem(image(62))!!.contains("x86_64")) + @Test fun acceptsAnX86_64ImageForFex() = assertNull(AppImageManager.problem(image(62))) + + @Test fun acceptsAnI386ImageForFex() = assertNull(AppImageManager.problem(image(3))) + + @Test fun refusesOtherProcessors() = assertTrue(AppImageManager.problem(image(40))!!.contains("neither")) @Test fun refusesTypeOne() = assertTrue(AppImageManager.problem(image(183, type = 1))!!.contains("type 2")) diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt index 364c3ef9e..e1e9fd9e9 100644 --- a/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/runtime/BwrapSpawnerTest.kt @@ -22,7 +22,7 @@ class BwrapSpawnerTest { Bind("/dev", "/dev"), Bind("/cache/shm", "/dev/shm"), Bind("/proc", "/proc"), - Bind("/rootfs/etc/bannerlator/proc/stat", "/proc/stat"), + Bind("/rootfs/etc/droiddeck/proc/stat", "/proc/stat"), Bind("/data/files", "/data/files"), ) @@ -52,7 +52,7 @@ class BwrapSpawnerTest { @Test fun nestedBindsFollowARenamedDestination() { val binds = BwrapSpawner.bindsFor(session, "/rootfs", "/proc", "/newproc") - assertTrue(Bind("/rootfs/etc/bannerlator/proc/stat", "/newproc/stat") in binds) + assertTrue(Bind("/rootfs/etc/droiddeck/proc/stat", "/newproc/stat") in binds) } @Test fun normalizeResolvesDotsLexically() { diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/LegacyNamesTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/LegacyNamesTest.kt new file mode 100644 index 000000000..82487c6a1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/LegacyNamesTest.kt @@ -0,0 +1,82 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class LegacyNamesTest { + private lateinit var root: File + + @Before fun setUp() { root = Files.createTempDirectory("rootfs").toFile() } + @After fun tearDown() { root.deleteRecursively() } + + private fun write(path: String, text: String) = File(root, path).apply { parentFile?.mkdirs(); writeText(text) } + private fun link(path: String) = Files.readSymbolicLink(File(root, path).toPath()).toString() + private fun isLink(path: String) = Files.isSymbolicLink(File(root, path).toPath()) + + @Test fun theImageEtcMovesAndTheOldPathStillReads() { + write("etc/bannerlator/proc/stat", "cpu 1") + write("etc/bannerlator/proc/pci_devices", "image") + File(root, "etc/bannerlator/empty").mkdirs() + write("etc/droiddeck/proc/pci_devices", "app") + LegacyNames.migrateEtc(root) + assertEquals("cpu 1", File(root, "etc/droiddeck/proc/stat").readText()) + assertEquals("app", File(root, "etc/droiddeck/proc/pci_devices").readText()) + assertTrue(File(root, "etc/droiddeck/empty").isDirectory) + assertEquals("droiddeck", link("etc/bannerlator")) + assertEquals("cpu 1", File(root, "etc/bannerlator/proc/stat").readText()) + LegacyNames.migrateEtc(root) + assertEquals("cpu 1", File(root, "etc/droiddeck/proc/stat").readText()) + assertEquals("droiddeck", link("etc/bannerlator")) + } + + @Test fun aFreshRuntimeGetsNoOldNames() { + write("usr/local/bin/droiddeck-session", "new") + LegacyNames.migrateRootfs(root) + assertTrue(File(root, "etc/droiddeck/empty").isDirectory) + assertFalse(File(root, "etc/bannerlator").exists() || isLink("etc/bannerlator")) + assertFalse(File(root, "usr/local/bin/bannerlator-session").exists() || isLink("usr/local/bin/bannerlator-session")) + } + + @Test fun oldScriptsBecomeLinksToTheNewOnes() { + write("usr/local/bin/bannerlator-session", "old") + write("usr/local/bin/droiddeck-session", "new") + write("usr/local/bin/bannerlator-appimage-run", "old") + write("usr/local/bin/droiddeck-appimage-run", "new") + write("usr/local/bin/droiddeck-bwrap", "new") + LegacyNames.migrateRootfs(root) + assertEquals("droiddeck-session", link("usr/local/bin/bannerlator-session")) + assertEquals("new", File(root, "usr/local/bin/bannerlator-session").readText()) + assertEquals("droiddeck-appimage-run", link("usr/local/bin/bannerlator-appimage-run")) + assertFalse(isLink("usr/local/bin/bannerlator-bwrap")) + LegacyNames.migrateRootfs(root) + assertEquals("new", File(root, "usr/local/bin/bannerlator-session").readText()) + assertFalse(File(root, "usr/local/bin/bannerlator-session.link").exists()) + } + + @Test fun generatedFilesMoveOnceAndMenuEntriesPointAtTheNewScripts() { + write("etc/bannerlator-net", "wlan0") + write("etc/bannerlator-network.json", "{}") + write("etc/droiddeck-network.json", "{\"new\":1}") + write("usr/local/share/bannerlator/system-bus.conf", "<busconfig/>") + write("usr/local/share/applications/droiddeck-appimage-emulator.desktop", + "[Desktop Entry]\nExec=/usr/local/bin/bannerlator-appimage-run /opt/appimages/user/emulator %U\n") + write("usr/local/share/applications/firefox.desktop", "Exec=/usr/local/bin/bannerlator-steam-launch\n") + LegacyNames.migrateRootfs(root) + assertEquals("wlan0", File(root, "etc/droiddeck-net").readText()) + assertFalse(File(root, "etc/bannerlator-net").exists()) + assertEquals("{\"new\":1}", File(root, "etc/droiddeck-network.json").readText()) + assertFalse(File(root, "etc/bannerlator-network.json").exists()) + assertEquals("<busconfig/>", File(root, "usr/local/share/droiddeck/system-bus.conf").readText()) + assertEquals("droiddeck", link("usr/local/share/bannerlator")) + assertEquals("[Desktop Entry]\nExec=/usr/local/bin/droiddeck-appimage-run /opt/appimages/user/emulator %U\n", + File(root, "usr/local/share/applications/droiddeck-appimage-emulator.desktop").readText()) + assertEquals("Exec=/usr/local/bin/bannerlator-steam-launch\n", + File(root, "usr/local/share/applications/firefox.desktop").readText()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/runtime/LinuxFexTest.kt b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxFexTest.kt new file mode 100644 index 000000000..e63bbcef5 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/runtime/LinuxFexTest.kt @@ -0,0 +1,67 @@ +package com.droiddeck.launcher.runtime + +import java.io.File +import java.nio.file.Files +import org.junit.After +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 + +class LinuxFexTest { + private lateinit var dir: File + + @Before fun setUp() { dir = Files.createTempDirectory("fex").toFile() } + @After fun tearDown() { dir.deleteRecursively() } + + private fun elf(machine: Int, bigEndian: Boolean = false): File { + val b = ByteArray(64) + b[0] = 0x7f; b[1] = 'E'.code.toByte(); b[2] = 'L'.code.toByte(); b[3] = 'F'.code.toByte() + b[4] = 2; b[5] = if (bigEndian) 2 else 1 + if (bigEndian) { b[18] = (machine shr 8).toByte(); b[19] = (machine and 0xff).toByte() } + else { b[18] = (machine and 0xff).toByte(); b[19] = (machine shr 8).toByte() } + return File(dir, "prog-$machine-$bigEndian").apply { writeBytes(b) } + } + + @Test fun readsTheMachine() { + assertEquals(62, LinuxFex.elfMachine(elf(62))) + assertEquals(3, LinuxFex.elfMachine(elf(3))) + assertEquals(183, LinuxFex.elfMachine(elf(183))) + assertEquals(62, LinuxFex.elfMachine(elf(62, bigEndian = true))) + assertNull(LinuxFex.elfMachine(File(dir, "script").apply { writeText("#!/bin/sh\necho hi\n") })) + assertNull(LinuxFex.elfMachine(File(dir, "missing"))) + assertTrue(LinuxFex.isX86(62) && LinuxFex.isX86(3)) + assertFalse(LinuxFex.isX86(183) || LinuxFex.isX86(null)) + } + + @Test fun modesAreStoredBesideTheApp() { + assertEquals(LinuxFex.AUTO, LinuxFex.mode(dir)) + assertTrue(LinuxFex.setMode(dir, LinuxFex.ON)) + assertEquals(LinuxFex.ON, LinuxFex.mode(dir)) + assertTrue(LinuxFex.setMode(dir, LinuxFex.OFF)) + assertEquals(LinuxFex.OFF, LinuxFex.mode(dir)) + assertTrue(LinuxFex.setMode(dir, LinuxFex.AUTO)) + assertFalse(File(dir, "fex").exists()) + assertFalse(LinuxFex.setMode(dir, "sometimes")) + File(dir, "fex").writeText("garbage") + assertEquals(LinuxFex.AUTO, LinuxFex.mode(dir)) + } + + @Test fun theArchitectureIsRecorded() { + LinuxFex.writeArch(dir, 62) + assertEquals("x86_64", File(dir, "arch").readText().trim()) + LinuxFex.writeArch(dir, 183) + assertEquals("arm64", File(dir, "arch").readText().trim()) + LinuxFex.writeArch(dir, null) + assertFalse(File(dir, "arch").exists()) + } + + @Test fun releasesFallBackToTheX86Image() { + assertEquals("App-aarch64.AppImage", UserApps.pickAsset(listOf("App-x86_64.AppImage", "App-aarch64.AppImage"))) + assertEquals("App.AppImage", UserApps.pickAsset(listOf("App-x86_64.AppImage", "App.AppImage"))) + assertEquals("App-x86_64.AppImage", UserApps.pickAsset(listOf("App-x86_64.AppImage", "App-armhf.AppImage", "notes.txt"))) + assertNull(UserApps.pickAsset(listOf("App-armhf.AppImage", "App-i686.AppImage"))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/EsyncPacksTest.kt b/app/src/test/java/com/droiddeck/launcher/session/EsyncPacksTest.kt new file mode 100644 index 000000000..7d44b1087 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/EsyncPacksTest.kt @@ -0,0 +1,843 @@ +package com.droiddeck.launcher.session + +import com.droiddeck.launcher.core.GameEnvironment +import org.apache.commons.compress.archivers.tar.TarArchiveEntry +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream +import org.apache.commons.compress.archivers.tar.TarConstants +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.spec.ECGenParameterSpec + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class EsyncPacksTest { + @get:Rule val temp = TemporaryFolder() + + private val ntdllBytes = "patched ntdll.so".toByteArray() + private val wineserverBytes = "patched wineserver".toByteArray() + private val stockNtdll = "a".repeat(64) + private val stockWineserver = "b".repeat(64) + private val exports = "c".repeat(64) + private val toolDir = "/root/.local/share/Steam/compatibilitytools.d/GE-Proton11-7" + + private fun sha(bytes: ByteArray) = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun packJson( + id: String, + rev: Int = 1, + ntdll: String = stockNtdll, + wineserver: String = stockWineserver, + sourceMatch: Boolean = false, + version: String = "GE-Proton11-7", + exportsHash: String = exports, + ): JSONObject = JSONObject() + .put("format", 1) + .put("id", id) + .put("flavor", "ge") + .put("rev", rev) + .put("version", version) + .put("version_line", "1789520806 $version") + .put("stock", JSONObject().put(EsyncPacks.NTDLL, ntdll).put(EsyncPacks.WINESERVER, wineserver)) + .put("exports", exportsHash) + .put("source_match", sourceMatch) + .put("files", JSONObject().put(EsyncPacks.NTDLL, sha(ntdllBytes)).put(EsyncPacks.WINESERVER, sha(wineserverBytes))) + + private fun entryJson( + id: String, + rev: Int = 1, + ntdll: String = stockNtdll, + wineserver: String = stockWineserver, + sourceMatch: Boolean = false, + version: String = "GE-Proton11-7", + exportsHash: String = exports, + url: String = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/$id.tzst", + revoked: Boolean = false, + ): JSONObject = packJson(id, rev, ntdll, wineserver, sourceMatch, version, exportsHash) + .put("asset", JSONObject().put("url", url).put("sha256", "d".repeat(64)).put("size", 123)) + .put("revoked", revoked) + .apply { remove("format") } + + private fun indexBytes(generated: Long, vararg entries: JSONObject): ByteArray = + JSONObject().put("schema", 1).put("generated", generated).put("packs", JSONArray(entries.toList())) + .toString().toByteArray() + + private fun entries(vararg entries: JSONObject): List<EsyncPacks.Entry> = + EsyncPacks.parseIndex(indexBytes(1, *entries)).packs + + private fun row( + ntdll: String = stockNtdll, + wineserver: String = stockWineserver, + version: String = "GE-Proton11-7", + exportsHash: String = exports, + ) = EsyncPacks.Wanted(ntdll, wineserver, version, exportsHash, toolDir, 0L) + + private fun keyPair(): KeyPair = + KeyPairGenerator.getInstance("EC").apply { initialize(ECGenParameterSpec("secp256r1")) }.generateKeyPair() + + private fun sign(bytes: ByteArray, pair: KeyPair): ByteArray = Signature.getInstance("SHA256withECDSA").run { + initSign(pair.private) + update(bytes) + sign() + } + + private fun tar(vararg items: Pair<TarArchiveEntry, ByteArray?>): TarArchiveInputStream { + val out = ByteArrayOutputStream() + TarArchiveOutputStream(out).use { stream -> + stream.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX) + for ((entry, data) in items) { + if (data != null) entry.size = data.size.toLong() + stream.putArchiveEntry(entry) + if (data != null) stream.write(data) + stream.closeArchiveEntry() + } + } + return TarArchiveInputStream(ByteArrayInputStream(out.toByteArray())) + } + + private fun regular(name: String, data: ByteArray) = TarArchiveEntry(name) to data + + private fun directory(name: String) = TarArchiveEntry(name) to null + + private fun packTar(pack: JSONObject, ntdll: ByteArray = ntdllBytes, prefix: String = ""): TarArchiveInputStream = tar( + directory("${prefix}files/"), + directory("${prefix}files/bin-arm64/"), + regular("${prefix}files/bin-arm64/wineserver", wineserverBytes), + directory("${prefix}files/lib/"), + directory("${prefix}files/lib/wine/"), + directory("${prefix}files/lib/wine/aarch64-unix/"), + regular("${prefix}files/lib/wine/aarch64-unix/ntdll.so", ntdll), + regular("${prefix}pack.json", pack.toString().toByteArray()), + ) + + private fun rejects(block: () -> Unit) { + try { + block() + } catch (e: IllegalArgumentException) { + return + } + fail("expected the entry to be rejected") + } + + @Test fun parseIndexReadsEveryField() { + val index = EsyncPacks.parseIndex(indexBytes(1791234567, entryJson("ge-GE-Proton11-7-1789520806-6ba770ec0df5-r1", rev = 3, sourceMatch = true))) + assertEquals(1, index.schema) + assertEquals(1791234567L, index.generated) + val entry = index.packs.single() + assertEquals("ge-GE-Proton11-7-1789520806-6ba770ec0df5-r1", entry.id) + assertEquals("ge", entry.flavor) + assertEquals(3, entry.rev) + assertEquals("GE-Proton11-7", entry.version) + assertEquals("1789520806 GE-Proton11-7", entry.versionLine) + assertEquals(mapOf(EsyncPacks.NTDLL to stockNtdll, EsyncPacks.WINESERVER to stockWineserver), entry.stock) + assertEquals(exports, entry.exports) + assertTrue(entry.sourceMatch) + assertEquals(sha(ntdllBytes), entry.files[EsyncPacks.NTDLL]) + assertEquals(sha(wineserverBytes), entry.files[EsyncPacks.WINESERVER]) + assertEquals("d".repeat(64), entry.asset.sha256) + assertEquals(123L, entry.asset.size) + assertFalse(entry.revoked) + } + + @Test fun parseIndexDropsEntriesOutsideTheReleasePrefixOrMalformed() { + val packs = entries( + entryJson("good-r1"), + entryJson("other-repo-r1", url = "https://github.com/Someone/DroidDeck/releases/download/droiddeck-esync-ge/other-repo-r1.tzst"), + entryJson("plain-http-r1", url = "http://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/plain-http-r1.tzst"), + entryJson("dotdot-r1", url = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/../../x/dotdot-r1.tzst"), + entryJson("other-tag-r1", url = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/v1/other-tag-r1.tzst"), + entryJson("query-r1", url = "https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/query-r1.tzst?x=1"), + entryJson("bad/id"), + entryJson("bad-hash-r1", ntdll = "xyz"), + entryJson("good-r1", rev = 9), + ) + assertEquals(listOf("good-r1"), packs.map { it.id }) + assertEquals(1, packs.single().rev) + assertTrue(EsyncPacks.allowedUrl("https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-cachyos/a-r2.tzst")) + assertFalse(EsyncPacks.allowedUrl("https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/..")) + assertFalse(EsyncPacks.allowedUrl("https://github.com/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/a/b.tzst")) + assertFalse(EsyncPacks.allowedUrl("https://github.com.evil.example/Droid-Deck/DroidDeck-Components/releases/download/droiddeck-esync-ge/a.tzst")) + assertFalse(EsyncPacks.allowedUrl("https://github.com/Droid-Deck/DroidDeck/releases/download/droiddeck-esync-ge/a.tzst")) + } + + @Test fun parseIndexRejectsAnUnknownSchema() { + val bytes = JSONObject().put("schema", 2).put("generated", 1).put("packs", JSONArray()).toString().toByteArray() + try { + EsyncPacks.parseIndex(bytes) + fail("schema 2 must not parse") + } catch (e: IllegalArgumentException) { + } + } + + @Test fun signatureVerifiesOnlyTheSignedBytesWithTheRightKey() { + val pair = keyPair() + val bytes = indexBytes(5, entryJson("ge-a-r1")) + val sig = sign(bytes, pair) + assertTrue(EsyncPacks.verify(bytes, sig, pair.public)) + val tampered = bytes.copyOf().also { it[it.size / 2] = (it[it.size / 2] + 1).toByte() } + assertFalse(EsyncPacks.verify(tampered, sig, pair.public)) + val badSig = sig.copyOf().also { it[it.size - 1] = (it[it.size - 1] + 1).toByte() } + assertFalse(EsyncPacks.verify(bytes, badSig, pair.public)) + assertFalse(EsyncPacks.verify(bytes, ByteArray(0), pair.public)) + assertFalse(EsyncPacks.verify(bytes, "not a signature".toByteArray(), pair.public)) + assertFalse(EsyncPacks.verify(bytes, sig, keyPair().public)) + } + + @Test fun theShippedKeyDecodesAndRejectsAForeignSignature() { + assertEquals("EC", EsyncPacks.key(EsyncPacks.PUBLIC_KEY).algorithm) + val bytes = indexBytes(5, entryJson("ge-a-r1")) + assertFalse(EsyncPacks.verify(bytes, sign(bytes, keyPair()))) + } + + @Test fun acceptEnforcesSignatureAndAntiRollback() { + val pair = keyPair() + val cached = EsyncPacks.parseIndex(indexBytes(200, entryJson("ge-a-r1"))) + val older = indexBytes(199, entryJson("ge-a-r1")) + val same = indexBytes(200, entryJson("ge-a-r1")) + val newer = indexBytes(201, entryJson("ge-a-r2", rev = 2)) + assertNull(EsyncPacks.accept(older, sign(older, pair), cached, pair.public)) + assertNotNull(EsyncPacks.accept(same, sign(same, pair), cached, pair.public)) + assertEquals(201L, EsyncPacks.accept(newer, sign(newer, pair), cached, pair.public)?.generated) + assertNotNull(EsyncPacks.accept(older, sign(older, pair), null, pair.public)) + assertNull(EsyncPacks.accept(newer, sign(older, pair), null, pair.public)) + assertNull(EsyncPacks.accept(newer, sign(newer, keyPair()), null, pair.public)) + assertNull(EsyncPacks.accept(older, sign(older, pair), null, pair.public, floor = 200)) + assertNotNull(EsyncPacks.accept(same, sign(same, pair), null, pair.public, floor = 200)) + assertNull(EsyncPacks.accept(same, sign(same, pair), cached, pair.public, floor = 201)) + assertEquals(201L, EsyncPacks.accept(newer, sign(newer, pair), null, pair.public, floor = 201)?.generated) + } + + @Test fun exactMatchBeatsSourceMatchEvenAtALowerRev() { + val packs = entries( + entryJson("valve-source-r5", rev = 5, ntdll = "e".repeat(64), wineserver = "f".repeat(64), sourceMatch = true), + entryJson("ge-exact-r1", rev = 1), + ) + assertEquals("ge-exact-r1", EsyncPacks.best(packs, row())?.id) + assertEquals(2, EsyncPacks.kind(packs[1], row())) + assertEquals(1, EsyncPacks.kind(packs[0], row())) + } + + @Test fun sourceMatchNeedsTheFlagTheVersionAndTheExports() { + val other = row(ntdll = "1".repeat(64), wineserver = "2".repeat(64)) + val flagged = entries(entryJson("valve-r1", sourceMatch = true)).single() + val unflagged = entries(entryJson("valve-r1", sourceMatch = false)).single() + assertTrue(EsyncPacks.matches(flagged, other)) + assertFalse(EsyncPacks.matches(unflagged, other)) + assertFalse(EsyncPacks.matches(flagged, other.copy(version = "GE-Proton11-8"))) + assertFalse(EsyncPacks.matches(flagged, other.copy(exports = "9".repeat(64)))) + assertFalse(EsyncPacks.matches(flagged, other.copy(exports = ""))) + assertFalse(EsyncPacks.matches(unflagged, row(wineserver = "2".repeat(64)))) + } + + @Test fun higherRevThenGreaterIdWinsAndRevokedNeverMatches() { + val packs = entries( + entryJson("ge-a-r1", rev = 1), + entryJson("ge-a-r3", rev = 3), + entryJson("ge-b-r3", rev = 3), + entryJson("ge-z-r9", rev = 9, revoked = true), + ) + assertEquals("ge-b-r3", EsyncPacks.best(packs, row())?.id) + assertFalse(EsyncPacks.matches(packs[3], row())) + assertNull(EsyncPacks.best(listOf(packs[3]), row())) + } + + @Test fun planSkipsInstalledRevokedAndUnmatchedRows() { + val index = EsyncPacks.parseIndex(indexBytes( + 1, + entryJson("ge-a-r1"), + entryJson("ge-b-r1", ntdll = "1".repeat(64), wineserver = "2".repeat(64)), + entryJson("ge-c-r1", ntdll = "3".repeat(64), wineserver = "4".repeat(64), revoked = true), + )) + val wanted = listOf( + row(), + row(ntdll = "1".repeat(64), wineserver = "2".repeat(64)), + row(ntdll = "3".repeat(64), wineserver = "4".repeat(64)), + row(ntdll = "5".repeat(64), wineserver = "6".repeat(64)), + ) + assertEquals(listOf("ge-a-r1", "ge-b-r1"), EsyncPacks.plan(index, wanted, emptySet()).map { it.id }) + assertEquals(listOf("ge-b-r1"), EsyncPacks.plan(index, wanted, setOf("ge-a-r1")).map { it.id }) + } + + @Test fun aNewerRevOfAPackInUseIsPlanned() { + val index = EsyncPacks.parseIndex(indexBytes(1, entryJson("ge-a-r2", rev = 2), entryJson("ge-x-r1", ntdll = "1".repeat(64), wineserver = "2".repeat(64)))) + val tools = listOf( + EsyncPacks.ToolState(toolDir, "1789520806 GE-Proton11-7", stockNtdll.uppercase(), stockWineserver, "pack", "ge-a-r1"), + EsyncPacks.ToolState("/root/b", "1 b", "1".repeat(64), "2".repeat(64), "builtin", null), + EsyncPacks.ToolState("/root/c", "1 c", "1".repeat(64), "2".repeat(64), "wanted", null), + ) + val store = temp.newFolder("store") + File(store, "packs/ge-a-r1").mkdirs() + File(store, "packs/ge-a-r1/pack.json").writeText(packJson("ge-a-r1").toString()) + File(store, "packs/ge-a-r1/.complete").writeText("") + val rows = EsyncPacks.upgradeRows(store, tools) + assertEquals(1, rows.size) + assertEquals("GE-Proton11-7", rows.single().version) + assertEquals("", rows.single().exports) + assertEquals(listOf("ge-a-r2"), EsyncPacks.plan(index, rows, setOf("ge-a-r1")).map { it.id }) + assertTrue(EsyncPacks.plan(index, rows, setOf("ge-a-r2")).isEmpty()) + } + + @Test fun aNewerRevOfASourcePackInUseIsPlanned() { + val store = temp.newFolder("store") + val version = "experimental-11.0-20260910b-arm64" + val r1 = "valve-experimental-11.0-20260910b-arm64-1789159687-aaaaaaaaaaaa-r1" + val r2 = "valve-experimental-11.0-20260910b-arm64-1789159687-aaaaaaaaaaaa-r2" + File(store, "packs/$r1").mkdirs() + File(store, "packs/$r1/pack.json").writeText(packJson(r1, sourceMatch = true, version = version).toString()) + File(store, "packs/$r1/.complete").writeText("") + val index = EsyncPacks.parseIndex(indexBytes(2, entryJson(r2, rev = 2, sourceMatch = true, version = version))) + val tool = EsyncPacks.ToolState( + "/root/.local/share/Steam/steamapps/common/Proton Experimental (ARM64)", "1789159687 $version", + "1".repeat(64), "2".repeat(64), "pack", r1, + ) + val rows = EsyncPacks.upgradeRows(store, listOf(tool)) + assertEquals(exports, rows.single().exports) + assertEquals(version, rows.single().version) + assertEquals("1".repeat(64), rows.single().ntdll) + assertEquals(listOf(r2), EsyncPacks.plan(index, rows, setOf(r1)).map { it.id }) + assertTrue(EsyncPacks.plan(index, rows, setOf(r1, r2)).isEmpty()) + val retagged = tool.copy(versionLine = "1789159687 experimental-11.0-20260911-arm64") + assertTrue(EsyncPacks.plan(index, EsyncPacks.upgradeRows(store, listOf(retagged)), setOf(r1)).isEmpty()) + File(store, "packs/$r1/.complete").delete() + assertEquals("", EsyncPacks.upgradeRows(store, listOf(tool)).single().exports) + assertEquals("", EsyncPacks.upgradeRows(store, listOf(tool.copy(packId = "../$r1"))).single().exports) + } + + @Test fun identicalReleasesPreferThePackBuiltForTheInstalledOne() { + val a = "valve-proton-11.0-2a-arm64-1787000000-aaaaaaaaaaaa-r1" + val b = "valve-proton-11.0-2b-arm64-1787950357-aaaaaaaaaaaa-r1" + val b2 = "valve-proton-11.0-2b-arm64-1787950357-aaaaaaaaaaaa-r2" + val c = "valve-proton-11.0-2c-arm64-1788504814-aaaaaaaaaaaa-r1" + val c2 = "valve-proton-11.0-2c-arm64-1788504814-aaaaaaaaaaaa-r2" + val va = "proton-11.0-2a-arm64" + val vb = "proton-11.0-2b-arm64" + val vc = "proton-11.0-2c-arm64" + val lines = mapOf(va to "1787000000 $va", vb to "1787950357 $vb", vc to "1788504814 $vc") + fun entry(id: String, version: String, rev: Int = 1) = entryJson(id, rev = rev, version = version).put("version_line", lines[version]) + fun onB(epoch: Long = 1787950357L) = row(version = vb).copy(epoch = epoch) + val both = entries(entry(b, vb), entry(c, vc)) + assertEquals(b, EsyncPacks.best(both, onB())?.id) + assertEquals(c, EsyncPacks.best(both, row(version = vc).copy(epoch = 1788504814L))?.id) + assertNull(EsyncPacks.best(entries(entry(c, vc)), onB())) + assertEquals(c, EsyncPacks.best(entries(entry(c, vc)), onB(epoch = 0L))?.id) + assertEquals(a, EsyncPacks.best(entries(entry(a, va), entry(c, vc)), onB())?.id) + assertEquals(b, EsyncPacks.best(entries(entry(b, vb), entry(c2, vc, rev = 2)), onB())?.id) + assertEquals(listOf(b), EsyncPacks.plan(EsyncPacks.Index(1, 1, both), listOf(onB()), emptySet()).map { it.id }) + assertTrue(EsyncPacks.plan(EsyncPacks.Index(1, 1, entries(entry(c, vc))), listOf(onB()), emptySet()).isEmpty()) + + val tools = listOf( + EsyncPacks.ToolState(toolDir, lines[vb]!!, stockNtdll.uppercase(), stockWineserver, "wanted", null), + EsyncPacks.ToolState("/root/other", "1 other", "1".repeat(64), "2".repeat(64), "wanted", null), + ) + assertEquals(listOf(1787950357L, 0L), EsyncPacks.withEpochs(listOf(row(version = vb), row(ntdll = "3".repeat(64))), tools).map { it.epoch }) + + val store = temp.newFolder("store") + fun install(id: String, version: String, rev: Int = 1) { + File(store, "packs/$id").mkdirs() + File(store, "packs/$id/pack.json").writeText(packJson(id, rev = rev, version = version).put("version_line", lines[version]).toString()) + File(store, "packs/$id/.complete").writeText("") + } + fun tool(pack: String) = EsyncPacks.ToolState(toolDir, lines[vb]!!, stockNtdll, stockWineserver, "pack", pack) + install(c, vc) + assertEquals("wanted", EsyncPacks.settled(store, tools.take(1), emptyList()).single().state) + install(b, vb) + assertEquals(b, EsyncPacks.settled(store, tools.take(1), emptyList()).single().packId) + val onOwn = EsyncPacks.upgradeRows(store, listOf(tool(b))) + assertEquals(EsyncPacks.Rank(2, 1, 1), onOwn.single().inUse) + assertEquals(1787950357L, onOwn.single().epoch) + assertTrue(EsyncPacks.plan(EsyncPacks.Index(1, 1, both), onOwn, setOf(b)).isEmpty()) + assertTrue(EsyncPacks.plan(EsyncPacks.Index(1, 1, entries(entry(c2, vc, rev = 2))), onOwn, setOf(b)).isEmpty()) + assertTrue(EsyncPacks.plan(EsyncPacks.Index(1, 1, entries(entry(a, va, rev = 2))), onOwn, setOf(b)).isEmpty()) + val ownRev = EsyncPacks.Index(1, 1, entries(entry(b2, vb, rev = 2), entry(c2, vc, rev = 2))) + assertEquals(listOf(b2), EsyncPacks.plan(ownRev, onOwn, setOf(b)).map { it.id }) + + val onOlder = EsyncPacks.upgradeRows(store, listOf(tool(c).copy(versionLine = "1787950357 proton-11.0-2a-arm64"))) + assertNull(onOlder.single().inUse) + } + + @Test fun installRejectsAnArchiveThatDisagreesWithTheIndex() { + val store = temp.newFolder("store") + val bytes = "not really a pack".toByteArray() + val archive = temp.newFile("pack.tzst").apply { writeBytes(bytes) } + fun entry(size: Int, sha256: String) = entries(entryJson("ge-a-r1").apply { + getJSONObject("asset").put("size", size).put("sha256", sha256) + }).single() + val good = sha(bytes) + val flipped = (if (good[0] == '0') "1" else "0") + good.substring(1) + rejects { EsyncPacks.install(store, entry(bytes.size + 1, good), archive) } + rejects { EsyncPacks.install(store, entry(bytes.size - 1, good), archive) } + rejects { EsyncPacks.install(store, entry(bytes.size, flipped), archive) } + assertTrue(File(store, "packs").listFiles().isNullOrEmpty()) + } + + @Test fun fetchWantedDownloadsOnlyThePackForTheInstalledBuild() { + val context = RuntimeEnvironment.getApplication() + val cache = File(context.filesDir, "droiddeck-esync").apply { deleteRecursively() } + val pair = keyPair() + val base = EsyncPacks.ASSET_PREFIX + "valve/" + val own = "valve-proton-11.0-2b-arm64-1787950357-aaaaaaaaaaaa-r1" + val sibling = "valve-proton-11.0-2c-arm64-1788504814-aaaaaaaaaaaa-r1" + val next = "valve-proton-11.0-3-arm64-1790000000-111111111111-r1" + val revoked = "valve-proton-11.0-2b-arm64-1787950357-aaaaaaaaaaaa-r2" + val ownEntry = entryJson(own, version = "proton-11.0-2b-arm64", url = "$base$own.tzst") + .put("version_line", "1787950357 proton-11.0-2b-arm64") + val others = arrayOf( + entryJson(sibling, version = "proton-11.0-2c-arm64", url = "$base$sibling.tzst").put("version_line", "1788504814 proton-11.0-2c-arm64"), + entryJson(next, ntdll = "1".repeat(64), wineserver = "2".repeat(64), version = "proton-11.0-3-arm64", url = "$base$next.tzst"), + entryJson(revoked, rev = 2, version = "proton-11.0-2b-arm64", url = "$base$revoked.tzst", revoked = true), + ) + var index = indexBytes(6, *others) + var sig = sign(index, pair) + val requested = ArrayList<String>() + val original = EsyncPacks.fetcher + EsyncPacks.keyOverride = pair.public + EsyncPacks.fetcher = { url, _, out, _ -> + requested.add(url) + out.write( + when (url) { + EsyncPacks.INDEX_URL -> index + EsyncPacks.SIG_URL -> sig + else -> ByteArray(123) + }, + ) + true + } + try { + val root = temp.newFolder("rootfs") + val store = EsyncPacks.store(root).apply { mkdirs() } + File(store, "wanted.tsv").writeText("$stockNtdll\t$stockWineserver\tproton-11.0-2b-arm64\t$exports\t/t\t1\n") + File(store, "tools.tsv").writeText("/t\t1787950357 proton-11.0-2b-arm64\t$stockNtdll\t$stockWineserver\twanted\t-\n") + assertEquals(0, EsyncPacks.fetchWanted(context, root)) + assertEquals(listOf(EsyncPacks.INDEX_URL, EsyncPacks.SIG_URL), requested) + requested.clear() + index = indexBytes(7, ownEntry, *others) + sig = sign(index, pair) + File(cache, "state.json").delete() + assertEquals(0, EsyncPacks.fetchWanted(context, root)) + assertEquals(listOf(EsyncPacks.INDEX_URL, EsyncPacks.SIG_URL, "$base$own.tzst"), requested) + val failures = JSONObject(File(cache, "state.json").readText()).getJSONObject("packFailures") + assertEquals(listOf(own), failures.keys().asSequence().toList()) + assertTrue(File(store, "packs").listFiles().orEmpty().none { it.name == own }) + requested.clear() + assertEquals(0, EsyncPacks.fetchWanted(context, root)) + assertTrue(requested.isEmpty()) + } finally { + EsyncPacks.keyOverride = null + EsyncPacks.fetcher = original + cache.deleteRecursively() + } + } + + @Test fun aReplayedOlderIndexIsRefusedAndATamperedCacheIgnored() { + val context = RuntimeEnvironment.getApplication() + val cache = File(context.filesDir, "droiddeck-esync").apply { deleteRecursively() } + val pair = keyPair() + var served = indexBytes(5, entryJson("ge-a-r1")) + var online = true + val original = EsyncPacks.fetcher + EsyncPacks.keyOverride = pair.public + EsyncPacks.fetcher = { url, _, out, _ -> + if (online) out.write(if (url == EsyncPacks.SIG_URL) sign(served, pair) else served) + online + } + try { + assertEquals(5L, EsyncPacks.refreshIndex(context)?.generated) + assertEquals("5", File(cache, "floor").readText().trim()) + File(cache, "index.json").delete() + File(cache, "state.json").delete() + served = indexBytes(4, entryJson("ge-old-r1")) + assertNull(EsyncPacks.refreshIndex(context)) + assertFalse(File(cache, "index.json").exists()) + File(cache, "state.json").delete() + served = indexBytes(6, entryJson("ge-b-r1")) + assertEquals(6L, EsyncPacks.refreshIndex(context)?.generated) + assertEquals("6", File(cache, "floor").readText().trim()) + val bytes = File(cache, "index.json").readBytes() + bytes[bytes.size / 2] = (bytes[bytes.size / 2] + 1).toByte() + File(cache, "index.json").writeBytes(bytes) + File(cache, "state.json").delete() + online = false + assertNull(EsyncPacks.refreshIndex(context)) + } finally { + EsyncPacks.keyOverride = null + EsyncPacks.fetcher = original + cache.deleteRecursively() + } + } + + @Test fun removeRevokedDeletesOnlyRevokedPacks() { + val store = temp.newFolder("store") + for (id in listOf("ge-a-r1", "ge-b-r1")) { + File(store, "packs/$id/files").mkdirs() + File(store, "packs/$id/.complete").writeText("") + } + val index = EsyncPacks.parseIndex(indexBytes(1, entryJson("ge-a-r1", revoked = true), entryJson("ge-b-r1"))) + assertEquals(listOf("ge-a-r1"), EsyncPacks.removeRevoked(store, index)) + assertFalse(File(store, "packs/ge-a-r1").exists()) + assertTrue(File(store, "packs/ge-b-r1/.complete").isFile) + assertEquals(setOf("ge-b-r1"), EsyncPacks.installedIds(store)) + assertTrue(File(store, "packs").list()!!.none { it.startsWith(".") }) + } + + @Test fun entryPathAcceptsTheThreeFilesAndTheirDirectories() { + assertEquals("pack.json", EsyncPacks.entryPath(TarArchiveEntry("pack.json"))) + assertEquals("pack.json", EsyncPacks.entryPath(TarArchiveEntry("./pack.json"))) + assertEquals(EsyncPacks.WINESERVER, EsyncPacks.entryPath(TarArchiveEntry("files/bin-arm64/wineserver"))) + assertEquals(EsyncPacks.NTDLL, EsyncPacks.entryPath(TarArchiveEntry("./files/lib/wine/aarch64-unix/ntdll.so"))) + assertNull(EsyncPacks.entryPath(TarArchiveEntry("./"))) + assertNull(EsyncPacks.entryPath(TarArchiveEntry("files/"))) + assertNull(EsyncPacks.entryPath(TarArchiveEntry("./files/lib/wine/aarch64-unix/"))) + } + + @Test fun entryPathRejectsUnsafeOrUnexpectedEntries() { + rejects { EsyncPacks.entryPath(TarArchiveEntry("/pack.json", true)) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("../pack.json")) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("files/../pack.json")) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("files/./bin-arm64/wineserver")) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("pack.json", TarConstants.LF_SYMLINK).apply { linkName = "/etc/passwd" }) } + rejects { EsyncPacks.entryPath(TarArchiveEntry(EsyncPacks.WINESERVER, TarConstants.LF_LINK).apply { linkName = "pack.json" }) } + rejects { EsyncPacks.entryPath(TarArchiveEntry(EsyncPacks.NTDLL, TarConstants.LF_CHR)) } + rejects { EsyncPacks.entryPath(TarArchiveEntry(EsyncPacks.NTDLL, TarConstants.LF_BLK)) } + rejects { EsyncPacks.entryPath(TarArchiveEntry(EsyncPacks.NTDLL, TarConstants.LF_FIFO)) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("files/bin-arm64/wine")) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("files/lib/wine/aarch64-windows/")) } + rejects { EsyncPacks.entryPath(TarArchiveEntry("etc/")) } + } + + @Test fun unpackRejectsMissingDuplicateAndOversizedEntries() { + val pack = packJson("ge-a-r1").toString().toByteArray() + rejects { + EsyncPacks.unpack(tar(regular("pack.json", pack), regular(EsyncPacks.NTDLL, ntdllBytes)), temp.newFolder()) + } + rejects { + EsyncPacks.unpack(tar( + regular("pack.json", pack), regular(EsyncPacks.NTDLL, ntdllBytes), + regular(EsyncPacks.WINESERVER, wineserverBytes), regular("./pack.json", pack), + ), temp.newFolder()) + } + rejects { + EsyncPacks.unpack(tar( + regular("pack.json", ByteArray((1 shl 20) + 1)), regular(EsyncPacks.NTDLL, ntdllBytes), + regular(EsyncPacks.WINESERVER, wineserverBytes), + ), temp.newFolder()) + } + val dest = temp.newFolder() + EsyncPacks.unpack(tar(regular("./pack.json", pack), regular(EsyncPacks.NTDLL, ntdllBytes), regular(EsyncPacks.WINESERVER, wineserverBytes)), dest) + assertTrue(File(dest, EsyncPacks.NTDLL).readBytes().contentEquals(ntdllBytes)) + assertTrue(File(dest, EsyncPacks.WINESERVER).readBytes().contentEquals(wineserverBytes)) + } + + @Test fun placeInstallsAVerifiedPackOnce() { + val store = temp.newFolder("store") + val id = "ge-GE-Proton11-7-1789520806-6ba770ec0df5-r1" + val entry = entries(entryJson(id)).single() + assertTrue(EsyncPacks.place(store, entry, packTar(packJson(id)))) + val dir = File(store, "packs/$id") + assertTrue(File(dir, ".complete").isFile) + assertTrue(File(dir, EsyncPacks.NTDLL).canExecute()) + assertTrue(File(dir, EsyncPacks.WINESERVER).canExecute()) + assertTrue(File(dir, EsyncPacks.NTDLL).readBytes().contentEquals(ntdllBytes)) + assertEquals(setOf(id), EsyncPacks.installedIds(store)) + assertFalse(EsyncPacks.place(store, entry, packTar(packJson(id), prefix = "./"))) + assertTrue(File(dir, ".complete").isFile) + assertTrue(File(store, "packs").list()!!.none { it.startsWith(".") }) + } + + @Test fun placeReplacesAnIncompleteDirectory() { + val store = temp.newFolder("store") + val id = "ge-a-r1" + File(store, "packs/$id/files").mkdirs() + assertTrue(EsyncPacks.place(store, entries(entryJson(id)).single(), packTar(packJson(id)))) + assertTrue(File(store, "packs/$id/.complete").isFile) + assertTrue(File(store, "packs").list()!!.none { it.startsWith(".") }) + } + + @Test fun placeRejectsAPackThatDisagreesWithTheIndex() { + val store = temp.newFolder("store") + val id = "ge-a-r1" + val entry = entries(entryJson(id)).single() + rejects { EsyncPacks.place(store, entry, packTar(packJson(id, rev = 2))) } + rejects { EsyncPacks.place(store, entry, packTar(packJson("ge-b-r1"))) } + rejects { EsyncPacks.place(store, entry, packTar(packJson(id, sourceMatch = true))) } + rejects { EsyncPacks.place(store, entry, packTar(packJson(id, ntdll = "9".repeat(64)))) } + rejects { EsyncPacks.place(store, entry, packTar(packJson(id), ntdll = "something else".toByteArray())) } + assertFalse(File(store, "packs/$id").exists()) + assertTrue(File(store, "packs").list()!!.isEmpty()) + } + + @Test fun placeRejectsAPackJsonTheGuestWouldSkip() { + val store = temp.newFolder("store") + val id = "ge-a-r1" + val entry = entries(entryJson(id)).single() + fun bad(change: JSONObject.() -> Unit) = rejects { EsyncPacks.place(store, entry, packTar(packJson(id).apply(change))) } + bad { remove("format") } + bad { put("format", "1") } + bad { put("format", true) } + bad { remove("source_match") } + bad { put("source_match", "false") } + bad { put("source_match", 0) } + bad { put("rev", "1") } + bad { remove("rev") } + bad { remove("version") } + bad { put("version_line", 5) } + bad { put("version_line", JSONObject.NULL) } + bad { remove("exports") } + bad { put("exports", exports.uppercase()) } + bad { put("stock", JSONObject().put(EsyncPacks.NTDLL, stockNtdll.uppercase()).put(EsyncPacks.WINESERVER, stockWineserver)) } + bad { put("files", JSONObject().put(EsyncPacks.NTDLL, sha(ntdllBytes).uppercase()).put(EsyncPacks.WINESERVER, sha(wineserverBytes))) } + bad { put("copy", "files/bin-arm64/wine") } + bad { put("copy", JSONObject.NULL) } + bad { put("copy", JSONArray(listOf("files/bin-arm64/wine", "files/bin-arm64/../wine"))) } + bad { put("copy", JSONArray(listOf(EsyncPacks.NTDLL))) } + bad { put("revoked", true) } + bad { put("revoked", "yes") } + bad { put("ignored", 1) } + assertTrue(File(store, "packs").list()!!.isEmpty()) + val copy = JSONArray(listOf("files/bin-arm64/wine", "files/lib/wine/aarch64-unix/wine", "files/lib/wine/aarch64-unix/wine-preloader", "proton")) + assertTrue(EsyncPacks.place(store, entry, packTar(packJson(id).put("copy", copy).put("revoked", false).put("ignored", JSONObject.NULL)))) + assertEquals(setOf(id), EsyncPacks.installedIds(store)) + } + + @Test fun copyPathsFollowTheGuestRules() { + for (rel in listOf("files/bin-arm64/wine", "files/lib/wine/aarch64-unix/wine-preloader", "proton", "files/extra", "files/lib/wine/x")) { + assertTrue(rel, EsyncPacks.copyPath(rel)) + } + for (rel in listOf( + "", "/proton", "./proton", "files/../proton", "files//wine", "files/bin-arm64/", "files", "files/lib/wine/aarch64-unix", + EsyncPacks.NTDLL, EsyncPacks.WINESERVER, "dist.lock", "files/steampipe_fixups_mtime", ".droiddeck-esync.json", + "files/share/wine/x", "files/lib/wine/aarch64-windows/ntdll.dll", "a\u0000b", + )) { + assertFalse(rel, EsyncPacks.copyPath(rel)) + } + assertFalse(EsyncPacks.copyPath(5)) + assertFalse(EsyncPacks.copyPath(null)) + } + + @Test fun failedPackDownloadsBackOffPerAssetChecksum() { + val minute = 60_000L + val now = 10_000L * minute + val entry = entries(entryJson("ge-a-r1")).single() + val failures = JSONObject() + assertFalse(EsyncPacks.failedRecently(failures, entry, now)) + failures.put(entry.id, EsyncPacks.failure(failures, entry, now)) + assertEquals(1, failures.getJSONObject(entry.id).getInt("n")) + assertTrue(EsyncPacks.failedRecently(failures, entry, now)) + assertTrue(EsyncPacks.failedRecently(failures, entry, now + 9 * minute)) + assertFalse(EsyncPacks.failedRecently(failures, entry, now + 10 * minute)) + assertFalse(EsyncPacks.failedRecently(failures, entry, now - minute)) + failures.put(entry.id, EsyncPacks.failure(failures, entry, now)) + assertEquals(2, failures.getJSONObject(entry.id).getInt("n")) + assertTrue(EsyncPacks.failedRecently(failures, entry, now + 19 * minute)) + assertFalse(EsyncPacks.failedRecently(failures, entry, now + 20 * minute)) + repeat(10) { failures.put(entry.id, EsyncPacks.failure(failures, entry, now)) } + assertTrue(EsyncPacks.failedRecently(failures, entry, now + 11 * 60 * minute)) + assertFalse(EsyncPacks.failedRecently(failures, entry, now + 12 * 60 * minute)) + val reuploaded = entries(entryJson("ge-a-r1").apply { getJSONObject("asset").put("sha256", "e".repeat(64)) }).single() + assertFalse(EsyncPacks.failedRecently(failures, reuploaded, now + minute)) + assertEquals(1, EsyncPacks.failure(failures, reuploaded, now).getInt("n")) + val other = entries(entryJson("ge-b-r1")).single() + failures.put(other.id, EsyncPacks.failure(failures, other, now)) + failures.put("ge-gone-r1", JSONObject().put("sha", "d".repeat(64)).put("n", 1).put("at", now)) + val index = EsyncPacks.parseIndex(indexBytes( + 2, + entryJson("ge-a-r1").apply { getJSONObject("asset").put("sha256", "e".repeat(64)) }, + entryJson("ge-b-r1"), + )) + val pruned = EsyncPacks.pruneFailures(failures, index) + assertEquals(listOf("ge-b-r1"), pruned.keys().asSequence().toList()) + assertEquals(0, EsyncPacks.pruneFailures(null, index).length()) + } + + @Test fun aPerGameFallbackOptInKeepsPackFetchingOn() { + val context = RuntimeEnvironment.getApplication() + val settings = File(context.filesDir, "game-environment.json") + settings.delete() + try { + SessionPrefs.setSyncFallback(context, false) + assertFalse(EsyncPacks.enabled(context)) + val root = temp.newFolder("rootfs") + EsyncPacks.store(root).mkdirs() + assertEquals(0, EsyncPacks.fetchWanted(context, root)) + GameEnvironmentStore.save(context, GameEnvironment.Config(games = mapOf("42" to mapOf("BL_SYNC_FALLBACK" to "1")))) + assertTrue(EsyncPacks.enabled(context)) + GameEnvironmentStore.save(context, GameEnvironment.Config(shared = mapOf("BL_SYNC_FALLBACK" to "1"))) + assertTrue(EsyncPacks.enabled(context)) + GameEnvironmentStore.save(context, GameEnvironment.Config(games = mapOf("42" to mapOf("BL_SYNC_FALLBACK" to "0")))) + assertFalse(EsyncPacks.enabled(context)) + SessionPrefs.setSyncFallback(context, true) + assertTrue(EsyncPacks.enabled(context)) + } finally { + SessionPrefs.setSyncFallback(context, true) + settings.delete() + } + } + + @Test fun wantedRowsAreDeduplicatedAndToolsParsed() { + val wanted = EsyncPacks.parseWanted( + "$stockNtdll\t$stockWineserver\tGE-Proton11-7\t$exports\t$toolDir\t1789520806\n" + + "$stockNtdll\t$stockWineserver\tGE-Proton11-7\t$exports\t/elsewhere\t1789520900\n" + + "short\tline\n" + + "${"1".repeat(64)}\t${"2".repeat(64)}\texperimental-11.0-20260910b-arm64\t$exports\t/root/.local/share/Steam/steamapps/common/Proton Experimental (ARM64)\t5\r\n" + + "${"3".repeat(64)}\t${"4".repeat(64)}\t-\t-\t/root/odd\t6\n", + ) + assertEquals(3, wanted.size) + assertEquals(toolDir, wanted[0].toolDir) + assertEquals(1789520806L, wanted[0].firstSeen) + assertEquals("/root/.local/share/Steam/steamapps/common/Proton Experimental (ARM64)", wanted[1].toolDir) + assertEquals("", wanted[2].version) + assertEquals("", wanted[2].exports) + assertFalse(EsyncPacks.matches(entries(entryJson("valve-r1", sourceMatch = true, version = "")).single(), wanted[2])) + val tools = EsyncPacks.parseTools( + "$toolDir\t1789520806 GE-Proton11-7\t$stockNtdll\t$stockWineserver\tpack\tge-a-r1\n" + + "/root/x\t1 y\t$stockNtdll\t$stockWineserver\twanted\t-\n", + ) + assertEquals("ge-a-r1", tools[0].packId) + assertEquals("wanted", tools[1].state) + assertNull(tools[1].packId) + assertEquals("pack", EsyncPacks.Status(1, 0, tools).tool("$toolDir/")?.state) + } + + @Test fun statusCountsCompletePacksAndWantedRows() { + val root = temp.newFolder("rootfs") + val store = EsyncPacks.store(root) + assertEquals(EsyncPacks.Status(0, 0, emptyList()), EsyncPacks.status(root)) + File(store, "packs/ge-a-r1").mkdirs() + File(store, "packs/ge-a-r1/.complete").writeText("") + File(store, "packs/ge-b-r1").mkdirs() + File(store, "wanted.tsv").writeText("${"1".repeat(64)}\t${"2".repeat(64)}\tv\t$exports\t/t\t1\n") + File(store, "tools.tsv").writeText("/t\t1 v\t${"1".repeat(64)}\t${"2".repeat(64)}\twanted\t-\n") + val status = EsyncPacks.status(root) + assertEquals(1, status.installed) + assertEquals(1, status.wanted) + assertEquals("wanted", status.tool("/t")?.state) + } + + @Test fun statusCountsAnInstalledMatchingPackBeforeTheNextReconcile() { + val root = temp.newFolder("rootfs") + val store = EsyncPacks.store(root) + val ntdll = "1".repeat(64) + val wineserver = "2".repeat(64) + fun install(id: String, pack: JSONObject, ignored: Boolean = false) { + val dir = File(store, "packs/$id").apply { mkdirs() } + File(dir, "pack.json").writeText(pack.toString()) + File(dir, ".complete").writeText("") + if (ignored) File(dir, ".ignored").writeText("") + } + fun stock(n: String, w: String) = JSONObject().put(EsyncPacks.NTDLL, n).put(EsyncPacks.WINESERVER, w) + store.mkdirs() + File(store, "wanted.tsv").writeText("$ntdll\t$wineserver\tv-arm64\t$exports\t/t\t1\n") + File(store, "tools.tsv").writeText( + "/t\t1 v-arm64\t$ntdll\t$wineserver\twanted\t-\n/u\t1 other\t${"3".repeat(64)}\t${"4".repeat(64)}\twanted\t-\n" + + "/b\t1 builtin\t${"5".repeat(64)}\t${"6".repeat(64)}\tbuiltin\t-\n", + ) + install("valve-ignored-r1", JSONObject().put("rev", 1).put("stock", stock(ntdll, wineserver)), ignored = true) + install("valve-other-r1", JSONObject().put("rev", 1).put("stock", stock("7".repeat(64), "8".repeat(64))) + .put("source_match", true).put("version", "v-other").put("exports", exports)) + assertEquals(listOf("wanted", "wanted", "builtin"), EsyncPacks.status(root).tools.map { it.state }) + + install("valve-source-r2", JSONObject().put("rev", 2).put("stock", stock("7".repeat(64), "8".repeat(64))) + .put("source_match", true).put("version", "v-arm64").put("exports", exports)) + var tools = EsyncPacks.status(root).tools + assertEquals("pack", tools[0].state) + assertEquals("valve-source-r2", tools[0].packId) + assertEquals("wanted", tools[1].state) + assertEquals("builtin", tools[2].state) + + install("valve-exact-r1", JSONObject().put("rev", 1).put("stock", stock(ntdll, wineserver))) + tools = EsyncPacks.status(root).tools + assertEquals("valve-exact-r1", tools[0].packId) + assertEquals(4, EsyncPacks.status(root).installed) + } + + @Test fun unusedPacksArePrunedAfterAMonth() { + val root = temp.newFolder("rootfs") + val store = EsyncPacks.store(root) + val day = 24L * 60 * 60 * 1000 + val now = 400L * day + fun install(id: String, ageDays: Long) { + val dir = File(store, "packs/$id").apply { mkdirs() } + File(dir, ".complete").writeText("") + dir.setLastModified(now - ageDays * day) + } + install("ge-tool-r1", 90) + install("ge-dist-r1", 90) + install("ge-bundled-r1", 90) + install("ge-wanted-r1", 90) + install("ge-old-r1", 31) + install("ge-young-r1", 29) + File(store, "dist/ge-dist-r1~01234567").mkdirs() + File(store, "dist/ge-dist-r1~01234567/.droiddeck-esync.json").writeText(JSONObject().put("pack", "ge-dist-r1").toString()) + val index = EsyncPacks.parseIndex(indexBytes(1, entryJson("ge-wanted-r1"), entryJson("ge-other-r1", ntdll = "e".repeat(64)))) + val tools = listOf(EsyncPacks.ToolState("/t", "1 v", "1".repeat(64), "2".repeat(64), "pack", "ge-tool-r1")) + val keep = EsyncPacks.keep(store, index, listOf(row()), tools, setOf("ge-bundled-r1")) + assertEquals(setOf("ge-tool-r1", "ge-dist-r1", "ge-bundled-r1", "ge-wanted-r1"), keep) + assertEquals(listOf("ge-old-r1"), EsyncPacks.prune(store, keep, now)) + assertEquals(setOf("ge-tool-r1", "ge-dist-r1", "ge-bundled-r1", "ge-wanted-r1", "ge-young-r1"), EsyncPacks.installedIds(store)) + assertTrue(File(store, "packs").listFiles().orEmpty().none { it.name.startsWith(".") }) + assertTrue(EsyncPacks.prune(store, keep, now).isEmpty()) + } + + @Test fun distPathsMatchTheStockOrTheLaunchedToolPath() { + val root = temp.newFolder("rootfs") + val dist = File(EsyncPacks.store(root), "dist") + File(dist, "ge-a-r1~01234567").mkdirs() + File(dist, "ge-a-r1~01234567/.droiddeck-esync.json").writeText(JSONObject().put("pack", "ge-a-r1").put("stock", toolDir).put("tool", toolDir).toString()) + File(dist, "ge-b-r1~89abcdef").mkdirs() + File(dist, "ge-b-r1~89abcdef/.droiddeck-esync.json").writeText(JSONObject().put("pack", "ge-b-r1").put("stock", "/real/GE-Proton11-7").put("tool", "$toolDir/").toString()) + File(dist, "valve-x-r1~00000000").mkdirs() + File(dist, "valve-x-r1~00000000/.droiddeck-esync.json").writeText(JSONObject().put("pack", "valve-x-r1").put("stock", "/other").put("tool", "/other").toString()) + File(dist, ".ge-c-r1~11111111.tmp-42").mkdirs() + File(dist, "ge-a-r1~01234567.lock").writeText("") + assertEquals( + listOf("${EsyncPacks.GUEST_STORE}/dist/ge-a-r1~01234567", "${EsyncPacks.GUEST_STORE}/dist/ge-b-r1~89abcdef"), + EsyncPacks.distPathsFor(root, "$toolDir/"), + ) + assertEquals(listOf("${EsyncPacks.GUEST_STORE}/dist/ge-b-r1~89abcdef"), EsyncPacks.distPathsFor(root, "/real/GE-Proton11-7")) + assertTrue(EsyncPacks.distPathsFor(root, "").isEmpty()) + assertTrue(EsyncPacks.distPathsFor(temp.newFolder("empty"), toolDir).isEmpty()) + } + + @Test fun refreshIsDueByAgeMissesAndFailures() { + val hour = 60L * 60 * 1000 + val now = 1_000L * hour + val cached = EsyncPacks.parseIndex(indexBytes(1, entryJson("ge-a-r1"))) + val unmatched = row(ntdll = "1".repeat(64), wineserver = "2".repeat(64)) + fun state(checked: Long, failed: Long = 0, misses: Int = 0, missAt: Long = 0) = JSONObject().apply { + put("checkedAt", checked) + if (failed > 0) put("failedAt", failed) + if (misses > 0) put("misses", JSONObject().put(unmatched.key, JSONObject().put("n", misses).put("at", missAt))) + } + assertTrue(EsyncPacks.due(null, JSONObject(), emptyList(), now)) + assertFalse(EsyncPacks.due(null, state(0, failed = now - 60_000), emptyList(), now)) + assertTrue(EsyncPacks.due(cached, state(now - 13 * hour), emptyList(), now)) + assertFalse(EsyncPacks.due(cached, state(now - hour), emptyList(), now)) + assertFalse(EsyncPacks.due(cached, state(now - hour), listOf(row()), now)) + assertTrue(EsyncPacks.due(cached, state(now - hour), listOf(unmatched), now)) + assertFalse(EsyncPacks.due(cached, state(now - 10 * 60_000), listOf(unmatched), now)) + assertFalse(EsyncPacks.due(cached, state(now - 2 * hour, misses = 2, missAt = now - 40 * 60_000), listOf(unmatched), now)) + assertTrue(EsyncPacks.due(cached, state(now - 2 * hour, misses = 2, missAt = now - 61 * 60_000), listOf(unmatched), now)) + assertFalse(EsyncPacks.due(cached, state(now - 2 * hour, misses = 9, missAt = now - 11 * hour), listOf(unmatched), now)) + assertTrue(EsyncPacks.due(cached, state(now + hour), emptyList(), now)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt new file mode 100644 index 000000000..ce830cca8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonDefaultTest.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +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 ProtonDefaultTest { + private val state = """ + {"default": "droiddeck-proton-11-arm64", "applied": 5, "live": true, + "tools": [{"name": "droiddeck-proton-arm64", "display": "Proton Experimental ARM64", "dir": "Proton Experimental (ARM64)", "valve": true}, + {"name": "droiddeck-proton-11-arm64", "display": "Proton 11.0 ARM64", "dir": "Proton 11.0 (ARM64)", "valve": true}, + {"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": false}]} + """.trimIndent() + + private fun proton(dir: String, valve: Boolean) = + ComponentsManager.Proton(dir, dir, File("/x/$dir"), "/x/$dir", "1", valve) + + @Test + fun theGuestAnswerNamesTheProtonGamesUse() { + val parsed = ProtonDefault.parseState(state) + assertEquals(true to "Proton 11.0 (ARM64)", ProtonDefault.chosen(parsed, null)) + assertTrue(parsed.live) + assertEquals(3, parsed.tools.size) + } + + @Test + fun aRequestNotYetTakenWinsAndATakenOneDoesNot() { + val parsed = ProtonDefault.parseState(state) + val newer = ProtonDefault.parseRequest("""{"seq": 6, "valve": false, "dir": "GE-Proton11-7"}""") + assertEquals(false to "GE-Proton11-7", ProtonDefault.chosen(parsed, newer)) + val taken = ProtonDefault.parseRequest("""{"seq": 5, "valve": false, "dir": "GE-Proton11-7"}""") + assertEquals(true to "Proton 11.0 (ARM64)", ProtonDefault.chosen(parsed, taken)) + } + + @Test + fun onlyToolsTheGuestCanStartAreOffered() { + val parsed = ProtonDefault.parseState(state) + assertTrue(ProtonDefault.runnable(parsed, proton("GE-Proton11-7", false))) + assertFalse(ProtonDefault.runnable(parsed, proton("Proton Experimental", true))) + assertTrue(ProtonDefault.runnable(ProtonDefault.parseState(null), proton("Proton Experimental", true))) + } + + @Test + fun aNewRequestAlwaysOrdersAfterTheOldOnes() { + val parsed = ProtonDefault.parseState(state) + assertEquals(1_000L, ProtonDefault.nextSeq(1_000L, parsed, null)) + assertEquals(6L, ProtonDefault.nextSeq(2L, parsed, null)) + assertEquals(10_001L, ProtonDefault.nextSeq(2L, parsed, ProtonDefault.parseRequest("""{"seq": 10000, "dir": "x"}"""))) + } + + @Test + fun brokenFilesMeanNoAnswer() { + assertNull(ProtonDefault.chosen(ProtonDefault.parseState("{"), ProtonDefault.parseRequest("[]"))) + assertNull(ProtonDefault.parseRequest("""{"seq": 0, "dir": "x"}""")) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt index cdd553cff..0f65cecc3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SecondaryLibraryTest.kt @@ -59,6 +59,19 @@ class SecondaryLibraryTest { }) } + @Test fun fexAndItsRuntimeInstallWhereTheirProgramsCanRun() { + val files = tmp.newFolder("files") + val library = tmp.newFolder("card") + val manifest = File(library, "steamapps/appmanifest_3127680.acf") + manifest.parentFile!!.mkdirs() + manifest.writeText("\"AppState\" { \"appid\" \"3127680\" \"installdir\" \"FEX\" }") + val private = SecondaryLibrary.privateRoot(files, library) + val binds = SecondaryLibrary.binds(files, library) + assertTrue(binds.contains("$private/steamapps/common/FEX:/mnt/droiddeck-sd/steamapps/common/FEX")) + assertTrue(binds.contains("$private/steamapps/downloading/3127680:/mnt/droiddeck-sd/steamapps/downloading/3127680")) + assertTrue(binds.contains("$private/steamapps/common/SteamLinuxRuntime_sniper:/mnt/droiddeck-sd/steamapps/common/SteamLinuxRuntime_sniper")) + } + @Test fun deletingAStagedDepotDoesNotRestoreTheOldSharedDownload() { val files = tmp.newFolder("files") val library = tmp.newFolder("card") diff --git a/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt new file mode 100644 index 000000000..c4b5e2667 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/SyncBackendTest.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.session + +import org.robolectric.RuntimeEnvironment +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SyncBackendTest { + private val context: Context = RuntimeEnvironment.getApplication() + + @Test + fun aFreshInstallStartsOnFsync() { + assertEquals(SessionPrefs.SYNC_FSYNC, SessionPrefs.syncBackend(context)) + } + + @Test + fun eachChoiceIsKeptAndSetsTheSwitchesTogether() { + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_NTSYNC) + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackend(context)) + assertTrue(SessionPrefs.fastSync(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_FSYNC) + assertEquals(SessionPrefs.SYNC_FSYNC, SessionPrefs.syncBackend(context)) + assertEquals(false, SessionPrefs.fastSync(context)) + assertTrue(SessionPrefs.fsyncFirst(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + assertEquals(false, SessionPrefs.fsyncFirst(context)) + } + + @Test + fun pickingATabTurnsSyncBackOn() { + SessionPrefs.setSyncFallback(context, false) + SessionPrefs.setFsyncFirst(context, false) + assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_FSYNC) + assertTrue(SessionPrefs.syncFallback(context)) + assertEquals(SessionPrefs.SYNC_FSYNC, SessionPrefs.syncBackend(context)) + } + + @Test + fun wineserverTurnsEverySyncOffAndBackOn() { + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_WINESERVER) + assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackend(context)) + assertEquals(false, SessionPrefs.fastSync(context)) + assertEquals(false, SessionPrefs.fsyncFirst(context)) + assertEquals(false, SessionPrefs.syncFallback(context)) + SessionPrefs.setSyncBackend(context, SessionPrefs.SYNC_ESYNC) + assertTrue(SessionPrefs.syncFallback(context)) + assertEquals(SessionPrefs.SYNC_ESYNC, SessionPrefs.syncBackend(context)) + } + + @Test + fun ntsyncWinsOverFsyncFirstAsTheLauncherDoes() { + assertEquals(SessionPrefs.SYNC_NTSYNC, SessionPrefs.syncBackendOf(fastSync = true, fsyncFirst = true, syncFallback = true)) + assertEquals(SessionPrefs.SYNC_FSYNC, SessionPrefs.syncBackendOf(fastSync = false, fsyncFirst = true, syncFallback = false)) + assertEquals(SessionPrefs.SYNC_WINESERVER, SessionPrefs.syncBackendOf(fastSync = false, fsyncFirst = false, syncFallback = false)) + } +} diff --git a/docs/development/flatpak.md b/docs/development/flatpak.md index 006bab55a..939b8c6be 100644 --- a/docs/development/flatpak.md +++ b/docs/development/flatpak.md @@ -11,7 +11,7 @@ ARM64 builds are listed. Everything lives in one per-user installation at ## Putting Flatpak in the runtime -`bannerlator-flatpak-setup` runs under proot's fake root (`-0`), because pacman and pacman-key +`droiddeck-flatpak-setup` runs under proot's fake root (`-0`), because pacman and pacman-key refuse any other uid. The runtime's pacman database lists only the base image. The desktop and emulator packages were unpacked over it without registering. A plain `pacman -S flatpak` would therefore reinstall about a hundred packages, Mesa among them, over the runtime's KGSL @@ -28,7 +28,7 @@ setup script also puts Flatpak's own error text in the failure message the app s ## bubblewrap without namespaces Android gives apps no user namespaces, so `bwrap` cannot work. Flatpak is pointed at -`bannerlator-bwrap` instead (`FLATPAK_BWRAP`, exported by `bannerlator-session` and by the +`droiddeck-bwrap` instead (`FLATPAK_BWRAP`, exported by `droiddeck-session` and by the store's commands). It reads bwrap's command line, including `--args` fds and the `--file` and `--bind-data` payloads, and then does one of two things: @@ -64,7 +64,7 @@ The spawner also adds what the rootfs gives its own programs: ## Running apps -The front end starts an app as a run-mode session of `bannerlator-flatpak-run <app-id>`, full +The front end starts an app as a run-mode session of `droiddeck-flatpak-run <app-id>`, full screen under gamescope. Under gamescope it passes `--nosocket=wayland --socket=x11` and sets `XDG_SESSION_TYPE=x11`. Otherwise Flatpak finds the app compositor's `wayland-0` and the window opens behind gamescope. gamescope's own Wayland socket is no alternative: Chromium on Wayland @@ -76,7 +76,7 @@ wrapped through `droiddeck-gpu` like the rootfs's own. ## The store `FlathubApi` reads flathub.org's public API: collections, search filtered to `aarch64`, and -AppStream details. `bannerlator-flatpak` drives libflatpak through PyGObject and prints one +AppStream details. `droiddeck-flatpak` drives libflatpak through PyGObject and prints one JSON object per line (`op`, `progress`, `error`, `done`). `FlatpakManager` turns those lines into the store's progress bar. Store commands run in a proot of their own, and `OrphanReaper` spares them when a session starts. @@ -90,7 +90,7 @@ proot has no FUSE, and extracting at every start would cost the whole image each comment and icon come from the image's own desktop entry. A menu entry goes to `/usr/local/share/applications`, where the desktop's GPU wrapper picks it up. -`bannerlator-appimage-run` starts it: +`droiddeck-appimage-run` starts it: - `APPIMAGE` stays unset, so apps don't offer to add themselves to the menu or update in place. - Firefox forks get the same sandbox switches as Flatpaks. diff --git a/docs/development/game-environment.md b/docs/development/game-environment.md index 92e4ede45..888766aaf 100644 --- a/docs/development/game-environment.md +++ b/docs/development/game-environment.md @@ -62,7 +62,7 @@ Windows/Wine graphics helper also maps the selected feature level to DroidDeck instead publishes an atomic JSON snapshot at `/root/.config/droiddeck/game-environment.json`. Both its Valve ARM64 Proton -wrapper and adopted third-party Proton wrappers execute `bannerlator-game-env`, +wrapper and adopted third-party Proton wrappers execute `droiddeck-game-env`, which reads that snapshot for each real game launch and uses `execvpe` to start Proton. Probe prefix `compatdata/0` and non-launch verbs are unchanged. Malformed configuration falls back to the inherited environment without evaluating its diff --git a/docs/development/proot-performance.md b/docs/development/proot-performance.md index a9696fe3a..e9f18847b 100644 --- a/docs/development/proot-performance.md +++ b/docs/development/proot-performance.md @@ -41,9 +41,9 @@ fastest correct setup is. Everything below was measured on a Snapdragon 8 Elite ``` SessionService ─HostProcess─▶ libproot.so (ONE tracer process, nice -6 after 2 s) - └─ /usr/bin/env -i … bannerlator-session steam - └─ gamescope ─▶ bannerlator-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree) - └─ reaper ─▶ bannerlator-game-env ─▶ bannerlator-proton ─▶ Valve ARM64 Proton (python) + └─ /usr/bin/env -i … droiddeck-session steam + └─ gamescope ─▶ droiddeck-session (BL_INSIDE) ─▶ steamrtarm64/steam (+ steamwebhelper/CEF tree) + └─ reaper ─▶ droiddeck-game-env ─▶ droiddeck-proton ─▶ Valve ARM64 Proton (python) └─ wine (arm64ec, FEX loaded in-process as a DLL) ─▶ wineserver, game.exe, … ``` @@ -182,9 +182,9 @@ Checked against bionic `android16-release`: |---|---|---| | `rseq`, `set_robust_list` | **blocked** | glibc, at the start of every thread, while all signals are blocked | | `faccessat2` | **blocked** | glibc's `faccessat()`, which tries it on **every** call before falling back, so 2 round trips (`access()` calls the plain `faccessat` syscall directly) | -| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx | +| `fchmodat2`, `openat2` | **blocked** | newer glibc, Flatpak/libglnx, FEX's rootfs lookups (`libblsession.so` turns `openat2` into `openat`) | | `setuid`, `setgid` and the rest of the family | **blocked** | Xwayland (hence `-i`) | -| `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync, some engines | +| `futex_waitv`, `io_uring_*`, `landlock_*` | **blocked** | Proton's fsync (droiddeck-fsync answers `futex_waitv` in `libblsession.so`), some engines | | `clone3` | blocked before Android 15 | glibc `pthread_create` | | `close_range` | blocked on Android 12 | | | `statx`, `process_vm_readv`/`writev`, `memfd_create`, `pidfd_*`, `seccomp` | allowed | | @@ -227,7 +227,8 @@ call can be answered in-process with the 0014 design. 2. **Hybrid by process (Steam in proot, game on glibc, or the reverse).** The game side is the *harder* half. Wine re-execs itself and `wineserver`, which hits `PT_INTERP`. Its server directory is hard-coded under `/tmp`, and Proton's esync and fsync use `shm_open` (`/dev/shm`) - and `futex_waitv`, which is blocked. A game's steady state is already mostly untraced, so the + and `futex_waitv`, which is blocked (droiddeck-fsync emulates it in the session preload, which a + glibc-side game would have to load too). A game's steady state is already mostly untraced, so the gain is in loading. Not worth it before the fast path. 3. **Seccomp user-notification instead of ptrace.** It measures the same as ptrace, and it can't rewrite arguments, so every call would have to be emulated. No. diff --git a/docs/development/steam-game-imports.md b/docs/development/steam-game-imports.md index 4c4efc0e1..7967dd650 100644 --- a/docs/development/steam-game-imports.md +++ b/docs/development/steam-game-imports.md @@ -4,7 +4,7 @@ Second Library still registers the selected Steam library. It now also scans Win executables in its immediate game subfolders and `steamapps/common`, using the same importer as Added Games. Existing Steam manifests are preserved. -`bannerlator-steam-games watch` runs beside the client. It identifies a game by an +`droiddeck-steam-games watch` runs beside the client. It identifies a game by an existing manifest's install directory, a `steam_appid.txt` beside the game or selected executable, or a unique normalized title from Steam's store search with an explicit country. Matching also accepts the terminal Windows Edition and INTERGRADE suffixes; diff --git a/tools/build_local.sh b/tools/build_local.sh index bab37c8c8..2742ac6fc 100755 --- a/tools/build_local.sh +++ b/tools/build_local.sh @@ -39,9 +39,9 @@ for tool in curl tar zstd shasum unzip; do exit 1 fi done -if [[ -f "${repo_root}/tools/gamescope/release.env" || -f "${repo_root}/tools/wlroots/release.env" ]] \ - && ! command -v gh >/dev/null 2>&1; then - echo "GitHub CLI is required to download the pinned Gamescope and wlroots release assets." >&2 +if [[ -f "${repo_root}/tools/gamescope/release.env" || -f "${repo_root}/tools/wlroots/release.env" \ + || -f "${repo_root}/tools/droiddeck-esync/release.env" ]] && ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required to download the pinned Gamescope, wlroots and droiddeck-esync release assets." >&2 exit 1 fi @@ -129,7 +129,7 @@ docker run --rm --platform linux/amd64 \ 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" - for script in tools/linuxfs/overlay/usr/local/bin/bannerlator-*; do + 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 @@ -166,8 +166,18 @@ docker run --rm --platform linux/amd64 \ exit 1 } done - test -f "$d/usr/local/bin/bannerlator-session" - test -f "$d/usr/local/bin/bannerlator-proton-extra" + test -f "$d/usr/local/bin/droiddeck-session" + test -f "$d/usr/local/bin/droiddeck-proton-extra" + ' + +docker run --rm --platform linux/amd64 \ + -v "${repo_root}:/src" -w /src debian:bullseye bash -c ' + set -euo pipefail + printf "deb http://archive.debian.org/debian bullseye main\ndeb http://archive.debian.org/debian-security bullseye-security main\n" > /etc/apt/sources.list + 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 + tools/linuxfs/build-x86-preloads.sh app/src/main/assets/linuxfs + chown -R '"$(id -u):$(id -g)"' app/src/main/assets/linuxfs ' github_repo=${DROIDDECK_GITHUB_REPOSITORY:-} @@ -223,6 +233,36 @@ if [[ -f "${repo_root}/tools/wlroots/release.env" ]]; then test -f "${linuxfs_dir}/usr/local/lib/droiddeck-wlroots/libwlroots-0.20.so" fi +. "${repo_root}/tools/linuxfs/uruntime.env" +uruntime_binary=$(cached "${URUNTIME_SHA256}" "${URUNTIME_ASSET}" \ + bash -c 'curl -fsSL --retry 3 -o "$out" "$0"' "https://github.com/VHSgunzo/uruntime/releases/download/${URUNTIME_VERSION}/${URUNTIME_ASSET}") +install -Dm644 "${uruntime_binary}" "${linuxfs_dir}/usr/local/lib/droiddeck/uruntime" +install -Dm644 "${repo_root}/tools/linuxfs/licenses/uruntime-LICENSE" "${linuxfs_dir}/usr/local/share/licenses/uruntime/LICENSE" + +sync_assets="${repo_root}/app/src/main/assets/droiddeck-esync" +if [[ -f "${repo_root}/tools/droiddeck-esync/release.env" ]]; then + . "${repo_root}/tools/droiddeck-esync/release.env" + sync_archive=$(cached "${SYNC_BUNDLE_SHA256}" "${SYNC_BUNDLE_ASSET}" \ + bash -c 'gh release download "$0" -R "$2" -p "$1" -O "$out"' "${SYNC_BUNDLE_TAG}" "${SYNC_BUNDLE_ASSET}" "${SYNC_BUNDLE_REPO}") + rm -rf "${sync_assets}" + mkdir -p "${sync_assets}" + zstd -dc "${sync_archive}" | tar -xf - -C "${sync_assets}" + test -f "${sync_assets}/index.json" + test -f "${sync_assets}/index.json.sig" + sync_index=$(mktemp) + gh release download "${SYNC_BUNDLE_TAG}" -R "${SYNC_BUNDLE_REPO}" -p index.json -O "${sync_index}" --clobber + 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}") + for id in ${revoked}; do + if [[ -e "${sync_assets}/packs/${id}.tzst" ]]; then + echo "${SYNC_BUNDLE_ASSET} carries revoked pack ${id}; it is left out of the APK" >&2 + rm -f "${sync_assets}/packs/${id}.tzst" + fi + done + rm -f "${sync_index}" +elif [[ -d "${sync_assets}" ]]; then + echo "No tools/droiddeck-esync/release.env: the APK bundles the droiddeck-esync packs already in ${sync_assets}." >&2 +fi + mango_dir="${linuxfs_dir}/usr/local/lib/mangoapp" mango_pkgs="${staging_dir}/mango-pkgs" mkdir -p "${mango_dir}" "${mango_pkgs}" diff --git a/tools/droiddeck-esync/PATCHES.md b/tools/droiddeck-esync/PATCHES.md deleted file mode 100644 index bfefaade9..000000000 --- a/tools/droiddeck-esync/PATCHES.md +++ /dev/null @@ -1,170 +0,0 @@ -# droiddeck-esync packs - -DroidDeck gives Proton three in-process sync backends, picked with the Wine sync tabs in the -Steam settings' Games group: ntsync, fsync (the default), esync and wineserver. The wineserver tab -sets `PROTON_NO_ESYNC=1`, `PROTON_NO_FSYNC=1` and `PROTON_NO_NTSYNC=1` (unless a game's own -environment sets them), so every sync object goes through the server. - -- droiddeck-esync: eventfd and shared memory sync. Stock Wine 11 / Proton 11 builds - have no eventfd-based sync, so the patch series under `patches/<series>` adds it to wineserver - and the unix side of ntdll. A Proton build without a pack (a release newer than the published - packs, or one whose pack failed to install) runs stock with droiddeck-fsync standing in, or - droiddeck-ntsync where fsync is off for the game (`PROTON_NO_FSYNC=1`, or one of the games - Proton itself keeps off fsync and esync), so it still gets in-process sync; `PROTON_NO_NTSYNC=1` - as well leaves it on wineserver-only sync. A Proton with esync built in (GE, cachyos) runs its - own esync, and a game that turns esync off moves to droiddeck-fsync or droiddeck-ntsync there - too. -- droiddeck-fsync: Proton's own fsync on the stock binaries. fsync waits with `futex_waitv(2)`, - which the app sandbox refuses, so `libblsession.so` (`BL_FSYNC=1`) answers that system call in - userspace: every waiter registers the futexes it waits on in a shared table next to the fsync - shared memory and sleeps on a futex of its own, and every `FUTEX_WAKE` on the fsync shared memory - wakes the waiters registered on that word. No pack is involved, so it keeps working the moment - Steam updates Proton. It is the default (the fsync tab, `BL_FSYNC_FIRST=1`, also per game), - ahead of droiddeck-esync; on the esync tab it stands in where no pack fits, and it also takes - over for a game whose environment turns esync off (`PROTON_NO_ESYNC=1`, `WINEESYNC=0`, or too - few file descriptors); where fsync is off as well, such a game gets droiddeck-ntsync, with the - pack's wineserver when a pack fits. A launch into a prefix whose wineserver is still running - keeps the sync choice that server was started with (`prefixes.json` in the store), because a - Wine client that disagrees with its server about fsync exits. It inherits stock fsync's behaviour, the same as on a Linux kernel: - a wait-all on a mutex abandoned by a dead thread is not satisfied, a pending user APC is - delivered ahead of an object that is already signaled, a mutex owned by a process killed with - `TerminateProcess` is not abandoned, and `PulseEvent` can miss a waiter that has not run yet. -- droiddeck-ntsync: a userspace implementation of the `/dev/ntsync` interface in - `libblsession.so` (`BL_SYNC=1`), which stock Proton 11 builds use as they would the kernel - driver. It is the ntsync tab. A pack still supplies the binaries when one fits, and its - wineserver then picks ntsync over esync; a Proton whose wineserver has no ntsync client (no - `/dev/ntsync` in it) gets droiddeck-fsync instead. - -wineserver picks the backend at startup: `PROTON_NO_NTSYNC` unset -> `/dev/ntsync` -(droiddeck-ntsync, or a kernel driver the device provides) -> fsync (droiddeck-fsync, or a kernel -and sandbox that allow `futex_waitv`) -> droiddeck-esync (needs `WINEESYNC` nonzero) -> -server-side sync. When a pack is in use and droiddeck-esync is on, the guest's `droiddeck-esync` -script also sets `PROTON_NO_FSYNC=1` unless the game's environment sets it, so droiddeck-esync -comes before fsync unless droiddeck-fsync was chosen. A wineserver from a pack reports the -backend on stderr as `droiddeck-ntsync: up and running.` or `droiddeck-esync: up and running.`; -with droiddeck-fsync the stock wineserver prints `fsync: up and running.` after -`droiddeck-fsync: up and running.`. A pack's wineserver also abandons, when a process dies, every -mutex still owned by one of its threads, and, once a thread killed by `TerminateThread` has really -exited, every mutex it still owns. A killed thread keeps running until the kill signal reaches it -and can take a mutex after the server abandoned its mutexes; without these passes that mutex would -stay locked for good (they cover droiddeck-ntsync and kernel ntsync as well). The ntdll side -reads, writes and polls its eventfds with raw system calls, so no `LD_PRELOAD` wrapper of -`read`/`write`/`poll` (the guest preloads several) sits in the sync path or in the unwind of a -thread Wine kills. - -The patch changes no wineserver request: `make_pack.py` refuses a build whose -`server_protocol.h`, `request_handlers.h` or `request_trace.h` differ from the unpatched -control build, or whose ntdll.so exports other symbols. Only `files/lib/wine/aarch64-unix/ntdll.so` -and `files/bin-arm64/wineserver` are replaced, so every Proton build needs its own pair, built -from that build's exact wine source. That pair plus `pack.json` is a pack (`<id>.tzst`). On the -device the guest runs Proton from a shadow tree that links the stock tool and holds the two -patched files (`droiddeck-esync`, store in `~/.local/share/droiddeck-esync`). - -## Credits - -droiddeck-esync follows the esync design and droiddeck-ntsync implements the ntsync interface; -both are the work of Elizabeth Figura for Wine, Proton and the Linux kernel. droiddeck-fsync runs -fsync, by Elizabeth Figura and Paul Gofman for Proton, over `futex_waitv(2)` by André Almeida. -DroidDeck builds on that work for Android, where neither the ntsync driver, `futex_waitv` nor a -Proton build with esync is available. - -## Matching - -- `ge`, `cachyos`: built against the release tarball's own binaries. `pack.json` `stock` holds - their sha256; the guest uses the pack only when both files hash the same. `make_pack.py` also - requires the control build to reproduce both files byte for byte, so the patched pair differs - from the release only by the patch (`--allow-unreproduced` lifts this for local experiments). -- `valve`: the depots cannot be downloaded without a Steam login, so `stock` holds the control - build's hashes and `source_match` is true: the guest also accepts a tool whose version token - (second field of `<tool>/version`) and ntdll.so exports hash equal the pack's. With the optional - `STEAM_DEPOT_USER` / `STEAM_DEPOT_TOKEN` secrets (and the `DEPOT_DOWNLOADER_SHA256` variable) - the job downloads the depot instead and, when the depot is at the tag being built, makes an - exact pack. The guest's launch line says which kind matched (`exact match` or `source match`). -- A stock wineserver that already has esync built in (it prints `esync: up and running`) never - gets a pack. - -## Building and publishing - -`.github/workflows/watch-proton-releases.yml` runs every 20 minutes and decides when a build is -due. It asks Steam anonymously (`steamcmd +app_info_print`) which public build of each Valve ARM64 -depot app in `flavors.json` is live, and `discover.py watch` compares that, the newest `keep` tags -and releases of every flavor, each flavor's `rev` and its patch series digest with the state of the -last run (`watch-state.json` in the `droiddeck-esync-index` release). A flavor whose builds, rev or -patch series changed, or whose Steam build moved, starts -`.github/workflows/build-droiddeck-esync-packs.yml` for that flavor; nothing else starts a build. -A run that finds no change takes about a minute and builds nothing. If steamcmd fails, the run -watches the tags only and keeps the last Steam state. - -`.github/workflows/build-droiddeck-esync-packs.yml` also runs once a day as a safety net, on pushes -to `tools/droiddeck-esync/**` and by hand (flavor, one tag, publish yes/no). Only runs on `main` of -`Droid-Deck/DroidDeck` publish packs; the schedule and pushes do nothing in other repositories, -and manual runs elsewhere only build. - -1. `discover.py matrix` lists the newest `keep` builds of each family in `flavors.json` (Valve - tags, GE and CachyOS releases with an arm64 tarball) and drops the ones the published index, - or the `droiddeck-esync-<flavor>` release, already has at the flavor's `rev`, the ones - `revoked.txt` names, and the ones that failed in the last day at the same `rev` and patch - series (a `<key>-r<rev>-<series digest>.failed` asset in the flavor's release). Naming the tag - in a manual run builds it anyway. -2. Each build runs on an arm64 runner with a read-only token, since it runs upstream code: the - wine source is checked out at the tag (GE also runs its `protonprep-valve-staging.sh`), - `build-pack.sh` builds a control and a patched pair inside the SDK image named by the - source's own `Makefile.in` (arm64-llvm), at the flavor's `build_dir` in `flavors.json` (the - path the release itself was built at, which ends up in the binaries), and `make_pack.py` makes - the pack. Patches apply with `--fuzz=0`: a hunk whose context moved fails the build instead of - landing somewhere else. For GE and CachyOS, whose release tarball holds the whole Proton, - `smoke-test.sh` then boots a prefix with that stock Proton and runs `tests/esstress.c` (built - with the SDK's llvm-mingw) under server-side sync as a baseline, and again with the pack under - droiddeck-esync. A pack that fails while stock passes is not published; when stock itself cannot - run on the runner the step only warns. -3. The publish job checks each build's artifact against the matrix (`discover.py packs`) and - uploads it to the `droiddeck-esync-valve`, `droiddeck-esync-ge` or `droiddeck-esync-cachyos` - release as `<id>.tzst` with its index entry `<id>.json`. A build that made no pack gets its - `.failed` marker there (`discover.py failures`), so a patch that does not apply or a stock - wineserver with esync built in is not rebuilt on every run. -4. The index job (environment `droiddeck-esync-signing`) runs only when the run built a pack or - a published pack is not in the index yet. It collects every `<id>.json`, keeps the - highest `rev` per stock pair and version, marks revoked ids, signs `index.json` (ECDSA P-256, - `index-key.pub`) and publishes `bundle-<YYYYMMDD>-<HHMMSS>.tzst` (named after `generated`, so - every signed index has its own), `index.json` and `index.json.sig` to `droiddeck-esync-index`. - `generated` only grows. Packs stay in the index until they are revoked, also when their - entry asset disappears; the previous index counts only when its signature verifies. - -A pack is rebuilt only for a new `rev`: bump `rev` of the flavor in `flavors.json` whenever the -patch series or the build changes. Local builds: `BUILD_DIR=<the flavor's build_dir> SYNC_PROFILE=<the -flavor's profile> build-local.sh <sdk-rootfs> <wine-source> <work-dir> <out-dir> [patch-dir]`, -then `make_pack.py`. - -## Setup - -- Signing key: create the repository environment `droiddeck-esync-signing` (Settings -> - Environments, limit it to `main`) and store the PEM private key as its secret - `SYNC_INDEX_KEY`: `gh secret set SYNC_INDEX_KEY --env droiddeck-esync-signing < index-key.pem`. - The key never enters the repository; the app trusts only `index-key.pub` (also compiled into - `EsyncPacks.kt`), so a new key needs an app release. -- Optional: `STEAM_DEPOT_USER` and `STEAM_DEPOT_TOKEN` repository secrets for exact Valve packs, - plus the repository variable `DEPOT_DOWNLOADER_SHA256`: the sha256 of - `DepotDownloader-linux-arm64.zip` from the DepotDownloader release the workflow pins - (`DEPOT_DOWNLOADER_TAG`). The depot step is skipped while it is unset. -- APK bundle: after an index run, the job summary prints three lines. Put them in - `tools/droiddeck-esync/release.env`: - - ``` - SYNC_BUNDLE_TAG=droiddeck-esync-index - SYNC_BUNDLE_ASSET=bundle-20261002-061733.tzst - SYNC_BUNDLE_SHA256=<sha256> - ``` - - `build.yml` then unpacks that bundle into `app/src/main/assets/droiddeck-esync/` (gitignored). A - published bundle is never overwritten, so the pinned sha256 stays valid; bump `release.env` - to ship newer packs inside the apk. The app fetches newer packs on its own. A pack the bundle - carries that `revoked.txt` names is left out of the apk, with a warning to bump `release.env`. - -## Revocation - -Add the pack id as the first word of a line in `revoked.txt` and push to `main`. The workflow -re-signs the index with `"revoked": true` for that id; the app deletes installed copies and -never installs it again, and `discover.py` does not rebuild it at the same `rev`. The apk build -also leaves it out of the bundled packs. To ship a fixed pack for the same Proton build, bump the -flavor's `rev`. Runs that only build (other refs, or manual runs without publish) never queue in -front of a publishing run, so a revocation is signed by the next run on `main`. diff --git a/tools/droiddeck-esync/build-local.sh b/tools/droiddeck-esync/build-local.sh deleted file mode 100755 index c10d93980..000000000 --- a/tools/droiddeck-esync/build-local.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -set -euo pipefail - -if [ $# -lt 4 ]; then - echo "usage: $0 <sdk-rootfs> <wine-source> <work-dir> <out-dir> [patch-dir]" >&2 - echo "env: BUILD_DIR (path the build runs at inside the SDK), SYNC_PROFILE (valve or cachyos), JOBS, SOURCE_DATE_EPOCH" >&2 - exit 2 -fi -here=$(cd "$(dirname "$0")" && pwd) -sdk=$(realpath "$1") -src=$(realpath "$2") -work=$(realpath -m "$3") -out=$(realpath -m "$4") -patches=${5:+$(realpath "$5")} -build_dir=${BUILD_DIR:-/builds/proton/proton/build-dir} -rm -rf "$work" -mkdir -p "$work" "$out" -"$here/build-pack.sh" prepare "$src" "$work" ${patches:+"$patches"} -bwrap --unshare-user --uid 0 --gid 0 --unshare-ipc --unshare-pid \ - --bind "$sdk" / --proc /proc --dev /dev --tmpfs /tmp \ - --bind "$work" "$build_dir" --bind "$out" /out --ro-bind "$here/build-pack.sh" /build-pack.sh \ - --setenv PATH /opt/llvm-mingw/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ - --setenv HOME /tmp --setenv LANG C.UTF-8 --setenv JOBS "${JOBS:-$(nproc)}" \ - --setenv SOURCE_DATE_EPOCH "${SOURCE_DATE_EPOCH:-1700000000}" --setenv SYNC_PROFILE "${SYNC_PROFILE:-valve}" \ - --chdir "$build_dir" \ - /bin/bash /build-pack.sh build "$build_dir" /out -sha256sum "$out/files/lib/wine/aarch64-unix/ntdll.so" "$out/files/bin-arm64/wineserver" diff --git a/tools/droiddeck-esync/build-pack.sh b/tools/droiddeck-esync/build-pack.sh deleted file mode 100755 index 33627d8c8..000000000 --- a/tools/droiddeck-esync/build-pack.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash -set -euo pipefail - -usage() { - echo "usage: $0 prepare <wine-source> <build-dir> [patch-dir]" >&2 - echo " $0 build <build-dir> <out-dir>" >&2 - echo " $0 all <wine-source> <out-dir> [patch-dir]" >&2 - exit 2 -} - -prepare() { - local origin=$1 b=$2 patches=${3:-} - local s=$b/src-wine - mkdir -p "$s" - rsync --filter=:C --exclude '*~' --exclude .git --exclude compile_commands.json \ - --exclude configure --exclude autom4te.cache --exclude include/config.h.in --exclude include/config.h.ink \ - --exclude include/wine/vulkan.h --exclude dlls/vulkan-1/vulkan-1.spec \ - --exclude dlls/winevulkan/loader_thunks.c --exclude dlls/winevulkan/loader_thunks.h \ - --exclude dlls/winevulkan/vulkan_thunks.c --exclude dlls/winevulkan/vulkan_thunks.h \ - --exclude dlls/winevulkan/winevulkan.json --exclude dlls/winevulkan/winevulkan.spec \ - --exclude dlls/ntdll/ntsyscalls.h --exclude dlls/win32u/win32syscalls.h \ - --exclude include/wine/server_protocol.h --exclude server/request_handlers.h --exclude server/request_trace.h \ - -Oarx --delete "$origin/" "$s/" - for f in include/wine/server_protocol.h server/request_handlers.h server/request_trace.h; do - cp -a "$origin/$f" "$s/$f" - done - if [ -n "$patches" ]; then - local p found=0 - for p in "$patches"/*.patch; do - [ -f "$p" ] || continue - patch -d "$s" -p1 --forward --fuzz=0 --no-backup-if-mismatch -s < "$p" - found=1 - done - [ "$found" = 1 ] || { echo "no patches in $patches" >&2; exit 1; } - fi - ( - cd "$s" - XDG_CACHE_HOME=$b/cache dlls/winevulkan/make_vulkan -x vk.xml -X video.xml - tools/make_specfiles || true - tools/make_requests - ) > "$b/generate.log" 2>&1 - touch "$b/.prepared" -} - -build() { - local b=$1 out=$2 - local s=$b/src-wine o=$b/obj-wine-aarch64 d=$b/dst-wine-aarch64 - [ -f "$b/.prepared" ] || { echo "$b is not prepared" >&2; exit 1; } - local jobs=${JOBS:-$(nproc)} - local epoch=$(( ${SOURCE_DATE_EPOCH:-1700000000} - 20 )) - local flags args pe_common i386_flags x86_64_flags conf - case ${SYNC_PROFILE:-valve} in - valve) - flags="-Wno-discarded-qualifiers -march=armv8.2-a -mtune=cortex-x3 -O2 -fwrapv -fno-strict-aliasing -ggdb -ffunction-sections -fdata-sections -fno-omit-frame-pointer -ffile-prefix-map=$s=." - args=(--enable-werror --with-mingw=clang --disable-tests --enable-archs=arm64ec,aarch64,i386,x86_64) - pe_common="-O2 -fwrapv -fno-strict-aliasing -ggdb -ffunction-sections -fdata-sections -fno-omit-frame-pointer -ffile-prefix-map=$s=." - i386_flags="-Wno-discarded-qualifiers -mstackrealign -march=nocona -mtune=core-avx2 -mfpmath=sse $pe_common" - x86_64_flags="-Wno-discarded-qualifiers -mcmodel=small -march=nocona -mtune=core-avx2 -mfpmath=sse $pe_common" - conf="../$(basename "$s")/configure" - ;; - cachyos) - flags="-Wno-discarded-qualifiers -Wno-stringop-overflow -Wno-incompatible-pointer-types -O2 -march=armv8.2-a -mtune=cortex-x3 -fwrapv -fno-strict-aliasing -ffunction-sections -fdata-sections -fno-omit-frame-pointer -ffile-prefix-map=$s=." - args=(--with-mingw=clang --enable-build-id --disable-tests --enable-archs=arm64ec,aarch64,i386,x86_64) - pe_common="-fwrapv -fno-strict-aliasing -ffunction-sections -fdata-sections -fno-omit-frame-pointer -ffile-prefix-map=$s=." - i386_flags="-Wno-discarded-qualifiers -Wno-stringop-overflow -Wno-incompatible-pointer-types -mstackrealign -O2 -march=nocona -mtune=core-avx2 -pipe -mfpmath=sse $pe_common" - x86_64_flags="-Wno-discarded-qualifiers -Wno-stringop-overflow -Wno-incompatible-pointer-types -mcmodel=small -O2 -march=nocona -mtune=core-avx2 -pipe -mfpmath=sse $pe_common" - conf="$s/configure" - ;; - *) - echo "unknown SYNC_PROFILE ${SYNC_PROFILE}" >&2 - exit 2 - ;; - esac - local env=(STRIP=llvm-strip AR=aarch64-linux-gnu-ar RANLIB=aarch64-linux-gnu-ranlib - CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++ LD=aarch64-linux-gnu-ld - RC=aarch64-w64-mingw32-windres WIDL=aarch64-w64-mingw32-widl PKG_CONFIG=aarch64-linux-gnu-pkg-config - "PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig" - "CFLAGS=$flags" "CPPFLAGS=$flags" "CXXFLAGS=-std=c++17 $flags" "LDFLAGS=" "SOURCE_DATE_EPOCH=$epoch" - "CROSSCFLAGS=$flags" "CROSSLDFLAGS=" - "aarch64_CFLAGS=$flags" "aarch64_CPPFLAGS=$flags" "aarch64_CXXFLAGS=-std=c++17 $flags" "aarch64_LDFLAGS=" - i386_AR=i686-w64-mingw32-ar i386_RANLIB=i686-w64-mingw32-ranlib i386_CC=i686-w64-mingw32-gcc - i386_CXX=i686-w64-mingw32-g++ i386_LD=i686-w64-mingw32-ld - "i386_CFLAGS=$i386_flags" "i386_CPPFLAGS=$i386_flags" "i386_CXXFLAGS=-std=c++17 $i386_flags" "i386_LDFLAGS=" - "i386_PKG_CONFIG_LIBDIR=/usr/lib/i386-w64-mingw32/pkgconfig:/usr/share/pkgconfig" - x86_64_AR=x86_64-w64-mingw32-ar x86_64_RANLIB=x86_64-w64-mingw32-ranlib x86_64_CC=x86_64-w64-mingw32-gcc - x86_64_CXX=x86_64-w64-mingw32-g++ x86_64_LD=x86_64-w64-mingw32-ld - "x86_64_CFLAGS=$x86_64_flags" "x86_64_CPPFLAGS=$x86_64_flags" "x86_64_CXXFLAGS=-std=c++17 $x86_64_flags" "x86_64_LDFLAGS=" - "x86_64_PKG_CONFIG_LIBDIR=/usr/lib/x86_64-w64-mingw32/pkgconfig:/usr/share/pkgconfig") - export PATH=/opt/llvm-mingw/bin:$PATH - ( cd "$s" && autoreconf -fi && rm -rf autom4te.cache ) > "$b/autoreconf.log" 2>&1 - rm -rf "$o" "$d" - mkdir -p "$o" "$d" - ( cd "$o" && env "${env[@]}" "$conf" -C --prefix="$d" --libdir="$d/lib" --host=aarch64-linux-gnu "${env[@]}" \ - "${args[@]}" ) > "$b/configure.log" 2>&1 - ( cd "$o" && env "${env[@]}" make -j"$jobs" __tooldeps__ ) > "$b/tools.log" 2>&1 - ( cd "$o" && env "${env[@]}" make -j"$jobs" dlls/ntdll/ntdll.so server/wineserver ) > "$b/make.log" 2>&1 - mkdir -p "$out/files/lib/wine/aarch64-unix" "$out/files/bin-arm64" - llvm-objcopy --strip-debug --set-section-flags .text=contents,alloc,load,readonly,code \ - "$o/dlls/ntdll/ntdll.so" "$out/files/lib/wine/aarch64-unix/ntdll.so" - llvm-objcopy --strip-debug "$o/server/wineserver" "$out/files/bin-arm64/wineserver" - chmod 0755 "$out/files/lib/wine/aarch64-unix/ntdll.so" "$out/files/bin-arm64/wineserver" - cp "$s/include/wine/server_protocol.h" "$s/server/request_handlers.h" "$s/server/request_trace.h" "$out/" - sed -n 's/^#define SERVER_PROTOCOL_VERSION \([0-9]*\)$/\1/p' "$out/server_protocol.h" > "$out/protocol" -} - -[ $# -ge 1 ] || usage -case $1 in - prepare) - [ $# -ge 3 ] || usage - prepare "$(realpath "$2")" "$(realpath -m "$3")" "${4:+$(realpath "$4")}" - ;; - build) - [ $# -eq 3 ] || usage - build "$(realpath "$2")" "$(realpath -m "$3")" - ;; - all) - [ $# -ge 3 ] || usage - b=${BUILD_DIR:-/builds/proton/proton/build-dir} - prepare "$(realpath "$2")" "$b" "${4:+$(realpath "$4")}" - build "$b" "$(realpath -m "$3")" - ;; - *) - usage - ;; -esac diff --git a/tools/droiddeck-esync/discover.py b/tools/droiddeck-esync/discover.py deleted file mode 100755 index 028603e6d..000000000 --- a/tools/droiddeck-esync/discover.py +++ /dev/null @@ -1,654 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import re -import subprocess -import sys -import urllib.parse -from datetime import datetime, timedelta, timezone -from pathlib import Path - -from make_index import check_entry -from make_pack import FLAVORS, HERE, PATCHES, REVOKED, SERIES, PackError, patch_digest, read_revoked, sha256_file, slug - -FLAVORS_FILE = HERE / "flavors.json" -ARCHIVE = re.compile(r"\.tar\.(gz|xz)$") -ASSIGNMENT = re.compile(r"(?:export\s+|override\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:\?|\+|!|:{1,3})?=\s*(.*)") -REFERENCE = re.compile(r"\$[({]([A-Za-z_][A-Za-z0-9_]*)[)}]") -IMAGE = re.compile(r"[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]+)?(?:/[A-Za-z0-9._-]+)+(?::[A-Za-z0-9._-]+)?(?:@sha256:[0-9a-f]{64})?") -REPO = re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+") -CLONES = ("shallow", "treeless") -PACK_REV = re.compile(r"-r([0-9]+)$") -ARTIFACT_PREFIX = "droiddeck-esync-pack-" -FAILED = ".failed" -RETRY_AFTER = timedelta(days=1) -VDF_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])') -APP_HEADER = re.compile(r"^AppID : ([0-9]+),", re.M) -ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") - - -def gh(*args: str) -> subprocess.CompletedProcess: - try: - return subprocess.run(["gh", *args], capture_output=True, text=True, check=False) - except FileNotFoundError as e: - raise PackError("the GitHub CLI (gh) is not installed") from e - - -def json_values(text: str) -> list: - decoder = json.JSONDecoder() - values = [] - position = 0 - while True: - while position < len(text) and text[position].isspace(): - position += 1 - if position == len(text): - return values - value, position = decoder.raw_decode(text, position) - values.append(value) - - -def gh_api(path: str, paginate: bool = False, missing_ok: bool = False): - result = gh("api", *(["--paginate"] if paginate else []), path) - if result.returncode != 0: - if missing_ok and "HTTP 404" in result.stderr: - return None - raise PackError(f"gh api {path}: {result.stderr.strip() or f'exit {result.returncode}'}") - try: - values = json_values(result.stdout) - except ValueError as e: - raise PackError(f"gh api {path}: {e}") from e - if len(values) == 1: - return values[0] - if values and all(isinstance(value, list) for value in values): - return [item for value in values for item in value] - raise PackError(f"gh api {path}: unexpected output") - - -def quote(value: str) -> str: - return urllib.parse.quote(value, safe="") - - -def load_flavors(path: Path) -> dict: - try: - data = json.loads(path.read_text()) - except (OSError, ValueError) as e: - raise PackError(f"cannot read {path}: {e}") from e - - def fail(why: str): - raise PackError(f"{path}: {why}") - - def compiles(pattern: str, where: str): - try: - re.compile(pattern) - except re.error as e: - fail(f"{where}: {pattern!r} is not a regular expression: {e}") - - if not isinstance(data, dict) or data.get("format") != 1 or not isinstance(data.get("flavors"), dict): - fail("is not a format 1 flavor list") - image = data.get("sdk_image") - if not isinstance(image, dict) or not all(isinstance(image.get(key), str) and image[key] for key in ("file", "variable", "match")): - fail("sdk_image needs file, variable and match") - for name, cfg in data["flavors"].items(): - if name not in FLAVORS: - fail(f"unknown flavor {name!r}") - if not isinstance(cfg, dict) or not isinstance(cfg.get("repo"), str) or not REPO.fullmatch(cfg["repo"]): - fail(f"{name}: repo must be owner/name") - for key in ("keep", "rev"): - if type(cfg.get(key)) is not int or cfg[key] < 1: - fail(f"{name}: {key} must be a positive number") - if not isinstance(cfg.get("series"), str) or not SERIES.fullmatch(cfg["series"]): - fail(f"{name}: series is not a patch series name") - if cfg.get("profile") not in ("valve", "cachyos"): - fail(f"{name}: profile must be valve or cachyos") - if type(cfg.get("source_match")) is not bool: - fail(f"{name}: source_match must be a boolean") - build_dir = cfg.get("build_dir") - if not isinstance(build_dir, str) or not re.fullmatch(r"(?:/[A-Za-z0-9._-]+)+", build_dir) or ".." in build_dir.split("/"): - fail(f"{name}: build_dir must be the absolute path the release was built at") - wine = cfg.get("wine") - submodules = wine.get("submodules") if isinstance(wine, dict) else None - if not isinstance(submodules, list) or "wine" not in submodules \ - or not all(isinstance(item, str) and re.fullmatch(r"[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*", item) for item in submodules): - fail(f"{name}: wine.submodules must list the submodules to check out, wine among them") - if wine.get("clone") not in CLONES: - fail(f"{name}: wine.clone must be one of {', '.join(CLONES)}") - prep = wine.get("prep") - if not isinstance(prep, str) or (prep and not re.fullmatch(r"[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*", prep)) or ".." in prep.split("/"): - fail(f"{name}: wine.prep must be a path inside the checkout or empty") - if cfg.get("list") == "tags": - families = cfg.get("families") - if not isinstance(families, dict) or not families: - fail(f"{name}: families must name at least one tag family") - for family, settings in families.items(): - if not isinstance(settings, dict) or not isinstance(settings.get("tag"), str) \ - or not isinstance(settings.get("prefix"), str) or not re.fullmatch(r"[A-Za-z0-9._-]+", settings["prefix"]) \ - or not isinstance(settings.get("depot_app", ""), str): - fail(f"{name}: family {family} needs a tag prefix and a tag pattern") - compiles(settings["tag"], f"{name}: family {family}") - if not isinstance(cfg.get("version_token"), str) or "{tag}" not in cfg["version_token"]: - fail(f"{name}: version_token must contain {{tag}}") - elif cfg.get("list") == "releases": - if not isinstance(cfg.get("tag"), str) or not isinstance(cfg.get("assets"), list) or not cfg["assets"] \ - or not all(isinstance(pattern, str) for pattern in cfg["assets"]): - fail(f"{name}: releases need a tag pattern and asset patterns") - for pattern in (cfg["tag"], *cfg["assets"]): - compiles(pattern, name) - else: - fail(f"{name}: list must be tags or releases") - return data - - -def natural_key(text: str) -> list[tuple]: - return [(0, int(part), "") if part[0] in "0123456789" else (1, 0, part) for part in re.findall(r"[0-9]+|[^0-9]+", text)] - - -def candidate(flavor: str, cfg: dict, tag: str, asset: str = "", token: str = "", depot_app: str = "") -> dict: - return { - "key": slug(f"{flavor}-{ARCHIVE.sub('', asset) if asset else tag}"), - "flavor": flavor, - "repo": cfg["repo"], - "tag": tag, - "asset": asset, - "token": token, - "depot_app": depot_app, - "rev": cfg["rev"], - "series": cfg["series"], - "profile": cfg["profile"], - "source_match": cfg["source_match"], - "submodules": " ".join(cfg["wine"]["submodules"]), - "clone": cfg["wine"]["clone"], - "prep": cfg["wine"]["prep"], - "build_dir": cfg["build_dir"], - } - - -def tag_candidates(flavor: str, cfg: dict, ref: str) -> list[dict]: - found = [] - for family, settings in sorted(cfg["families"].items()): - if ref and not ref.startswith(settings["prefix"]): - continue - refs = gh_api(f"repos/{cfg['repo']}/git/matching-refs/tags/{quote(settings['prefix'])}", paginate=True) - if not isinstance(refs, list): - raise PackError(f"{cfg['repo']}: the tag list is not a list") - pattern = re.compile(settings["tag"]) - names = set() - for item in refs: - name = item.get("ref", "") if isinstance(item, dict) else "" - if isinstance(name, str) and name.startswith("refs/tags/") and pattern.search(name[len("refs/tags/"):]): - names.add(name[len("refs/tags/"):]) - ordered = sorted(names, key=natural_key, reverse=True) - chosen = [name for name in ordered if name == ref] if ref else ordered[:cfg["keep"]] - for name in chosen: - found.append(candidate(flavor, cfg, name, token=cfg["version_token"].format(tag=name), depot_app=settings.get("depot_app", ""))) - return found - - -def pick_assets(assets: list, patterns: list[re.Pattern]) -> list[str]: - chosen = {} - for asset in assets: - name = asset.get("name") if isinstance(asset, dict) else None - if not isinstance(name, str) or not any(pattern.search(name) for pattern in patterns): - continue - stem = ARCHIVE.sub("", name) - if stem not in chosen or name.endswith(".tar.xz"): - chosen[stem] = name - return sorted(chosen.values()) - - -def release_candidates(flavor: str, cfg: dict, ref: str) -> list[dict]: - repo = cfg["repo"] - if ref: - release = gh_api(f"repos/{repo}/releases/tags/{quote(ref)}", missing_ok=True) - releases = [release] if release else [] - else: - releases = gh_api(f"repos/{repo}/releases?per_page=100") - if not isinstance(releases, list): - raise PackError(f"{repo}: the release list is not a list") - pattern = re.compile(cfg["tag"]) - patterns = [re.compile(item) for item in cfg["assets"]] - usable = [] - for release in releases: - tag = release.get("tag_name") if isinstance(release, dict) else None - if not isinstance(tag, str) or release.get("draft") or not pattern.search(tag): - continue - assets = pick_assets(release.get("assets") or [], patterns) - if assets: - usable.append((release.get("published_at") or release.get("created_at") or "", natural_key(tag), tag, assets)) - usable.sort(reverse=True) - if not ref: - usable = usable[:cfg["keep"]] - return [candidate(flavor, cfg, tag, asset=asset) for _, _, tag, assets in usable for asset in assets] - - -def built(index: dict) -> dict[tuple[str, str, str], int]: - revs = {} - packs = index.get("packs") if isinstance(index, dict) else None - for pack in packs if isinstance(packs, list) else []: - if not isinstance(pack, dict) or not isinstance(pack.get("source"), dict): - continue - rev = pack.get("rev") if type(pack.get("rev")) is int else 0 - key = (pack.get("flavor"), pack["source"].get("ref"), pack["source"].get("asset", "")) - revs[key] = max(revs.get(key, 0), rev) - return revs - - -def revoked_build(entry: dict, revoked: set[str]) -> bool: - prefixes = {f"{entry['flavor']}-{slug(name)}-" for name in (entry["token"], entry["tag"]) if name} - suffix = f"-r{entry['rev']}" - return any(ident.endswith(suffix) and ident.startswith(prefix) for ident in revoked for prefix in prefixes) - - -def chosen_flavors(flavors: dict, selected: str) -> list[str]: - return sorted(flavors["flavors"]) if selected == "all" else [selected] - - -def failure_marker(entry: dict, digests: dict[str, str] | None = None) -> str: - series = entry["series"] - if not SERIES.fullmatch(series): - raise PackError(f"{series!r} is not a patch series name") - digest = digests.get(series) if digests is not None else None - if digest is None: - digest = patch_digest(PATCHES / series) - if digests is not None: - digests[series] = digest - return f"{entry['key']}-r{entry['rev']}-{digest[:12]}{FAILED}" - - -def discover(flavors: dict, selected: str, ref: str, index: dict, revoked: set[str], - failed: frozenset[str] = frozenset()) -> list[dict]: - names = chosen_flavors(flavors, selected) - done = built(index) - digests = {} - entries = [] - seen = set() - matched = False - for flavor in names: - cfg = flavors["flavors"].get(flavor) - if cfg is None: - raise PackError(f"flavors.json has no {flavor}") - found = tag_candidates(flavor, cfg, ref) if cfg["list"] == "tags" else release_candidates(flavor, cfg, ref) - matched = matched or bool(found) - for entry in found: - if entry["key"] in seen: - continue - seen.add(entry["key"]) - label = f"{flavor} {entry['tag']}{' ' + entry['asset'] if entry['asset'] else ''}" - if revoked_build(entry, revoked): - print(f"discover: {label}: revoked at rev {entry['rev']}", file=sys.stderr) - elif done.get((flavor, entry["tag"], entry["asset"]), 0) >= entry["rev"]: - print(f"discover: {label}: in the index at rev {entry['rev']}", file=sys.stderr) - elif not ref and failure_marker(entry, digests) in failed: - print(f"discover: {label}: failed at rev {entry['rev']} with this patch series in the last " - f"{RETRY_AFTER.days} days; skipped (bump rev, change the series or build the tag by name)", file=sys.stderr) - else: - print(f"discover: {label}: to build", file=sys.stderr) - entries.append(entry) - if ref and not matched: - raise PackError(f"no {'' if selected == 'all' else selected + ' '}build is tagged {ref}") - return entries - - -def vdf(text: str) -> dict: - stack = [{}] - key = None - for match in VDF_TOKEN.finditer(text): - word, brace = match.group(1), match.group(2) - if brace == "{": - if key is None: - raise PackError("app info: a block has no name") - child = {} - stack[-1][key] = child - stack.append(child) - key = None - elif brace == "}": - if len(stack) == 1: - raise PackError("app info: unbalanced braces") - stack.pop() - key = None - elif key is None: - key = word - else: - stack[-1][key] = word - key = None - if len(stack) != 1: - raise PackError("app info: unbalanced braces") - return stack[0] - - -def steam_builds(text: str, apps: list[str]) -> dict[str, dict]: - found = {} - text = ANSI.sub("", text) - starts = [(m.start(), m.group(1)) for m in APP_HEADER.finditer(text)] - for number, (start, app) in enumerate(starts): - end = starts[number + 1][0] if number + 1 < len(starts) else len(text) - if app not in apps: - continue - body = text[text.find("\n", start) + 1:end] - info = vdf(body).get(app) - depots = info.get("depots") if isinstance(info, dict) else None - public = depots.get("branches", {}).get("public") if isinstance(depots, dict) else None - if not isinstance(public, dict) or not str(public.get("buildid", "")).isdigit(): - continue - manifests = sorted(depot["manifests"]["public"]["gid"] for name, depot in depots.items() - if name.isdigit() and isinstance(depot, dict) and isinstance(depot.get("manifests"), dict) - and isinstance(depot["manifests"].get("public"), dict) and depot["manifests"]["public"].get("gid")) - found[app] = {"buildid": public["buildid"], "built": public.get("timebuildupdated", ""), "manifests": manifests} - return found - - -def steam_apps(flavors: dict) -> list[str]: - return sorted({family["depot_app"] for cfg in flavors["flavors"].values() if cfg["list"] == "tags" - for family in cfg["families"].values() if family.get("depot_app")}) - - -def watch(flavors: dict, previous: dict, steam: dict[str, dict] | None) -> tuple[dict, list[str], list[str]]: - state = {} - changed = [] - notes = [] - for flavor in sorted(flavors["flavors"]): - cfg = flavors["flavors"][flavor] - found = tag_candidates(flavor, cfg, "") if cfg["list"] == "tags" else release_candidates(flavor, cfg, "") - builds = sorted(f"{entry['tag']} {entry['asset']}".strip() for entry in found) - ships = {} - if cfg["list"] == "tags" and steam is not None: - for family in cfg["families"].values(): - app = family.get("depot_app") - if app and app in steam: - ships[app] = steam[app] - old = previous.get(flavor) if isinstance(previous.get(flavor), dict) else {} - if steam is None and isinstance(old.get("steam"), dict): - ships = old["steam"] - mark = {"builds": builds, "rev": cfg["rev"], "series": cfg["series"], "patch": patch_digest(PATCHES / cfg["series"])} - fingerprint = hashlib.sha256(json.dumps(mark, sort_keys=True).encode()).hexdigest() - state[flavor] = {**mark, "fingerprint": fingerprint, "steam": ships} - if old.get("fingerprint") != fingerprint: - changed.append(flavor) - fresh = sorted(set(builds) - set(old.get("builds") or [])) - notes.append(f"{flavor}: {', '.join(fresh) if fresh else 'the patch series or rev changed'}") - for app, ship in sorted(ships.items()): - before = (old.get("steam") or {}).get(app) if isinstance(old.get("steam"), dict) else None - if isinstance(before, dict) and before.get("buildid") != ship.get("buildid"): - notes.append(f"{flavor}: Steam app {app} moved from build {before.get('buildid')} to {ship.get('buildid')}") - if flavor not in changed: - changed.append(flavor) - return state, changed, notes - - -def asset_text(repo: str, asset_id, what: str) -> str: - result = gh("api", "-H", "Accept: application/octet-stream", f"repos/{repo}/releases/assets/{asset_id}") - if result.returncode != 0: - raise PackError(f"cannot download {what}: {result.stderr.strip()}") - return result.stdout - - -def release_index(repo: str, tag: str) -> dict: - release = gh_api(f"repos/{repo}/releases/tags/{quote(tag)}", missing_ok=True) - if release is None: - return {} - for asset in release.get("assets") or []: - if isinstance(asset, dict) and asset.get("name") == "index.json": - text = asset_text(repo, asset["id"], f"the index of {repo}") - try: - return json.loads(text) - except ValueError as e: - raise PackError(f"the index of {repo} is not JSON: {e}") from e - return {} - - -def published(repo: str, flavors: dict, selected: str, index: dict) -> list[dict]: - packs = index.get("packs") if isinstance(index, dict) else None - listed = {pack.get("id") for pack in packs if isinstance(pack, dict)} if isinstance(packs, list) else set() - found = [] - for flavor in chosen_flavors(flavors, selected): - cfg = flavors["flavors"].get(flavor) - tag = f"droiddeck-esync-{flavor}" - release = gh_api(f"repos/{repo}/releases/tags/{quote(tag)}", missing_ok=True) if cfg else None - if not isinstance(release, dict) or type(release.get("id")) is not int: - continue - assets = gh_api(f"repos/{repo}/releases/{release['id']}/assets?per_page=100", paginate=True) - if not isinstance(assets, list): - raise PackError(f"{repo} {tag}: the asset list is not a list") - for asset in assets: - name = asset.get("name") if isinstance(asset, dict) else None - if not isinstance(name, str) or not name.endswith(".json"): - continue - ident = name[:-len(".json")] - rev = PACK_REV.search(ident) - if ident in listed or not rev or int(rev.group(1)) < cfg["rev"]: - continue - try: - entry = json.loads(asset_text(repo, asset.get("id"), f"{tag} {name}")) - except ValueError: - entry = None - if not isinstance(entry, dict) or entry.get("id") != ident or entry.get("flavor") != flavor: - print(f"discover: {tag} {name} is not a pack entry", file=sys.stderr) - continue - print(f"discover: {tag} {name}: published, not in the index yet", file=sys.stderr) - found.append(entry) - return found - - -def parse_time(value) -> datetime | None: - if not isinstance(value, str): - return None - try: - moment = datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) - - -def recent_failures(repo: str, flavors: dict, selected: str, now: datetime) -> frozenset[str]: - found = set() - for flavor in chosen_flavors(flavors, selected): - tag = f"droiddeck-esync-{flavor}" - release = gh_api(f"repos/{repo}/releases/tags/{quote(tag)}", missing_ok=True) if flavor in flavors["flavors"] else None - if not isinstance(release, dict) or type(release.get("id")) is not int: - continue - assets = gh_api(f"repos/{repo}/releases/{release['id']}/assets?per_page=100", paginate=True) - if not isinstance(assets, list): - raise PackError(f"{repo} {tag}: the asset list is not a list") - for asset in assets: - name = asset.get("name") if isinstance(asset, dict) else None - if not isinstance(name, str) or not name.endswith(FAILED): - continue - moment = parse_time(asset.get("updated_at")) or parse_time(asset.get("created_at")) - if moment is not None and now - moment < RETRY_AFTER: - found.add(name) - return frozenset(found) - - -def failed_builds(matrix: dict, directory: Path) -> list[tuple[str, str]]: - rows = matrix.get("include") if isinstance(matrix, dict) else None - if not isinstance(rows, list) or not all(isinstance(row, dict) and isinstance(row.get("key"), str) for row in rows): - raise PackError("the build matrix has no include list") - made = {row["key"] for row in rows if (directory / f"{ARTIFACT_PREFIX}{row['key']}").is_dir()} - digests = {} - return [(row["flavor"], failure_marker(row, digests)) for row in rows if row["key"] not in made] - - -def artifact_packs(matrix: dict, directory: Path) -> list[tuple[str, Path, str]]: - rows = matrix.get("include") if isinstance(matrix, dict) else None - if not isinstance(rows, list) or not all(isinstance(row, dict) and isinstance(row.get("key"), str) for row in rows): - raise PackError("the build matrix has no include list") - builds = {row["key"]: row for row in rows} - found = [] - for folder in sorted(directory.iterdir()) if directory.is_dir() else []: - row = builds.get(folder.name[len(ARTIFACT_PREFIX):]) if folder.name.startswith(ARTIFACT_PREFIX) else None - if row is None or folder.is_symlink() or not folder.is_dir(): - raise PackError(f"{folder.name} is not the artifact of a build in this run") - names = sorted(path.name for path in folder.iterdir()) - entries = [name for name in names if name.endswith(".json")] - if len(entries) != 1 or names != sorted([entries[0], entries[0][:-len(".json")] + ".tzst"]): - raise PackError(f"{folder.name} must hold exactly <id>.json and <id>.tzst") - ident = entries[0][:-len(".json")] - entry_path, archive = folder / entries[0], folder / f"{ident}.tzst" - if entry_path.is_symlink() or archive.is_symlink() or not entry_path.is_file() or not archive.is_file(): - raise PackError(f"{folder.name} holds links or special files") - try: - data = json.loads(entry_path.read_text()) - except ValueError as e: - raise PackError(f"{entry_path}: {e}") from e - entry = check_entry(data, str(entry_path)) - source = data.get("source") if isinstance(data.get("source"), dict) else {} - if entry["id"] != ident or entry["flavor"] != row["flavor"] or entry["rev"] != row["rev"] \ - or source.get("ref") != row["tag"] or source.get("asset", "") != row["asset"]: - raise PackError(f"{entry_path} is not the pack of {row['flavor']} {row['tag']} at rev {row['rev']}") - if archive.stat().st_size != entry["asset"]["size"] or sha256_file(archive) != entry["asset"]["sha256"]: - raise PackError(f"{archive} is not the asset its entry describes") - found.append((row["flavor"], folder, ident)) - return found - - -def emit(entries: list[dict], unindexed: int = 0) -> None: - matrix = json.dumps({"include": entries}, sort_keys=True, separators=(",", ":")) - path = os.environ.get("GITHUB_OUTPUT") - if path: - with open(path, "a") as stream: - stream.write(f"matrix={matrix}\ncount={len(entries)}\nunindexed={unindexed}\n") - print(matrix) - - -def make_variables(text: str) -> tuple[dict[str, str], list[tuple[str, str]]]: - variables = {} - assignments = [] - for raw in text.splitlines(): - match = ASSIGNMENT.fullmatch(raw.split("#", 1)[0].strip()) - if match: - name, value = match.group(1), match.group(2).strip() - variables.setdefault(name, value) - assignments.append((name, value)) - return variables, assignments - - -def expand(value: str, variables: dict[str, str], depth: int = 0) -> str: - if depth > 16: - raise PackError("Makefile variables nest too deeply") - - def lookup(match: re.Match) -> str: - if match.group(1) not in variables: - raise PackError(f"$({match.group(1)}) is not set in the Makefile") - return expand(variables[match.group(1)], variables, depth + 1) - - return REFERENCE.sub(lookup, value) - - -def sdk_image(text: str, variable: str = "STEAMRT_IMAGE", match: str = "arm64-llvm") -> str: - variables, assignments = make_variables(text) - problem = None - for name, value in assignments: - if not name.startswith(variable): - continue - try: - image = expand(value, variables) - except PackError as e: - problem = problem or e - continue - if match not in image: - continue - if not IMAGE.fullmatch(image): - raise PackError(f"{name} = {image!r} is not an image reference") - return image - raise problem or PackError(f"no {variable} assignment names an {match} image") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Find the Proton builds that have no droiddeck-esync pack yet, as a GitHub Actions matrix.") - parser.add_argument("--flavors", default=str(FLAVORS_FILE)) - commands = parser.add_subparsers(dest="command", required=True) - matrix = commands.add_parser("matrix") - matrix.add_argument("--flavor", default="all", choices=("all", *FLAVORS)) - matrix.add_argument("--ref", default="") - matrix.add_argument("--index", default="") - matrix.add_argument("--index-release", default="") - matrix.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY", "Droid-Deck/DroidDeck")) - matrix.add_argument("--revoked", default=str(REVOKED)) - image = commands.add_parser("sdk-image") - image.add_argument("makefile") - artifacts = commands.add_parser("packs") - artifacts.add_argument("--matrix", required=True) - artifacts.add_argument("directory") - failures = commands.add_parser("failures") - failures.add_argument("--matrix", required=True) - failures.add_argument("directory") - commands.add_parser("steam-apps") - watching = commands.add_parser("watch") - watching.add_argument("--state", default="") - watching.add_argument("--steam-info", default="") - watching.add_argument("--out", required=True) - args = parser.parse_args(argv) - try: - flavors = load_flavors(Path(args.flavors)) - if args.command == "sdk-image": - settings = flavors["sdk_image"] - print(sdk_image(Path(args.makefile).read_text(), settings["variable"], settings["match"])) - return 0 - if args.command == "packs": - for flavor, folder, ident in artifact_packs(json.loads(args.matrix), Path(args.directory)): - print(f"{flavor}\t{folder}\t{ident}") - return 0 - if args.command == "failures": - for flavor, marker in failed_builds(json.loads(args.matrix), Path(args.directory)): - print(f"{flavor}\t{marker}") - return 0 - if args.command == "steam-apps": - print(" ".join(steam_apps(flavors))) - return 0 - if args.command == "watch": - previous = {} - if args.state and Path(args.state).is_file(): - try: - previous = json.loads(Path(args.state).read_text()) - except ValueError: - print("discover: the previous watch state is not JSON; every flavor counts as changed", file=sys.stderr) - previous = previous.get("flavors", {}) if isinstance(previous, dict) and previous.get("format") == 1 else {} - steam = None - if args.steam_info: - try: - steam = steam_builds(Path(args.steam_info).read_text(errors="replace"), steam_apps(flavors)) - except (OSError, PackError) as e: - print(f"discover: Steam app info unusable ({e}); watching the source tags only", file=sys.stderr) - if steam is not None and len(steam) != len(steam_apps(flavors)): - print(f"discover: Steam app info covers {sorted(steam)} of {steam_apps(flavors)}", file=sys.stderr) - state, changed, notes = watch(flavors, previous, steam) - Path(args.out).write_text(json.dumps({"format": 1, "flavors": state}, indent=1, sort_keys=True) + "\n") - for note in notes: - print(f"discover: {note}", file=sys.stderr) - path = os.environ.get("GITHUB_OUTPUT") - if path: - with open(path, "a") as stream: - stream.write(f"changed={' '.join(changed)}\n") - print(" ".join(changed)) - return 0 - if args.index and args.index_release: - raise PackError("pass --index or --index-release, not both") - failed = frozenset() - unindexed = 0 - if args.index: - path = Path(args.index) - index = json.loads(path.read_text()) if path.exists() else {} - elif args.index_release: - if not REPO.fullmatch(args.repo): - raise PackError(f"{args.repo!r} is not owner/name") - index = release_index(args.repo, args.index_release) - listed = index.get("packs") if isinstance(index, dict) and isinstance(index.get("packs"), list) else [] - waiting = published(args.repo, flavors, args.flavor, index) - unindexed = len(waiting) - index = {"packs": [*listed, *waiting]} - failed = recent_failures(args.repo, flavors, args.flavor, datetime.now(timezone.utc)) - else: - index = {} - revoked = read_revoked(Path(args.revoked)) if args.revoked else set() - emit(discover(flavors, args.flavor, args.ref.strip(), index, revoked, failed), unindexed) - except (PackError, OSError, ValueError) as e: - print(f"discover: {e}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/droiddeck-esync/flavors.json b/tools/droiddeck-esync/flavors.json deleted file mode 100644 index c4673f1f3..000000000 --- a/tools/droiddeck-esync/flavors.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "format": 1, - "sdk_image": { - "file": "Makefile.in", - "variable": "STEAMRT_IMAGE", - "match": "arm64-llvm" - }, - "flavors": { - "valve": { - "repo": "ValveSoftware/Proton", - "list": "tags", - "families": { - "experimental": { - "prefix": "experimental-11.0-", - "tag": "^experimental-11\\.0-\\d{8}[a-z]?$", - "depot_app": "4427310" - }, - "proton": { - "prefix": "proton-11.0-", - "tag": "^proton-11\\.0-\\d+[a-z]?$", - "depot_app": "4628740" - } - }, - "version_token": "{tag}-arm64", - "keep": 2, - "rev": 1, - "series": "valve-11", - "profile": "valve", - "source_match": true, - "build_dir": "/builds/proton/proton/build-dir", - "wine": { - "submodules": [ - "wine" - ], - "clone": "shallow", - "prep": "" - } - }, - "ge": { - "repo": "GloriousEggroll/proton-ge-custom", - "list": "releases", - "tag": "^GE-Proton11-\\d+$", - "assets": [ - "aarch64\\.tar\\.(gz|xz)$", - "arm64\\.tar\\.(gz|xz)$" - ], - "keep": 3, - "rev": 1, - "series": "valve-11", - "profile": "valve", - "source_match": false, - "build_dir": "/home/tcrider/build", - "wine": { - "submodules": [ - "wine", - "wine-staging" - ], - "clone": "treeless", - "prep": "patches/protonprep-valve-staging.sh" - } - }, - "cachyos": { - "repo": "CachyOS/proton-cachyos", - "list": "releases", - "tag": "^cachyos-11\\.0-", - "assets": [ - "arm64\\.tar\\.(gz|xz)$" - ], - "keep": 3, - "rev": 1, - "series": "cachyos-11", - "profile": "cachyos", - "source_match": false, - "build_dir": "/home/runner/work/proton-cachyos/proton-cachyos/build", - "wine": { - "submodules": [ - "wine" - ], - "clone": "shallow", - "prep": "" - } - } - } -} diff --git a/tools/droiddeck-esync/index-key.pub b/tools/droiddeck-esync/index-key.pub deleted file mode 100644 index 706ca85b4..000000000 --- a/tools/droiddeck-esync/index-key.pub +++ /dev/null @@ -1,4 +0,0 @@ ------BEGIN PUBLIC KEY----- -MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFyqZQAMh3g3nsEGLU+kBGVPBEkXr -Dv5F3kPtuQ+FV1DkTCGmYUQ1LS9wJtHGt0Ht4wToHGGEp9yzDibYZ4/zhg== ------END PUBLIC KEY----- diff --git a/tools/droiddeck-esync/make_index.py b/tools/droiddeck-esync/make_index.py deleted file mode 100755 index 9802e277b..000000000 --- a/tools/droiddeck-esync/make_index.py +++ /dev/null @@ -1,285 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import datetime as dt -import json -import os -import re -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -from make_pack import ( - BINARIES, - EPOCH, - FLAVORS, - HERE, - NTDLL, - PACK_ID, - REVOKED, - SHA256, - WINESERVER, - PackError, - compress, - dump_json, - read_revoked, - sha256_bytes, - sha256_file, - tar_bytes, - write_atomic, -) - -PUBKEY = HERE / "index-key.pub" -SCHEMA = 1 -INDEX_KEYS = ("id", "flavor", "rev", "version", "version_line", "stock", "exports", "source_match", "files", "protocol", "source", "patch") - - -def check_entry(entry, origin: str) -> dict: - def fail(why: str): - raise PackError(f"{origin}: {why}") - - if not isinstance(entry, dict): - fail("is not an object") - ident = entry.get("id") - if not isinstance(ident, str) or not PACK_ID.fullmatch(ident): - fail(f"{ident!r} is not a pack id") - if entry.get("flavor") not in FLAVORS: - fail(f"unknown flavor {entry.get('flavor')!r}") - if type(entry.get("rev")) is not int or entry["rev"] < 1: - fail("rev is not a positive number") - version, line = entry.get("version"), entry.get("version_line") - fields = line.split() if isinstance(line, str) else [] - if not isinstance(version, str) or len(fields) < 2 or fields[1] != version or not EPOCH.fullmatch(fields[0]): - fail("version and version_line disagree") - for field in ("stock", "files"): - value = entry.get(field) - if not isinstance(value, dict) or set(value) != set(BINARIES) or not all(isinstance(v, str) and SHA256.fullmatch(v) for v in value.values()): - fail(f"{field} must give the sha256 of {' and '.join(BINARIES)}") - if not isinstance(entry.get("exports"), str) or not SHA256.fullmatch(entry["exports"]): - fail("exports is not a sha256") - if type(entry.get("source_match")) is not bool: - fail("source_match is not a boolean") - asset = entry.get("asset") - if not isinstance(asset, dict) or not isinstance(asset.get("sha256"), str) or not SHA256.fullmatch(asset["sha256"]) \ - or type(asset.get("size")) is not int or asset["size"] <= 0: - fail("asset needs its sha256 and size") - clean = {key: entry[key] for key in INDEX_KEYS if key in entry} - clean["asset"] = {"sha256": asset["sha256"], "size": asset["size"]} - return clean - - -def load_entries(directory: Path) -> list[dict]: - if not directory.is_dir(): - raise PackError(f"{directory} is not a directory") - entries = [] - for path in sorted(directory.glob("*.json")): - try: - data = json.loads(path.read_text()) - except ValueError as e: - raise PackError(f"{path}: {e}") from e - entry = check_entry(data, str(path)) - if path.stem != entry["id"]: - raise PackError(f"{path} holds the entry of {entry['id']}") - entries.append(entry) - return entries - - -def load_previous(path: Path) -> tuple[int, list[dict], list]: - try: - data = json.loads(path.read_text()) - except (OSError, ValueError) as e: - raise PackError(f"cannot read the previous index {path}: {e}") from e - if not isinstance(data, dict) or data.get("schema") != SCHEMA or type(data.get("generated")) is not int \ - or not isinstance(data.get("packs"), list): - raise PackError(f"{path} is not a schema {SCHEMA} index") - packs = [] - for number, pack in enumerate(data["packs"]): - entry = check_entry(pack, f"{path} pack {number}") - entry["revoked"] = pack.get("revoked") is True - packs.append(entry) - return data["generated"], packs, data["packs"] - - -def epoch(entry: dict) -> int: - return int(entry["version_line"].split()[0]) - - -def rank(entry: dict) -> tuple: - return entry["rev"], epoch(entry), entry["id"] - - -def choose(entries: list[dict], previous: list[dict], revoked: set[str]) -> list[dict]: - known = {entry["id"]: entry for entry in entries} - for entry in previous: - known.setdefault(entry["id"], entry) - active = {} - withdrawn = [] - for entry in known.values(): - if entry["id"] in revoked: - withdrawn.append(dict(entry, revoked=True)) - continue - key = (entry["stock"][NTDLL], entry["stock"][WINESERVER], entry["version"]) - if key not in active or rank(entry) > rank(active[key]): - active[key] = dict(entry, revoked=False) - return sorted([*active.values(), *withdrawn], key=lambda entry: entry["id"]) - - -def build_index(packs: list[dict], base_url: str, generated: int) -> dict: - listed = [] - for pack in packs: - entry = dict(pack) - entry["asset"] = { - "url": f"{base_url}/droiddeck-esync-{pack['flavor']}/{pack['id']}.tzst", - "sha256": pack["asset"]["sha256"], - "size": pack["asset"]["size"], - } - listed.append(entry) - return {"schema": SCHEMA, "generated": generated, "packs": listed} - - -def family(entry: dict) -> tuple[str, str, str]: - token = entry["version"] - major = re.search(r"[0-9]+", token) - return entry["flavor"], re.match(r"[A-Za-z]*", token).group(0).lower(), major.group(0) if major else "" - - -def bundle_choice(packs: list[dict], available: set[str] | None = None) -> list[dict]: - newest = {} - for pack in packs: - if pack["revoked"] or (available is not None and pack["id"] not in available): - continue - key = family(pack) - if key not in newest or (epoch(pack), pack["rev"], pack["id"]) > (epoch(newest[key]), newest[key]["rev"], newest[key]["id"]): - newest[key] = pack - return sorted(newest.values(), key=lambda pack: pack["id"]) - - -def openssl(*args: str) -> subprocess.CompletedProcess: - try: - return subprocess.run(["openssl", *args], capture_output=True, text=True, check=False) - except FileNotFoundError as e: - raise PackError("the openssl command is not installed") from e - - -def verified(index: Path, signature: Path, pubkey: Path) -> bool: - result = openssl("dgst", "-sha256", "-verify", str(pubkey), "-signature", str(signature), str(index)) - return result.returncode == 0 and "Verified OK" in result.stdout - - -def trusted_previous(path: Path, signature: Path | None, pubkey: Path) -> tuple[int, list[dict], list | None]: - if signature is not None and not (path.is_file() and signature.is_file() and verified(path, signature, pubkey)): - print(f"make_index: the previous index {path} does not verify with {signature} and {pubkey}; it is ignored", file=sys.stderr) - return 0, [], None - return load_previous(path) - - -def sign(index: Path, signature: Path, key: Path | None, pubkey: Path) -> None: - with tempfile.TemporaryDirectory() as tmp: - if key is None: - pem = os.environ.get("SYNC_INDEX_KEY", "") - if not pem.strip(): - raise PackError("no signing key: pass --key or set SYNC_INDEX_KEY") - key = Path(tmp) / "key.pem" - with os.fdopen(os.open(key, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as stream: - stream.write(pem if pem.endswith("\n") else pem + "\n") - result = openssl("dgst", "-sha256", "-sign", str(key), "-out", str(signature), str(index)) - if result.returncode != 0: - raise PackError(f"openssl could not sign the index: {result.stderr.strip()}") - if not verified(index, signature, pubkey): - raise PackError(f"the signature does not verify with {pubkey}: the signing key is not the one the app trusts") - - -def bundle_name(generated: int) -> str: - return f"bundle-{dt.datetime.fromtimestamp(generated, dt.timezone.utc):%Y%m%d-%H%M%S}.tzst" - - -def bundle(out: Path, packs: list[dict], index: bytes, signature: bytes, packs_dir: Path, generated: int) -> tuple[str, str]: - if not packs_dir.is_dir(): - raise PackError(f"{packs_dir} is not a directory") - available = {path.name[:-len(".tzst")] for path in packs_dir.glob("*.tzst") if path.is_file()} - members = {"index.json": (index, 0o644), "index.json.sig": (signature, 0o644)} - for pack in bundle_choice(packs, available): - path = packs_dir / f"{pack['id']}.tzst" - if path.stat().st_size != pack["asset"]["size"] or sha256_file(path) != pack["asset"]["sha256"]: - raise PackError(f"{path} is not the asset the index lists for {pack['id']}") - members[f"packs/{pack['id']}.tzst"] = (path.read_bytes(), 0o644) - name = bundle_name(generated) - archive = compress(tar_bytes(members)) - write_atomic(out / name, archive) - return name, sha256_bytes(archive) - - -def report(values: dict[str, str]) -> None: - path = os.environ.get("GITHUB_OUTPUT") - if path: - with open(path, "a") as stream: - for key, value in values.items(): - stream.write(f"{key}={value}\n") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Make, sign and bundle the droiddeck-esync pack index from the published pack entries.") - parser.add_argument("--entries", required=True) - parser.add_argument("--base-url", required=True) - parser.add_argument("--key", default="") - parser.add_argument("--pubkey", default=str(PUBKEY)) - parser.add_argument("--out", default="") - parser.add_argument("--bundle-packs-dir", default="") - parser.add_argument("--revoked", default=str(REVOKED)) - parser.add_argument("--previous", default="") - parser.add_argument("--previous-sig", default="") - parser.add_argument("--list-bundle", action="store_true") - args = parser.parse_args(argv) - try: - base_url = args.base_url.rstrip("/") - if not base_url.startswith("https://"): - raise PackError("--base-url must be an https URL") - revoked = read_revoked(Path(args.revoked)) if args.revoked else set() - if args.previous_sig and not args.previous: - raise PackError("--previous-sig needs --previous") - previous_signature = Path(args.previous_sig) if args.previous_sig else None - previous_generated, previous, previous_raw = trusted_previous(Path(args.previous), previous_signature, Path(args.pubkey)) \ - if args.previous else (0, [], None) - packs = choose(load_entries(Path(args.entries)), previous, revoked) - if args.list_bundle: - for pack in bundle_choice(packs): - print(f"{pack['flavor']}\t{pack['id']}") - return 0 - if not args.out: - raise PackError("--out is required") - out = Path(args.out) - out.mkdir(parents=True, exist_ok=True) - generated = max(int(time.time()), previous_generated + 1) - index = build_index(packs, base_url, generated) - data = dump_json(index) - changed = bool(packs) if previous_raw is None else previous_raw != index["packs"] - temp_index = out / f".index.json.tmp-{os.getpid()}" - temp_signature = out / f".index.json.sig.tmp-{os.getpid()}" - try: - temp_index.write_bytes(data) - sign(temp_index, temp_signature, Path(args.key) if args.key else None, Path(args.pubkey)) - signature = temp_signature.read_bytes() - os.replace(temp_index, out / "index.json") - os.replace(temp_signature, out / "index.json.sig") - finally: - temp_index.unlink(missing_ok=True) - temp_signature.unlink(missing_ok=True) - outputs = {"changed": "true" if changed else "false", "generated": str(generated), "index_sha256": sha256_bytes(data)} - live = sum(1 for pack in packs if not pack["revoked"]) - print(f"index.json {outputs['index_sha256']} {live} pack(s), {len(packs) - live} revoked, generated {generated}, {'changed' if changed else 'unchanged'}") - if args.bundle_packs_dir: - name, digest = bundle(out, packs, data, signature, Path(args.bundle_packs_dir), generated) - outputs.update(bundle=name, bundle_sha256=digest) - print(f"{name} {digest}") - report(outputs) - except (PackError, OSError) as e: - print(f"make_index: {e}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/droiddeck-esync/make_pack.py b/tools/droiddeck-esync/make_pack.py deleted file mode 100755 index 9f7676b95..000000000 --- a/tools/droiddeck-esync/make_pack.py +++ /dev/null @@ -1,367 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import functools -import hashlib -import importlib.machinery -import importlib.util -import io -import json -import os -import re -import subprocess -import sys -import tarfile -from pathlib import Path - -HERE = Path(__file__).resolve().parent -ROOT = HERE.parents[1] -GUEST = ROOT / "tools/linuxfs/overlay/usr/local/bin/droiddeck-esync" -PATCHES = HERE / "patches" -REVOKED = HERE / "revoked.txt" -NTDLL = "files/lib/wine/aarch64-unix/ntdll.so" -WINESERVER = "files/bin-arm64/wineserver" -BINARIES = (NTDLL, WINESERVER) -COPY = ("files/bin-arm64/wine", "files/lib/wine/aarch64-unix/wine", "files/lib/wine/aarch64-unix/wine-preloader") -HEADERS = ("server_protocol.h", "request_handlers.h", "request_trace.h") -FLAVORS = ("valve", "ge", "cachyos") -SOURCE_KEYS = ("repo", "ref", "commit", "wine_commit", "sdk_image") -ENTRY_KEYS = ("id", "flavor", "rev", "version", "version_line", "stock", "exports", "source_match", "files", "protocol", "source", "patch") -PACK_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}") -SERIES = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") -SHA256 = re.compile(r"[0-9a-f]{64}") -EPOCH = re.compile(r"[0-9]{1,12}") -PROTOCOL = re.compile(rb"^#define SERVER_PROTOCOL_VERSION ([0-9]+)[ \t]*$", re.M) -BUILTIN_ESYNC = b"esync: up and running" -ELF_MAGIC = b"\x7fELF" -GUEST_MODULE = "droiddeck_esync_tools" - - -class PackError(RuntimeError): - pass - - -@functools.lru_cache(maxsize=None) -def guest(): - loader = importlib.machinery.SourceFileLoader(GUEST_MODULE, str(GUEST)) - module = importlib.util.module_from_spec(importlib.util.spec_from_loader(GUEST_MODULE, loader)) - sys.modules[GUEST_MODULE] = module - write_bytecode = sys.dont_write_bytecode - sys.dont_write_bytecode = True - try: - loader.exec_module(module) - except Exception as e: - sys.modules.pop(GUEST_MODULE, None) - raise PackError(f"cannot load {GUEST}: {e}") from e - finally: - sys.dont_write_bytecode = write_bytecode - return module - - -def sha256_bytes(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def sha256_file(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1 << 20), b""): - digest.update(block) - return digest.hexdigest() - - -def slug(text: str) -> str: - return re.sub(r"[^A-Za-z0-9._-]", "-", text) - - -def dump_json(value) -> bytes: - return (json.dumps(value, indent=1, sort_keys=True, ensure_ascii=True) + "\n").encode() - - -def write_atomic(path: Path, data: bytes) -> None: - temp = path.with_name(f".{path.name}.tmp-{os.getpid()}") - try: - with temp.open("wb") as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temp, path) - except BaseException: - temp.unlink(missing_ok=True) - raise - - -def read_revoked(path: Path | None) -> set[str]: - if path is None: - return set() - try: - text = path.read_text() - except OSError as e: - raise PackError(f"cannot read the revocation list {path}: {e}") from e - revoked = set() - for raw in text.splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - ident = line.split()[0] - if not PACK_ID.fullmatch(ident): - raise PackError(f"{path}: {ident!r} is not a pack id") - revoked.add(ident) - return revoked - - -def tar_bytes(members: dict[str, tuple[bytes, int]]) -> bytes: - buffer = io.BytesIO() - with tarfile.open(fileobj=buffer, mode="w", format=tarfile.USTAR_FORMAT) as tar: - for name in sorted(members): - data, mode = members[name] - info = tarfile.TarInfo(name) - info.size = len(data) - info.mode = mode - info.mtime = 0 - info.uid = 0 - info.gid = 0 - info.uname = "" - info.gname = "" - tar.addfile(info, io.BytesIO(data)) - return buffer.getvalue() - - -def zstd(data: bytes, *args: str) -> bytes: - try: - result = subprocess.run(["zstd", "-q", *args], input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) - except FileNotFoundError as e: - raise PackError("the zstd command is not installed") from e - if result.returncode != 0: - raise PackError(f"zstd {' '.join(args)} failed: {result.stderr.decode(errors='replace').strip()}") - return result.stdout - - -def compress(data: bytes) -> bytes: - return zstd(data, "-19", "-T1", "-c") - - -def unpack(archive: bytes) -> dict[str, tuple[bytes, int]]: - members = {} - try: - with tarfile.open(fileobj=io.BytesIO(zstd(archive, "-d", "-c")), mode="r:") as tar: - for info in tar: - if not info.isreg() or info.name in members: - raise PackError(f"unexpected entry {info.name!r} in the archive") - members[info.name] = (tar.extractfile(info).read(), info.mode) - except tarfile.TarError as e: - raise PackError(f"the archive does not read back: {e}") from e - return members - - -def binary(base: Path, rel: str) -> Path: - path = base / rel - if path.is_symlink() or not path.is_file(): - raise PackError(f"{path} is not a regular file") - with path.open("rb") as stream: - if stream.read(len(ELF_MAGIC)) != ELF_MAGIC: - raise PackError(f"{path} is not an ELF file") - return path - - -def exports_hash(path: Path) -> str: - try: - value = guest().exports_hash(path) - except PackError: - raise - except Exception as e: - raise PackError(f"{path}: cannot read its exported symbols: {e}") from e - if not isinstance(value, str) or not SHA256.fullmatch(value): - raise PackError(f"{path}: has no exported symbols") - return value - - -def parse_version_line(line: str) -> tuple[int, str]: - fields = line.split() - if "\n" in line or len(fields) < 2 or not EPOCH.fullmatch(fields[0]): - raise PackError(f"version line {line!r} is not '<build epoch> <version>'") - token = guest().version_token(line) - if not isinstance(token, str) or not token: - raise PackError(f"version line {line!r} has no version token") - return int(fields[0]), token - - -def parse_source(text: str) -> dict[str, str]: - try: - source = json.loads(text) - except ValueError as e: - raise PackError(f"--source-json is not JSON: {e}") from e - if not isinstance(source, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in source.items()): - raise PackError("--source-json must be an object of strings") - missing = [key for key in SOURCE_KEYS if not source.get(key)] - if missing: - raise PackError(f"--source-json lacks {', '.join(missing)}") - return source - - -def patch_digest(directory: Path) -> str: - patches = sorted(path for path in directory.glob("*.patch") if path.is_file()) - if not patches: - raise PackError(f"{directory} holds no *.patch files") - digest = hashlib.sha256() - for path in patches: - digest.update(f"{path.name}\0{sha256_file(path)}\n".encode()) - return digest.hexdigest() - - -def protocol_version(control: Path, patched: Path) -> int: - for name in HEADERS: - before, after = control / name, patched / name - if not before.is_file() or not after.is_file(): - raise PackError(f"{name} is missing from the control or the patched build") - if before.read_bytes() != after.read_bytes(): - raise PackError(f"the patch changes {name}: the droiddeck-esync patch must not change the wineserver protocol") - found = PROTOCOL.findall((control / HEADERS[0]).read_bytes()) - if len(found) != 1: - raise PackError("server_protocol.h does not define SERVER_PROTOCOL_VERSION once") - version = int(found[0]) - stamp = control / "protocol" - if stamp.is_file() and stamp.read_text().strip() != str(version): - raise PackError(f"{stamp} disagrees with server_protocol.h ({version})") - return version - - -def make_pack( - flavor: str, - rev: int, - stock_dir: Path | None, - version_line: str, - control: Path, - patched: Path, - source: dict[str, str], - series: str, - out: Path, - source_match: bool, - patch_dir: Path | None = None, - allow_unreproduced: bool = False, -) -> dict: - if flavor not in FLAVORS: - raise PackError(f"unknown flavor {flavor!r}") - if type(rev) is not int or rev < 1: - raise PackError("--rev must be a positive number") - if not SERIES.fullmatch(series): - raise PackError(f"{series!r} is not a patch series name") - if stock_dir is None and not source_match: - raise PackError("without --stock-dir a pack can only match by source: pass --source-match") - if stock_dir is not None and source_match: - raise PackError("a pack made against stock binaries matches them exactly: drop --source-match") - patch_dir = patch_dir or PATCHES / series - if not patch_dir.is_dir(): - raise PackError(f"the patch series {patch_dir} does not exist") - version_line = version_line.strip() - if stock_dir is not None: - stock_line = guest().version_line(stock_dir) - if stock_line and version_line and stock_line != version_line: - raise PackError(f"{stock_dir}/version says {stock_line!r}, not {version_line!r}") - version_line = version_line or stock_line - epoch, token = parse_version_line(version_line) - - stock_paths = {rel: binary(stock_dir or control, rel) for rel in BINARIES} - control_paths = {rel: binary(control, rel) for rel in BINARIES} - patched_paths = {rel: binary(patched, rel) for rel in BINARIES} - if BUILTIN_ESYNC in stock_paths[WINESERVER].read_bytes(): - raise PackError(f"{stock_paths[WINESERVER]} has esync built in and needs no pack") - stock = {rel: sha256_file(path) for rel, path in stock_paths.items()} - built = {rel: sha256_file(path) for rel, path in control_paths.items()} - files = {rel: sha256_file(path) for rel, path in patched_paths.items()} - for rel in BINARIES: - if files[rel] == built[rel]: - raise PackError(f"the patch left {rel} unchanged") - - exports = exports_hash(stock_paths[NTDLL]) - control_exports = exports_hash(control_paths[NTDLL]) - if exports_hash(patched_paths[NTDLL]) != control_exports: - raise PackError("the patch changes the symbols ntdll.so exports") - if exports != control_exports: - raise PackError("the control build of ntdll.so exports other symbols than the stock one: the wine source does not match this release") - protocol = protocol_version(control, patched) - if stock_dir is not None: - for rel in BINARIES: - state = "reproduces" if stock[rel] == built[rel] else "differs from" - print(f"make_pack: the control build {state} the stock {rel}", file=sys.stderr) - unreproduced = [rel for rel in BINARIES if stock[rel] != built[rel]] - if unreproduced and not allow_unreproduced: - raise PackError(f"the control build does not reproduce the stock {', '.join(unreproduced)}: the patched files would differ " - "from the release in more than the patch (check the build directory and the SDK image, or pass --allow-unreproduced)") - - pack_id = f"{flavor}-{slug(token)}-{epoch}-{stock[NTDLL][:12]}-r{rev}" - if not PACK_ID.fullmatch(pack_id): - raise PackError(f"{pack_id!r} is not a valid pack id") - pack = { - "format": 1, - "id": pack_id, - "flavor": flavor, - "rev": rev, - "version": token, - "version_line": version_line, - "stock": stock, - "exports": exports, - "source_match": source_match, - "files": files, - "copy": list(COPY), - "protocol": protocol, - "source": dict(source), - "patch": {"series": series, "sha256": patch_digest(patch_dir)}, - } - members = { - "pack.json": (dump_json(pack), 0o644), - NTDLL: (patched_paths[NTDLL].read_bytes(), 0o755), - WINESERVER: (patched_paths[WINESERVER].read_bytes(), 0o755), - } - archive = compress(tar_bytes(members)) - if unpack(archive) != members: - raise PackError("the archive does not read back as written") - entry = {key: pack[key] for key in ENTRY_KEYS} - entry["asset"] = {"sha256": sha256_bytes(archive), "size": len(archive)} - entry["revoked"] = False - out.mkdir(parents=True, exist_ok=True) - write_atomic(out / f"{pack_id}.tzst", archive) - write_atomic(out / f"{pack_id}.json", dump_json(entry)) - return entry - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="Make a droiddeck-esync pack from a control and a patched wine build of one Proton release.") - parser.add_argument("--flavor", required=True, choices=FLAVORS) - parser.add_argument("--rev", required=True, type=int) - parser.add_argument("--stock-dir", default="") - parser.add_argument("--version-line", default="") - parser.add_argument("--control", required=True) - parser.add_argument("--patched", required=True) - parser.add_argument("--source-json", required=True) - parser.add_argument("--patch-series", required=True) - parser.add_argument("--patch-dir", default="") - parser.add_argument("--out", required=True) - parser.add_argument("--source-match", action="store_true") - parser.add_argument("--allow-unreproduced", action="store_true") - args = parser.parse_args(argv) - try: - entry = make_pack( - flavor=args.flavor, - rev=args.rev, - stock_dir=Path(args.stock_dir) if args.stock_dir else None, - version_line=args.version_line, - control=Path(args.control), - patched=Path(args.patched), - source=parse_source(args.source_json), - series=args.patch_series, - out=Path(args.out), - source_match=args.source_match, - patch_dir=Path(args.patch_dir) if args.patch_dir else None, - allow_unreproduced=args.allow_unreproduced, - ) - except (PackError, OSError) as e: - print(f"make_pack: {e}", file=sys.stderr) - return 1 - print(entry["id"]) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/droiddeck-esync/patches/cachyos-11/0001-droiddeck-esync.patch b/tools/droiddeck-esync/patches/cachyos-11/0001-droiddeck-esync.patch deleted file mode 100644 index 80beac4ac..000000000 --- a/tools/droiddeck-esync/patches/cachyos-11/0001-droiddeck-esync.patch +++ /dev/null @@ -1,2436 +0,0 @@ -diff --git a/dlls/ntdll/Makefile.in b/dlls/ntdll/Makefile.in -index 38c865b..2a7afcc 100644 ---- a/dlls/ntdll/Makefile.in -+++ b/dlls/ntdll/Makefile.in -@@ -50,6 +50,7 @@ SOURCES = \ - unix/debug.c \ - unix/env.c \ - unix/file.c \ -+ unix/esync.c \ - unix/fsync.c \ - unix/loader.c \ - unix/loadorder.c \ -diff --git a/dlls/ntdll/unix/esync.c b/dlls/ntdll/unix/esync.c -new file mode 100644 -index 0000000..de02a56 ---- /dev/null -+++ b/dlls/ntdll/unix/esync.c -@@ -0,0 +1,1140 @@ -+#if 0 -+#pragma makedep unix -+#endif -+/* droiddeck-esync: the client side of eventfd and shared memory synchronization. -+ * Credit: based on the esync design by Elizabeth Figura for Wine and Proton. */ -+ -+#include "config.h" -+ -+#include <assert.h> -+#include <errno.h> -+#include <limits.h> -+#include <poll.h> -+#include <pthread.h> -+#include <sched.h> -+#include <signal.h> -+#include <stdarg.h> -+#include <stddef.h> -+#include <stdint.h> -+#include <stdio.h> -+#include <stdlib.h> -+#include <string.h> -+#include <sys/mman.h> -+#include <sys/syscall.h> -+#include <time.h> -+#include <unistd.h> -+ -+#include "ntstatus.h" -+#define WIN32_NO_STATUS -+#include "windef.h" -+#include "winternl.h" -+#include "wine/server.h" -+#include "wine/debug.h" -+ -+#include "unix_private.h" -+#include "esync.h" -+ -+WINE_DEFAULT_DEBUG_CHANNEL(esync); -+ -+#define ESYNC_CHUNK_SIZE 0x10000 -+#define ESYNC_SLOT_SIZE 16 -+#define ESYNC_PER_CHUNK (ESYNC_CHUNK_SIZE / ESYNC_SLOT_SIZE) -+#define ESYNC_CHUNKS 8192 -+#define ESYNC_MANUAL 1 -+#define ESYNC_WAITER 0x100 -+#define ESYNC_RELEASING 1 -+#define ESYNC_PULSE_NS 10000000 -+#define ESYNC_STALL_NS 2000000 -+#define ESYNC_TOKEN_MS 100 -+#define ESYNC_TOKEN_TRIES 10 -+#define ESYNC_SPINS 16 -+#define ESYNC_SPIN_NS 50000 -+#define ESYNC_SLICE_NS 1000000000 -+ -+struct esync_slot -+{ -+ union -+ { -+ struct -+ { -+ int state; -+ int aux; -+ }; -+ LONG64 pair; -+ }; -+ int flags; -+ int ref; -+}; -+ -+C_ASSERT( sizeof(struct esync_slot) == ESYNC_SLOT_SIZE ); -+ -+union esync_pair -+{ -+ struct { int state; int aux; } s; -+ LONG64 word; -+}; -+ -+C_ASSERT( offsetof( struct esync_slot, state ) == offsetof( union esync_pair, s.state ) ); -+C_ASSERT( offsetof( struct esync_slot, aux ) == offsetof( union esync_pair, s.aux ) ); -+ -+static BOOL swap_pair( struct esync_slot *slot, int state, int aux, int new_state, int new_aux ) -+{ -+ union esync_pair cur = { .s = { state, aux } }, next = { .s = { new_state, new_aux } }; -+ -+ return __atomic_compare_exchange_n( &slot->pair, &cur.word, next.word, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+} -+ -+enum esync_kind -+{ -+ ESYNC_KIND_SEMAPHORE, -+ ESYNC_KIND_MUTEX, -+ ESYNC_KIND_AUTO, -+ ESYNC_KIND_MANUAL, -+}; -+ -+struct esync_obj -+{ -+ int fd; -+ enum esync_kind kind; -+ struct esync_slot *slot; -+}; -+ -+enum esync_clock -+{ -+ ESYNC_NO_TIMEOUT, -+ ESYNC_MONOTONIC, -+ ESYNC_REALTIME, -+}; -+ -+struct esync_deadline -+{ -+ enum esync_clock clock; -+ ULONGLONG end; -+}; -+ -+struct esync_held -+{ -+ struct esync_slot *slot; -+ int fd; -+}; -+ -+struct esync_waits -+{ -+ struct esync_slot *slots[MAXIMUM_WAIT_OBJECTS]; -+ DWORD count; -+ struct esync_held held[MAXIMUM_WAIT_OBJECTS]; -+ DWORD held_count; -+ DWORD tid; -+ int unix_tid; -+}; -+ -+static int shm_fd = -1; -+static void *chunks[ESYNC_CHUNKS]; -+static sigset_t block_set; -+static __thread struct esync_waits waits; -+static pthread_key_t waits_key; -+static pthread_once_t waits_once = PTHREAD_ONCE_INIT; -+ -+#ifdef __linux__ -+ -+void esync_init( int fd ) -+{ -+ shm_fd = fd; -+ block_set = server_block_set; -+ sigaddset( &block_set, SIGQUIT ); -+ TRACE( "shm fd %d\n", fd ); -+} -+ -+static struct esync_slot *get_slot( unsigned int idx ) -+{ -+ unsigned int chunk = idx / ESYNC_PER_CHUNK; -+ void *base, *addr; -+ -+ if (!idx || chunk >= ESYNC_CHUNKS) return NULL; -+ if (!(base = __atomic_load_n( &chunks[chunk], __ATOMIC_ACQUIRE ))) -+ { -+ addr = mmap( NULL, ESYNC_CHUNK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, -+ (off_t)chunk * ESYNC_CHUNK_SIZE ); -+ if (addr == MAP_FAILED) -+ { -+ ERR( "cannot map chunk %u: %s\n", chunk, strerror( errno ) ); -+ return NULL; -+ } -+ if (__atomic_compare_exchange_n( &chunks[chunk], &base, addr, FALSE, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE )) -+ base = addr; -+ else -+ munmap( addr, ESYNC_CHUNK_SIZE ); -+ } -+ return (struct esync_slot *)((char *)base + (idx % ESYNC_PER_CHUNK) * ESYNC_SLOT_SIZE); -+} -+ -+void esync_release( unsigned int shm_idx ) -+{ -+ if (!shm_idx) return; -+ SERVER_START_REQ( fsync_free_shm_idx ) -+ { -+ req->shm_idx = shm_idx; -+ wine_server_call( req ); -+ } -+ SERVER_END_REQ; -+} -+ -+static void block_signals( sigset_t *old ) -+{ -+ pthread_sigmask( SIG_BLOCK, &block_set, old ); -+} -+ -+static void restore_signals( const sigset_t *old ) -+{ -+ pthread_sigmask( SIG_SETMASK, old, NULL ); -+} -+ -+static ssize_t read_fd( int fd, uint64_t *value ) -+{ -+ return syscall( __NR_read, fd, value, sizeof(*value) ); -+} -+ -+static int poll_fds( struct pollfd *fds, nfds_t count, const struct timespec *timeout ) -+{ -+ struct timespec left; -+ -+ if (timeout) left = *timeout; -+ return syscall( __NR_ppoll, fds, count, timeout ? &left : NULL, NULL, 0 ); -+} -+ -+static NTSTATUS wake_fd( int fd, uint64_t count ) -+{ -+ if (syscall( __NR_write, fd, &count, sizeof(count) ) == -1) return errno_to_status( errno ); -+ return STATUS_SUCCESS; -+} -+ -+static BOOL drain_fd( int fd ) -+{ -+ uint64_t value; -+ -+ if (read_fd( fd, &value ) == sizeof(value)) return TRUE; -+ if (errno != EAGAIN) WARN( "read fd %d: %s\n", fd, strerror( errno ) ); -+ return FALSE; -+} -+ -+static BOOL fd_readable( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ struct timespec now = { 0, 0 }; -+ int ret; -+ -+ while ((ret = poll_fds( &pfd, 1, &now )) < 0 && errno == EINTR); -+ return ret > 0 && (pfd.revents & POLLIN); -+} -+ -+static BOOL consume_token( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ struct timespec pause = { 0, ESYNC_TOKEN_MS * 1000000 }; -+ unsigned int tries = 0; -+ uint64_t value; -+ -+ while (read_fd( fd, &value ) != sizeof(value)) -+ { -+ if (errno != EAGAIN && errno != EINTR) -+ { -+ WARN( "read fd %d: %s\n", fd, strerror( errno ) ); -+ return TRUE; -+ } -+ if (tries == ESYNC_TOKEN_TRIES) -+ { -+ WARN( "no token on fd %d, the count it was released with is lost\n", fd ); -+ return FALSE; -+ } -+ if (!poll_fds( &pfd, 1, &pause )) tries++; -+ } -+ return TRUE; -+} -+ -+static int current_unix_tid(void) -+{ -+ if (!waits.unix_tid) waits.unix_tid = syscall( __NR_gettid ); -+ return waits.unix_tid; -+} -+ -+static void repair_slot( struct esync_slot *slot, int fd ) -+{ -+ BOOL readable = fd_readable( fd ); -+ -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) -+ { -+ if (!readable) wake_fd( fd, 1 ); -+ } -+ else if (readable) drain_fd( fd ); -+} -+ -+static void lock_slot( struct esync_slot *slot, int fd, int self ) -+{ -+ unsigned int spins = 0; -+ int owner; -+ -+ for (;;) -+ { -+ owner = 0; -+ if (__atomic_compare_exchange_n( &slot->aux, &owner, self, FALSE, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) return; -+ if (++spins < 128) -+ { -+ YieldProcessor(); -+ continue; -+ } -+ if (!(spins & 1023) && owner > 0 && kill( owner, 0 ) == -1 && errno == ESRCH && -+ __atomic_compare_exchange_n( &slot->aux, &owner, self, FALSE, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) -+ { -+ WARN( "lock owner %d is gone, taking over\n", owner ); -+ repair_slot( slot, fd ); -+ return; -+ } -+ sched_yield(); -+ } -+} -+ -+static void unlock_slot( struct esync_slot *slot, int self ) -+{ -+ __atomic_compare_exchange_n( &slot->aux, &self, 0, FALSE, __ATOMIC_RELEASE, __ATOMIC_RELAXED ); -+} -+ -+static void drop_waits( void *arg ) -+{ -+ struct esync_waits *list = arg; -+ struct esync_held *held; -+ -+ while (list->count) __atomic_sub_fetch( &list->slots[--list->count]->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ while (list->held_count) -+ { -+ held = &list->held[--list->held_count]; -+ if (swap_pair( held->slot, list->tid, __atomic_load_n( &held->slot->aux, __ATOMIC_SEQ_CST ), ~0, 0 )) -+ wake_fd( held->fd, 1 ); -+ } -+} -+ -+static void init_waits_key(void) -+{ -+ pthread_key_create( &waits_key, drop_waits ); -+} -+ -+static void arm_waits(void) -+{ -+ pthread_once( &waits_once, init_waits_key ); -+ if (!pthread_getspecific( waits_key )) pthread_setspecific( waits_key, &waits ); -+} -+ -+static void add_waits( DWORD count, const struct esync_obj *objs ) -+{ -+ DWORD i; -+ -+ arm_waits(); -+ for (i = 0; i < count && waits.count < MAXIMUM_WAIT_OBJECTS; i++) -+ { -+ if (objs[i].kind != ESYNC_KIND_AUTO && objs[i].kind != ESYNC_KIND_MANUAL) continue; -+ waits.slots[waits.count] = objs[i].slot; -+ __atomic_add_fetch( &objs[i].slot->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ __atomic_store_n( &waits.count, waits.count + 1, __ATOMIC_RELAXED ); -+ } -+} -+ -+static void remove_waits( DWORD base ) -+{ -+ struct esync_slot *slot; -+ -+ while (waits.count > base) -+ { -+ slot = waits.slots[waits.count - 1]; -+ __atomic_store_n( &waits.count, waits.count - 1, __ATOMIC_RELAXED ); -+ __atomic_sub_fetch( &slot->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ } -+} -+ -+static void hold_mutex( struct esync_obj *obj, DWORD tid ) -+{ -+ arm_waits(); -+ waits.tid = tid; -+ waits.held[waits.held_count].slot = obj->slot; -+ waits.held[waits.held_count].fd = obj->fd; -+ waits.held_count++; -+} -+ -+static ULONGLONG monotonic_ns(void) -+{ -+ struct timespec ts; -+ -+ clock_gettime( CLOCK_MONOTONIC, &ts ); -+ return (ULONGLONG)ts.tv_sec * 1000000000 + ts.tv_nsec; -+} -+ -+NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret = STATUS_SUCCESS; -+ sigset_t old; -+ int current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ block_signals( &old ); -+ current = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ do -+ { -+ if ((ULONGLONG)(unsigned int)current + count > (unsigned int)slot->aux) -+ { -+ restore_signals( &old ); -+ return STATUS_SEMAPHORE_LIMIT_EXCEEDED; -+ } -+ } -+ while (!__atomic_compare_exchange_n( &slot->state, ¤t, (int)((unsigned int)current + count), -+ FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (count) ret = wake_fd( fd, count ); -+ restore_signals( &old ); -+ if (!ret && prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ info->CurrentCount = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->MaximumCount = slot->aux; -+ return STATUS_SUCCESS; -+} -+ -+static NTSTATUS set_manual( struct esync_slot *slot, int fd, LONG *prev ) -+{ -+ int self = current_unix_tid(); -+ NTSTATUS ret = STATUS_SUCCESS; -+ sigset_t old; -+ LONG current; -+ -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ) == 1) -+ { -+ if (prev) *prev = 1; -+ return STATUS_SUCCESS; -+ } -+ block_signals( &old ); -+ lock_slot( slot, fd, self ); -+ if (!(current = __atomic_exchange_n( &slot->state, 1, __ATOMIC_SEQ_CST ))) ret = wake_fd( fd, 1 ); -+ unlock_slot( slot, self ); -+ restore_signals( &old ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+static LONG reset_manual( struct esync_slot *slot, int fd ) -+{ -+ int self = current_unix_tid(); -+ sigset_t old; -+ LONG current; -+ -+ if (!__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) return 0; -+ block_signals( &old ); -+ lock_slot( slot, fd, self ); -+ if ((current = __atomic_exchange_n( &slot->state, 0, __ATOMIC_SEQ_CST ))) drain_fd( fd ); -+ unlock_slot( slot, self ); -+ restore_signals( &old ); -+ return current; -+} -+ -+NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret; -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) return set_manual( slot, fd, prev ); -+ current = prev ? fd_readable( fd ) : 0; -+ ret = wake_fd( fd, 1 ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) current = reset_manual( slot, fd ); -+ else -+ { -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ current = drain_fd( fd ); -+ } -+ if (prev) *prev = current; -+ return STATUS_SUCCESS; -+} -+ -+static void pulse_wait( struct esync_slot *slot, int fd, BOOL manual ) -+{ -+ ULONGLONG now = monotonic_ns(), deadline = now + ESYNC_PULSE_NS, stall = now + ESYNC_STALL_NS; -+ int waiters = __atomic_load_n( &slot->flags, __ATOMIC_SEQ_CST ) / ESYNC_WAITER, left; -+ -+ while (waiters > 0 && now < deadline && now < stall) -+ { -+ if (!manual && !fd_readable( fd )) break; -+ sched_yield(); -+ left = __atomic_load_n( &slot->flags, __ATOMIC_SEQ_CST ) / ESYNC_WAITER; -+ now = monotonic_ns(); -+ if (left < waiters) stall = now + ESYNC_STALL_NS; -+ waiters = left; -+ } -+} -+ -+NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret; -+ sigset_t old; -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ block_signals( &old ); -+ if (slot->flags & ESYNC_MANUAL) -+ { -+ if (!(ret = set_manual( slot, fd, ¤t ))) pulse_wait( slot, fd, TRUE ); -+ reset_manual( slot, fd ); -+ } -+ else -+ { -+ current = prev ? fd_readable( fd ) : 0; -+ ret = wake_fd( fd, 1 ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ if (!ret) pulse_wait( slot, fd, FALSE ); -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ drain_fd( fd ); -+ } -+ restore_signals( &old ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) -+ { -+ info->EventState = !!__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->EventType = NotificationEvent; -+ } -+ else -+ { -+ info->EventState = fd_readable( fd ); -+ info->EventType = SynchronizationEvent; -+ } -+ return STATUS_SUCCESS; -+} -+ -+NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ DWORD tid = GetCurrentThreadId(); -+ NTSTATUS ret = STATUS_SUCCESS; -+ int owner = tid | ESYNC_RELEASING, count; -+ sigset_t old; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if ((DWORD)__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ) != tid) return STATUS_MUTANT_NOT_OWNED; -+ block_signals( &old ); -+ count = __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ if (count > 1) -+ { -+ if (!swap_pair( slot, tid, count, tid, count - 1 )) ret = STATUS_MUTANT_NOT_OWNED; -+ } -+ else if (!swap_pair( slot, tid, count, tid | ESYNC_RELEASING, 0 )) ret = STATUS_MUTANT_NOT_OWNED; -+ else if ((ret = wake_fd( fd, 1 ))) swap_pair( slot, tid | ESYNC_RELEASING, 0, tid, count ); -+ else __atomic_compare_exchange_n( &slot->state, &owner, 0, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+ restore_signals( &old ); -+ if (!ret && prev) *prev = 1 - count; -+ return ret; -+} -+ -+NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ DWORD owner; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ owner = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->CurrentCount = 1 - __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ info->OwnedByCaller = owner == GetCurrentThreadId(); -+ info->AbandonedState = owner == ~0u; -+ return STATUS_SUCCESS; -+} -+ -+static void get_deadline( const LARGE_INTEGER *timeout, struct esync_deadline *end ) -+{ -+ ULONGLONG now, rel; -+ -+ end->clock = ESYNC_NO_TIMEOUT; -+ if (!timeout || timeout->QuadPart == TIMEOUT_INFINITE) return; -+ if (timeout->QuadPart > 0) -+ { -+ end->clock = ESYNC_REALTIME; -+ end->end = timeout->QuadPart; -+ return; -+ } -+ now = monotonic_ns(); -+ rel = -(ULONGLONG)timeout->QuadPart; -+ if (rel > (ULLONG_MAX - now) / 100) return; -+ end->clock = ESYNC_MONOTONIC; -+ end->end = now + rel * 100; -+} -+ -+static BOOL time_left( const struct esync_deadline *end, struct timespec *ts, BOOL *sliced ) -+{ -+ ULONGLONG left = 0, now; -+ struct timespec rt; -+ LONGLONG ticks; -+ -+ *sliced = FALSE; -+ switch (end->clock) -+ { -+ case ESYNC_NO_TIMEOUT: -+ return FALSE; -+ case ESYNC_MONOTONIC: -+ now = monotonic_ns(); -+ if (now < end->end) left = end->end - now; -+ break; -+ case ESYNC_REALTIME: -+ clock_gettime( CLOCK_REALTIME, &rt ); -+ ticks = ticks_from_time_t( rt.tv_sec ) + rt.tv_nsec / 100; -+ if ((LONGLONG)end->end > ticks) -+ { -+ left = (ULONGLONG)((LONGLONG)end->end - ticks); -+ left = left > ESYNC_SLICE_NS / 100 ? ESYNC_SLICE_NS : left * 100; -+ } -+ if (left == ESYNC_SLICE_NS) *sliced = TRUE; -+ break; -+ } -+ ts->tv_sec = left / 1000000000; -+ ts->tv_nsec = left % 1000000000; -+ return TRUE; -+} -+ -+static BOOL expired( const struct esync_deadline *end ) -+{ -+ struct timespec ts; -+ BOOL sliced; -+ -+ return time_left( end, &ts, &sliced ) && !sliced && !ts.tv_sec && !ts.tv_nsec; -+} -+ -+static int do_poll( struct pollfd *fds, nfds_t count, const struct esync_deadline *end ) -+{ -+ struct timespec ts; -+ BOOL sliced; -+ int ret; -+ -+ for (;;) -+ { -+ ret = poll_fds( fds, count, time_left( end, &ts, &sliced ) ? &ts : NULL ); -+ if (ret < 0 && errno == EINTR) continue; -+ if (!ret && sliced) continue; -+ return ret; -+ } -+} -+ -+static NTSTATUS user_apc(void) -+{ -+ static const LARGE_INTEGER zero; -+ -+ return server_wait( NULL, 0, SELECT_INTERRUPTIBLE | SELECT_ALERTABLE, &zero ); -+} -+ -+static BOOL take_semaphore( struct esync_obj *obj ) -+{ -+ int current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ sigset_t old; -+ BOOL ret; -+ -+ if (current <= 0) return FALSE; -+ block_signals( &old ); -+ do -+ { -+ if (current <= 0) -+ { -+ restore_signals( &old ); -+ return FALSE; -+ } -+ } -+ while (!__atomic_compare_exchange_n( &obj->slot->state, ¤t, current - 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ ret = consume_token( obj->fd ); -+ restore_signals( &old ); -+ return ret; -+} -+ -+static void back_off( unsigned int *spins ) -+{ -+ struct timespec pause = { 0, 1000000 }; -+ -+ if (++*spins <= ESYNC_SPINS) sched_yield(); -+ else nanosleep( &pause, NULL ); -+} -+ -+static BOOL mutex_free( int state ) -+{ -+ return !state || (state & ESYNC_RELEASING); -+} -+ -+static BOOL claim_mutex( struct esync_obj *obj, DWORD tid, int *prev ) -+{ -+ int owner = tid; -+ uint64_t value; -+ -+ if (!mutex_free( *prev )) return FALSE; -+ if (!__atomic_compare_exchange_n( &obj->slot->state, prev, tid, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ return FALSE; -+ if (read_fd( obj->fd, &value ) != sizeof(value)) -+ { -+ __atomic_compare_exchange_n( &obj->slot->state, &owner, *prev, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+ return FALSE; -+ } -+ if ((DWORD)__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ) != tid) -+ { -+ wake_fd( obj->fd, 1 ); -+ return FALSE; -+ } -+ return TRUE; -+} -+ -+static void unclaim_mutex( struct esync_obj *obj, DWORD tid, int prev ) -+{ -+ int owner = tid; -+ -+ if ((DWORD)prev == ~0u) -+ { -+ if (__atomic_compare_exchange_n( &obj->slot->state, &owner, ~0, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ wake_fd( obj->fd, 1 ); -+ return; -+ } -+ if (!__atomic_compare_exchange_n( &obj->slot->state, &owner, tid | ESYNC_RELEASING, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ return; -+ wake_fd( obj->fd, 1 ); -+ owner = tid | ESYNC_RELEASING; -+ __atomic_compare_exchange_n( &obj->slot->state, &owner, prev, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+} -+ -+static BOOL take_mutex( struct esync_obj *obj, DWORD tid, BOOL *abandoned ) -+{ -+ int prev = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ BOOL ret = FALSE; -+ sigset_t old; -+ -+ if (!mutex_free( prev )) return FALSE; -+ block_signals( &old ); -+ if (claim_mutex( obj, tid, &prev )) -+ { -+ if ((DWORD)prev == ~0u) *abandoned = TRUE; -+ hold_mutex( obj, tid ); -+ swap_pair( obj->slot, tid, __atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ), tid, 1 ); -+ ret = TRUE; -+ } -+ restore_signals( &old ); -+ waits.held_count = 0; -+ return ret; -+} -+ -+static BOOL take_auto( struct esync_obj *obj ) -+{ -+ uint64_t value; -+ -+ __atomic_store_n( &obj->slot->state, 0, __ATOMIC_SEQ_CST ); -+ return read_fd( obj->fd, &value ) == sizeof(value); -+} -+ -+static NTSTATUS recurse_mutex( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ int count = __atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ); -+ -+ if (count == INT_MAX) return STATUS_MUTANT_LIMIT_EXCEEDED; -+ swap_pair( obj->slot, tid, count, tid, count + 1 ); -+ return i; -+} -+ -+static NTSTATUS try_now( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ int state = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ BOOL abandoned = FALSE; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ if ((DWORD)state == tid) return recurse_mutex( obj, i, tid ); -+ if (!mutex_free( state )) break; -+ if (take_mutex( obj, tid, &abandoned )) return abandoned ? STATUS_ABANDONED_WAIT_0 + i : i; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ if (take_semaphore( obj )) return i; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (state && take_auto( obj )) return i; -+ break; -+ case ESYNC_KIND_MANUAL: -+ if (state) return i; -+ break; -+ } -+ return STATUS_PENDING; -+} -+ -+static NTSTATUS try_ready( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ BOOL abandoned = FALSE; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ if (take_mutex( obj, tid, &abandoned )) return abandoned ? STATUS_ABANDONED_WAIT_0 + i : i; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ if (take_semaphore( obj )) return i; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (take_auto( obj )) return i; -+ break; -+ case ESYNC_KIND_MANUAL: -+ return i; -+ } -+ return STATUS_PENDING; -+} -+ -+static NTSTATUS wait_any( DWORD count, struct esync_obj *objs, int alert_fd, const struct esync_deadline *end, BOOL track ) -+{ -+ struct pollfd fds[MAXIMUM_WAIT_OBJECTS + 1]; -+ DWORD tid = GetCurrentThreadId(), i; -+ unsigned int spins = 0; -+ ULONGLONG polled; -+ NTSTATUS status; -+ BOOL busy; -+ nfds_t nfds; -+ int ret; -+ -+ for (;;) -+ { -+ for (i = 0; i < count; i++) -+ if ((status = try_now( &objs[i], i, tid )) != STATUS_PENDING) return status; -+ if (track) -+ { -+ add_waits( count, objs ); -+ track = FALSE; -+ continue; -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ fds[i].fd = objs[i].fd; -+ fds[i].events = POLLIN; -+ fds[i].revents = 0; -+ } -+ nfds = count; -+ if (alert_fd >= 0) -+ { -+ fds[nfds].fd = alert_fd; -+ fds[nfds].events = POLLIN; -+ fds[nfds].revents = 0; -+ nfds++; -+ } -+ -+ polled = monotonic_ns(); -+ if ((ret = do_poll( fds, nfds, end )) < 0) return errno_to_status( errno ); -+ if (!ret) return STATUS_TIMEOUT; -+ -+ busy = FALSE; -+ for (i = 0; i < count; i++) -+ { -+ if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) return STATUS_INVALID_HANDLE; -+ if (!(fds[i].revents & POLLIN)) continue; -+ if ((status = try_ready( &objs[i], i, tid )) != STATUS_PENDING) return status; -+ busy = TRUE; -+ } -+ if (alert_fd >= 0 && (fds[count].revents & POLLIN) && (status = user_apc()) != STATUS_TIMEOUT) -+ return status; -+ if (expired( end )) return STATUS_TIMEOUT; -+ if (busy && monotonic_ns() - polled < ESYNC_SPIN_NS) back_off( &spins ); -+ else spins = 0; -+ } -+} -+ -+static BOOL owned_mutex( struct esync_obj *obj, DWORD tid ) -+{ -+ return obj->kind == ESYNC_KIND_MUTEX && (DWORD)__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ) == tid; -+} -+ -+static BOOL ready_now( struct esync_obj *obj, DWORD tid ) -+{ -+ int state = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ return (DWORD)state == tid || (mutex_free( state ) && fd_readable( obj->fd )); -+ case ESYNC_KIND_SEMAPHORE: -+ return state > 0; -+ case ESYNC_KIND_AUTO: -+ return fd_readable( obj->fd ); -+ case ESYNC_KIND_MANUAL: -+ return state != 0; -+ } -+ return FALSE; -+} -+ -+static NTSTATUS grab_all( DWORD count, struct esync_obj *objs, DWORD tid ) -+{ -+ BOOL grabbed[MAXIMUM_WAIT_OBJECTS], owned[MAXIMUM_WAIT_OBJECTS], abandoned = FALSE; -+ DWORD order[MAXIMUM_WAIT_OBJECTS], n = 0, i, j; -+ int current, prev[MAXIMUM_WAIT_OBJECTS]; -+ NTSTATUS status = STATUS_PENDING; -+ sigset_t old; -+ -+ for (i = 0; i < count; i++) -+ if (!ready_now( &objs[i], tid )) return STATUS_PENDING; -+ for (i = 0; i < count; i++) -+ { -+ grabbed[i] = owned[i] = FALSE; -+ if (objs[i].kind != ESYNC_KIND_AUTO) order[n++] = i; -+ } -+ for (i = 0; i < count; i++) -+ if (objs[i].kind == ESYNC_KIND_AUTO) order[n++] = i; -+ -+ block_signals( &old ); -+ for (j = 0; j < count; j++) -+ { -+ struct esync_obj *obj = &objs[i = order[j]]; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ if ((DWORD)current == tid) -+ { -+ if (__atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ) == INT_MAX) -+ { -+ status = STATUS_MUTANT_LIMIT_EXCEEDED; -+ goto rollback; -+ } -+ owned[i] = TRUE; -+ break; -+ } -+ prev[i] = current; -+ if (!claim_mutex( obj, tid, &prev[i] )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ do if (current <= 0) goto rollback; -+ while (!__atomic_compare_exchange_n( &obj->slot->state, ¤t, current - 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (!consume_token( obj->fd )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (!take_auto( obj )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_MANUAL: -+ if (!__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST )) goto rollback; -+ break; -+ } -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ struct esync_slot *slot = objs[i].slot; -+ -+ if (owned[i]) -+ { -+ current = __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ swap_pair( slot, tid, current, tid, current + 1 ); -+ } -+ else if (grabbed[i] && objs[i].kind == ESYNC_KIND_MUTEX) -+ { -+ if ((DWORD)prev[i] == ~0u) abandoned = TRUE; -+ hold_mutex( &objs[i], tid ); -+ swap_pair( slot, tid, __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ), tid, 1 ); -+ } -+ } -+ restore_signals( &old ); -+ waits.held_count = 0; -+ return abandoned ? STATUS_ABANDONED : STATUS_SUCCESS; -+ -+rollback: -+ while (j--) -+ { -+ i = order[j]; -+ if (!grabbed[i]) continue; -+ switch (objs[i].kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ unclaim_mutex( &objs[i], tid, prev[i] ); -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ current = __atomic_load_n( &objs[i].slot->state, __ATOMIC_SEQ_CST ); -+ do if (current >= objs[i].slot->aux) break; -+ while (!__atomic_compare_exchange_n( &objs[i].slot->state, ¤t, current + 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (current < objs[i].slot->aux) wake_fd( objs[i].fd, 1 ); -+ break; -+ case ESYNC_KIND_AUTO: -+ wake_fd( objs[i].fd, 1 ); -+ __atomic_store_n( &objs[i].slot->state, 1, __ATOMIC_SEQ_CST ); -+ break; -+ case ESYNC_KIND_MANUAL: -+ break; -+ } -+ } -+ restore_signals( &old ); -+ return status; -+} -+ -+static NTSTATUS wait_all( DWORD count, struct esync_obj *objs, int alert_fd, const struct esync_deadline *end, BOOL track ) -+{ -+ struct pollfd fds[2]; -+ DWORD tid = GetCurrentThreadId(), i, j; -+ NTSTATUS status; -+ nfds_t nfds; -+ int ret; -+ -+ for (i = 0; i < count; i++) -+ for (j = i + 1; j < count; j++) -+ if (objs[i].slot == objs[j].slot) return STATUS_INVALID_PARAMETER; -+ -+ for (;;) -+ { -+ if ((status = grab_all( count, objs, tid )) != STATUS_PENDING) return status; -+ if (track) -+ { -+ add_waits( count, objs ); -+ track = FALSE; -+ continue; -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ if (owned_mutex( &objs[i], tid )) continue; -+ fds[0].fd = objs[i].fd; -+ fds[0].events = POLLIN; -+ fds[0].revents = 0; -+ nfds = 1; -+ if (alert_fd >= 0) -+ { -+ fds[1].fd = alert_fd; -+ fds[1].events = POLLIN; -+ fds[1].revents = 0; -+ nfds = 2; -+ } -+ if ((ret = do_poll( fds, nfds, end )) < 0) return errno_to_status( errno ); -+ if (!ret) return STATUS_TIMEOUT; -+ if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) return STATUS_INVALID_HANDLE; -+ if (!(fds[0].revents & POLLIN) && nfds == 2 && (fds[1].revents & POLLIN)) -+ { -+ if ((status = grab_all( count, objs, tid )) != STATUS_PENDING) return status; -+ if ((status = user_apc()) != STATUS_TIMEOUT) return status; -+ break; -+ } -+ } -+ if (i == count) -+ { -+ if (expired( end )) return STATUS_TIMEOUT; -+ sched_yield(); -+ } -+ } -+} -+ -+NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ) -+{ -+ struct esync_obj objs[MAXIMUM_WAIT_OBJECTS]; -+ struct esync_deadline end; -+ DWORD i, base = waits.count; -+ BOOL track = FALSE; -+ NTSTATUS ret; -+ -+ assert( count <= MAXIMUM_WAIT_OBJECTS ); -+ for (i = 0; i < count; i++) -+ { -+ if (!(objs[i].slot = get_slot( shm_idxs[i] ))) return STATUS_INVALID_HANDLE; -+ objs[i].fd = fds[i]; -+ switch (types[i]) -+ { -+ case INPROC_SYNC_SEMAPHORE: -+ objs[i].kind = ESYNC_KIND_SEMAPHORE; -+ break; -+ case INPROC_SYNC_MUTEX: -+ objs[i].kind = ESYNC_KIND_MUTEX; -+ break; -+ default: -+ objs[i].kind = (objs[i].slot->flags & ESYNC_MANUAL) ? ESYNC_KIND_MANUAL : ESYNC_KIND_AUTO; -+ track = TRUE; -+ break; -+ } -+ } -+ -+ get_deadline( timeout, &end ); -+ if (timeout && !timeout->QuadPart) track = FALSE; -+ if (type != WaitAll || count <= 1) ret = wait_any( count, objs, alert_fd, &end, track ); -+ else ret = wait_all( count, objs, alert_fd, &end, track ); -+ remove_waits( base ); -+ return ret; -+} -+ -+#else -+ -+void esync_init( int fd ) -+{ -+} -+ -+void esync_release( unsigned int shm_idx ) -+{ -+} -+ -+NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+#endif -diff --git a/dlls/ntdll/unix/esync.h b/dlls/ntdll/unix/esync.h -new file mode 100644 -index 0000000..afa19fc ---- /dev/null -+++ b/dlls/ntdll/unix/esync.h -@@ -0,0 +1,20 @@ -+#define ESYNC_USED_BY_SERVER 0x7eadeeef -+#define ESYNC_SHM_TOKEN 0x7eadeef0 -+ -+static inline int do_esync(void) -+{ -+ return inproc_device_fd == ESYNC_USED_BY_SERVER; -+} -+ -+extern void esync_init( int shm_fd ); -+extern void esync_release( unsigned int shm_idx ); -+extern NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ); -+extern NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ); -diff --git a/dlls/ntdll/unix/server.c b/dlls/ntdll/unix/server.c -index 112157b..864c2f9 100644 ---- a/dlls/ntdll/unix/server.c -+++ b/dlls/ntdll/unix/server.c -@@ -81,6 +81,7 @@ - #include "unix_private.h" - #include "ddk/wdm.h" - -+#include "esync.h" - #include "fsync.h" - - WINE_DEFAULT_DEBUG_CHANNEL(server); -@@ -1703,6 +1704,14 @@ size_t server_init_process(void) - inproc_device_fd = FSYNC_USED_BY_SERVER; - fsync_init( pid ); - } -+ else if (reply->inproc_device == ESYNC_USED_BY_SERVER) -+ { -+ int shm_fd = wine_server_receive_fd( &handle ); -+ assert( handle == ESYNC_SHM_TOKEN ); -+ if (shm_fd < 0) fatal_error( "droiddeck-esync: no shared memory from wineserver\n" ); -+ inproc_device_fd = ESYNC_USED_BY_SERVER; -+ esync_init( shm_fd ); -+ } - else if (reply->inproc_device) - { - inproc_device_fd = wine_server_receive_fd( &handle ); -diff --git a/dlls/ntdll/unix/sync.c b/dlls/ntdll/unix/sync.c -index 67ab945..f44a526 100644 ---- a/dlls/ntdll/unix/sync.c -+++ b/dlls/ntdll/unix/sync.c -@@ -72,6 +72,7 @@ - #include "wine/debug.h" - #include "unix_private.h" - -+#include "esync.h" - #include "fsync.h" - - WINE_DEFAULT_DEBUG_CHANNEL(sync); -@@ -542,9 +543,10 @@ struct inproc_sync - unsigned int access; /* handle access rights */ - unsigned short type; /* enum inproc_sync_type as short to save space */ - unsigned short closed; /* fd has been closed but sync is still referenced */ -+ unsigned int shm_idx; - }; - --#define INPROC_SYNC_CACHE_BLOCK_SIZE (65536 / sizeof(struct inproc_sync)) -+#define INPROC_SYNC_CACHE_BLOCK_SIZE 4096 - #define INPROC_SYNC_CACHE_ENTRIES 128 - - static struct inproc_sync *inproc_sync_cache[INPROC_SYNC_CACHE_ENTRIES]; -@@ -582,7 +584,8 @@ static struct inproc_sync *cache_inproc_sync( HANDLE handle, struct inproc_sync - if (!entry) inproc_sync_cache[0] = inproc_sync_cache_initial_block; - else - { -- static const size_t size = INPROC_SYNC_CACHE_BLOCK_SIZE * sizeof(struct inproc_sync); -+ size_t page = sysconf( _SC_PAGESIZE ); -+ size_t size = (INPROC_SYNC_CACHE_BLOCK_SIZE * sizeof(struct inproc_sync) + page - 1) & ~(page - 1); - void *ptr = anon_mmap_alloc( size, PROT_READ | PROT_WRITE ); - if (ptr == MAP_FAILED) return sync; - inproc_sync_cache[entry] = ptr; -@@ -604,6 +607,7 @@ static struct inproc_sync *cache_inproc_sync( HANDLE handle, struct inproc_sync - cache->access = sync->access; - cache->type = sync->type; - cache->closed = sync->closed; -+ cache->shm_idx = sync->shm_idx; - /* Make sure we set the other members before the refcount; this store needs - * release semantics [paired with the load in get_cached_inproc_sync()]. - * Set the refcount to 2 (one for the handle, one for the caller). */ -@@ -633,10 +637,13 @@ static void release_inproc_sync( struct inproc_sync *sync ) - /* save the fd now; as soon as the refcount hits 0 we cannot - * access the cache anymore */ - int fd = sync->fd; -+ unsigned int shm_idx = sync->shm_idx; - LONG ref = InterlockedDecrement( &sync->refcount ); - - assert( ref >= 0 ); -- if (!ref) close( fd ); -+ if (ref) return; -+ if (fd >= 0) close( fd ); -+ if (shm_idx) esync_release( shm_idx ); - } - - static struct inproc_sync *get_cached_inproc_sync( HANDLE handle ) -@@ -682,10 +689,19 @@ static NTSTATUS get_server_inproc_sync( HANDLE handle, struct inproc_sync *sync - sync->access = reply->access; - sync->type = reply->type; - sync->closed = 0; -+ sync->shm_idx = do_esync() ? reply->fsync_shm_idx : 0; - } - } - SERVER_END_REQ; - -+ if (!ret && do_esync() && (sync->fd < 0 || !sync->shm_idx)) -+ { -+ ERR( "droiddeck-esync: cannot receive fd for handle %p, out of file descriptors?\n", handle ); -+ if (sync->fd >= 0) close( sync->fd ); -+ esync_release( sync->shm_idx ); -+ memset( sync, 0, sizeof(*sync) ); -+ ret = STATUS_TOO_MANY_OPENED_FILES; -+ } - return ret; - } - -@@ -789,7 +805,8 @@ static NTSTATUS inproc_release_semaphore( HANDLE handle, ULONG count, ULONG *pre - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_SEMAPHORE, SEMAPHORE_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_release_semaphore_obj( sync->fd, count, prev_count ); -+ if (do_esync()) ret = esync_release_semaphore( sync->fd, sync->shm_idx, count, prev_count ); -+ else ret = linux_release_semaphore_obj( sync->fd, count, prev_count ); - release_inproc_sync( sync ); - return ret; - } -@@ -803,7 +820,8 @@ static NTSTATUS inproc_query_semaphore( HANDLE handle, SEMAPHORE_BASIC_INFORMATI - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_SEMAPHORE, SEMAPHORE_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_semaphore_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_semaphore( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_semaphore_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -817,7 +835,8 @@ static NTSTATUS inproc_set_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_set_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_set_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_set_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -831,7 +850,8 @@ static NTSTATUS inproc_reset_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_reset_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_reset_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_reset_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -845,7 +865,8 @@ static NTSTATUS inproc_pulse_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_pulse_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_pulse_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_pulse_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -859,7 +880,8 @@ static NTSTATUS inproc_query_event( HANDLE handle, EVENT_BASIC_INFORMATION *info - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_event_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_event( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_event_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -873,7 +895,8 @@ static NTSTATUS inproc_release_mutex( HANDLE handle, LONG *prev_count ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_MUTEX, 0, &stack, &sync ))) return ret; -- ret = linux_release_mutex_obj( sync->fd, prev_count ); -+ if (do_esync()) ret = esync_release_mutex( sync->fd, sync->shm_idx, prev_count ); -+ else ret = linux_release_mutex_obj( sync->fd, prev_count ); - release_inproc_sync( sync ); - return ret; - } -@@ -887,7 +910,8 @@ static NTSTATUS inproc_query_mutex( HANDLE handle, MUTANT_BASIC_INFORMATION *inf - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_MUTEX, MUTANT_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_mutex_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_mutex( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_mutex_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -928,6 +952,8 @@ static NTSTATUS inproc_wait( DWORD count, const HANDLE *handles, WAIT_TYPE type, - { - struct inproc_sync *syncs[64], stack[ARRAY_SIZE(syncs)]; - int objs[ARRAY_SIZE(syncs)], alert_fd = 0; -+ unsigned int shm_idxs[ARRAY_SIZE(syncs)]; -+ unsigned short types[ARRAY_SIZE(syncs)]; - NTSTATUS ret; - - if (do_fsync()) return fsync_wait_objects( count, handles, type != WaitAll, alertable, timeout ); -@@ -944,6 +970,17 @@ static NTSTATUS inproc_wait( DWORD count, const HANDLE *handles, WAIT_TYPE type, - return ret; - } - objs[i] = syncs[i]->fd; -+ shm_idxs[i] = syncs[i]->shm_idx; -+ types[i] = syncs[i]->type; -+ } -+ -+ if (do_esync()) -+ { -+ alert_fd = alertable ? get_inproc_alert_fd() : -1; -+ if (alertable && alert_fd < 0) ret = STATUS_TOO_MANY_OPENED_FILES; -+ else ret = esync_wait( count, objs, shm_idxs, types, type, alert_fd, timeout ); -+ while (count--) release_inproc_sync( syncs[count] ); -+ return ret; - } - - if (alertable) alert_fd = get_inproc_alert_fd(); -@@ -969,6 +1006,25 @@ static NTSTATUS inproc_signal_and_wait( HANDLE signal, HANDLE wait, - - if ((ret = get_inproc_sync( wait, INPROC_SYNC_UNKNOWN, SYNCHRONIZE, &stack_wait, &wait_sync ))) goto done; - -+ if (do_esync()) -+ { -+ switch (signal_sync->type) -+ { -+ case INPROC_SYNC_EVENT: ret = esync_set_event( signal_sync->fd, signal_sync->shm_idx, NULL ); break; -+ case INPROC_SYNC_MUTEX: ret = esync_release_mutex( signal_sync->fd, signal_sync->shm_idx, NULL ); break; -+ case INPROC_SYNC_SEMAPHORE: ret = esync_release_semaphore( signal_sync->fd, signal_sync->shm_idx, 1, NULL ); break; -+ default: assert( 0 ); break; -+ } -+ if (!ret) -+ { -+ alert_fd = alertable ? get_inproc_alert_fd() : -1; -+ if (alertable && alert_fd < 0) ret = STATUS_TOO_MANY_OPENED_FILES; -+ else ret = esync_wait( 1, &wait_sync->fd, &wait_sync->shm_idx, &wait_sync->type, WaitAny, alert_fd, timeout ); -+ } -+ release_inproc_sync( wait_sync ); -+ goto done; -+ } -+ - switch (signal_sync->type) - { - case INPROC_SYNC_EVENT: ret = linux_set_event_obj( signal_sync->fd, NULL ); break; -@@ -2461,6 +2517,20 @@ NTSTATUS WINAPI NtDelayExecution( BOOLEAN alertable, const LARGE_INTEGER *timeou - if (ret != STATUS_NOT_IMPLEMENTED) - return ret; - } -+ else if (do_esync()) -+ { -+ int alert_fd = get_inproc_alert_fd(); -+ -+ if (alert_fd >= 0) -+ { -+ if ((status = esync_wait( 0, NULL, NULL, NULL, WaitAny, alert_fd, timeout )) == STATUS_TIMEOUT) -+ { -+ NtYieldExecution(); -+ status = STATUS_SUCCESS; -+ } -+ return status; -+ } -+ } - /* Since server_wait will result in an unconditional implicit yield, - we never return STATUS_NO_YIELD_PERFORMED */ - if ((status = server_wait( NULL, 0, SELECT_INTERRUPTIBLE | SELECT_ALERTABLE, timeout )) == STATUS_TIMEOUT) -diff --git a/server/Makefile.in b/server/Makefile.in -index d9ac607..b370a0b 100644 ---- a/server/Makefile.in -+++ b/server/Makefile.in -@@ -12,6 +12,7 @@ SOURCES = \ - debugger.c \ - device.c \ - directory.c \ -+ esync.c \ - event.c \ - fd.c \ - file.c \ -diff --git a/server/esync.c b/server/esync.c -new file mode 100644 -index 0000000..508a189 ---- /dev/null -+++ b/server/esync.c -@@ -0,0 +1,525 @@ -+/* droiddeck-esync: eventfd and shared memory synchronization for Wine. -+ * Credit: based on the esync design by Elizabeth Figura for Wine and Proton. */ -+#include "config.h" -+ -+#include <assert.h> -+#include <errno.h> -+#include <poll.h> -+#include <sched.h> -+#include <signal.h> -+#include <stdarg.h> -+#include <stdint.h> -+#include <stdio.h> -+#include <stdlib.h> -+#include <string.h> -+#include <sys/mman.h> -+#include <sys/resource.h> -+#ifdef HAVE_SYS_EVENTFD_H -+# include <sys/eventfd.h> -+#endif -+#include <unistd.h> -+ -+#include "ntstatus.h" -+#define WIN32_NO_STATUS -+#include "windef.h" -+#include "winternl.h" -+ -+#include "handle.h" -+#include "request.h" -+#include "esync.h" -+ -+#define ESYNC_CHUNK_SIZE 0x10000 -+#define ESYNC_SLOT_SIZE 16 -+#define ESYNC_PER_CHUNK (ESYNC_CHUNK_SIZE / ESYNC_SLOT_SIZE) -+#define ESYNC_CHUNKS 8192 -+#define ESYNC_MANUAL 1 -+#define ESYNC_RELEASING 1 -+ -+struct esync_slot -+{ -+ union -+ { -+ struct -+ { -+ int state; -+ int aux; -+ }; -+ int64_t pair; -+ }; -+ int flags; -+ int ref; -+}; -+ -+union esync_pair -+{ -+ struct { int state; int aux; } s; -+ int64_t word; -+}; -+ -+struct holder -+{ -+ process_id_t pid; -+ unsigned int count; -+ struct holder *next; -+}; -+ -+int esync_active; -+ -+static int shm_fd = -1; -+static unsigned int shm_chunks; -+static void *chunks[ESYNC_CHUNKS]; -+static struct holder *holders[ESYNC_CHUNKS]; -+static uint64_t *free_map; -+static unsigned int free_words; -+static unsigned int free_hint; -+ -+#if defined(HAVE_SYS_EVENTFD_H) && defined(HAVE_MEMFD_CREATE) -+ -+static void *map_chunk( unsigned int chunk ) -+{ -+ void *addr; -+ -+ if (chunk >= ESYNC_CHUNKS) return NULL; -+ if (chunks[chunk]) return chunks[chunk]; -+ if (!holders[chunk] && !(holders[chunk] = calloc( ESYNC_PER_CHUNK, sizeof(struct holder) ))) return NULL; -+ if (chunk >= shm_chunks) -+ { -+ if (ftruncate( shm_fd, (off_t)(chunk + 1) * ESYNC_CHUNK_SIZE ) == -1) -+ { -+ perror( "droiddeck-esync: ftruncate" ); -+ return NULL; -+ } -+ shm_chunks = chunk + 1; -+ } -+ addr = mmap( NULL, ESYNC_CHUNK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, -+ (off_t)chunk * ESYNC_CHUNK_SIZE ); -+ if (addr == MAP_FAILED) -+ { -+ perror( "droiddeck-esync: mmap" ); -+ return NULL; -+ } -+ return chunks[chunk] = addr; -+} -+ -+static struct esync_slot *get_slot( unsigned int idx ) -+{ -+ char *base = chunks[idx / ESYNC_PER_CHUNK]; -+ return (struct esync_slot *)(base + (idx % ESYNC_PER_CHUNK) * ESYNC_SLOT_SIZE); -+} -+ -+static struct holder *get_holder( unsigned int idx ) -+{ -+ return &holders[idx / ESYNC_PER_CHUNK][idx % ESYNC_PER_CHUNK]; -+} -+ -+static void clear_holders( unsigned int idx ) -+{ -+ struct holder *head = get_holder( idx ), *next; -+ -+ while ((next = head->next)) -+ { -+ head->next = next->next; -+ free( next ); -+ } -+ head->pid = 0; -+ head->count = 0; -+} -+ -+static unsigned int alloc_slot(void) -+{ -+ unsigned int i, bit, idx; -+ -+ for (i = free_hint; i < free_words; i++) if (free_map[i]) break; -+ if (i == free_words) -+ { -+ unsigned int words = free_words * 2; -+ uint64_t *map; -+ -+ if (words > ESYNC_CHUNKS * (ESYNC_PER_CHUNK / 64)) words = ESYNC_CHUNKS * (ESYNC_PER_CHUNK / 64); -+ if (words == free_words) return 0; -+ if (!(map = realloc( free_map, words * sizeof(*map) ))) return 0; -+ memset( map + free_words, 0xff, (words - free_words) * sizeof(*map) ); -+ free_map = map; -+ free_words = words; -+ } -+ bit = __builtin_ctzll( free_map[i] ); -+ idx = i * 64 + bit; -+ if (!map_chunk( idx / ESYNC_PER_CHUNK )) return 0; -+ free_map[i] &= ~((uint64_t)1 << bit); -+ free_hint = i; -+ return idx; -+} -+ -+static void free_slot( unsigned int idx ) -+{ -+ clear_holders( idx ); -+ free_map[idx / 64] |= (uint64_t)1 << (idx % 64); -+ if (idx / 64 < free_hint) free_hint = idx / 64; -+} -+ -+static int slot_in_use( unsigned int idx ) -+{ -+ if (!idx || idx / 64 >= free_words) return 0; -+ return !(free_map[idx / 64] & ((uint64_t)1 << (idx % 64))); -+} -+ -+static void put_ref( unsigned int idx ) -+{ -+ struct esync_slot *slot = get_slot( idx ); -+ -+ if (slot->ref > 0 && !--slot->ref) free_slot( idx ); -+} -+ -+static void report_fd_error( const char *what ) -+{ -+ static int warned; -+ struct rlimit limit; -+ -+ if (warned) return; -+ warned = 1; -+ if ((errno == EMFILE || errno == ENFILE) && !getrlimit( RLIMIT_NOFILE, &limit )) -+ fprintf( stderr, "droiddeck-esync: out of file descriptors in %s (limit %llu); set PROTON_NO_ESYNC=1 for this game if it fails.\n", -+ what, (unsigned long long)limit.rlim_cur ); -+ else -+ fprintf( stderr, "droiddeck-esync: %s failed: %s\n", what, strerror( errno )); -+} -+ -+static void wake_fd( int fd ) -+{ -+ static const uint64_t value = 1; -+ if (fd >= 0 && write( fd, &value, sizeof(value) ) == -1 && errno != EAGAIN) perror( "droiddeck-esync: write" ); -+} -+ -+static void drain_fd( int fd ) -+{ -+ uint64_t value; -+ if (fd >= 0 && read( fd, &value, sizeof(value) ) == -1 && errno != EAGAIN) perror( "droiddeck-esync: read" ); -+} -+ -+static int fd_readable( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ -+ return fd >= 0 && poll( &pfd, 1, 0 ) > 0 && (pfd.revents & POLLIN); -+} -+ -+static int owner_dead( int owner ) -+{ -+ return owner > 0 && kill( owner, 0 ) == -1 && errno == ESRCH; -+} -+ -+static void repair_slot( struct esync_slot *slot, int fd ) -+{ -+ int readable; -+ -+ if (fd < 0) return; -+ readable = fd_readable( fd ); -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) -+ { -+ if (!readable) wake_fd( fd ); -+ } -+ else if (readable) drain_fd( fd ); -+} -+ -+static void lock_slot( struct esync_slot *slot, int fd ) -+{ -+ int self = getpid(), owner; -+ unsigned int spins = 0; -+ -+ for (;;) -+ { -+ owner = 0; -+ if (__atomic_compare_exchange_n( &slot->aux, &owner, self, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) return; -+ if (++spins < 128) continue; -+ if (!(spins & 1023) && owner_dead( owner ) && -+ __atomic_compare_exchange_n( &slot->aux, &owner, self, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) -+ { -+ repair_slot( slot, fd ); -+ return; -+ } -+ sched_yield(); -+ } -+} -+ -+static void unlock_slot( struct esync_slot *slot ) -+{ -+ int self = getpid(); -+ __atomic_compare_exchange_n( &slot->aux, &self, 0, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED ); -+} -+ -+int esync_init(void) -+{ -+ const char *env = getenv( "WINEESYNC" ); -+ struct rlimit limit; -+ int probe; -+ -+ if (!env || !atoi( env )) return 0; -+ if ((probe = eventfd( 0, EFD_CLOEXEC | EFD_NONBLOCK )) == -1) -+ { -+ perror( "droiddeck-esync: eventfd" ); -+ return 0; -+ } -+ close( probe ); -+ if ((shm_fd = memfd_create( "droiddeck-esync", MFD_CLOEXEC )) == -1) -+ { -+ perror( "droiddeck-esync: memfd_create" ); -+ return 0; -+ } -+ free_words = 64; -+ if (!(free_map = malloc( free_words * sizeof(*free_map) )) || !map_chunk( 0 )) -+ { -+ free( free_map ); -+ free_map = NULL; -+ free_words = 0; -+ close( shm_fd ); -+ shm_fd = -1; -+ return 0; -+ } -+ memset( free_map, 0xff, free_words * sizeof(*free_map) ); -+ free_map[0] &= ~(uint64_t)1; -+ esync_active = 1; -+ fprintf( stderr, "droiddeck-esync: up and running.\n" ); -+ if (!getrlimit( RLIMIT_NOFILE, &limit )) -+ fprintf( stderr, "droiddeck-esync: file descriptor limit %llu.\n", (unsigned long long)limit.rlim_cur ); -+ return 1; -+} -+ -+int esync_shm_fd(void) -+{ -+ return shm_fd; -+} -+ -+int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ) -+{ -+ struct esync_slot *slot; -+ unsigned int idx; -+ int fd = ESYNC_NO_FD; -+ -+ if (!lazy && (fd = eventfd( initval, EFD_CLOEXEC | EFD_NONBLOCK | (semaphore ? EFD_SEMAPHORE : 0) )) == -1) -+ { -+ report_fd_error( "eventfd" ); -+ return -1; -+ } -+ if (!(idx = alloc_slot())) -+ { -+ if (fd >= 0) close( fd ); -+ return -1; -+ } -+ slot = get_slot( idx ); -+ slot->state = state; -+ slot->aux = aux; -+ slot->flags = manual ? ESYNC_MANUAL : 0; -+ __atomic_store_n( &slot->ref, 1, __ATOMIC_SEQ_CST ); -+ *shm_idx = idx; -+ return fd; -+} -+ -+int esync_open( unsigned int shm_idx ) -+{ -+ int fd; -+ -+ if (!slot_in_use( shm_idx )) return -1; -+ if ((fd = eventfd( __atomic_load_n( &get_slot( shm_idx )->state, __ATOMIC_SEQ_CST ) ? 1 : 0, -+ EFD_CLOEXEC | EFD_NONBLOCK )) == -1) -+ report_fd_error( "eventfd" ); -+ return fd; -+} -+ -+void esync_destroy( int fd, unsigned int shm_idx ) -+{ -+ if (fd >= 0) close( fd ); -+ if (slot_in_use( shm_idx )) put_ref( shm_idx ); -+} -+ -+unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ) -+{ -+ struct holder *head, *node; -+ -+ if (!slot_in_use( shm_idx )) return 0; -+ head = get_holder( shm_idx ); -+ for (node = head; node; node = node->next) -+ { -+ if (node->count && node->pid == pid) -+ { -+ node->count++; -+ get_slot( shm_idx )->ref++; -+ return shm_idx; -+ } -+ } -+ if (!head->count) -+ { -+ head->pid = pid; -+ head->count = 1; -+ } -+ else -+ { -+ if (!(node = malloc( sizeof(*node) ))) return 0; -+ node->pid = pid; -+ node->count = 1; -+ node->next = head->next; -+ head->next = node; -+ } -+ get_slot( shm_idx )->ref++; -+ return shm_idx; -+} -+ -+static unsigned int drop_holder( unsigned int idx, process_id_t pid, int all ) -+{ -+ struct holder *head = get_holder( idx ), *prev = NULL, *node, *next; -+ unsigned int dropped; -+ -+ for (node = head; node; prev = node, node = node->next) -+ if (node->count && node->pid == pid) break; -+ if (!node) return 0; -+ dropped = all ? node->count : 1; -+ if ((node->count -= dropped)) return dropped; -+ if (node != head) -+ { -+ prev->next = node->next; -+ free( node ); -+ } -+ else if ((next = head->next)) -+ { -+ *head = *next; -+ free( next ); -+ } -+ else head->pid = 0; -+ return dropped; -+} -+ -+void esync_client_release( process_id_t pid, unsigned int shm_idx ) -+{ -+ if (!slot_in_use( shm_idx )) return; -+ if (drop_holder( shm_idx, pid, 0 )) put_ref( shm_idx ); -+} -+ -+void esync_cleanup_process( process_id_t pid ) -+{ -+ unsigned int i, idx, count; -+ uint64_t used; -+ -+ for (i = 0; i < free_words; i++) -+ { -+ used = ~free_map[i]; -+ while (used) -+ { -+ idx = i * 64 + __builtin_ctzll( used ); -+ used &= used - 1; -+ if (!idx || !slot_in_use( idx )) continue; -+ for (count = drop_holder( idx, pid, 1 ); count && slot_in_use( idx ); count--) put_ref( idx ); -+ } -+ } -+} -+ -+void esync_set( int fd, unsigned int shm_idx, int locked ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!(slot->flags & ESYNC_MANUAL)) -+ { -+ wake_fd( fd ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ return; -+ } -+ if (locked) lock_slot( slot, fd ); -+ if (!__atomic_exchange_n( &slot->state, 1, __ATOMIC_SEQ_CST )) wake_fd( fd ); -+ if (locked) unlock_slot( slot ); -+} -+ -+void esync_reset( int fd, unsigned int shm_idx, int locked ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!(slot->flags & ESYNC_MANUAL)) -+ { -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ drain_fd( fd ); -+ return; -+ } -+ if (locked) lock_slot( slot, fd ); -+ if (__atomic_exchange_n( &slot->state, 0, __ATOMIC_SEQ_CST )) drain_fd( fd ); -+ if (locked) unlock_slot( slot ); -+} -+ -+void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ union esync_pair cur, next = { .s = { ~0, 0 } }; -+ -+ cur.word = __atomic_load_n( &slot->pair, __ATOMIC_SEQ_CST ); -+ for (;;) -+ { -+ if (cur.s.state != (int)tid && -+ (!releasing || cur.s.state != (int)(tid | ESYNC_RELEASING) || fd_readable( fd ))) return; -+ if (__atomic_compare_exchange_n( &slot->pair, &cur.word, next.word, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) break; -+ } -+ wake_fd( fd ); -+} -+ -+thread_id_t esync_mutex_owner( unsigned int shm_idx ) -+{ -+ int owner; -+ -+ if (!slot_in_use( shm_idx )) return 0; -+ owner = __atomic_load_n( &get_slot( shm_idx )->state, __ATOMIC_SEQ_CST ); -+ return owner == ~0 ? 0 : owner & ~ESYNC_RELEASING; -+} -+ -+#else -+ -+int esync_init(void) -+{ -+ return 0; -+} -+ -+int esync_shm_fd(void) -+{ -+ return -1; -+} -+ -+int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ) -+{ -+ errno = ENOSYS; -+ return -1; -+} -+ -+int esync_open( unsigned int shm_idx ) -+{ -+ return -1; -+} -+ -+void esync_destroy( int fd, unsigned int shm_idx ) -+{ -+} -+ -+unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ) -+{ -+ return 0; -+} -+ -+void esync_client_release( process_id_t pid, unsigned int shm_idx ) -+{ -+} -+ -+void esync_cleanup_process( process_id_t pid ) -+{ -+} -+ -+void esync_set( int fd, unsigned int shm_idx, int locked ) -+{ -+} -+ -+void esync_reset( int fd, unsigned int shm_idx, int locked ) -+{ -+} -+ -+void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ) -+{ -+} -+ -+thread_id_t esync_mutex_owner( unsigned int shm_idx ) -+{ -+ return 0; -+} -+ -+#endif -diff --git a/server/esync.h b/server/esync.h -new file mode 100644 -index 0000000..b94565c ---- /dev/null -+++ b/server/esync.h -@@ -0,0 +1,22 @@ -+#define ESYNC_USED_BY_SERVER 0x7eadeeef -+#define ESYNC_SHM_TOKEN 0x7eadeef0 -+#define ESYNC_NO_FD (-2) -+ -+extern int esync_active; -+static inline int do_esync(void) -+{ -+ return esync_active; -+} -+ -+extern int esync_init(void); -+extern int esync_shm_fd(void); -+extern int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ); -+extern int esync_open( unsigned int shm_idx ); -+extern void esync_destroy( int fd, unsigned int shm_idx ); -+extern unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ); -+extern void esync_client_release( process_id_t pid, unsigned int shm_idx ); -+extern void esync_cleanup_process( process_id_t pid ); -+extern void esync_set( int fd, unsigned int shm_idx, int locked ); -+extern void esync_reset( int fd, unsigned int shm_idx, int locked ); -+extern void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ); -+extern thread_id_t esync_mutex_owner( unsigned int shm_idx ); -diff --git a/server/fsync.c b/server/fsync.c -index 7d3ec64..f4d9ac9 100644 ---- a/server/fsync.c -+++ b/server/fsync.c -@@ -43,6 +43,9 @@ - - #include "handle.h" - #include "request.h" -+#include "process.h" -+#include "thread.h" -+#include "esync.h" - #include "fsync.h" - - #include "pshpack4.h" -@@ -359,6 +362,11 @@ void fsync_abandon_mutex( unsigned int shm_idx, thread_id_t tid ) - - DECL_HANDLER(fsync_free_shm_idx) - { -+ if (do_esync()) -+ { -+ esync_client_release( get_process_id( current->process ), req->shm_idx ); -+ return; -+ } - if (!req->shm_idx || req->shm_idx >= shm_idx_free_map_size * BITS_IN_FREE_MAP_WORD) - { - set_error( STATUS_INVALID_PARAMETER ); -diff --git a/server/inproc_sync.c b/server/inproc_sync.c -index a7020ef..b555837 100644 ---- a/server/inproc_sync.c -+++ b/server/inproc_sync.c -@@ -23,6 +23,7 @@ - #include <assert.h> - #include <stdint.h> - #include <stdio.h> -+#include <stdlib.h> - - #include "ntstatus.h" - #define WIN32_NO_STATUS -@@ -31,15 +32,18 @@ - #include "file.h" - #include "handle.h" - #include "request.h" -+#include "process.h" - #include "thread.h" - #include "user.h" - -+#include "esync.h" - #include "fsync.h" - - #include "ntsync_tmp.h" - - #ifdef NTSYNC_IOC_EVENT_READ - -+#include <errno.h> - #include <fcntl.h> - #include <sys/ioctl.h> - #include <sys/stat.h> -@@ -57,9 +61,10 @@ int get_inproc_device_fd(void) - if (fd >= 0) - { - do_fsync_cached = 0; -- fprintf( stderr, "ntsync: up and running.\n" ); -+ fprintf( stderr, "droiddeck-ntsync: up and running.\n" ); - } - else if (do_fsync()) fd = FSYNC_USED_BY_SERVER; -+ else if (esync_init()) fd = ESYNC_USED_BY_SERVER; - else fprintf( stderr, "wineserver: using server-side synchronization.\n" ); - } - return fd; -@@ -71,6 +76,7 @@ struct inproc_sync - enum inproc_sync_type type; - int fd; - struct list entry; -+ unsigned int shm_idx; - }; - - static struct list inproc_mutexes = LIST_INIT( inproc_mutexes ); -@@ -107,6 +113,12 @@ static const struct object_ops inproc_sync_ops = - int get_inproc_sync_fd( struct inproc_sync *sync ) - { - if (!sync) return -1; -+ if (do_esync() && sync->fd == ESYNC_NO_FD && (sync->fd = esync_open( sync->shm_idx )) == -1) -+ { -+ sync->fd = ESYNC_NO_FD; -+ set_error( STATUS_TOO_MANY_OPENED_FILES ); -+ return -1; -+ } - return sync->fd; - } - -@@ -116,11 +128,17 @@ struct inproc_sync *create_inproc_internal_sync( int manual, int signaled ) - struct inproc_sync *event; - - if (!(event = alloc_object( &inproc_sync_ops ))) return NULL; -+ event->shm_idx = 0; - if (do_fsync()) - { - event->type = manual ? FSYNC_MANUAL_SERVER : FSYNC_AUTO_SERVER; - event->fd = fsync_alloc_shm( signaled, 0xdeadbeef ); - } -+ else if (do_esync()) -+ { -+ event->type = INPROC_SYNC_INTERNAL; -+ event->fd = esync_create( 0, manual, !!signaled, !!signaled, 0, 1, &event->shm_idx ); -+ } - else - { - event->type = INPROC_SYNC_INTERNAL; -@@ -143,11 +161,17 @@ struct inproc_sync *create_inproc_event_sync( int manual, int signaled ) - struct inproc_sync *event; - - if (!(event = alloc_object( &inproc_sync_ops ))) return NULL; -+ event->shm_idx = 0; - if (do_fsync()) - { - event->type = manual ? FSYNC_MANUAL_EVENT : FSYNC_AUTO_EVENT; - event->fd = fsync_alloc_shm( signaled, 0xdeadbeef ); - } -+ else if (do_esync()) -+ { -+ event->type = INPROC_SYNC_EVENT; -+ event->fd = esync_create( 0, manual, !!signaled, !!signaled, 0, 0, &event->shm_idx ); -+ } - else - { - event->type = INPROC_SYNC_EVENT; -@@ -170,11 +194,17 @@ struct inproc_sync *create_inproc_mutex_sync( thread_id_t owner, unsigned int co - struct inproc_sync *mutex; - - if (!(mutex = alloc_object( &inproc_sync_ops ))) return NULL; -+ mutex->shm_idx = 0; - if (do_fsync()) - { - mutex->type = FSYNC_MUTEX; - mutex->fd = fsync_alloc_shm( owner, count ); - } -+ else if (do_esync()) -+ { -+ mutex->type = INPROC_SYNC_MUTEX; -+ mutex->fd = esync_create( 0, 0, owner ? 0 : 1, owner, owner ? count : 0, 0, &mutex->shm_idx ); -+ } - else - { - mutex->type = INPROC_SYNC_MUTEX; -@@ -197,12 +227,18 @@ struct inproc_sync *create_inproc_semaphore_sync( unsigned int initial, unsigned - struct inproc_sync *sem; - - if (!(sem = alloc_object( &inproc_sync_ops ))) return NULL; -+ sem->shm_idx = 0; - - if (do_fsync()) - { - sem->type = FSYNC_SEMAPHORE; - sem->fd = fsync_alloc_shm( initial, max ); - } -+ else if (do_esync()) -+ { -+ sem->type = INPROC_SYNC_SEMAPHORE; -+ sem->fd = esync_create( 1, 0, initial, initial, max, 0, &sem->shm_idx ); -+ } - else - { - sem->type = INPROC_SYNC_SEMAPHORE; -@@ -231,6 +267,7 @@ void signal_inproc_sync( struct inproc_sync *sync ) - __u32 count; - if (debug_level) fprintf( stderr, "set_inproc_event %d\n", sync->fd ); - if (do_fsync()) fsync_set_event( sync->fd ); -+ else if (do_esync()) esync_set( sync->fd, sync->shm_idx, sync->type == INPROC_SYNC_EVENT ); - else ioctl( sync->fd, NTSYNC_IOC_EVENT_SET, &count ); - } - -@@ -239,6 +276,7 @@ void reset_inproc_sync( struct inproc_sync *sync ) - __u32 count; - if (debug_level) fprintf( stderr, "reset_inproc_event %d\n", sync->fd ); - if (do_fsync()) fsync_reset_event( sync->fd ); -+ else if (do_esync()) esync_reset( sync->fd, sync->shm_idx, sync->type == INPROC_SYNC_EVENT ); - else ioctl( sync->fd, NTSYNC_IOC_EVENT_RESET, &count ); - } - -@@ -262,6 +300,7 @@ static void inproc_sync_destroy( struct object *obj ) - assert( obj->ops == &inproc_sync_ops ); - list_remove( &sync->entry ); - if (do_fsync()) fsync_free_shm_idx( sync->fd ); -+ else if (do_esync()) esync_destroy( sync->fd, sync->shm_idx ); - else close( sync->fd ); - } - -@@ -275,12 +314,97 @@ void abandon_inproc_mutexes( thread_id_t tid ) - fsync_abandon_mutex( mutex->fd, tid ); - return; - } -+ if (do_esync()) -+ { -+ LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) -+ esync_abandon_mutex( mutex->fd, mutex->shm_idx, tid, 0 ); -+ return; -+ } - - LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) - ioctl( mutex->fd, NTSYNC_IOC_MUTEX_KILL, &tid ); - } - --static int get_obj_inproc_sync( struct object *obj, int *type ) -+struct mutex_reaper -+{ -+ struct list entry; -+ struct thread *thread; -+ struct timeout_user *timeout; -+ unsigned int checks; -+}; -+ -+static struct list mutex_reapers = LIST_INIT( mutex_reapers ); -+ -+static void finish_reaper( struct mutex_reaper *reaper ) -+{ -+ if (reaper->timeout) remove_timeout_user( reaper->timeout ); -+ list_remove( &reaper->entry ); -+ abandon_inproc_mutexes( reaper->thread->id ); -+ release_object( reaper->thread ); -+ free( reaper ); -+} -+ -+static void reap_inproc_mutexes( void *private ) -+{ -+ struct mutex_reaper *reaper = private; -+ -+ reaper->timeout = NULL; -+ if (++reaper->checks < 20 && send_thread_signal( reaper->thread, 0 ) && -+ (reaper->timeout = add_timeout_user( -TICKS_PER_SEC / 20, reap_inproc_mutexes, reaper ))) -+ return; -+ finish_reaper( reaper ); -+} -+ -+void abandon_inproc_mutexes_later( struct thread *thread ) -+{ -+ struct mutex_reaper *reaper; -+ -+ if (list_empty( &inproc_mutexes ) || thread->process->is_terminating) return; -+ if (!(reaper = malloc( sizeof(*reaper) ))) return; -+ reaper->thread = (struct thread *)grab_object( thread ); -+ reaper->checks = 0; -+ if (!(reaper->timeout = add_timeout_user( -TICKS_PER_SEC / 100, reap_inproc_mutexes, reaper ))) -+ { -+ release_object( thread ); -+ free( reaper ); -+ return; -+ } -+ list_add_tail( &mutex_reapers, &reaper->entry ); -+} -+ -+void abandon_dead_inproc_mutexes( struct process *process ) -+{ -+ unsigned int error = get_error(); -+ struct mutex_reaper *reaper, *next; -+ struct ntsync_mutex_args args; -+ struct inproc_sync *mutex; -+ struct thread *thread; -+ thread_id_t owner; -+ int dead, gone; -+ -+ LIST_FOR_EACH_ENTRY_SAFE( reaper, next, &mutex_reapers, struct mutex_reaper, entry ) -+ if (reaper->thread->process == process) finish_reaper( reaper ); -+ if (!do_fsync()) -+ { -+ LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) -+ { -+ if (do_esync()) owner = esync_mutex_owner( mutex->shm_idx ); -+ else if (ioctl( mutex->fd, NTSYNC_IOC_MUTEX_READ, &args ) == -1 && errno != EOWNERDEAD) continue; -+ else owner = args.owner; -+ if (!owner) continue; -+ thread = get_thread_from_id( owner ); -+ dead = !thread || thread->state == TERMINATED; -+ gone = !thread || thread->process == process; -+ if (thread) release_object( thread ); -+ if (!dead) continue; -+ if (do_esync()) esync_abandon_mutex( mutex->fd, mutex->shm_idx, owner, gone ); -+ else ioctl( mutex->fd, NTSYNC_IOC_MUTEX_KILL, &owner ); -+ } -+ } -+ set_error( error ); -+} -+ -+static int get_obj_inproc_sync( struct object *obj, int *type, unsigned int *shm_idx ) - { - struct object *sync; - int fd = -1; -@@ -290,7 +414,8 @@ static int get_obj_inproc_sync( struct object *obj, int *type ) - { - struct inproc_sync *inproc = (struct inproc_sync *)sync; - *type = inproc->type; -- fd = inproc->fd; -+ *shm_idx = inproc->shm_idx; -+ fd = get_inproc_sync_fd( inproc ); - } - - release_object( sync ); -@@ -341,7 +466,15 @@ void abandon_inproc_mutexes( thread_id_t tid ) - { - } - --static int get_obj_inproc_sync( struct object *obj, int *type ) -+void abandon_inproc_mutexes_later( struct thread *thread ) -+{ -+} -+ -+void abandon_dead_inproc_mutexes( struct process *process ) -+{ -+} -+ -+static int get_obj_inproc_sync( struct object *obj, int *type, unsigned int *shm_idx ) - { - return -1; - } -@@ -351,14 +484,23 @@ static int get_obj_inproc_sync( struct object *obj, int *type ) - DECL_HANDLER(get_inproc_sync_fd) - { - struct object *obj; -+ unsigned int shm_idx = 0; - int fd; - - if (!(obj = get_handle_obj( current->process, req->handle, 0, NULL ))) return; - - reply->access = get_handle_access( current->process, req->handle ); - -- if ((fd = get_obj_inproc_sync( obj, &reply->type )) < 0) set_error( STATUS_NOT_IMPLEMENTED ); -+ if ((fd = get_obj_inproc_sync( obj, &reply->type, &shm_idx )) < 0) -+ { -+ if (!get_error()) set_error( STATUS_NOT_IMPLEMENTED ); -+ } - else if (do_fsync()) reply->fsync_shm_idx = fsync_grab_shm_idx( fd ); -+ else if (do_esync()) -+ { -+ if (!(reply->fsync_shm_idx = esync_grab( get_process_id( current->process ), shm_idx ))) set_error( STATUS_NO_MEMORY ); -+ else send_client_fd( current->process, fd, req->handle ); -+ } - else send_client_fd( current->process, fd, req->handle ); - - release_object( obj ); -diff --git a/server/object.h b/server/object.h -index 21f5ea8..cd21fb7 100644 ---- a/server/object.h -+++ b/server/object.h -@@ -255,6 +255,8 @@ extern struct inproc_sync *create_inproc_event_sync( int manual, int signaled ); - extern struct inproc_sync *create_inproc_semaphore_sync( unsigned int initial, unsigned int max ); - extern struct inproc_sync *create_inproc_mutex_sync( thread_id_t owner, unsigned int count ); - extern void abandon_inproc_mutexes( thread_id_t owner ); -+extern void abandon_inproc_mutexes_later( struct thread *thread ); -+extern void abandon_dead_inproc_mutexes( struct process *process ); - extern void signal_inproc_sync( struct inproc_sync *sync ); - extern void reset_inproc_sync( struct inproc_sync *sync ); - -diff --git a/server/process.c b/server/process.c -index 287718a..5963884 100644 ---- a/server/process.c -+++ b/server/process.c -@@ -65,6 +65,7 @@ - #include "user.h" - #include "security.h" - -+#include "esync.h" - #include "fsync.h" - - /* process object */ -@@ -618,6 +619,8 @@ static void process_died( struct process *process ) - if (!--user_processes && !shutdown_stage && master_socket_timeout != TIMEOUT_INFINITE) - shutdown_timeout = add_timeout_user( master_socket_timeout, server_shutdown_timeout, NULL ); - } -+ abandon_dead_inproc_mutexes( process ); -+ if (do_esync()) esync_cleanup_process( process->id ); - release_object( process ); - if (!--running_processes && shutdown_stage) close_master_socket( 0 ); - } -diff --git a/server/thread.c b/server/thread.c -index 435d12a..f35a788 100644 ---- a/server/thread.c -+++ b/server/thread.c -@@ -61,6 +61,7 @@ - #include "user.h" - #include "security.h" - -+#include "esync.h" - #include "fsync.h" - - /* thread queues */ -@@ -1658,7 +1659,11 @@ void kill_thread( struct thread *thread, int violent_death ) - kill_console_processes( thread, 0 ); - abandon_mutexes( thread ); - signal_sync( thread->sync ); -- if (violent_death) send_thread_signal( thread, SIGQUIT ); -+ if (violent_death) -+ { -+ send_thread_signal( thread, SIGQUIT ); -+ abandon_inproc_mutexes_later( thread ); -+ } - cleanup_thread( thread ); - remove_process_thread( thread->process, thread ); - release_object( thread ); -@@ -1836,7 +1841,12 @@ DECL_HANDLER(init_first_thread) - { - reply->inproc_device = FSYNC_USED_BY_SERVER; - } -- else if ((fd = get_inproc_device_fd()) >= 0) -+ else if ((fd = get_inproc_device_fd()) == ESYNC_USED_BY_SERVER) -+ { -+ reply->inproc_device = ESYNC_USED_BY_SERVER; -+ send_client_fd( process, esync_shm_fd(), ESYNC_SHM_TOKEN ); -+ } -+ else if (fd >= 0) - { - reply->inproc_device = get_process_id( process ) | 1; - send_client_fd( process, fd, reply->inproc_device ); -@@ -2478,7 +2488,10 @@ DECL_HANDLER(get_inproc_alert_fd) - { - int fd; - -- if ((fd = get_inproc_sync_fd( current->alert_sync )) < 0) set_error( STATUS_INVALID_PARAMETER ); -+ if ((fd = get_inproc_sync_fd( current->alert_sync )) < 0) -+ { -+ if (!get_error()) set_error( STATUS_INVALID_PARAMETER ); -+ } - else if (do_fsync()) reply->fsync_shm_idx = fd; - else - { diff --git a/tools/droiddeck-esync/patches/valve-11/0001-droiddeck-esync.patch b/tools/droiddeck-esync/patches/valve-11/0001-droiddeck-esync.patch deleted file mode 100644 index 80beac4ac..000000000 --- a/tools/droiddeck-esync/patches/valve-11/0001-droiddeck-esync.patch +++ /dev/null @@ -1,2436 +0,0 @@ -diff --git a/dlls/ntdll/Makefile.in b/dlls/ntdll/Makefile.in -index 38c865b..2a7afcc 100644 ---- a/dlls/ntdll/Makefile.in -+++ b/dlls/ntdll/Makefile.in -@@ -50,6 +50,7 @@ SOURCES = \ - unix/debug.c \ - unix/env.c \ - unix/file.c \ -+ unix/esync.c \ - unix/fsync.c \ - unix/loader.c \ - unix/loadorder.c \ -diff --git a/dlls/ntdll/unix/esync.c b/dlls/ntdll/unix/esync.c -new file mode 100644 -index 0000000..de02a56 ---- /dev/null -+++ b/dlls/ntdll/unix/esync.c -@@ -0,0 +1,1140 @@ -+#if 0 -+#pragma makedep unix -+#endif -+/* droiddeck-esync: the client side of eventfd and shared memory synchronization. -+ * Credit: based on the esync design by Elizabeth Figura for Wine and Proton. */ -+ -+#include "config.h" -+ -+#include <assert.h> -+#include <errno.h> -+#include <limits.h> -+#include <poll.h> -+#include <pthread.h> -+#include <sched.h> -+#include <signal.h> -+#include <stdarg.h> -+#include <stddef.h> -+#include <stdint.h> -+#include <stdio.h> -+#include <stdlib.h> -+#include <string.h> -+#include <sys/mman.h> -+#include <sys/syscall.h> -+#include <time.h> -+#include <unistd.h> -+ -+#include "ntstatus.h" -+#define WIN32_NO_STATUS -+#include "windef.h" -+#include "winternl.h" -+#include "wine/server.h" -+#include "wine/debug.h" -+ -+#include "unix_private.h" -+#include "esync.h" -+ -+WINE_DEFAULT_DEBUG_CHANNEL(esync); -+ -+#define ESYNC_CHUNK_SIZE 0x10000 -+#define ESYNC_SLOT_SIZE 16 -+#define ESYNC_PER_CHUNK (ESYNC_CHUNK_SIZE / ESYNC_SLOT_SIZE) -+#define ESYNC_CHUNKS 8192 -+#define ESYNC_MANUAL 1 -+#define ESYNC_WAITER 0x100 -+#define ESYNC_RELEASING 1 -+#define ESYNC_PULSE_NS 10000000 -+#define ESYNC_STALL_NS 2000000 -+#define ESYNC_TOKEN_MS 100 -+#define ESYNC_TOKEN_TRIES 10 -+#define ESYNC_SPINS 16 -+#define ESYNC_SPIN_NS 50000 -+#define ESYNC_SLICE_NS 1000000000 -+ -+struct esync_slot -+{ -+ union -+ { -+ struct -+ { -+ int state; -+ int aux; -+ }; -+ LONG64 pair; -+ }; -+ int flags; -+ int ref; -+}; -+ -+C_ASSERT( sizeof(struct esync_slot) == ESYNC_SLOT_SIZE ); -+ -+union esync_pair -+{ -+ struct { int state; int aux; } s; -+ LONG64 word; -+}; -+ -+C_ASSERT( offsetof( struct esync_slot, state ) == offsetof( union esync_pair, s.state ) ); -+C_ASSERT( offsetof( struct esync_slot, aux ) == offsetof( union esync_pair, s.aux ) ); -+ -+static BOOL swap_pair( struct esync_slot *slot, int state, int aux, int new_state, int new_aux ) -+{ -+ union esync_pair cur = { .s = { state, aux } }, next = { .s = { new_state, new_aux } }; -+ -+ return __atomic_compare_exchange_n( &slot->pair, &cur.word, next.word, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+} -+ -+enum esync_kind -+{ -+ ESYNC_KIND_SEMAPHORE, -+ ESYNC_KIND_MUTEX, -+ ESYNC_KIND_AUTO, -+ ESYNC_KIND_MANUAL, -+}; -+ -+struct esync_obj -+{ -+ int fd; -+ enum esync_kind kind; -+ struct esync_slot *slot; -+}; -+ -+enum esync_clock -+{ -+ ESYNC_NO_TIMEOUT, -+ ESYNC_MONOTONIC, -+ ESYNC_REALTIME, -+}; -+ -+struct esync_deadline -+{ -+ enum esync_clock clock; -+ ULONGLONG end; -+}; -+ -+struct esync_held -+{ -+ struct esync_slot *slot; -+ int fd; -+}; -+ -+struct esync_waits -+{ -+ struct esync_slot *slots[MAXIMUM_WAIT_OBJECTS]; -+ DWORD count; -+ struct esync_held held[MAXIMUM_WAIT_OBJECTS]; -+ DWORD held_count; -+ DWORD tid; -+ int unix_tid; -+}; -+ -+static int shm_fd = -1; -+static void *chunks[ESYNC_CHUNKS]; -+static sigset_t block_set; -+static __thread struct esync_waits waits; -+static pthread_key_t waits_key; -+static pthread_once_t waits_once = PTHREAD_ONCE_INIT; -+ -+#ifdef __linux__ -+ -+void esync_init( int fd ) -+{ -+ shm_fd = fd; -+ block_set = server_block_set; -+ sigaddset( &block_set, SIGQUIT ); -+ TRACE( "shm fd %d\n", fd ); -+} -+ -+static struct esync_slot *get_slot( unsigned int idx ) -+{ -+ unsigned int chunk = idx / ESYNC_PER_CHUNK; -+ void *base, *addr; -+ -+ if (!idx || chunk >= ESYNC_CHUNKS) return NULL; -+ if (!(base = __atomic_load_n( &chunks[chunk], __ATOMIC_ACQUIRE ))) -+ { -+ addr = mmap( NULL, ESYNC_CHUNK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, -+ (off_t)chunk * ESYNC_CHUNK_SIZE ); -+ if (addr == MAP_FAILED) -+ { -+ ERR( "cannot map chunk %u: %s\n", chunk, strerror( errno ) ); -+ return NULL; -+ } -+ if (__atomic_compare_exchange_n( &chunks[chunk], &base, addr, FALSE, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE )) -+ base = addr; -+ else -+ munmap( addr, ESYNC_CHUNK_SIZE ); -+ } -+ return (struct esync_slot *)((char *)base + (idx % ESYNC_PER_CHUNK) * ESYNC_SLOT_SIZE); -+} -+ -+void esync_release( unsigned int shm_idx ) -+{ -+ if (!shm_idx) return; -+ SERVER_START_REQ( fsync_free_shm_idx ) -+ { -+ req->shm_idx = shm_idx; -+ wine_server_call( req ); -+ } -+ SERVER_END_REQ; -+} -+ -+static void block_signals( sigset_t *old ) -+{ -+ pthread_sigmask( SIG_BLOCK, &block_set, old ); -+} -+ -+static void restore_signals( const sigset_t *old ) -+{ -+ pthread_sigmask( SIG_SETMASK, old, NULL ); -+} -+ -+static ssize_t read_fd( int fd, uint64_t *value ) -+{ -+ return syscall( __NR_read, fd, value, sizeof(*value) ); -+} -+ -+static int poll_fds( struct pollfd *fds, nfds_t count, const struct timespec *timeout ) -+{ -+ struct timespec left; -+ -+ if (timeout) left = *timeout; -+ return syscall( __NR_ppoll, fds, count, timeout ? &left : NULL, NULL, 0 ); -+} -+ -+static NTSTATUS wake_fd( int fd, uint64_t count ) -+{ -+ if (syscall( __NR_write, fd, &count, sizeof(count) ) == -1) return errno_to_status( errno ); -+ return STATUS_SUCCESS; -+} -+ -+static BOOL drain_fd( int fd ) -+{ -+ uint64_t value; -+ -+ if (read_fd( fd, &value ) == sizeof(value)) return TRUE; -+ if (errno != EAGAIN) WARN( "read fd %d: %s\n", fd, strerror( errno ) ); -+ return FALSE; -+} -+ -+static BOOL fd_readable( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ struct timespec now = { 0, 0 }; -+ int ret; -+ -+ while ((ret = poll_fds( &pfd, 1, &now )) < 0 && errno == EINTR); -+ return ret > 0 && (pfd.revents & POLLIN); -+} -+ -+static BOOL consume_token( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ struct timespec pause = { 0, ESYNC_TOKEN_MS * 1000000 }; -+ unsigned int tries = 0; -+ uint64_t value; -+ -+ while (read_fd( fd, &value ) != sizeof(value)) -+ { -+ if (errno != EAGAIN && errno != EINTR) -+ { -+ WARN( "read fd %d: %s\n", fd, strerror( errno ) ); -+ return TRUE; -+ } -+ if (tries == ESYNC_TOKEN_TRIES) -+ { -+ WARN( "no token on fd %d, the count it was released with is lost\n", fd ); -+ return FALSE; -+ } -+ if (!poll_fds( &pfd, 1, &pause )) tries++; -+ } -+ return TRUE; -+} -+ -+static int current_unix_tid(void) -+{ -+ if (!waits.unix_tid) waits.unix_tid = syscall( __NR_gettid ); -+ return waits.unix_tid; -+} -+ -+static void repair_slot( struct esync_slot *slot, int fd ) -+{ -+ BOOL readable = fd_readable( fd ); -+ -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) -+ { -+ if (!readable) wake_fd( fd, 1 ); -+ } -+ else if (readable) drain_fd( fd ); -+} -+ -+static void lock_slot( struct esync_slot *slot, int fd, int self ) -+{ -+ unsigned int spins = 0; -+ int owner; -+ -+ for (;;) -+ { -+ owner = 0; -+ if (__atomic_compare_exchange_n( &slot->aux, &owner, self, FALSE, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) return; -+ if (++spins < 128) -+ { -+ YieldProcessor(); -+ continue; -+ } -+ if (!(spins & 1023) && owner > 0 && kill( owner, 0 ) == -1 && errno == ESRCH && -+ __atomic_compare_exchange_n( &slot->aux, &owner, self, FALSE, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) -+ { -+ WARN( "lock owner %d is gone, taking over\n", owner ); -+ repair_slot( slot, fd ); -+ return; -+ } -+ sched_yield(); -+ } -+} -+ -+static void unlock_slot( struct esync_slot *slot, int self ) -+{ -+ __atomic_compare_exchange_n( &slot->aux, &self, 0, FALSE, __ATOMIC_RELEASE, __ATOMIC_RELAXED ); -+} -+ -+static void drop_waits( void *arg ) -+{ -+ struct esync_waits *list = arg; -+ struct esync_held *held; -+ -+ while (list->count) __atomic_sub_fetch( &list->slots[--list->count]->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ while (list->held_count) -+ { -+ held = &list->held[--list->held_count]; -+ if (swap_pair( held->slot, list->tid, __atomic_load_n( &held->slot->aux, __ATOMIC_SEQ_CST ), ~0, 0 )) -+ wake_fd( held->fd, 1 ); -+ } -+} -+ -+static void init_waits_key(void) -+{ -+ pthread_key_create( &waits_key, drop_waits ); -+} -+ -+static void arm_waits(void) -+{ -+ pthread_once( &waits_once, init_waits_key ); -+ if (!pthread_getspecific( waits_key )) pthread_setspecific( waits_key, &waits ); -+} -+ -+static void add_waits( DWORD count, const struct esync_obj *objs ) -+{ -+ DWORD i; -+ -+ arm_waits(); -+ for (i = 0; i < count && waits.count < MAXIMUM_WAIT_OBJECTS; i++) -+ { -+ if (objs[i].kind != ESYNC_KIND_AUTO && objs[i].kind != ESYNC_KIND_MANUAL) continue; -+ waits.slots[waits.count] = objs[i].slot; -+ __atomic_add_fetch( &objs[i].slot->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ __atomic_store_n( &waits.count, waits.count + 1, __ATOMIC_RELAXED ); -+ } -+} -+ -+static void remove_waits( DWORD base ) -+{ -+ struct esync_slot *slot; -+ -+ while (waits.count > base) -+ { -+ slot = waits.slots[waits.count - 1]; -+ __atomic_store_n( &waits.count, waits.count - 1, __ATOMIC_RELAXED ); -+ __atomic_sub_fetch( &slot->flags, ESYNC_WAITER, __ATOMIC_SEQ_CST ); -+ } -+} -+ -+static void hold_mutex( struct esync_obj *obj, DWORD tid ) -+{ -+ arm_waits(); -+ waits.tid = tid; -+ waits.held[waits.held_count].slot = obj->slot; -+ waits.held[waits.held_count].fd = obj->fd; -+ waits.held_count++; -+} -+ -+static ULONGLONG monotonic_ns(void) -+{ -+ struct timespec ts; -+ -+ clock_gettime( CLOCK_MONOTONIC, &ts ); -+ return (ULONGLONG)ts.tv_sec * 1000000000 + ts.tv_nsec; -+} -+ -+NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret = STATUS_SUCCESS; -+ sigset_t old; -+ int current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ block_signals( &old ); -+ current = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ do -+ { -+ if ((ULONGLONG)(unsigned int)current + count > (unsigned int)slot->aux) -+ { -+ restore_signals( &old ); -+ return STATUS_SEMAPHORE_LIMIT_EXCEEDED; -+ } -+ } -+ while (!__atomic_compare_exchange_n( &slot->state, ¤t, (int)((unsigned int)current + count), -+ FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (count) ret = wake_fd( fd, count ); -+ restore_signals( &old ); -+ if (!ret && prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ info->CurrentCount = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->MaximumCount = slot->aux; -+ return STATUS_SUCCESS; -+} -+ -+static NTSTATUS set_manual( struct esync_slot *slot, int fd, LONG *prev ) -+{ -+ int self = current_unix_tid(); -+ NTSTATUS ret = STATUS_SUCCESS; -+ sigset_t old; -+ LONG current; -+ -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ) == 1) -+ { -+ if (prev) *prev = 1; -+ return STATUS_SUCCESS; -+ } -+ block_signals( &old ); -+ lock_slot( slot, fd, self ); -+ if (!(current = __atomic_exchange_n( &slot->state, 1, __ATOMIC_SEQ_CST ))) ret = wake_fd( fd, 1 ); -+ unlock_slot( slot, self ); -+ restore_signals( &old ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+static LONG reset_manual( struct esync_slot *slot, int fd ) -+{ -+ int self = current_unix_tid(); -+ sigset_t old; -+ LONG current; -+ -+ if (!__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) return 0; -+ block_signals( &old ); -+ lock_slot( slot, fd, self ); -+ if ((current = __atomic_exchange_n( &slot->state, 0, __ATOMIC_SEQ_CST ))) drain_fd( fd ); -+ unlock_slot( slot, self ); -+ restore_signals( &old ); -+ return current; -+} -+ -+NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret; -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) return set_manual( slot, fd, prev ); -+ current = prev ? fd_readable( fd ) : 0; -+ ret = wake_fd( fd, 1 ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) current = reset_manual( slot, fd ); -+ else -+ { -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ current = drain_fd( fd ); -+ } -+ if (prev) *prev = current; -+ return STATUS_SUCCESS; -+} -+ -+static void pulse_wait( struct esync_slot *slot, int fd, BOOL manual ) -+{ -+ ULONGLONG now = monotonic_ns(), deadline = now + ESYNC_PULSE_NS, stall = now + ESYNC_STALL_NS; -+ int waiters = __atomic_load_n( &slot->flags, __ATOMIC_SEQ_CST ) / ESYNC_WAITER, left; -+ -+ while (waiters > 0 && now < deadline && now < stall) -+ { -+ if (!manual && !fd_readable( fd )) break; -+ sched_yield(); -+ left = __atomic_load_n( &slot->flags, __ATOMIC_SEQ_CST ) / ESYNC_WAITER; -+ now = monotonic_ns(); -+ if (left < waiters) stall = now + ESYNC_STALL_NS; -+ waiters = left; -+ } -+} -+ -+NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ NTSTATUS ret; -+ sigset_t old; -+ LONG current; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ block_signals( &old ); -+ if (slot->flags & ESYNC_MANUAL) -+ { -+ if (!(ret = set_manual( slot, fd, ¤t ))) pulse_wait( slot, fd, TRUE ); -+ reset_manual( slot, fd ); -+ } -+ else -+ { -+ current = prev ? fd_readable( fd ) : 0; -+ ret = wake_fd( fd, 1 ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ if (!ret) pulse_wait( slot, fd, FALSE ); -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ drain_fd( fd ); -+ } -+ restore_signals( &old ); -+ if (prev) *prev = current; -+ return ret; -+} -+ -+NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if (slot->flags & ESYNC_MANUAL) -+ { -+ info->EventState = !!__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->EventType = NotificationEvent; -+ } -+ else -+ { -+ info->EventState = fd_readable( fd ); -+ info->EventType = SynchronizationEvent; -+ } -+ return STATUS_SUCCESS; -+} -+ -+NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ DWORD tid = GetCurrentThreadId(); -+ NTSTATUS ret = STATUS_SUCCESS; -+ int owner = tid | ESYNC_RELEASING, count; -+ sigset_t old; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ if ((DWORD)__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ) != tid) return STATUS_MUTANT_NOT_OWNED; -+ block_signals( &old ); -+ count = __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ if (count > 1) -+ { -+ if (!swap_pair( slot, tid, count, tid, count - 1 )) ret = STATUS_MUTANT_NOT_OWNED; -+ } -+ else if (!swap_pair( slot, tid, count, tid | ESYNC_RELEASING, 0 )) ret = STATUS_MUTANT_NOT_OWNED; -+ else if ((ret = wake_fd( fd, 1 ))) swap_pair( slot, tid | ESYNC_RELEASING, 0, tid, count ); -+ else __atomic_compare_exchange_n( &slot->state, &owner, 0, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+ restore_signals( &old ); -+ if (!ret && prev) *prev = 1 - count; -+ return ret; -+} -+ -+NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ DWORD owner; -+ -+ if (!slot) return STATUS_INVALID_HANDLE; -+ owner = __atomic_load_n( &slot->state, __ATOMIC_SEQ_CST ); -+ info->CurrentCount = 1 - __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ info->OwnedByCaller = owner == GetCurrentThreadId(); -+ info->AbandonedState = owner == ~0u; -+ return STATUS_SUCCESS; -+} -+ -+static void get_deadline( const LARGE_INTEGER *timeout, struct esync_deadline *end ) -+{ -+ ULONGLONG now, rel; -+ -+ end->clock = ESYNC_NO_TIMEOUT; -+ if (!timeout || timeout->QuadPart == TIMEOUT_INFINITE) return; -+ if (timeout->QuadPart > 0) -+ { -+ end->clock = ESYNC_REALTIME; -+ end->end = timeout->QuadPart; -+ return; -+ } -+ now = monotonic_ns(); -+ rel = -(ULONGLONG)timeout->QuadPart; -+ if (rel > (ULLONG_MAX - now) / 100) return; -+ end->clock = ESYNC_MONOTONIC; -+ end->end = now + rel * 100; -+} -+ -+static BOOL time_left( const struct esync_deadline *end, struct timespec *ts, BOOL *sliced ) -+{ -+ ULONGLONG left = 0, now; -+ struct timespec rt; -+ LONGLONG ticks; -+ -+ *sliced = FALSE; -+ switch (end->clock) -+ { -+ case ESYNC_NO_TIMEOUT: -+ return FALSE; -+ case ESYNC_MONOTONIC: -+ now = monotonic_ns(); -+ if (now < end->end) left = end->end - now; -+ break; -+ case ESYNC_REALTIME: -+ clock_gettime( CLOCK_REALTIME, &rt ); -+ ticks = ticks_from_time_t( rt.tv_sec ) + rt.tv_nsec / 100; -+ if ((LONGLONG)end->end > ticks) -+ { -+ left = (ULONGLONG)((LONGLONG)end->end - ticks); -+ left = left > ESYNC_SLICE_NS / 100 ? ESYNC_SLICE_NS : left * 100; -+ } -+ if (left == ESYNC_SLICE_NS) *sliced = TRUE; -+ break; -+ } -+ ts->tv_sec = left / 1000000000; -+ ts->tv_nsec = left % 1000000000; -+ return TRUE; -+} -+ -+static BOOL expired( const struct esync_deadline *end ) -+{ -+ struct timespec ts; -+ BOOL sliced; -+ -+ return time_left( end, &ts, &sliced ) && !sliced && !ts.tv_sec && !ts.tv_nsec; -+} -+ -+static int do_poll( struct pollfd *fds, nfds_t count, const struct esync_deadline *end ) -+{ -+ struct timespec ts; -+ BOOL sliced; -+ int ret; -+ -+ for (;;) -+ { -+ ret = poll_fds( fds, count, time_left( end, &ts, &sliced ) ? &ts : NULL ); -+ if (ret < 0 && errno == EINTR) continue; -+ if (!ret && sliced) continue; -+ return ret; -+ } -+} -+ -+static NTSTATUS user_apc(void) -+{ -+ static const LARGE_INTEGER zero; -+ -+ return server_wait( NULL, 0, SELECT_INTERRUPTIBLE | SELECT_ALERTABLE, &zero ); -+} -+ -+static BOOL take_semaphore( struct esync_obj *obj ) -+{ -+ int current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ sigset_t old; -+ BOOL ret; -+ -+ if (current <= 0) return FALSE; -+ block_signals( &old ); -+ do -+ { -+ if (current <= 0) -+ { -+ restore_signals( &old ); -+ return FALSE; -+ } -+ } -+ while (!__atomic_compare_exchange_n( &obj->slot->state, ¤t, current - 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ ret = consume_token( obj->fd ); -+ restore_signals( &old ); -+ return ret; -+} -+ -+static void back_off( unsigned int *spins ) -+{ -+ struct timespec pause = { 0, 1000000 }; -+ -+ if (++*spins <= ESYNC_SPINS) sched_yield(); -+ else nanosleep( &pause, NULL ); -+} -+ -+static BOOL mutex_free( int state ) -+{ -+ return !state || (state & ESYNC_RELEASING); -+} -+ -+static BOOL claim_mutex( struct esync_obj *obj, DWORD tid, int *prev ) -+{ -+ int owner = tid; -+ uint64_t value; -+ -+ if (!mutex_free( *prev )) return FALSE; -+ if (!__atomic_compare_exchange_n( &obj->slot->state, prev, tid, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ return FALSE; -+ if (read_fd( obj->fd, &value ) != sizeof(value)) -+ { -+ __atomic_compare_exchange_n( &obj->slot->state, &owner, *prev, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+ return FALSE; -+ } -+ if ((DWORD)__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ) != tid) -+ { -+ wake_fd( obj->fd, 1 ); -+ return FALSE; -+ } -+ return TRUE; -+} -+ -+static void unclaim_mutex( struct esync_obj *obj, DWORD tid, int prev ) -+{ -+ int owner = tid; -+ -+ if ((DWORD)prev == ~0u) -+ { -+ if (__atomic_compare_exchange_n( &obj->slot->state, &owner, ~0, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ wake_fd( obj->fd, 1 ); -+ return; -+ } -+ if (!__atomic_compare_exchange_n( &obj->slot->state, &owner, tid | ESYNC_RELEASING, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) -+ return; -+ wake_fd( obj->fd, 1 ); -+ owner = tid | ESYNC_RELEASING; -+ __atomic_compare_exchange_n( &obj->slot->state, &owner, prev, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST ); -+} -+ -+static BOOL take_mutex( struct esync_obj *obj, DWORD tid, BOOL *abandoned ) -+{ -+ int prev = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ BOOL ret = FALSE; -+ sigset_t old; -+ -+ if (!mutex_free( prev )) return FALSE; -+ block_signals( &old ); -+ if (claim_mutex( obj, tid, &prev )) -+ { -+ if ((DWORD)prev == ~0u) *abandoned = TRUE; -+ hold_mutex( obj, tid ); -+ swap_pair( obj->slot, tid, __atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ), tid, 1 ); -+ ret = TRUE; -+ } -+ restore_signals( &old ); -+ waits.held_count = 0; -+ return ret; -+} -+ -+static BOOL take_auto( struct esync_obj *obj ) -+{ -+ uint64_t value; -+ -+ __atomic_store_n( &obj->slot->state, 0, __ATOMIC_SEQ_CST ); -+ return read_fd( obj->fd, &value ) == sizeof(value); -+} -+ -+static NTSTATUS recurse_mutex( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ int count = __atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ); -+ -+ if (count == INT_MAX) return STATUS_MUTANT_LIMIT_EXCEEDED; -+ swap_pair( obj->slot, tid, count, tid, count + 1 ); -+ return i; -+} -+ -+static NTSTATUS try_now( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ int state = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ BOOL abandoned = FALSE; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ if ((DWORD)state == tid) return recurse_mutex( obj, i, tid ); -+ if (!mutex_free( state )) break; -+ if (take_mutex( obj, tid, &abandoned )) return abandoned ? STATUS_ABANDONED_WAIT_0 + i : i; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ if (take_semaphore( obj )) return i; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (state && take_auto( obj )) return i; -+ break; -+ case ESYNC_KIND_MANUAL: -+ if (state) return i; -+ break; -+ } -+ return STATUS_PENDING; -+} -+ -+static NTSTATUS try_ready( struct esync_obj *obj, DWORD i, DWORD tid ) -+{ -+ BOOL abandoned = FALSE; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ if (take_mutex( obj, tid, &abandoned )) return abandoned ? STATUS_ABANDONED_WAIT_0 + i : i; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ if (take_semaphore( obj )) return i; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (take_auto( obj )) return i; -+ break; -+ case ESYNC_KIND_MANUAL: -+ return i; -+ } -+ return STATUS_PENDING; -+} -+ -+static NTSTATUS wait_any( DWORD count, struct esync_obj *objs, int alert_fd, const struct esync_deadline *end, BOOL track ) -+{ -+ struct pollfd fds[MAXIMUM_WAIT_OBJECTS + 1]; -+ DWORD tid = GetCurrentThreadId(), i; -+ unsigned int spins = 0; -+ ULONGLONG polled; -+ NTSTATUS status; -+ BOOL busy; -+ nfds_t nfds; -+ int ret; -+ -+ for (;;) -+ { -+ for (i = 0; i < count; i++) -+ if ((status = try_now( &objs[i], i, tid )) != STATUS_PENDING) return status; -+ if (track) -+ { -+ add_waits( count, objs ); -+ track = FALSE; -+ continue; -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ fds[i].fd = objs[i].fd; -+ fds[i].events = POLLIN; -+ fds[i].revents = 0; -+ } -+ nfds = count; -+ if (alert_fd >= 0) -+ { -+ fds[nfds].fd = alert_fd; -+ fds[nfds].events = POLLIN; -+ fds[nfds].revents = 0; -+ nfds++; -+ } -+ -+ polled = monotonic_ns(); -+ if ((ret = do_poll( fds, nfds, end )) < 0) return errno_to_status( errno ); -+ if (!ret) return STATUS_TIMEOUT; -+ -+ busy = FALSE; -+ for (i = 0; i < count; i++) -+ { -+ if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) return STATUS_INVALID_HANDLE; -+ if (!(fds[i].revents & POLLIN)) continue; -+ if ((status = try_ready( &objs[i], i, tid )) != STATUS_PENDING) return status; -+ busy = TRUE; -+ } -+ if (alert_fd >= 0 && (fds[count].revents & POLLIN) && (status = user_apc()) != STATUS_TIMEOUT) -+ return status; -+ if (expired( end )) return STATUS_TIMEOUT; -+ if (busy && monotonic_ns() - polled < ESYNC_SPIN_NS) back_off( &spins ); -+ else spins = 0; -+ } -+} -+ -+static BOOL owned_mutex( struct esync_obj *obj, DWORD tid ) -+{ -+ return obj->kind == ESYNC_KIND_MUTEX && (DWORD)__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ) == tid; -+} -+ -+static BOOL ready_now( struct esync_obj *obj, DWORD tid ) -+{ -+ int state = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ return (DWORD)state == tid || (mutex_free( state ) && fd_readable( obj->fd )); -+ case ESYNC_KIND_SEMAPHORE: -+ return state > 0; -+ case ESYNC_KIND_AUTO: -+ return fd_readable( obj->fd ); -+ case ESYNC_KIND_MANUAL: -+ return state != 0; -+ } -+ return FALSE; -+} -+ -+static NTSTATUS grab_all( DWORD count, struct esync_obj *objs, DWORD tid ) -+{ -+ BOOL grabbed[MAXIMUM_WAIT_OBJECTS], owned[MAXIMUM_WAIT_OBJECTS], abandoned = FALSE; -+ DWORD order[MAXIMUM_WAIT_OBJECTS], n = 0, i, j; -+ int current, prev[MAXIMUM_WAIT_OBJECTS]; -+ NTSTATUS status = STATUS_PENDING; -+ sigset_t old; -+ -+ for (i = 0; i < count; i++) -+ if (!ready_now( &objs[i], tid )) return STATUS_PENDING; -+ for (i = 0; i < count; i++) -+ { -+ grabbed[i] = owned[i] = FALSE; -+ if (objs[i].kind != ESYNC_KIND_AUTO) order[n++] = i; -+ } -+ for (i = 0; i < count; i++) -+ if (objs[i].kind == ESYNC_KIND_AUTO) order[n++] = i; -+ -+ block_signals( &old ); -+ for (j = 0; j < count; j++) -+ { -+ struct esync_obj *obj = &objs[i = order[j]]; -+ -+ switch (obj->kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ if ((DWORD)current == tid) -+ { -+ if (__atomic_load_n( &obj->slot->aux, __ATOMIC_SEQ_CST ) == INT_MAX) -+ { -+ status = STATUS_MUTANT_LIMIT_EXCEEDED; -+ goto rollback; -+ } -+ owned[i] = TRUE; -+ break; -+ } -+ prev[i] = current; -+ if (!claim_mutex( obj, tid, &prev[i] )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ current = __atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST ); -+ do if (current <= 0) goto rollback; -+ while (!__atomic_compare_exchange_n( &obj->slot->state, ¤t, current - 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (!consume_token( obj->fd )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_AUTO: -+ if (!take_auto( obj )) goto rollback; -+ grabbed[i] = TRUE; -+ break; -+ case ESYNC_KIND_MANUAL: -+ if (!__atomic_load_n( &obj->slot->state, __ATOMIC_SEQ_CST )) goto rollback; -+ break; -+ } -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ struct esync_slot *slot = objs[i].slot; -+ -+ if (owned[i]) -+ { -+ current = __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ); -+ swap_pair( slot, tid, current, tid, current + 1 ); -+ } -+ else if (grabbed[i] && objs[i].kind == ESYNC_KIND_MUTEX) -+ { -+ if ((DWORD)prev[i] == ~0u) abandoned = TRUE; -+ hold_mutex( &objs[i], tid ); -+ swap_pair( slot, tid, __atomic_load_n( &slot->aux, __ATOMIC_SEQ_CST ), tid, 1 ); -+ } -+ } -+ restore_signals( &old ); -+ waits.held_count = 0; -+ return abandoned ? STATUS_ABANDONED : STATUS_SUCCESS; -+ -+rollback: -+ while (j--) -+ { -+ i = order[j]; -+ if (!grabbed[i]) continue; -+ switch (objs[i].kind) -+ { -+ case ESYNC_KIND_MUTEX: -+ unclaim_mutex( &objs[i], tid, prev[i] ); -+ break; -+ case ESYNC_KIND_SEMAPHORE: -+ current = __atomic_load_n( &objs[i].slot->state, __ATOMIC_SEQ_CST ); -+ do if (current >= objs[i].slot->aux) break; -+ while (!__atomic_compare_exchange_n( &objs[i].slot->state, ¤t, current + 1, FALSE, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )); -+ if (current < objs[i].slot->aux) wake_fd( objs[i].fd, 1 ); -+ break; -+ case ESYNC_KIND_AUTO: -+ wake_fd( objs[i].fd, 1 ); -+ __atomic_store_n( &objs[i].slot->state, 1, __ATOMIC_SEQ_CST ); -+ break; -+ case ESYNC_KIND_MANUAL: -+ break; -+ } -+ } -+ restore_signals( &old ); -+ return status; -+} -+ -+static NTSTATUS wait_all( DWORD count, struct esync_obj *objs, int alert_fd, const struct esync_deadline *end, BOOL track ) -+{ -+ struct pollfd fds[2]; -+ DWORD tid = GetCurrentThreadId(), i, j; -+ NTSTATUS status; -+ nfds_t nfds; -+ int ret; -+ -+ for (i = 0; i < count; i++) -+ for (j = i + 1; j < count; j++) -+ if (objs[i].slot == objs[j].slot) return STATUS_INVALID_PARAMETER; -+ -+ for (;;) -+ { -+ if ((status = grab_all( count, objs, tid )) != STATUS_PENDING) return status; -+ if (track) -+ { -+ add_waits( count, objs ); -+ track = FALSE; -+ continue; -+ } -+ -+ for (i = 0; i < count; i++) -+ { -+ if (owned_mutex( &objs[i], tid )) continue; -+ fds[0].fd = objs[i].fd; -+ fds[0].events = POLLIN; -+ fds[0].revents = 0; -+ nfds = 1; -+ if (alert_fd >= 0) -+ { -+ fds[1].fd = alert_fd; -+ fds[1].events = POLLIN; -+ fds[1].revents = 0; -+ nfds = 2; -+ } -+ if ((ret = do_poll( fds, nfds, end )) < 0) return errno_to_status( errno ); -+ if (!ret) return STATUS_TIMEOUT; -+ if (fds[0].revents & (POLLERR | POLLHUP | POLLNVAL)) return STATUS_INVALID_HANDLE; -+ if (!(fds[0].revents & POLLIN) && nfds == 2 && (fds[1].revents & POLLIN)) -+ { -+ if ((status = grab_all( count, objs, tid )) != STATUS_PENDING) return status; -+ if ((status = user_apc()) != STATUS_TIMEOUT) return status; -+ break; -+ } -+ } -+ if (i == count) -+ { -+ if (expired( end )) return STATUS_TIMEOUT; -+ sched_yield(); -+ } -+ } -+} -+ -+NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ) -+{ -+ struct esync_obj objs[MAXIMUM_WAIT_OBJECTS]; -+ struct esync_deadline end; -+ DWORD i, base = waits.count; -+ BOOL track = FALSE; -+ NTSTATUS ret; -+ -+ assert( count <= MAXIMUM_WAIT_OBJECTS ); -+ for (i = 0; i < count; i++) -+ { -+ if (!(objs[i].slot = get_slot( shm_idxs[i] ))) return STATUS_INVALID_HANDLE; -+ objs[i].fd = fds[i]; -+ switch (types[i]) -+ { -+ case INPROC_SYNC_SEMAPHORE: -+ objs[i].kind = ESYNC_KIND_SEMAPHORE; -+ break; -+ case INPROC_SYNC_MUTEX: -+ objs[i].kind = ESYNC_KIND_MUTEX; -+ break; -+ default: -+ objs[i].kind = (objs[i].slot->flags & ESYNC_MANUAL) ? ESYNC_KIND_MANUAL : ESYNC_KIND_AUTO; -+ track = TRUE; -+ break; -+ } -+ } -+ -+ get_deadline( timeout, &end ); -+ if (timeout && !timeout->QuadPart) track = FALSE; -+ if (type != WaitAll || count <= 1) ret = wait_any( count, objs, alert_fd, &end, track ); -+ else ret = wait_all( count, objs, alert_fd, &end, track ); -+ remove_waits( base ); -+ return ret; -+} -+ -+#else -+ -+void esync_init( int fd ) -+{ -+} -+ -+void esync_release( unsigned int shm_idx ) -+{ -+} -+ -+NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ) -+{ -+ return STATUS_NOT_IMPLEMENTED; -+} -+ -+#endif -diff --git a/dlls/ntdll/unix/esync.h b/dlls/ntdll/unix/esync.h -new file mode 100644 -index 0000000..afa19fc ---- /dev/null -+++ b/dlls/ntdll/unix/esync.h -@@ -0,0 +1,20 @@ -+#define ESYNC_USED_BY_SERVER 0x7eadeeef -+#define ESYNC_SHM_TOKEN 0x7eadeef0 -+ -+static inline int do_esync(void) -+{ -+ return inproc_device_fd == ESYNC_USED_BY_SERVER; -+} -+ -+extern void esync_init( int shm_fd ); -+extern void esync_release( unsigned int shm_idx ); -+extern NTSTATUS esync_release_semaphore( int fd, unsigned int shm_idx, ULONG count, ULONG *prev ); -+extern NTSTATUS esync_query_semaphore( int fd, unsigned int shm_idx, SEMAPHORE_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_set_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_reset_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_pulse_event( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_query_event( int fd, unsigned int shm_idx, EVENT_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_release_mutex( int fd, unsigned int shm_idx, LONG *prev ); -+extern NTSTATUS esync_query_mutex( int fd, unsigned int shm_idx, MUTANT_BASIC_INFORMATION *info ); -+extern NTSTATUS esync_wait( DWORD count, const int *fds, const unsigned int *shm_idxs, const unsigned short *types, -+ WAIT_TYPE type, int alert_fd, const LARGE_INTEGER *timeout ); -diff --git a/dlls/ntdll/unix/server.c b/dlls/ntdll/unix/server.c -index 112157b..864c2f9 100644 ---- a/dlls/ntdll/unix/server.c -+++ b/dlls/ntdll/unix/server.c -@@ -81,6 +81,7 @@ - #include "unix_private.h" - #include "ddk/wdm.h" - -+#include "esync.h" - #include "fsync.h" - - WINE_DEFAULT_DEBUG_CHANNEL(server); -@@ -1703,6 +1704,14 @@ size_t server_init_process(void) - inproc_device_fd = FSYNC_USED_BY_SERVER; - fsync_init( pid ); - } -+ else if (reply->inproc_device == ESYNC_USED_BY_SERVER) -+ { -+ int shm_fd = wine_server_receive_fd( &handle ); -+ assert( handle == ESYNC_SHM_TOKEN ); -+ if (shm_fd < 0) fatal_error( "droiddeck-esync: no shared memory from wineserver\n" ); -+ inproc_device_fd = ESYNC_USED_BY_SERVER; -+ esync_init( shm_fd ); -+ } - else if (reply->inproc_device) - { - inproc_device_fd = wine_server_receive_fd( &handle ); -diff --git a/dlls/ntdll/unix/sync.c b/dlls/ntdll/unix/sync.c -index 67ab945..f44a526 100644 ---- a/dlls/ntdll/unix/sync.c -+++ b/dlls/ntdll/unix/sync.c -@@ -72,6 +72,7 @@ - #include "wine/debug.h" - #include "unix_private.h" - -+#include "esync.h" - #include "fsync.h" - - WINE_DEFAULT_DEBUG_CHANNEL(sync); -@@ -542,9 +543,10 @@ struct inproc_sync - unsigned int access; /* handle access rights */ - unsigned short type; /* enum inproc_sync_type as short to save space */ - unsigned short closed; /* fd has been closed but sync is still referenced */ -+ unsigned int shm_idx; - }; - --#define INPROC_SYNC_CACHE_BLOCK_SIZE (65536 / sizeof(struct inproc_sync)) -+#define INPROC_SYNC_CACHE_BLOCK_SIZE 4096 - #define INPROC_SYNC_CACHE_ENTRIES 128 - - static struct inproc_sync *inproc_sync_cache[INPROC_SYNC_CACHE_ENTRIES]; -@@ -582,7 +584,8 @@ static struct inproc_sync *cache_inproc_sync( HANDLE handle, struct inproc_sync - if (!entry) inproc_sync_cache[0] = inproc_sync_cache_initial_block; - else - { -- static const size_t size = INPROC_SYNC_CACHE_BLOCK_SIZE * sizeof(struct inproc_sync); -+ size_t page = sysconf( _SC_PAGESIZE ); -+ size_t size = (INPROC_SYNC_CACHE_BLOCK_SIZE * sizeof(struct inproc_sync) + page - 1) & ~(page - 1); - void *ptr = anon_mmap_alloc( size, PROT_READ | PROT_WRITE ); - if (ptr == MAP_FAILED) return sync; - inproc_sync_cache[entry] = ptr; -@@ -604,6 +607,7 @@ static struct inproc_sync *cache_inproc_sync( HANDLE handle, struct inproc_sync - cache->access = sync->access; - cache->type = sync->type; - cache->closed = sync->closed; -+ cache->shm_idx = sync->shm_idx; - /* Make sure we set the other members before the refcount; this store needs - * release semantics [paired with the load in get_cached_inproc_sync()]. - * Set the refcount to 2 (one for the handle, one for the caller). */ -@@ -633,10 +637,13 @@ static void release_inproc_sync( struct inproc_sync *sync ) - /* save the fd now; as soon as the refcount hits 0 we cannot - * access the cache anymore */ - int fd = sync->fd; -+ unsigned int shm_idx = sync->shm_idx; - LONG ref = InterlockedDecrement( &sync->refcount ); - - assert( ref >= 0 ); -- if (!ref) close( fd ); -+ if (ref) return; -+ if (fd >= 0) close( fd ); -+ if (shm_idx) esync_release( shm_idx ); - } - - static struct inproc_sync *get_cached_inproc_sync( HANDLE handle ) -@@ -682,10 +689,19 @@ static NTSTATUS get_server_inproc_sync( HANDLE handle, struct inproc_sync *sync - sync->access = reply->access; - sync->type = reply->type; - sync->closed = 0; -+ sync->shm_idx = do_esync() ? reply->fsync_shm_idx : 0; - } - } - SERVER_END_REQ; - -+ if (!ret && do_esync() && (sync->fd < 0 || !sync->shm_idx)) -+ { -+ ERR( "droiddeck-esync: cannot receive fd for handle %p, out of file descriptors?\n", handle ); -+ if (sync->fd >= 0) close( sync->fd ); -+ esync_release( sync->shm_idx ); -+ memset( sync, 0, sizeof(*sync) ); -+ ret = STATUS_TOO_MANY_OPENED_FILES; -+ } - return ret; - } - -@@ -789,7 +805,8 @@ static NTSTATUS inproc_release_semaphore( HANDLE handle, ULONG count, ULONG *pre - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_SEMAPHORE, SEMAPHORE_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_release_semaphore_obj( sync->fd, count, prev_count ); -+ if (do_esync()) ret = esync_release_semaphore( sync->fd, sync->shm_idx, count, prev_count ); -+ else ret = linux_release_semaphore_obj( sync->fd, count, prev_count ); - release_inproc_sync( sync ); - return ret; - } -@@ -803,7 +820,8 @@ static NTSTATUS inproc_query_semaphore( HANDLE handle, SEMAPHORE_BASIC_INFORMATI - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_SEMAPHORE, SEMAPHORE_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_semaphore_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_semaphore( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_semaphore_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -817,7 +835,8 @@ static NTSTATUS inproc_set_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_set_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_set_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_set_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -831,7 +850,8 @@ static NTSTATUS inproc_reset_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_reset_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_reset_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_reset_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -845,7 +865,8 @@ static NTSTATUS inproc_pulse_event( HANDLE handle, LONG *prev_state ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_MODIFY_STATE, &stack, &sync ))) return ret; -- ret = linux_pulse_event_obj( sync->fd, prev_state ); -+ if (do_esync()) ret = esync_pulse_event( sync->fd, sync->shm_idx, prev_state ); -+ else ret = linux_pulse_event_obj( sync->fd, prev_state ); - release_inproc_sync( sync ); - return ret; - } -@@ -859,7 +880,8 @@ static NTSTATUS inproc_query_event( HANDLE handle, EVENT_BASIC_INFORMATION *info - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_EVENT, EVENT_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_event_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_event( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_event_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -873,7 +895,8 @@ static NTSTATUS inproc_release_mutex( HANDLE handle, LONG *prev_count ) - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_MUTEX, 0, &stack, &sync ))) return ret; -- ret = linux_release_mutex_obj( sync->fd, prev_count ); -+ if (do_esync()) ret = esync_release_mutex( sync->fd, sync->shm_idx, prev_count ); -+ else ret = linux_release_mutex_obj( sync->fd, prev_count ); - release_inproc_sync( sync ); - return ret; - } -@@ -887,7 +910,8 @@ static NTSTATUS inproc_query_mutex( HANDLE handle, MUTANT_BASIC_INFORMATION *inf - - if (inproc_device_fd < 0) return STATUS_NOT_IMPLEMENTED; - if ((ret = get_inproc_sync( handle, INPROC_SYNC_MUTEX, MUTANT_QUERY_STATE, &stack, &sync ))) return ret; -- ret = linux_query_mutex_obj( sync->fd, info ); -+ if (do_esync()) ret = esync_query_mutex( sync->fd, sync->shm_idx, info ); -+ else ret = linux_query_mutex_obj( sync->fd, info ); - release_inproc_sync( sync ); - return ret; - } -@@ -928,6 +952,8 @@ static NTSTATUS inproc_wait( DWORD count, const HANDLE *handles, WAIT_TYPE type, - { - struct inproc_sync *syncs[64], stack[ARRAY_SIZE(syncs)]; - int objs[ARRAY_SIZE(syncs)], alert_fd = 0; -+ unsigned int shm_idxs[ARRAY_SIZE(syncs)]; -+ unsigned short types[ARRAY_SIZE(syncs)]; - NTSTATUS ret; - - if (do_fsync()) return fsync_wait_objects( count, handles, type != WaitAll, alertable, timeout ); -@@ -944,6 +970,17 @@ static NTSTATUS inproc_wait( DWORD count, const HANDLE *handles, WAIT_TYPE type, - return ret; - } - objs[i] = syncs[i]->fd; -+ shm_idxs[i] = syncs[i]->shm_idx; -+ types[i] = syncs[i]->type; -+ } -+ -+ if (do_esync()) -+ { -+ alert_fd = alertable ? get_inproc_alert_fd() : -1; -+ if (alertable && alert_fd < 0) ret = STATUS_TOO_MANY_OPENED_FILES; -+ else ret = esync_wait( count, objs, shm_idxs, types, type, alert_fd, timeout ); -+ while (count--) release_inproc_sync( syncs[count] ); -+ return ret; - } - - if (alertable) alert_fd = get_inproc_alert_fd(); -@@ -969,6 +1006,25 @@ static NTSTATUS inproc_signal_and_wait( HANDLE signal, HANDLE wait, - - if ((ret = get_inproc_sync( wait, INPROC_SYNC_UNKNOWN, SYNCHRONIZE, &stack_wait, &wait_sync ))) goto done; - -+ if (do_esync()) -+ { -+ switch (signal_sync->type) -+ { -+ case INPROC_SYNC_EVENT: ret = esync_set_event( signal_sync->fd, signal_sync->shm_idx, NULL ); break; -+ case INPROC_SYNC_MUTEX: ret = esync_release_mutex( signal_sync->fd, signal_sync->shm_idx, NULL ); break; -+ case INPROC_SYNC_SEMAPHORE: ret = esync_release_semaphore( signal_sync->fd, signal_sync->shm_idx, 1, NULL ); break; -+ default: assert( 0 ); break; -+ } -+ if (!ret) -+ { -+ alert_fd = alertable ? get_inproc_alert_fd() : -1; -+ if (alertable && alert_fd < 0) ret = STATUS_TOO_MANY_OPENED_FILES; -+ else ret = esync_wait( 1, &wait_sync->fd, &wait_sync->shm_idx, &wait_sync->type, WaitAny, alert_fd, timeout ); -+ } -+ release_inproc_sync( wait_sync ); -+ goto done; -+ } -+ - switch (signal_sync->type) - { - case INPROC_SYNC_EVENT: ret = linux_set_event_obj( signal_sync->fd, NULL ); break; -@@ -2461,6 +2517,20 @@ NTSTATUS WINAPI NtDelayExecution( BOOLEAN alertable, const LARGE_INTEGER *timeou - if (ret != STATUS_NOT_IMPLEMENTED) - return ret; - } -+ else if (do_esync()) -+ { -+ int alert_fd = get_inproc_alert_fd(); -+ -+ if (alert_fd >= 0) -+ { -+ if ((status = esync_wait( 0, NULL, NULL, NULL, WaitAny, alert_fd, timeout )) == STATUS_TIMEOUT) -+ { -+ NtYieldExecution(); -+ status = STATUS_SUCCESS; -+ } -+ return status; -+ } -+ } - /* Since server_wait will result in an unconditional implicit yield, - we never return STATUS_NO_YIELD_PERFORMED */ - if ((status = server_wait( NULL, 0, SELECT_INTERRUPTIBLE | SELECT_ALERTABLE, timeout )) == STATUS_TIMEOUT) -diff --git a/server/Makefile.in b/server/Makefile.in -index d9ac607..b370a0b 100644 ---- a/server/Makefile.in -+++ b/server/Makefile.in -@@ -12,6 +12,7 @@ SOURCES = \ - debugger.c \ - device.c \ - directory.c \ -+ esync.c \ - event.c \ - fd.c \ - file.c \ -diff --git a/server/esync.c b/server/esync.c -new file mode 100644 -index 0000000..508a189 ---- /dev/null -+++ b/server/esync.c -@@ -0,0 +1,525 @@ -+/* droiddeck-esync: eventfd and shared memory synchronization for Wine. -+ * Credit: based on the esync design by Elizabeth Figura for Wine and Proton. */ -+#include "config.h" -+ -+#include <assert.h> -+#include <errno.h> -+#include <poll.h> -+#include <sched.h> -+#include <signal.h> -+#include <stdarg.h> -+#include <stdint.h> -+#include <stdio.h> -+#include <stdlib.h> -+#include <string.h> -+#include <sys/mman.h> -+#include <sys/resource.h> -+#ifdef HAVE_SYS_EVENTFD_H -+# include <sys/eventfd.h> -+#endif -+#include <unistd.h> -+ -+#include "ntstatus.h" -+#define WIN32_NO_STATUS -+#include "windef.h" -+#include "winternl.h" -+ -+#include "handle.h" -+#include "request.h" -+#include "esync.h" -+ -+#define ESYNC_CHUNK_SIZE 0x10000 -+#define ESYNC_SLOT_SIZE 16 -+#define ESYNC_PER_CHUNK (ESYNC_CHUNK_SIZE / ESYNC_SLOT_SIZE) -+#define ESYNC_CHUNKS 8192 -+#define ESYNC_MANUAL 1 -+#define ESYNC_RELEASING 1 -+ -+struct esync_slot -+{ -+ union -+ { -+ struct -+ { -+ int state; -+ int aux; -+ }; -+ int64_t pair; -+ }; -+ int flags; -+ int ref; -+}; -+ -+union esync_pair -+{ -+ struct { int state; int aux; } s; -+ int64_t word; -+}; -+ -+struct holder -+{ -+ process_id_t pid; -+ unsigned int count; -+ struct holder *next; -+}; -+ -+int esync_active; -+ -+static int shm_fd = -1; -+static unsigned int shm_chunks; -+static void *chunks[ESYNC_CHUNKS]; -+static struct holder *holders[ESYNC_CHUNKS]; -+static uint64_t *free_map; -+static unsigned int free_words; -+static unsigned int free_hint; -+ -+#if defined(HAVE_SYS_EVENTFD_H) && defined(HAVE_MEMFD_CREATE) -+ -+static void *map_chunk( unsigned int chunk ) -+{ -+ void *addr; -+ -+ if (chunk >= ESYNC_CHUNKS) return NULL; -+ if (chunks[chunk]) return chunks[chunk]; -+ if (!holders[chunk] && !(holders[chunk] = calloc( ESYNC_PER_CHUNK, sizeof(struct holder) ))) return NULL; -+ if (chunk >= shm_chunks) -+ { -+ if (ftruncate( shm_fd, (off_t)(chunk + 1) * ESYNC_CHUNK_SIZE ) == -1) -+ { -+ perror( "droiddeck-esync: ftruncate" ); -+ return NULL; -+ } -+ shm_chunks = chunk + 1; -+ } -+ addr = mmap( NULL, ESYNC_CHUNK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, -+ (off_t)chunk * ESYNC_CHUNK_SIZE ); -+ if (addr == MAP_FAILED) -+ { -+ perror( "droiddeck-esync: mmap" ); -+ return NULL; -+ } -+ return chunks[chunk] = addr; -+} -+ -+static struct esync_slot *get_slot( unsigned int idx ) -+{ -+ char *base = chunks[idx / ESYNC_PER_CHUNK]; -+ return (struct esync_slot *)(base + (idx % ESYNC_PER_CHUNK) * ESYNC_SLOT_SIZE); -+} -+ -+static struct holder *get_holder( unsigned int idx ) -+{ -+ return &holders[idx / ESYNC_PER_CHUNK][idx % ESYNC_PER_CHUNK]; -+} -+ -+static void clear_holders( unsigned int idx ) -+{ -+ struct holder *head = get_holder( idx ), *next; -+ -+ while ((next = head->next)) -+ { -+ head->next = next->next; -+ free( next ); -+ } -+ head->pid = 0; -+ head->count = 0; -+} -+ -+static unsigned int alloc_slot(void) -+{ -+ unsigned int i, bit, idx; -+ -+ for (i = free_hint; i < free_words; i++) if (free_map[i]) break; -+ if (i == free_words) -+ { -+ unsigned int words = free_words * 2; -+ uint64_t *map; -+ -+ if (words > ESYNC_CHUNKS * (ESYNC_PER_CHUNK / 64)) words = ESYNC_CHUNKS * (ESYNC_PER_CHUNK / 64); -+ if (words == free_words) return 0; -+ if (!(map = realloc( free_map, words * sizeof(*map) ))) return 0; -+ memset( map + free_words, 0xff, (words - free_words) * sizeof(*map) ); -+ free_map = map; -+ free_words = words; -+ } -+ bit = __builtin_ctzll( free_map[i] ); -+ idx = i * 64 + bit; -+ if (!map_chunk( idx / ESYNC_PER_CHUNK )) return 0; -+ free_map[i] &= ~((uint64_t)1 << bit); -+ free_hint = i; -+ return idx; -+} -+ -+static void free_slot( unsigned int idx ) -+{ -+ clear_holders( idx ); -+ free_map[idx / 64] |= (uint64_t)1 << (idx % 64); -+ if (idx / 64 < free_hint) free_hint = idx / 64; -+} -+ -+static int slot_in_use( unsigned int idx ) -+{ -+ if (!idx || idx / 64 >= free_words) return 0; -+ return !(free_map[idx / 64] & ((uint64_t)1 << (idx % 64))); -+} -+ -+static void put_ref( unsigned int idx ) -+{ -+ struct esync_slot *slot = get_slot( idx ); -+ -+ if (slot->ref > 0 && !--slot->ref) free_slot( idx ); -+} -+ -+static void report_fd_error( const char *what ) -+{ -+ static int warned; -+ struct rlimit limit; -+ -+ if (warned) return; -+ warned = 1; -+ if ((errno == EMFILE || errno == ENFILE) && !getrlimit( RLIMIT_NOFILE, &limit )) -+ fprintf( stderr, "droiddeck-esync: out of file descriptors in %s (limit %llu); set PROTON_NO_ESYNC=1 for this game if it fails.\n", -+ what, (unsigned long long)limit.rlim_cur ); -+ else -+ fprintf( stderr, "droiddeck-esync: %s failed: %s\n", what, strerror( errno )); -+} -+ -+static void wake_fd( int fd ) -+{ -+ static const uint64_t value = 1; -+ if (fd >= 0 && write( fd, &value, sizeof(value) ) == -1 && errno != EAGAIN) perror( "droiddeck-esync: write" ); -+} -+ -+static void drain_fd( int fd ) -+{ -+ uint64_t value; -+ if (fd >= 0 && read( fd, &value, sizeof(value) ) == -1 && errno != EAGAIN) perror( "droiddeck-esync: read" ); -+} -+ -+static int fd_readable( int fd ) -+{ -+ struct pollfd pfd = { .fd = fd, .events = POLLIN }; -+ -+ return fd >= 0 && poll( &pfd, 1, 0 ) > 0 && (pfd.revents & POLLIN); -+} -+ -+static int owner_dead( int owner ) -+{ -+ return owner > 0 && kill( owner, 0 ) == -1 && errno == ESRCH; -+} -+ -+static void repair_slot( struct esync_slot *slot, int fd ) -+{ -+ int readable; -+ -+ if (fd < 0) return; -+ readable = fd_readable( fd ); -+ if (__atomic_load_n( &slot->state, __ATOMIC_SEQ_CST )) -+ { -+ if (!readable) wake_fd( fd ); -+ } -+ else if (readable) drain_fd( fd ); -+} -+ -+static void lock_slot( struct esync_slot *slot, int fd ) -+{ -+ int self = getpid(), owner; -+ unsigned int spins = 0; -+ -+ for (;;) -+ { -+ owner = 0; -+ if (__atomic_compare_exchange_n( &slot->aux, &owner, self, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) return; -+ if (++spins < 128) continue; -+ if (!(spins & 1023) && owner_dead( owner ) && -+ __atomic_compare_exchange_n( &slot->aux, &owner, self, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED )) -+ { -+ repair_slot( slot, fd ); -+ return; -+ } -+ sched_yield(); -+ } -+} -+ -+static void unlock_slot( struct esync_slot *slot ) -+{ -+ int self = getpid(); -+ __atomic_compare_exchange_n( &slot->aux, &self, 0, 0, __ATOMIC_RELEASE, __ATOMIC_RELAXED ); -+} -+ -+int esync_init(void) -+{ -+ const char *env = getenv( "WINEESYNC" ); -+ struct rlimit limit; -+ int probe; -+ -+ if (!env || !atoi( env )) return 0; -+ if ((probe = eventfd( 0, EFD_CLOEXEC | EFD_NONBLOCK )) == -1) -+ { -+ perror( "droiddeck-esync: eventfd" ); -+ return 0; -+ } -+ close( probe ); -+ if ((shm_fd = memfd_create( "droiddeck-esync", MFD_CLOEXEC )) == -1) -+ { -+ perror( "droiddeck-esync: memfd_create" ); -+ return 0; -+ } -+ free_words = 64; -+ if (!(free_map = malloc( free_words * sizeof(*free_map) )) || !map_chunk( 0 )) -+ { -+ free( free_map ); -+ free_map = NULL; -+ free_words = 0; -+ close( shm_fd ); -+ shm_fd = -1; -+ return 0; -+ } -+ memset( free_map, 0xff, free_words * sizeof(*free_map) ); -+ free_map[0] &= ~(uint64_t)1; -+ esync_active = 1; -+ fprintf( stderr, "droiddeck-esync: up and running.\n" ); -+ if (!getrlimit( RLIMIT_NOFILE, &limit )) -+ fprintf( stderr, "droiddeck-esync: file descriptor limit %llu.\n", (unsigned long long)limit.rlim_cur ); -+ return 1; -+} -+ -+int esync_shm_fd(void) -+{ -+ return shm_fd; -+} -+ -+int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ) -+{ -+ struct esync_slot *slot; -+ unsigned int idx; -+ int fd = ESYNC_NO_FD; -+ -+ if (!lazy && (fd = eventfd( initval, EFD_CLOEXEC | EFD_NONBLOCK | (semaphore ? EFD_SEMAPHORE : 0) )) == -1) -+ { -+ report_fd_error( "eventfd" ); -+ return -1; -+ } -+ if (!(idx = alloc_slot())) -+ { -+ if (fd >= 0) close( fd ); -+ return -1; -+ } -+ slot = get_slot( idx ); -+ slot->state = state; -+ slot->aux = aux; -+ slot->flags = manual ? ESYNC_MANUAL : 0; -+ __atomic_store_n( &slot->ref, 1, __ATOMIC_SEQ_CST ); -+ *shm_idx = idx; -+ return fd; -+} -+ -+int esync_open( unsigned int shm_idx ) -+{ -+ int fd; -+ -+ if (!slot_in_use( shm_idx )) return -1; -+ if ((fd = eventfd( __atomic_load_n( &get_slot( shm_idx )->state, __ATOMIC_SEQ_CST ) ? 1 : 0, -+ EFD_CLOEXEC | EFD_NONBLOCK )) == -1) -+ report_fd_error( "eventfd" ); -+ return fd; -+} -+ -+void esync_destroy( int fd, unsigned int shm_idx ) -+{ -+ if (fd >= 0) close( fd ); -+ if (slot_in_use( shm_idx )) put_ref( shm_idx ); -+} -+ -+unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ) -+{ -+ struct holder *head, *node; -+ -+ if (!slot_in_use( shm_idx )) return 0; -+ head = get_holder( shm_idx ); -+ for (node = head; node; node = node->next) -+ { -+ if (node->count && node->pid == pid) -+ { -+ node->count++; -+ get_slot( shm_idx )->ref++; -+ return shm_idx; -+ } -+ } -+ if (!head->count) -+ { -+ head->pid = pid; -+ head->count = 1; -+ } -+ else -+ { -+ if (!(node = malloc( sizeof(*node) ))) return 0; -+ node->pid = pid; -+ node->count = 1; -+ node->next = head->next; -+ head->next = node; -+ } -+ get_slot( shm_idx )->ref++; -+ return shm_idx; -+} -+ -+static unsigned int drop_holder( unsigned int idx, process_id_t pid, int all ) -+{ -+ struct holder *head = get_holder( idx ), *prev = NULL, *node, *next; -+ unsigned int dropped; -+ -+ for (node = head; node; prev = node, node = node->next) -+ if (node->count && node->pid == pid) break; -+ if (!node) return 0; -+ dropped = all ? node->count : 1; -+ if ((node->count -= dropped)) return dropped; -+ if (node != head) -+ { -+ prev->next = node->next; -+ free( node ); -+ } -+ else if ((next = head->next)) -+ { -+ *head = *next; -+ free( next ); -+ } -+ else head->pid = 0; -+ return dropped; -+} -+ -+void esync_client_release( process_id_t pid, unsigned int shm_idx ) -+{ -+ if (!slot_in_use( shm_idx )) return; -+ if (drop_holder( shm_idx, pid, 0 )) put_ref( shm_idx ); -+} -+ -+void esync_cleanup_process( process_id_t pid ) -+{ -+ unsigned int i, idx, count; -+ uint64_t used; -+ -+ for (i = 0; i < free_words; i++) -+ { -+ used = ~free_map[i]; -+ while (used) -+ { -+ idx = i * 64 + __builtin_ctzll( used ); -+ used &= used - 1; -+ if (!idx || !slot_in_use( idx )) continue; -+ for (count = drop_holder( idx, pid, 1 ); count && slot_in_use( idx ); count--) put_ref( idx ); -+ } -+ } -+} -+ -+void esync_set( int fd, unsigned int shm_idx, int locked ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!(slot->flags & ESYNC_MANUAL)) -+ { -+ wake_fd( fd ); -+ __atomic_store_n( &slot->state, 1, __ATOMIC_SEQ_CST ); -+ return; -+ } -+ if (locked) lock_slot( slot, fd ); -+ if (!__atomic_exchange_n( &slot->state, 1, __ATOMIC_SEQ_CST )) wake_fd( fd ); -+ if (locked) unlock_slot( slot ); -+} -+ -+void esync_reset( int fd, unsigned int shm_idx, int locked ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ -+ if (!(slot->flags & ESYNC_MANUAL)) -+ { -+ __atomic_store_n( &slot->state, 0, __ATOMIC_SEQ_CST ); -+ drain_fd( fd ); -+ return; -+ } -+ if (locked) lock_slot( slot, fd ); -+ if (__atomic_exchange_n( &slot->state, 0, __ATOMIC_SEQ_CST )) drain_fd( fd ); -+ if (locked) unlock_slot( slot ); -+} -+ -+void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ) -+{ -+ struct esync_slot *slot = get_slot( shm_idx ); -+ union esync_pair cur, next = { .s = { ~0, 0 } }; -+ -+ cur.word = __atomic_load_n( &slot->pair, __ATOMIC_SEQ_CST ); -+ for (;;) -+ { -+ if (cur.s.state != (int)tid && -+ (!releasing || cur.s.state != (int)(tid | ESYNC_RELEASING) || fd_readable( fd ))) return; -+ if (__atomic_compare_exchange_n( &slot->pair, &cur.word, next.word, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST )) break; -+ } -+ wake_fd( fd ); -+} -+ -+thread_id_t esync_mutex_owner( unsigned int shm_idx ) -+{ -+ int owner; -+ -+ if (!slot_in_use( shm_idx )) return 0; -+ owner = __atomic_load_n( &get_slot( shm_idx )->state, __ATOMIC_SEQ_CST ); -+ return owner == ~0 ? 0 : owner & ~ESYNC_RELEASING; -+} -+ -+#else -+ -+int esync_init(void) -+{ -+ return 0; -+} -+ -+int esync_shm_fd(void) -+{ -+ return -1; -+} -+ -+int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ) -+{ -+ errno = ENOSYS; -+ return -1; -+} -+ -+int esync_open( unsigned int shm_idx ) -+{ -+ return -1; -+} -+ -+void esync_destroy( int fd, unsigned int shm_idx ) -+{ -+} -+ -+unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ) -+{ -+ return 0; -+} -+ -+void esync_client_release( process_id_t pid, unsigned int shm_idx ) -+{ -+} -+ -+void esync_cleanup_process( process_id_t pid ) -+{ -+} -+ -+void esync_set( int fd, unsigned int shm_idx, int locked ) -+{ -+} -+ -+void esync_reset( int fd, unsigned int shm_idx, int locked ) -+{ -+} -+ -+void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ) -+{ -+} -+ -+thread_id_t esync_mutex_owner( unsigned int shm_idx ) -+{ -+ return 0; -+} -+ -+#endif -diff --git a/server/esync.h b/server/esync.h -new file mode 100644 -index 0000000..b94565c ---- /dev/null -+++ b/server/esync.h -@@ -0,0 +1,22 @@ -+#define ESYNC_USED_BY_SERVER 0x7eadeeef -+#define ESYNC_SHM_TOKEN 0x7eadeef0 -+#define ESYNC_NO_FD (-2) -+ -+extern int esync_active; -+static inline int do_esync(void) -+{ -+ return esync_active; -+} -+ -+extern int esync_init(void); -+extern int esync_shm_fd(void); -+extern int esync_create( int semaphore, int manual, unsigned int initval, int state, int aux, int lazy, unsigned int *shm_idx ); -+extern int esync_open( unsigned int shm_idx ); -+extern void esync_destroy( int fd, unsigned int shm_idx ); -+extern unsigned int esync_grab( process_id_t pid, unsigned int shm_idx ); -+extern void esync_client_release( process_id_t pid, unsigned int shm_idx ); -+extern void esync_cleanup_process( process_id_t pid ); -+extern void esync_set( int fd, unsigned int shm_idx, int locked ); -+extern void esync_reset( int fd, unsigned int shm_idx, int locked ); -+extern void esync_abandon_mutex( int fd, unsigned int shm_idx, thread_id_t tid, int releasing ); -+extern thread_id_t esync_mutex_owner( unsigned int shm_idx ); -diff --git a/server/fsync.c b/server/fsync.c -index 7d3ec64..f4d9ac9 100644 ---- a/server/fsync.c -+++ b/server/fsync.c -@@ -43,6 +43,9 @@ - - #include "handle.h" - #include "request.h" -+#include "process.h" -+#include "thread.h" -+#include "esync.h" - #include "fsync.h" - - #include "pshpack4.h" -@@ -359,6 +362,11 @@ void fsync_abandon_mutex( unsigned int shm_idx, thread_id_t tid ) - - DECL_HANDLER(fsync_free_shm_idx) - { -+ if (do_esync()) -+ { -+ esync_client_release( get_process_id( current->process ), req->shm_idx ); -+ return; -+ } - if (!req->shm_idx || req->shm_idx >= shm_idx_free_map_size * BITS_IN_FREE_MAP_WORD) - { - set_error( STATUS_INVALID_PARAMETER ); -diff --git a/server/inproc_sync.c b/server/inproc_sync.c -index a7020ef..b555837 100644 ---- a/server/inproc_sync.c -+++ b/server/inproc_sync.c -@@ -23,6 +23,7 @@ - #include <assert.h> - #include <stdint.h> - #include <stdio.h> -+#include <stdlib.h> - - #include "ntstatus.h" - #define WIN32_NO_STATUS -@@ -31,15 +32,18 @@ - #include "file.h" - #include "handle.h" - #include "request.h" -+#include "process.h" - #include "thread.h" - #include "user.h" - -+#include "esync.h" - #include "fsync.h" - - #include "ntsync_tmp.h" - - #ifdef NTSYNC_IOC_EVENT_READ - -+#include <errno.h> - #include <fcntl.h> - #include <sys/ioctl.h> - #include <sys/stat.h> -@@ -57,9 +61,10 @@ int get_inproc_device_fd(void) - if (fd >= 0) - { - do_fsync_cached = 0; -- fprintf( stderr, "ntsync: up and running.\n" ); -+ fprintf( stderr, "droiddeck-ntsync: up and running.\n" ); - } - else if (do_fsync()) fd = FSYNC_USED_BY_SERVER; -+ else if (esync_init()) fd = ESYNC_USED_BY_SERVER; - else fprintf( stderr, "wineserver: using server-side synchronization.\n" ); - } - return fd; -@@ -71,6 +76,7 @@ struct inproc_sync - enum inproc_sync_type type; - int fd; - struct list entry; -+ unsigned int shm_idx; - }; - - static struct list inproc_mutexes = LIST_INIT( inproc_mutexes ); -@@ -107,6 +113,12 @@ static const struct object_ops inproc_sync_ops = - int get_inproc_sync_fd( struct inproc_sync *sync ) - { - if (!sync) return -1; -+ if (do_esync() && sync->fd == ESYNC_NO_FD && (sync->fd = esync_open( sync->shm_idx )) == -1) -+ { -+ sync->fd = ESYNC_NO_FD; -+ set_error( STATUS_TOO_MANY_OPENED_FILES ); -+ return -1; -+ } - return sync->fd; - } - -@@ -116,11 +128,17 @@ struct inproc_sync *create_inproc_internal_sync( int manual, int signaled ) - struct inproc_sync *event; - - if (!(event = alloc_object( &inproc_sync_ops ))) return NULL; -+ event->shm_idx = 0; - if (do_fsync()) - { - event->type = manual ? FSYNC_MANUAL_SERVER : FSYNC_AUTO_SERVER; - event->fd = fsync_alloc_shm( signaled, 0xdeadbeef ); - } -+ else if (do_esync()) -+ { -+ event->type = INPROC_SYNC_INTERNAL; -+ event->fd = esync_create( 0, manual, !!signaled, !!signaled, 0, 1, &event->shm_idx ); -+ } - else - { - event->type = INPROC_SYNC_INTERNAL; -@@ -143,11 +161,17 @@ struct inproc_sync *create_inproc_event_sync( int manual, int signaled ) - struct inproc_sync *event; - - if (!(event = alloc_object( &inproc_sync_ops ))) return NULL; -+ event->shm_idx = 0; - if (do_fsync()) - { - event->type = manual ? FSYNC_MANUAL_EVENT : FSYNC_AUTO_EVENT; - event->fd = fsync_alloc_shm( signaled, 0xdeadbeef ); - } -+ else if (do_esync()) -+ { -+ event->type = INPROC_SYNC_EVENT; -+ event->fd = esync_create( 0, manual, !!signaled, !!signaled, 0, 0, &event->shm_idx ); -+ } - else - { - event->type = INPROC_SYNC_EVENT; -@@ -170,11 +194,17 @@ struct inproc_sync *create_inproc_mutex_sync( thread_id_t owner, unsigned int co - struct inproc_sync *mutex; - - if (!(mutex = alloc_object( &inproc_sync_ops ))) return NULL; -+ mutex->shm_idx = 0; - if (do_fsync()) - { - mutex->type = FSYNC_MUTEX; - mutex->fd = fsync_alloc_shm( owner, count ); - } -+ else if (do_esync()) -+ { -+ mutex->type = INPROC_SYNC_MUTEX; -+ mutex->fd = esync_create( 0, 0, owner ? 0 : 1, owner, owner ? count : 0, 0, &mutex->shm_idx ); -+ } - else - { - mutex->type = INPROC_SYNC_MUTEX; -@@ -197,12 +227,18 @@ struct inproc_sync *create_inproc_semaphore_sync( unsigned int initial, unsigned - struct inproc_sync *sem; - - if (!(sem = alloc_object( &inproc_sync_ops ))) return NULL; -+ sem->shm_idx = 0; - - if (do_fsync()) - { - sem->type = FSYNC_SEMAPHORE; - sem->fd = fsync_alloc_shm( initial, max ); - } -+ else if (do_esync()) -+ { -+ sem->type = INPROC_SYNC_SEMAPHORE; -+ sem->fd = esync_create( 1, 0, initial, initial, max, 0, &sem->shm_idx ); -+ } - else - { - sem->type = INPROC_SYNC_SEMAPHORE; -@@ -231,6 +267,7 @@ void signal_inproc_sync( struct inproc_sync *sync ) - __u32 count; - if (debug_level) fprintf( stderr, "set_inproc_event %d\n", sync->fd ); - if (do_fsync()) fsync_set_event( sync->fd ); -+ else if (do_esync()) esync_set( sync->fd, sync->shm_idx, sync->type == INPROC_SYNC_EVENT ); - else ioctl( sync->fd, NTSYNC_IOC_EVENT_SET, &count ); - } - -@@ -239,6 +276,7 @@ void reset_inproc_sync( struct inproc_sync *sync ) - __u32 count; - if (debug_level) fprintf( stderr, "reset_inproc_event %d\n", sync->fd ); - if (do_fsync()) fsync_reset_event( sync->fd ); -+ else if (do_esync()) esync_reset( sync->fd, sync->shm_idx, sync->type == INPROC_SYNC_EVENT ); - else ioctl( sync->fd, NTSYNC_IOC_EVENT_RESET, &count ); - } - -@@ -262,6 +300,7 @@ static void inproc_sync_destroy( struct object *obj ) - assert( obj->ops == &inproc_sync_ops ); - list_remove( &sync->entry ); - if (do_fsync()) fsync_free_shm_idx( sync->fd ); -+ else if (do_esync()) esync_destroy( sync->fd, sync->shm_idx ); - else close( sync->fd ); - } - -@@ -275,12 +314,97 @@ void abandon_inproc_mutexes( thread_id_t tid ) - fsync_abandon_mutex( mutex->fd, tid ); - return; - } -+ if (do_esync()) -+ { -+ LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) -+ esync_abandon_mutex( mutex->fd, mutex->shm_idx, tid, 0 ); -+ return; -+ } - - LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) - ioctl( mutex->fd, NTSYNC_IOC_MUTEX_KILL, &tid ); - } - --static int get_obj_inproc_sync( struct object *obj, int *type ) -+struct mutex_reaper -+{ -+ struct list entry; -+ struct thread *thread; -+ struct timeout_user *timeout; -+ unsigned int checks; -+}; -+ -+static struct list mutex_reapers = LIST_INIT( mutex_reapers ); -+ -+static void finish_reaper( struct mutex_reaper *reaper ) -+{ -+ if (reaper->timeout) remove_timeout_user( reaper->timeout ); -+ list_remove( &reaper->entry ); -+ abandon_inproc_mutexes( reaper->thread->id ); -+ release_object( reaper->thread ); -+ free( reaper ); -+} -+ -+static void reap_inproc_mutexes( void *private ) -+{ -+ struct mutex_reaper *reaper = private; -+ -+ reaper->timeout = NULL; -+ if (++reaper->checks < 20 && send_thread_signal( reaper->thread, 0 ) && -+ (reaper->timeout = add_timeout_user( -TICKS_PER_SEC / 20, reap_inproc_mutexes, reaper ))) -+ return; -+ finish_reaper( reaper ); -+} -+ -+void abandon_inproc_mutexes_later( struct thread *thread ) -+{ -+ struct mutex_reaper *reaper; -+ -+ if (list_empty( &inproc_mutexes ) || thread->process->is_terminating) return; -+ if (!(reaper = malloc( sizeof(*reaper) ))) return; -+ reaper->thread = (struct thread *)grab_object( thread ); -+ reaper->checks = 0; -+ if (!(reaper->timeout = add_timeout_user( -TICKS_PER_SEC / 100, reap_inproc_mutexes, reaper ))) -+ { -+ release_object( thread ); -+ free( reaper ); -+ return; -+ } -+ list_add_tail( &mutex_reapers, &reaper->entry ); -+} -+ -+void abandon_dead_inproc_mutexes( struct process *process ) -+{ -+ unsigned int error = get_error(); -+ struct mutex_reaper *reaper, *next; -+ struct ntsync_mutex_args args; -+ struct inproc_sync *mutex; -+ struct thread *thread; -+ thread_id_t owner; -+ int dead, gone; -+ -+ LIST_FOR_EACH_ENTRY_SAFE( reaper, next, &mutex_reapers, struct mutex_reaper, entry ) -+ if (reaper->thread->process == process) finish_reaper( reaper ); -+ if (!do_fsync()) -+ { -+ LIST_FOR_EACH_ENTRY( mutex, &inproc_mutexes, struct inproc_sync, entry ) -+ { -+ if (do_esync()) owner = esync_mutex_owner( mutex->shm_idx ); -+ else if (ioctl( mutex->fd, NTSYNC_IOC_MUTEX_READ, &args ) == -1 && errno != EOWNERDEAD) continue; -+ else owner = args.owner; -+ if (!owner) continue; -+ thread = get_thread_from_id( owner ); -+ dead = !thread || thread->state == TERMINATED; -+ gone = !thread || thread->process == process; -+ if (thread) release_object( thread ); -+ if (!dead) continue; -+ if (do_esync()) esync_abandon_mutex( mutex->fd, mutex->shm_idx, owner, gone ); -+ else ioctl( mutex->fd, NTSYNC_IOC_MUTEX_KILL, &owner ); -+ } -+ } -+ set_error( error ); -+} -+ -+static int get_obj_inproc_sync( struct object *obj, int *type, unsigned int *shm_idx ) - { - struct object *sync; - int fd = -1; -@@ -290,7 +414,8 @@ static int get_obj_inproc_sync( struct object *obj, int *type ) - { - struct inproc_sync *inproc = (struct inproc_sync *)sync; - *type = inproc->type; -- fd = inproc->fd; -+ *shm_idx = inproc->shm_idx; -+ fd = get_inproc_sync_fd( inproc ); - } - - release_object( sync ); -@@ -341,7 +466,15 @@ void abandon_inproc_mutexes( thread_id_t tid ) - { - } - --static int get_obj_inproc_sync( struct object *obj, int *type ) -+void abandon_inproc_mutexes_later( struct thread *thread ) -+{ -+} -+ -+void abandon_dead_inproc_mutexes( struct process *process ) -+{ -+} -+ -+static int get_obj_inproc_sync( struct object *obj, int *type, unsigned int *shm_idx ) - { - return -1; - } -@@ -351,14 +484,23 @@ static int get_obj_inproc_sync( struct object *obj, int *type ) - DECL_HANDLER(get_inproc_sync_fd) - { - struct object *obj; -+ unsigned int shm_idx = 0; - int fd; - - if (!(obj = get_handle_obj( current->process, req->handle, 0, NULL ))) return; - - reply->access = get_handle_access( current->process, req->handle ); - -- if ((fd = get_obj_inproc_sync( obj, &reply->type )) < 0) set_error( STATUS_NOT_IMPLEMENTED ); -+ if ((fd = get_obj_inproc_sync( obj, &reply->type, &shm_idx )) < 0) -+ { -+ if (!get_error()) set_error( STATUS_NOT_IMPLEMENTED ); -+ } - else if (do_fsync()) reply->fsync_shm_idx = fsync_grab_shm_idx( fd ); -+ else if (do_esync()) -+ { -+ if (!(reply->fsync_shm_idx = esync_grab( get_process_id( current->process ), shm_idx ))) set_error( STATUS_NO_MEMORY ); -+ else send_client_fd( current->process, fd, req->handle ); -+ } - else send_client_fd( current->process, fd, req->handle ); - - release_object( obj ); -diff --git a/server/object.h b/server/object.h -index 21f5ea8..cd21fb7 100644 ---- a/server/object.h -+++ b/server/object.h -@@ -255,6 +255,8 @@ extern struct inproc_sync *create_inproc_event_sync( int manual, int signaled ); - extern struct inproc_sync *create_inproc_semaphore_sync( unsigned int initial, unsigned int max ); - extern struct inproc_sync *create_inproc_mutex_sync( thread_id_t owner, unsigned int count ); - extern void abandon_inproc_mutexes( thread_id_t owner ); -+extern void abandon_inproc_mutexes_later( struct thread *thread ); -+extern void abandon_dead_inproc_mutexes( struct process *process ); - extern void signal_inproc_sync( struct inproc_sync *sync ); - extern void reset_inproc_sync( struct inproc_sync *sync ); - -diff --git a/server/process.c b/server/process.c -index 287718a..5963884 100644 ---- a/server/process.c -+++ b/server/process.c -@@ -65,6 +65,7 @@ - #include "user.h" - #include "security.h" - -+#include "esync.h" - #include "fsync.h" - - /* process object */ -@@ -618,6 +619,8 @@ static void process_died( struct process *process ) - if (!--user_processes && !shutdown_stage && master_socket_timeout != TIMEOUT_INFINITE) - shutdown_timeout = add_timeout_user( master_socket_timeout, server_shutdown_timeout, NULL ); - } -+ abandon_dead_inproc_mutexes( process ); -+ if (do_esync()) esync_cleanup_process( process->id ); - release_object( process ); - if (!--running_processes && shutdown_stage) close_master_socket( 0 ); - } -diff --git a/server/thread.c b/server/thread.c -index 435d12a..f35a788 100644 ---- a/server/thread.c -+++ b/server/thread.c -@@ -61,6 +61,7 @@ - #include "user.h" - #include "security.h" - -+#include "esync.h" - #include "fsync.h" - - /* thread queues */ -@@ -1658,7 +1659,11 @@ void kill_thread( struct thread *thread, int violent_death ) - kill_console_processes( thread, 0 ); - abandon_mutexes( thread ); - signal_sync( thread->sync ); -- if (violent_death) send_thread_signal( thread, SIGQUIT ); -+ if (violent_death) -+ { -+ send_thread_signal( thread, SIGQUIT ); -+ abandon_inproc_mutexes_later( thread ); -+ } - cleanup_thread( thread ); - remove_process_thread( thread->process, thread ); - release_object( thread ); -@@ -1836,7 +1841,12 @@ DECL_HANDLER(init_first_thread) - { - reply->inproc_device = FSYNC_USED_BY_SERVER; - } -- else if ((fd = get_inproc_device_fd()) >= 0) -+ else if ((fd = get_inproc_device_fd()) == ESYNC_USED_BY_SERVER) -+ { -+ reply->inproc_device = ESYNC_USED_BY_SERVER; -+ send_client_fd( process, esync_shm_fd(), ESYNC_SHM_TOKEN ); -+ } -+ else if (fd >= 0) - { - reply->inproc_device = get_process_id( process ) | 1; - send_client_fd( process, fd, reply->inproc_device ); -@@ -2478,7 +2488,10 @@ DECL_HANDLER(get_inproc_alert_fd) - { - int fd; - -- if ((fd = get_inproc_sync_fd( current->alert_sync )) < 0) set_error( STATUS_INVALID_PARAMETER ); -+ if ((fd = get_inproc_sync_fd( current->alert_sync )) < 0) -+ { -+ if (!get_error()) set_error( STATUS_INVALID_PARAMETER ); -+ } - else if (do_fsync()) reply->fsync_shm_idx = fd; - else - { diff --git a/tools/droiddeck-esync/release.env b/tools/droiddeck-esync/release.env new file mode 100644 index 000000000..e95ee0b5e --- /dev/null +++ b/tools/droiddeck-esync/release.env @@ -0,0 +1,4 @@ +SYNC_BUNDLE_REPO=Droid-Deck/DroidDeck-Components +SYNC_BUNDLE_TAG=droiddeck-esync-index +SYNC_BUNDLE_ASSET=bundle-20261004-142603.tzst +SYNC_BUNDLE_SHA256=7e311e92444f0709de7779ef5334708511345d60556aa429e6761b86d711a9b2 diff --git a/tools/droiddeck-esync/revoked.txt b/tools/droiddeck-esync/revoked.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/droiddeck-esync/smoke-test.sh b/tools/droiddeck-esync/smoke-test.sh deleted file mode 100755 index 994f60bda..000000000 --- a/tools/droiddeck-esync/smoke-test.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/bin/bash -set -euo pipefail - -if [ $# -ne 4 ]; then - echo "usage: $0 <proton-dir> <pack.tzst> <esstress.exe> <work-dir>" >&2 - exit 2 -fi -tool=$(realpath "$1") -archive=$(realpath "$2") -exe=$(realpath "$3") -work=$(realpath -m "$4") -here=$(cd "$(dirname "$0")" && pwd) -guest=$here/../linuxfs/overlay/usr/local/bin/droiddeck-esync -rm -rf "$work" -mkdir -p "$work/pack" -tar --zstd -xf "$archive" -C "$work/pack" -python3 "$guest" build-shadow "$tool" "$work/pack" "$work/dist" >/dev/null -[ -e "$work/dist/files/bin" ] || ln -s bin-arm64 "$work/dist/files/bin" -export WINEDEBUG=-all WINEDLLOVERRIDES="mscoree,mshtml=" - -run() { - local mode=$1 bin=$2 want=$3 - shift 3 - local prefix=$work/prefix-$mode - mkdir -p "$prefix" - ( - export WINEPREFIX=$prefix WINESERVER=$bin/wineserver "$@" - timeout "${BOOT_TIMEOUT:-900}" "$bin/wine" wineboot -i > "$work/$mode-boot.log" 2>&1 || true - "$bin/wineserver" -w || true - if ! grep -q "$want" "$work/$mode-boot.log"; then - echo "$mode: wine never said \"$want\"" - tail -n 30 "$work/$mode-boot.log" - exit 1 - fi - cp "$exe" "$prefix/drive_c/esstress.exe" - status=0 - timeout "${STRESS_TIMEOUT:-1800}" "$bin/wine" 'C:\esstress.exe' > "$work/$mode-stress.log" 2>&1 || status=$? - "$bin/wineserver" -k 2>/dev/null || true - if ! grep -q '^esstress:' "$work/$mode-stress.log"; then - echo "$mode: esstress did not finish (status $status)" - tail -n 30 "$work/$mode-stress.log" - exit 1 - fi - grep -o '^FAIL [^ ]*:[0-9]*:' "$work/$mode-stress.log" | sort -u > "$work/$mode-failures" || true - echo "$mode: $(grep -E '^esstress:' "$work/$mode-stress.log" | tail -n 1)" - ) -} - -if ! run stock "$tool/files/bin-arm64" "server-side synchronization" PROTON_NO_NTSYNC=1 WINEESYNC=0 WINEFSYNC=0; then - echo "stock Proton does not run here either; the pack cannot be judged on this machine" - exit 3 -fi -run esync "$work/dist/files/bin-arm64" "esync: up and running" PROTON_NO_NTSYNC=1 WINEESYNC=1 WINEFSYNC=0 -if [ -s "$work/stock-failures" ]; then - echo "stock fails these as well, so they do not count against the pack: $(tr '\n' ' ' < "$work/stock-failures")" -fi -new=$(comm -13 "$work/stock-failures" "$work/esync-failures") -if [ -n "$new" ]; then - echo "esync: fails checks stock passes:" - grep -F "$new" "$work/esync-stress.log" || echo "$new" - exit 1 -fi diff --git a/tools/droiddeck-esync/tests/esstress.c b/tools/droiddeck-esync/tests/esstress.c deleted file mode 100644 index 7400f221b..000000000 --- a/tools/droiddeck-esync/tests/esstress.c +++ /dev/null @@ -1,618 +0,0 @@ -#include <windows.h> -#include <winternl.h> -#include <stdio.h> -#include <stdlib.h> - -static int failures; -#define CHECK(c, ...) do { if (!(c)) { failures++; printf("FAIL %s:%d: ", __FILE__, __LINE__); printf(__VA_ARGS__); printf("\n"); fflush(stdout); } } while (0) - -static volatile LONG counter; -static HANDLE mutex, sem, ev_auto, ev_manual, start_ev; -static volatile LONG stop; - -static DWORD WINAPI mutex_worker(void *arg) -{ - int i; - WaitForSingleObject(start_ev, INFINITE); - for (i = 0; i < 20000; i++) - { - DWORD r = WaitForSingleObject(mutex, INFINITE); - LONG v; - if (r != WAIT_OBJECT_0) { CHECK(0, "mutex wait %lu", r); break; } - v = counter; - if (i % 7 == 0) WaitForSingleObject(mutex, 0), ReleaseMutex(mutex); - counter = v + 1; - ReleaseMutex(mutex); - } - return 0; -} - -static void test_mutex_contention(void) -{ - HANDLE th[8]; - int i; - counter = 0; - mutex = CreateMutexA(NULL, FALSE, NULL); - start_ev = CreateEventA(NULL, TRUE, FALSE, NULL); - for (i = 0; i < 8; i++) th[i] = CreateThread(NULL, 0, mutex_worker, NULL, 0, NULL); - SetEvent(start_ev); - WaitForMultipleObjects(8, th, TRUE, INFINITE); - CHECK(counter == 8 * 20000, "mutex counter %ld", counter); - for (i = 0; i < 8; i++) CloseHandle(th[i]); - CloseHandle(mutex); - CloseHandle(start_ev); - printf("mutex_contention ok counter=%ld\n", counter); -} - -static volatile LONG produced, consumed; - -static DWORD WINAPI sem_producer(void *arg) -{ - int i; - for (i = 0; i < 50000; i++) - { - LONG prev; - while (!ReleaseSemaphore(sem, 1, &prev)) - { - CHECK(GetLastError() == ERROR_TOO_MANY_POSTS, "release err %lu", GetLastError()); - Sleep(0); - } - InterlockedIncrement(&produced); - } - return 0; -} - -static DWORD WINAPI sem_consumer(void *arg) -{ - for (;;) - { - DWORD r = WaitForSingleObject(sem, 2000); - if (r == WAIT_TIMEOUT) break; - CHECK(r == WAIT_OBJECT_0, "sem wait %lu", r); - InterlockedIncrement(&consumed); - } - return 0; -} - -static void test_semaphore(void) -{ - HANDLE th[8]; - LONG prev; - int i; - produced = consumed = 0; - sem = CreateSemaphoreA(NULL, 0, 16, NULL); - for (i = 0; i < 4; i++) th[i] = CreateThread(NULL, 0, sem_producer, NULL, 0, NULL); - for (i = 4; i < 8; i++) th[i] = CreateThread(NULL, 0, sem_consumer, NULL, 0, NULL); - WaitForMultipleObjects(8, th, TRUE, INFINITE); - CHECK(produced == 200000 && consumed == 200000, "sem produced %ld consumed %ld", produced, consumed); - CHECK(!ReleaseSemaphore(sem, 17, &prev) && GetLastError() == ERROR_TOO_MANY_POSTS, "limit"); - CHECK(ReleaseSemaphore(sem, 16, &prev) && prev == 0, "prev %ld", prev); - CHECK(!ReleaseSemaphore(sem, 1, &prev), "over max"); - for (i = 0; i < 8; i++) CloseHandle(th[i]); - CloseHandle(sem); - printf("semaphore ok %ld/%ld\n", produced, consumed); -} - -static volatile LONG auto_hits; - -static DWORD WINAPI auto_waiter(void *arg) -{ - while (!stop) - { - DWORD r = WaitForSingleObject(ev_auto, 50); - if (r == WAIT_OBJECT_0) InterlockedIncrement(&auto_hits); - } - return 0; -} - -static void test_auto_event(void) -{ - HANDLE th[6]; - int i; - LONG expected = 0; - stop = 0; auto_hits = 0; - ev_auto = CreateEventA(NULL, FALSE, FALSE, NULL); - for (i = 0; i < 6; i++) th[i] = CreateThread(NULL, 0, auto_waiter, NULL, 0, NULL); - for (i = 0; i < 30000; i++) - { - LONG before = auto_hits; - SetEvent(ev_auto); - expected++; - while (auto_hits == before) SwitchToThread(); - } - stop = 1; - WaitForMultipleObjects(6, th, TRUE, INFINITE); - CHECK(auto_hits == expected, "auto hits %ld expected %ld", auto_hits, expected); - CHECK(WaitForSingleObject(ev_auto, 0) == WAIT_TIMEOUT, "auto event left signaled"); - SetEvent(ev_auto); SetEvent(ev_auto); - CHECK(WaitForSingleObject(ev_auto, 0) == WAIT_OBJECT_0, "auto set"); - CHECK(WaitForSingleObject(ev_auto, 0) == WAIT_TIMEOUT, "auto coalesce"); - for (i = 0; i < 6; i++) CloseHandle(th[i]); - CloseHandle(ev_auto); - printf("auto_event ok hits=%ld\n", auto_hits); -} - -static volatile LONG manual_seen; - -static DWORD WINAPI manual_waiter(void *arg) -{ - DWORD r = WaitForSingleObject(ev_manual, 10000); - if (r == WAIT_OBJECT_0) InterlockedIncrement(&manual_seen); - return 0; -} - -static void test_manual_event(void) -{ - HANDLE th[16]; - int i, round; - ev_manual = CreateEventA(NULL, TRUE, FALSE, NULL); - for (round = 0; round < 50; round++) - { - manual_seen = 0; - ResetEvent(ev_manual); - for (i = 0; i < 16; i++) th[i] = CreateThread(NULL, 0, manual_waiter, NULL, 0, NULL); - Sleep(2); - SetEvent(ev_manual); - WaitForMultipleObjects(16, th, TRUE, INFINITE); - CHECK(manual_seen == 16, "manual round %d seen %ld", round, manual_seen); - for (i = 0; i < 16; i++) CloseHandle(th[i]); - } - for (i = 0; i < 20000; i++) - { - SetEvent(ev_manual); - if (i & 1) ResetEvent(ev_manual); - } - CHECK(WaitForSingleObject(ev_manual, 0) == WAIT_TIMEOUT, "manual final reset"); - SetEvent(ev_manual); - CHECK(WaitForSingleObject(ev_manual, 0) == WAIT_OBJECT_0, "manual stays"); - CHECK(WaitForSingleObject(ev_manual, 0) == WAIT_OBJECT_0, "manual stays 2"); - CloseHandle(ev_manual); - printf("manual_event ok\n"); -} - -static HANDLE wa_objs[3]; -static volatile LONG wa_count; - -static DWORD WINAPI wait_all_worker(void *arg) -{ - int i; - for (i = 0; i < 3000; i++) - { - DWORD r = WaitForMultipleObjects(3, wa_objs, TRUE, 5000); - if (r != WAIT_OBJECT_0) { CHECK(0, "wait all %lu", r); break; } - InterlockedIncrement(&wa_count); - ReleaseMutex(wa_objs[0]); - ReleaseSemaphore(wa_objs[1], 1, NULL); - SetEvent(wa_objs[2]); - } - return 0; -} - -static void test_wait_all(void) -{ - HANDLE th[6], dup[2]; - int i; - DWORD r; - wa_count = 0; - wa_objs[0] = CreateMutexA(NULL, FALSE, NULL); - wa_objs[1] = CreateSemaphoreA(NULL, 1, 1, NULL); - wa_objs[2] = CreateEventA(NULL, FALSE, TRUE, NULL); - for (i = 0; i < 6; i++) th[i] = CreateThread(NULL, 0, wait_all_worker, NULL, 0, NULL); - WaitForMultipleObjects(6, th, TRUE, INFINITE); - CHECK(wa_count == 18000, "wait all count %ld", wa_count); - dup[0] = wa_objs[2]; - DuplicateHandle(GetCurrentProcess(), wa_objs[2], GetCurrentProcess(), &dup[1], 0, FALSE, DUPLICATE_SAME_ACCESS); - r = WaitForMultipleObjects(2, dup, TRUE, 0); - CHECK(r == WAIT_FAILED && GetLastError() == ERROR_INVALID_PARAMETER, "dup wait all %lu %lu", r, GetLastError()); - CloseHandle(dup[1]); - for (i = 0; i < 6; i++) CloseHandle(th[i]); - for (i = 0; i < 3; i++) CloseHandle(wa_objs[i]); - printf("wait_all ok %ld\n", wa_count); -} - -static DWORD WINAPI hold_and_die(void *arg) -{ - WaitForSingleObject((HANDLE)arg, INFINITE); - return 0; -} - -static void test_abandoned(void) -{ - HANDLE m = CreateMutexA(NULL, FALSE, NULL); - HANDLE th = CreateThread(NULL, 0, hold_and_die, m, 0, NULL); - HANDLE objs[2]; - DWORD r; - WaitForSingleObject(th, INFINITE); - r = WaitForSingleObject(m, 1000); - CHECK(r == WAIT_ABANDONED, "abandoned %lu", r); - CHECK(ReleaseMutex(m), "release after abandon"); - th = CreateThread(NULL, 0, hold_and_die, m, 0, NULL); - WaitForSingleObject(th, INFINITE); - objs[0] = CreateEventA(NULL, TRUE, TRUE, NULL); - objs[1] = m; - r = WaitForMultipleObjects(2, objs, TRUE, 1000); - CHECK(r == WAIT_ABANDONED_0, "abandoned all %lu", r); - ReleaseMutex(m); - r = WaitForMultipleObjects(2, objs, FALSE, 1000); - CHECK(r == WAIT_OBJECT_0, "any after %lu", r); - CloseHandle(th); CloseHandle(objs[0]); CloseHandle(m); - printf("abandoned ok\n"); -} - -static volatile LONG apc_ran; -static void CALLBACK apc_proc(ULONG_PTR p) { InterlockedIncrement(&apc_ran); } - -static HANDLE apc_ready; - -static DWORD WINAPI alert_waiter(void *arg) -{ - SetEvent(apc_ready); - return WaitForSingleObjectEx((HANDLE)arg, 5000, TRUE); -} - -static void test_apc(void) -{ - HANDLE ev = CreateEventA(NULL, TRUE, FALSE, NULL); - HANDLE th; - DWORD r, code; - int i; - apc_ready = CreateEventA(NULL, FALSE, FALSE, NULL); - for (i = 0; i < 200; i++) - { - apc_ran = 0; - th = CreateThread(NULL, 0, alert_waiter, ev, 0, NULL); - WaitForSingleObject(apc_ready, INFINITE); - if (i & 1) Sleep(1); - QueueUserAPC(apc_proc, th, 0); - WaitForSingleObject(th, INFINITE); - GetExitCodeThread(th, &code); - CHECK(code == WAIT_IO_COMPLETION && apc_ran == 1, "apc round %d code %lu ran %ld", i, code, apc_ran); - CloseHandle(th); - } - apc_ran = 0; - QueueUserAPC(apc_proc, GetCurrentThread(), 0); - r = SleepEx(1000, TRUE); - CHECK(r == WAIT_IO_COMPLETION && apc_ran == 1, "sleepex %lu %ld", r, apc_ran); - r = SleepEx(10, TRUE); - CHECK(r == 0, "sleepex timeout %lu", r); - QueueUserAPC(apc_proc, GetCurrentThread(), 0); - r = WaitForSingleObjectEx(ev, 0, FALSE); - CHECK(r == WAIT_TIMEOUT, "non alertable %lu", r); - r = WaitForSingleObjectEx(ev, 0, TRUE); - CHECK(r == WAIT_IO_COMPLETION, "alertable pending %lu", r); - CloseHandle(ev); - printf("apc ok\n"); -} - -static DWORD WINAPI close_waiter(void *arg) -{ - return WaitForSingleObject((HANDLE)arg, 300); -} - -static void test_close_while_waiting(void) -{ - int i; - for (i = 0; i < 300; i++) - { - HANDLE ev = CreateEventA(NULL, TRUE, FALSE, NULL), ev2, th; - DWORD code; - th = CreateThread(NULL, 0, close_waiter, ev, 0, NULL); - Sleep(0); - CloseHandle(ev); - ev2 = CreateEventA(NULL, TRUE, TRUE, NULL); - WaitForSingleObject(th, INFINITE); - GetExitCodeThread(th, &code); - CHECK(code == WAIT_TIMEOUT || code == WAIT_FAILED || code == WAIT_OBJECT_0, "close round %d code %lu", i, code); - CHECK(WaitForSingleObject(ev2, 0) == WAIT_OBJECT_0, "reused handle state"); - CloseHandle(ev2); - CloseHandle(th); - } - printf("close_while_waiting ok\n"); -} - -static void test_pulse(void) -{ - HANDLE ev = CreateEventA(NULL, TRUE, FALSE, NULL), th[4]; - DWORD code; - int i, woke = 0; - for (i = 0; i < 4; i++) th[i] = CreateThread(NULL, 0, close_waiter, ev, 0, NULL); - Sleep(50); - PulseEvent(ev); - WaitForMultipleObjects(4, th, TRUE, INFINITE); - for (i = 0; i < 4; i++) { GetExitCodeThread(th[i], &code); if (code == WAIT_OBJECT_0) woke++; CloseHandle(th[i]); } - CHECK(WaitForSingleObject(ev, 0) == WAIT_TIMEOUT, "pulse left signaled"); - CHECK(woke == 4, "pulse woke %d of 4", woke); - CloseHandle(ev); - ev = CreateEventA(NULL, FALSE, FALSE, NULL); - PulseEvent(ev); - CHECK(WaitForSingleObject(ev, 0) == WAIT_TIMEOUT, "auto pulse without waiter left signaled"); - th[0] = CreateThread(NULL, 0, close_waiter, ev, 0, NULL); - th[1] = CreateThread(NULL, 0, close_waiter, ev, 0, NULL); - Sleep(50); - PulseEvent(ev); - WaitForMultipleObjects(2, th, TRUE, INFINITE); - woke = 0; - for (i = 0; i < 2; i++) { GetExitCodeThread(th[i], &code); if (code == WAIT_OBJECT_0) woke++; CloseHandle(th[i]); } - CHECK(woke == 1, "auto pulse woke %d of 2", woke); - CHECK(WaitForSingleObject(ev, 0) == WAIT_TIMEOUT, "auto pulse left signaled"); - CloseHandle(ev); - printf("pulse ok\n"); -} - -static void test_semantics(void) -{ - HANDLE ev = CreateEventA(NULL, TRUE, TRUE, NULL), sem = CreateSemaphoreA(NULL, 2, 3, NULL), h[2]; - NTSTATUS (WINAPI *pNtWaitForSingleObject)(HANDLE, BOOLEAN, const LARGE_INTEGER *); - LARGE_INTEGER when; - LONG prev = -1; - DWORD r, start; - int i; - pNtWaitForSingleObject = (void *)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtWaitForSingleObject"); - apc_ran = 0; - QueueUserAPC(apc_proc, GetCurrentThread(), 0); - r = WaitForSingleObjectEx(ev, INFINITE, TRUE); - CHECK(r == WAIT_OBJECT_0 && apc_ran == 0, "object before apc %lu ran %ld", r, apc_ran); - r = SleepEx(0, TRUE); - CHECK(r == WAIT_IO_COMPLETION && apc_ran == 1, "apc after object %lu ran %ld", r, apc_ran); - r = SleepEx(0, TRUE); - CHECK(r == 0, "sleepex zero %lu", r); - ResetEvent(ev); - GetSystemTimeAsFileTime((FILETIME *)&when); - when.QuadPart += 2000000; - start = GetTickCount(); - r = pNtWaitForSingleObject(ev, FALSE, &when); - CHECK(r == STATUS_TIMEOUT, "absolute timeout status %lx", r); - CHECK(GetTickCount() - start >= 150 && GetTickCount() - start < 1500, "absolute timeout took %lu", GetTickCount() - start); - CHECK(!ReleaseSemaphore(sem, 2, &prev) && GetLastError() == ERROR_TOO_MANY_POSTS, "semaphore limit"); - CHECK(ReleaseSemaphore(sem, 1, &prev) && prev == 2, "semaphore prev %ld", prev); - for (i = 0; i < 3; i++) CHECK(WaitForSingleObject(sem, 0) == WAIT_OBJECT_0, "semaphore take %d", i); - CHECK(WaitForSingleObject(sem, 0) == WAIT_TIMEOUT, "semaphore empty"); - h[0] = sem; h[1] = ev; - ReleaseSemaphore(sem, 1, NULL); - CHECK(WaitForMultipleObjects(2, h, TRUE, 50) == WAIT_TIMEOUT, "wait all partial"); - CHECK(ReleaseSemaphore(sem, 0, &prev) && prev == 1, "wait all rollback lost token prev %ld", prev); - SetEvent(ev); - CHECK(WaitForMultipleObjects(2, h, TRUE, 50) == WAIT_OBJECT_0, "wait all ready"); - CHECK(ReleaseSemaphore(sem, 0, &prev) && prev == 0, "wait all consumed prev %ld", prev); - CHECK(WaitForSingleObject(ev, 0) == WAIT_OBJECT_0, "manual stays set"); - CloseHandle(sem); - CloseHandle(ev); - printf("semantics ok\n"); -} - -static HANDLE token_sem; -static volatile LONG token_count; - -static DWORD WINAPI token_worker(void *arg) -{ - HANDLE h[2] = { token_sem, (HANDLE)arg }; - int i; - for (i = 0; i < 20000; i++) - { - DWORD r = (i & 1) ? WaitForSingleObject(token_sem, INFINITE) : WaitForMultipleObjects(2, h, TRUE, INFINITE); - if (r != WAIT_OBJECT_0) { CHECK(0, "token wait %lu", r); break; } - if (InterlockedIncrement(&token_count) > 3) CHECK(0, "token overcommit"); - InterlockedDecrement(&token_count); - if (!(i & 1)) SetEvent((HANDLE)arg); - ReleaseSemaphore(token_sem, 1, NULL); - } - return 0; -} - -static void test_semaphore_tokens(void) -{ - HANDLE th[6], ev = CreateEventA(NULL, FALSE, TRUE, NULL); - LONG prev = -1; - int i; - token_sem = CreateSemaphoreA(NULL, 3, 3, NULL); - for (i = 0; i < 6; i++) th[i] = CreateThread(NULL, 0, token_worker, ev, 0, NULL); - WaitForMultipleObjects(6, th, TRUE, INFINITE); - for (i = 0; i < 6; i++) CloseHandle(th[i]); - CHECK(ReleaseSemaphore(token_sem, 0, &prev) && prev == 3, "token final count %ld", prev); - for (i = 0; i < 3; i++) CHECK(WaitForSingleObject(token_sem, 0) == WAIT_OBJECT_0, "token drain %d", i); - CHECK(WaitForSingleObject(token_sem, 0) == WAIT_TIMEOUT, "token extra"); - CHECK(WaitForSingleObject(ev, 0) == WAIT_OBJECT_0, "token event lost"); - CloseHandle(token_sem); - CloseHandle(ev); - printf("semaphore_tokens ok\n"); -} - -static volatile LONG churn_ready; - -static DWORD WINAPI churn_mutex(void *arg) -{ - HANDLE h[2] = { (HANDLE)arg, ev_manual }; - unsigned int i = 0; - if (WaitForMultipleObjects(2, h, TRUE, INFINITE) <= WAIT_ABANDONED_0 + 1) ReleaseMutex(h[0]); - InterlockedIncrement(&churn_ready); - for (;;) - { - DWORD r = (i++ & 1) ? WaitForSingleObject(h[0], INFINITE) : WaitForMultipleObjects(2, h, TRUE, INFINITE); - if (r == WAIT_OBJECT_0 || r == WAIT_ABANDONED || r == WAIT_ABANDONED_0) ReleaseMutex(h[0]); - } - return 0; -} - -static DWORD WINAPI blocked_waiter(void *arg) -{ - WaitForSingleObject((HANDLE)arg, 0); - InterlockedIncrement(&churn_ready); - return WaitForSingleObject((HANDLE)arg, INFINITE); -} - -static void test_terminate(void) -{ - HANDLE th[4], m, ev; - DWORD r, start; - int round, i, stuck = 0; - ev_manual = CreateEventA(NULL, TRUE, TRUE, NULL); - for (round = 0; round < 150; round++) - { - m = CreateMutexA(NULL, FALSE, NULL); - churn_ready = 0; - for (i = 0; i < 4; i++) th[i] = CreateThread(NULL, 0, churn_mutex, m, 0, NULL); - while (churn_ready < 4) Sleep(0); - Sleep(round % 4); - for (i = 0; i < 4; i++) TerminateThread(th[i], 7); - WaitForMultipleObjects(4, th, TRUE, INFINITE); - r = WaitForSingleObject(m, 3000); - if (r == WAIT_TIMEOUT) stuck++; - else ReleaseMutex(m); - for (i = 0; i < 4; i++) CloseHandle(th[i]); - CloseHandle(m); - } - CHECK(!stuck, "mutex stuck after terminate in %d rounds", stuck); - ev = CreateEventA(NULL, TRUE, FALSE, NULL); - churn_ready = 0; - for (i = 0; i < 4; i++) th[i] = CreateThread(NULL, 0, blocked_waiter, ev, 0, NULL); - while (churn_ready < 4) Sleep(0); - Sleep(50); - for (i = 0; i < 4; i++) TerminateThread(th[i], 7); - WaitForMultipleObjects(4, th, TRUE, INFINITE); - for (i = 0; i < 4; i++) CloseHandle(th[i]); - start = GetTickCount(); - for (i = 0; i < 100; i++) PulseEvent(ev); - CHECK(GetTickCount() - start < 400, "pulse after killed waiters took %lu ms", GetTickCount() - start); - CHECK(WaitForSingleObject(ev, 0) == WAIT_TIMEOUT, "pulse left event set"); - CloseHandle(ev); - CloseHandle(ev_manual); - printf("terminate ok\n"); -} - -static void test_cross_process(const char *self) -{ - HANDLE ev = CreateEventA(NULL, FALSE, FALSE, "Local\\esstress_ev"); - HANDLE done = CreateEventA(NULL, TRUE, FALSE, "Local\\esstress_done"); - HANDLE sm = CreateSemaphoreA(NULL, 0, 100000, "Local\\esstress_sem"); - PROCESS_INFORMATION pi; - STARTUPINFOA si = { sizeof(si) }; - char cmd[MAX_PATH + 16]; - DWORD r, code; - int i; - sprintf(cmd, "\"%s\" child", self); - CHECK(CreateProcessA(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi), "createprocess %lu", GetLastError()); - for (i = 0; i < 5000; i++) - { - SetEvent(ev); - r = WaitForSingleObject(sm, 5000); - if (r != WAIT_OBJECT_0) { CHECK(0, "xproc wait %lu at %d", r, i); break; } - } - SetEvent(done); - r = WaitForSingleObject(pi.hProcess, 10000); - CHECK(r == WAIT_OBJECT_0, "child exit wait %lu", r); - GetExitCodeProcess(pi.hProcess, &code); - CHECK(code == 0, "child code %lu", code); - CloseHandle(pi.hProcess); CloseHandle(pi.hThread); - CloseHandle(ev); CloseHandle(done); CloseHandle(sm); - printf("cross_process ok\n"); -} - -static int child(void) -{ - HANDLE ev = OpenEventA(SYNCHRONIZE | EVENT_MODIFY_STATE, FALSE, "Local\\esstress_ev"); - HANDLE done = OpenEventA(SYNCHRONIZE, FALSE, "Local\\esstress_done"); - HANDLE sm = OpenSemaphoreA(SEMAPHORE_MODIFY_STATE | SYNCHRONIZE, FALSE, "Local\\esstress_sem"); - HANDLE objs[2]; - objs[0] = done; objs[1] = ev; - if (!ev || !done || !sm) return 1; - for (;;) - { - DWORD r = WaitForMultipleObjects(2, objs, FALSE, 10000); - if (r == WAIT_OBJECT_0) return 0; - if (r != WAIT_OBJECT_0 + 1) return 2; - ReleaseSemaphore(sm, 1, NULL); - } -} - -static void test_kill_holder(const char *self) -{ - HANDLE m = CreateMutexA(NULL, FALSE, "Local\\esstress_mutex"); - PROCESS_INFORMATION pi; - STARTUPINFOA si = { sizeof(si) }; - char cmd[MAX_PATH + 16]; - DWORD r; - sprintf(cmd, "\"%s\" holder", self); - CreateProcessA(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi); - Sleep(500); - TerminateProcess(pi.hProcess, 7); - WaitForSingleObject(pi.hProcess, INFINITE); - r = WaitForSingleObject(m, 3000); - CHECK(r == WAIT_ABANDONED, "killed holder %lu", r); - ReleaseMutex(m); - CloseHandle(pi.hProcess); CloseHandle(pi.hThread); CloseHandle(m); - printf("kill_holder ok\n"); -} - -static int holder(void) -{ - HANDLE m = OpenMutexA(SYNCHRONIZE, FALSE, "Local\\esstress_mutex"); - WaitForSingleObject(m, INFINITE); - Sleep(INFINITE); - return 0; -} - -static void test_many_objects(void) -{ - static HANDLE h[20000]; - int i, n = 0; - for (i = 0; i < 20000; i++) - { - if (!(h[i] = CreateEventA(NULL, i & 1, i & 2, NULL))) break; - n++; - } - CHECK(n == 20000, "created %d", n); - for (i = 0; i < n; i++) CHECK(WaitForSingleObject(h[i], 0) == ((i & 2) ? WAIT_OBJECT_0 : WAIT_TIMEOUT), "obj %d", i); - for (i = 0; i < n; i++) CloseHandle(h[i]); - for (i = 0; i < 100000; i++) - { - HANDLE e = CreateEventA(NULL, FALSE, TRUE, NULL); - if (WaitForSingleObject(e, 0) != WAIT_OBJECT_0) { CHECK(0, "churn %d", i); CloseHandle(e); break; } - CloseHandle(e); - } - printf("many_objects ok\n"); -} - -static void test_timers_and_threads(void) -{ - HANDLE t = CreateWaitableTimerA(NULL, FALSE, NULL); - LARGE_INTEGER due; - DWORD start = GetTickCount(), r; - int i; - due.QuadPart = -10 * 1000 * 20; - SetWaitableTimer(t, &due, 10, NULL, NULL, FALSE); - for (i = 0; i < 20; i++) - { - r = WaitForSingleObject(t, 1000); - CHECK(r == WAIT_OBJECT_0, "timer %d %lu", i, r); - } - CHECK(GetTickCount() - start < 2000, "timer slow %lu", GetTickCount() - start); - CancelWaitableTimer(t); - CloseHandle(t); - r = WaitForSingleObject(GetCurrentProcess(), 10); - CHECK(r == WAIT_TIMEOUT, "process self %lu", r); - printf("timers ok\n"); -} - -int main(int argc, char **argv) -{ - if (argc > 1 && !strcmp(argv[1], "child")) return child(); - if (argc > 1 && !strcmp(argv[1], "holder")) return holder(); - test_mutex_contention(); - test_semaphore(); - test_auto_event(); - test_manual_event(); - test_wait_all(); - test_abandoned(); - test_apc(); - test_close_while_waiting(); - test_pulse(); - test_semantics(); - test_semaphore_tokens(); - if (argc > 1 && !strcmp(argv[1], "terminate")) test_terminate(); - test_cross_process(argv[0]); - test_kill_holder(argv[0]); - test_many_objects(); - test_timers_and_threads(); - printf("esstress: %d failures\n", failures); - return failures ? 1 : 0; -} diff --git a/tools/linuxfs/build-x86-preloads.sh b/tools/linuxfs/build-x86-preloads.sh new file mode 100755 index 000000000..793cad1c5 --- /dev/null +++ b/tools/linuxfs/build-x86-preloads.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu +out=${1:?usage: build-x86-preloads.sh <linuxfs asset directory>} +for arch in x86_64 i386; do + case $arch in + x86_64) bits=-m64 ;; + i386) bits=-m32 ;; + esac + mkdir -p "$out/$arch" + gcc $bits -shared -fPIC -O2 -Wall -pthread \ + -o "$out/$arch/libblsession.so" tools/linuxfs/preload/*.c -ldl + g++ $bits -shared -fPIC -O2 -Wall -Wno-attributes -Wno-nonnull-compare \ + -pthread -std=c++17 -static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL \ + -o "$out/$arch/libfakeinput.so" app/src/main/cpp/fakeinput_steam.cpp -ldl + strip --strip-unneeded "$out/$arch/libblsession.so" "$out/$arch/libfakeinput.so" + for lib in "$out/$arch/libblsession.so" "$out/$arch/libfakeinput.so"; do + newest=$(objdump -T "$lib" | grep -o 'GLIBC_2\.[0-9]*' | sort -u -t. -k2,2n | tail -1) + minor=${newest#GLIBC_2.} + if [ "$minor" -gt 31 ]; then + echo "$lib needs $newest; Steam Linux Runtime 3.0 has glibc 2.31" >&2 + exit 1 + fi + if objdump -p "$lib" | grep -q 'NEEDED.*lib\(stdc++\|gcc_s\)'; then + echo "$lib links the C++ runtime dynamically" >&2 + exit 1 + fi + done +done +for name in faultreport thunkaudit; do + lib=$out/x86_64/lib$name.so + gcc -m64 -shared -fPIC -O2 -Wall -Wextra -nostdlib -fno-stack-protector -fno-builtin -fvisibility=hidden \ + -o "$lib" tools/linuxfs/fex/$name.c + strip --strip-unneeded "$lib" + if objdump -p "$lib" | grep -q NEEDED || [ -n "$(nm -D --undefined-only "$lib" 2>/dev/null)" ]; then + echo "$lib must not need any library" >&2 + exit 1 + fi +done +stub=$(mktemp -d) +gcc -m64 -shared -nostdlib -Wl,-soname,/usr/lib/x86_64-linux-gnu/libvulkan.so.1 -o "$stub/libvulkan.so.1" -x c /dev/null +gcc -m64 -shared -nostdlib -Wl,-z,nodelete -Wl,--no-as-needed \ + -o "$out/x86_64/libvulkan-thunk.so" -x c /dev/null -x none "$stub/libvulkan.so.1" +rm -rf "$stub" +if ! objdump -p "$out/x86_64/libvulkan-thunk.so" | grep -q 'NEEDED *\/usr/lib/x86_64-linux-gnu/libvulkan\.so\.1$' \ + || ! objdump -p "$out/x86_64/libvulkan-thunk.so" | grep -q 'FLAGS_1 *0x0*8$'; then + echo "$out/x86_64/libvulkan-thunk.so must be nodelete and need only the Vulkan thunk" >&2 + exit 1 +fi diff --git a/tools/linuxfs/desktop/autostart b/tools/linuxfs/desktop/autostart index b28f23535..bcc9b95b9 100644 --- a/tools/linuxfs/desktop/autostart +++ b/tools/linuxfs/desktop/autostart @@ -39,7 +39,7 @@ run lxqt-panel lxqt-panel # The desktop was asked for with Steam in it (the front end's "Steam Desktop UI", Big Picture's # "Switch to Desktop"): the client's desktop UI, as the menu's Steam entry opens it. A running # client just gets the request; on a software desktop it goes back to the app as a Steam session. -[ "${BL_DESKTOP_STEAM:-0}" = 1 ] && run steam /usr/local/bin/bannerlator-steam-launch desktop +[ "${BL_DESKTOP_STEAM:-0}" = 1 ] && run steam /usr/local/bin/droiddeck-steam-launch desktop # Remote runs, for driving the desktop from a debugger: with ~/.droiddeck-remote-run present (only # the app or root can create it), each script dropped into ~/.cache/droiddeck/run/ is run once, # inside the session with its environment, its output tagged into the desktop log. diff --git a/tools/linuxfs/desktop/droiddeck-desktop b/tools/linuxfs/desktop/droiddeck-desktop index 6e1d17595..7c7883b6a 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop +++ b/tools/linuxfs/desktop/droiddeck-desktop @@ -1,7 +1,7 @@ #!/bin/bash # The desktop session: labwc nested as a client of the app's compositor, with LXQt's panel and # file manager started from its autostart, and Xwayland for the X11 programs (the Steam client, -# most emulators). Run by bannerlator-session in place of gamescope for mode "lxqt". +# most emulators). Run by droiddeck-session in place of gamescope for mode "lxqt". # Staged by the app over the desktop package's copy on every launch, so a fix here reaches an # installed desktop without re-downloading the package. set -u @@ -114,16 +114,16 @@ if [ ! -f /var/cache/fontconfig/.droiddeck-built ] && command -v fc-cache >/dev/ echo "== fontconfig: cache rebuilt, default sans is $(fc-match sans-serif 2>/dev/null)" fi # The menu's "Steam" / "Steam Big Picture" entries and one entry per installed game: all of them -# hand off to the app (bannerlator-steam-launch), since the client cannot draw on this desktop. +# hand off to the app (droiddeck-steam-launch), since the client cannot draw on this desktop. export BL_DESKTOP=1 -ln -sf /usr/local/bin/bannerlator-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null -ln -sf /usr/local/bin/bannerlator-steam-shim /usr/local/bin/steam 2>/dev/null -python3 /usr/local/bin/bannerlator-desktop-games 2>&1 | sed 's/^/[games] /' +ln -sf /usr/local/bin/droiddeck-steam-launch /usr/local/bin/steamdeck-steam 2>/dev/null +ln -sf /usr/local/bin/droiddeck-steam-shim /usr/local/bin/steam 2>/dev/null +python3 /usr/local/bin/droiddeck-desktop-games 2>&1 | sed 's/^/[games] /' # Player 1 on the controller in every emulator that has no profile of its own yet - now, and again # every half minute: an emulator started for the first time writes its own config, which is then # given the controller for its next start without waiting for the next desktop. -python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 | sed 's/^/[pads] /' -( while sleep 30; do python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 | sed 's/^/[pads] /'; done ) & +python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 | sed 's/^/[pads] /' +( while sleep 30; do python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 | sed 's/^/[pads] /'; done ) & # Games and emulators from the menu run through droiddeck-gpu (their own gamescope on the app's # compositor) while the desktop is on pixman, where they could not draw with the GPU. # BL_DESKTOP_GPU=on/off overrides; each entry keeps "Run in a desktop window" either way. diff --git a/tools/linuxfs/desktop/droiddeck-desktop-gpu b/tools/linuxfs/desktop/droiddeck-desktop-gpu index 8a9a6b108..5b0a533fa 100644 --- a/tools/linuxfs/desktop/droiddeck-desktop-gpu +++ b/tools/linuxfs/desktop/droiddeck-desktop-gpu @@ -18,7 +18,7 @@ LOCAL_DIR = "/usr/local/share/applications" FLATPAK_DIR = os.path.join(HOME, ".local/share/flatpak/exports/share/applications") USER_DIR = os.path.join(HOME, ".local/share/applications") MARK = "X-DroidDeck-GPU=1" -# The client and its games already hand themselves to the app (bannerlator-steam-launch). +# The client and its games already hand themselves to the app (droiddeck-steam-launch). SKIP = re.compile(r"^(steam|droiddeck-game-|droiddeck-steam|steamdeck-)", re.I) @@ -45,7 +45,7 @@ def gpu_exec(exec_line): """The Exec that runs a program on the GPU; a Flatpak app goes through its launcher, which keeps it on gamescope's X server instead of the desktop's Wayland socket.""" if exec_line.startswith("/usr/bin/flatpak run ") or exec_line.startswith("flatpak run "): - exec_line = "/usr/local/bin/bannerlator-flatpak-run " + exec_line.split(" run ", 1)[1] + exec_line = "/usr/local/bin/droiddeck-flatpak-run " + exec_line.split(" run ", 1)[1] return "droiddeck-gpu " + exec_line diff --git a/tools/linuxfs/desktop/rc.xml b/tools/linuxfs/desktop/rc.xml index e32c083d6..cf5ab8a1f 100644 --- a/tools/linuxfs/desktop/rc.xml +++ b/tools/linuxfs/desktop/rc.xml @@ -20,7 +20,7 @@ </keyboard> <mouse><doubleClickTime>500</doubleClickTime></mouse> <!-- Programs opened on the desktop at a size that suits it. melonDS remembers a 1x window for the - front end's full-screen toggle (bannerlator-pad-defaults), too small for a desktop: here it + front end's full-screen toggle (droiddeck-pad-defaults), too small for a desktop: here it opens at 800x600, centred (AutoPlace alone kept the top-left corner). Only the desktop reads this file, so the front end is unaffected. --> <windowRules> <windowRule title="melonDS*"> diff --git a/tools/linuxfs/fex/faultreport.c b/tools/linuxfs/fex/faultreport.c new file mode 100644 index 000000000..83bba2423 --- /dev/null +++ b/tools/linuxfs/fex/faultreport.c @@ -0,0 +1,144 @@ +#if !defined(__x86_64__) +#error faultreport is built for x86_64 guests only +#endif + +#define SYS_read 0 +#define SYS_write 1 +#define SYS_close 3 +#define SYS_rt_sigaction 13 +#define SYS_getpid 39 +#define SYS_gettid 186 +#define SYS_tgkill 234 +#define SYS_openat 257 +#define AT_FDCWD (-100) +#define SIGILL 4 +#define SA_SIGINFO 0x4UL +#define SA_RESTORER 0x04000000UL +#define SA_NODEFER 0x40000000UL +#define SA_RESETHAND 0x80000000UL + +struct kernel_sigaction { + void (*handler)(int, void *, void *); + unsigned long flags; + void (*restorer)(void); + unsigned long mask; +}; + +static long sys(long n, long a, long b, long c, long d) +{ + long r; + register long r10 __asm__("r10") = d; + __asm__ volatile("syscall" : "=a"(r) : "a"(n), "D"(a), "S"(b), "d"(c), "r"(r10) : "rcx", "r11", "memory"); + return r; +} + +static char out[1024]; +static unsigned long used; +static char maps[65536]; + +static void put(const char *s) +{ + while (*s && used < sizeof(out) - 1) + out[used++] = *s++; +} + +static void put_hex(unsigned long v, int digits) +{ + char buf[17]; + for (int i = digits - 1; i >= 0; i--) { + buf[i] = "0123456789abcdef"[v & 15]; + v >>= 4; + } + buf[digits] = 0; + put(buf); +} + +static unsigned long parse_hex(const char **p) +{ + unsigned long v = 0; + for (;;) { + char c = **p; + if (c >= '0' && c <= '9') v = v * 16 + (c - '0'); + else if (c >= 'a' && c <= 'f') v = v * 16 + (c - 'a' + 10); + else return v; + (*p)++; + } +} + +static int find_mapping(unsigned long addr, const char **line, unsigned long *length, unsigned long *start, int *readable) +{ + long fd = sys(SYS_openat, AT_FDCWD, (long)"/proc/self/maps", 0, 0); + if (fd < 0) return 0; + unsigned long have = 0; + long n; + while (have < sizeof(maps) - 1 && (n = sys(SYS_read, fd, (long)(maps + have), sizeof(maps) - 1 - have, 0)) > 0) + have += n; + sys(SYS_close, fd, 0, 0, 0); + maps[have] = 0; + const char *p = maps; + while (*p) { + const char *begin = p; + unsigned long lo = parse_hex(&p); + if (*p == '-') p++; + unsigned long hi = parse_hex(&p); + while (*p == ' ') p++; + int r = *p == 'r'; + const char *end = begin; + while (*end && *end != '\n') end++; + if (addr >= lo && addr < hi) { + *line = begin; + *length = end - begin; + *start = lo; + *readable = r && addr + 16 <= hi; + return 1; + } + p = *end ? end + 1 : end; + } + return 0; +} + +static void report(int sig, void *info, void *context) +{ + (void)context; + unsigned long addr = *(unsigned long *)((char *)info + 16); + int code = *(int *)((char *)info + 8); + used = 0; + put("droiddeck-fex: SIGILL at 0x"); + put_hex(addr, 12); + put(" (si_code "); + put_hex((unsigned long)code, 2); + put(")"); + const char *line; + unsigned long length, start; + int readable; + if (find_mapping(addr, &line, &length, &start, &readable)) { + put(", offset 0x"); + put_hex(addr - start, 8); + if (readable) { + put(", bytes"); + for (int i = 0; i < 16; i++) { + put(" "); + put_hex(((unsigned char *)addr)[i], 2); + } + } + put("\n in "); + for (unsigned long i = 0; i < length && used < sizeof(out) - 2; i++) + out[used++] = line[i]; + } else { + put(", outside any mapping"); + } + put("\n"); + sys(SYS_write, 2, (long)out, used, 0); + struct kernel_sigaction fallback = {0, 0, 0, 0}; + sys(SYS_rt_sigaction, sig, (long)&fallback, 0, sizeof(fallback.mask)); + sys(SYS_tgkill, sys(SYS_getpid, 0, 0, 0, 0), sys(SYS_gettid, 0, 0, 0, 0), sig, 0); +} + +void faultreport_restore(void); +__asm__(".text\n.type faultreport_restore,@function\nfaultreport_restore:\nmov $15, %eax\nsyscall\nhlt\n"); + +__attribute__((constructor)) static void install(void) +{ + struct kernel_sigaction action = {report, SA_SIGINFO | SA_NODEFER | SA_RESETHAND | SA_RESTORER, faultreport_restore, 0}; + sys(SYS_rt_sigaction, SIGILL, (long)&action, 0, sizeof(action.mask)); +} diff --git a/tools/linuxfs/fex/thunkaudit.c b/tools/linuxfs/fex/thunkaudit.c new file mode 100644 index 000000000..413ac73ba --- /dev/null +++ b/tools/linuxfs/fex/thunkaudit.c @@ -0,0 +1,40 @@ +#if !defined(__x86_64__) +#error thunkaudit is built for x86_64 guests only +#endif + +#define LA_SER_ORIG 0x01 + +static const char vulkan_thunk[] = "/usr/lib/x86_64-linux-gnu/libvulkan.so.1"; +static const char vulkan_pin[] = "/usr/lib/x86_64-linux-gnu/droiddeck/libvulkan-thunk.so"; + +static int same(const char *a, const char *b) +{ + while (*a && *a == *b) { + a++; + b++; + } + return *a == *b; +} + +static const char *base_name(const char *path) +{ + const char *name = path; + for (; *path; path++) + if (*path == '/') + name = path + 1; + return name; +} + +__attribute__((visibility("default"))) unsigned int la_version(unsigned int version) +{ + return version; +} + +__attribute__((visibility("default"))) char *la_objsearch(const char *name, unsigned long *cookie, unsigned int flag) +{ + const char *file = base_name(name); + (void)cookie; + if (flag == LA_SER_ORIG && !same(name, vulkan_thunk) && (same(file, "libvulkan.so.1") || same(file, "libvulkan.so"))) + return (char *)vulkan_pin; + return (char *)name; +} diff --git a/tools/linuxfs/licenses/uruntime-LICENSE b/tools/linuxfs/licenses/uruntime-LICENSE new file mode 100644 index 000000000..bd1e3eb4e --- /dev/null +++ b/tools/linuxfs/licenses/uruntime-LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 VHSgunzo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/linuxfs/overlay/usr/bin/steamos-session-select b/tools/linuxfs/overlay/usr/bin/steamos-session-select index 30141e79f..871f67b48 100644 --- a/tools/linuxfs/overlay/usr/bin/steamos-session-select +++ b/tools/linuxfs/overlay/usr/bin/steamos-session-select @@ -3,7 +3,7 @@ # session: "plasma", "plasma-wayland-persistent", ...) and waits for Game Mode to end; with nothing # here it sat on "Switching to Desktop..." for ever. Here the desktop is DroidDeck's own: the app is # asked for a desktop session with Steam's desktop client open in it (the request file -# bannerlator-steam-launch uses; the app ends this session and starts that one). Anything else - +# droiddeck-steam-launch uses; the app ends this session and starts that one). Anything else - # "gamescope", the session Game Mode already is - has nothing to do. echo "steamos-session-select: $*" >&2 case "${1:-}" in diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run deleted file mode 100755 index b0cdfc6db..000000000 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-appimage-run +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -# An AppImage imported from the front end (AppImageManager), extracted once at import into -# /opt/appimages/user/<id>/app -# and started from there: proot has no FUSE to mount the image, and extracting it at every -# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time. -# bannerlator-appimage-run /opt/appimages/user/<id> [args...] -set -u -dir=${1:?usage: bannerlator-appimage-run <imported AppImage directory> [args...]} -shift -export APPDIR=$dir/app -# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers -# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the -# one, and there is no image file for the other. -unset APPIMAGE -export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir") -if [ ! -x "$APPDIR/AppRun" ]; then - echo "bannerlator-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2 - exit 127 -fi -# Browsers sandbox their own children with namespaces and a setuid helper, neither of which proot -# can give them (the Flatpak sandboxes get the same; see BwrapSpawner): Firefox and its forks (Zen, -# LibreWolf) by these switches, Electron and Chromium builds by --no-sandbox. Chromium on Wayland -# also asks the render node for a DRM version KGSL cannot give, so with an X server up it stays on -# X11, which it picks from the session type. -export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 -export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 -# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and -# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads. -if [ -s /etc/ld.so.preload ]; then - preload=$(tr -s '\n' ':' < /etc/ld.so.preload) - export LD_PRELOAD="${preload%:}${LD_PRELOAD:+:$LD_PRELOAD}" SHARUN_ALLOW_LD_PRELOAD=1 -fi -flags=() -if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then - flags+=(--no-sandbox) - if [ -n "${DISPLAY:-}" ]; then - export XDG_SESSION_TYPE=x11 - unset WAYLAND_DISPLAY - flags+=(--ozone-platform=x11) - fi -fi -echo "== appimage $ARGV0 ($APPDIR) ${flags[*]}" -cd "$APPDIR" || exit 1 -# Apps expect a session bus; Electron's main process traps (SIGTRAP) on an address it cannot -# parse. A run mode has none, so one is started for the app, as for Flatpak apps. -case ${DBUS_SESSION_BUS_ADDRESS:-} in - unix:*) "$APPDIR/AppRun" "${flags[@]}" "$@" ;; - *) unset DBUS_SESSION_BUS_ADDRESS - if command -v dbus-run-session >/dev/null 2>&1; then - dbus-run-session -- "$APPDIR/AppRun" "${flags[@]}" "$@" - else - "$APPDIR/AppRun" "${flags[@]}" "$@" - fi ;; -esac -rc=$? -# The program a session was started for may hand over to another process and exit: Electron apps -# relaunch themselves (Obsidian on its first start), launchers fork their real program. The -# session ends with this script, and takes the rest with it - so it waits while anything running -# from the image is still up. -image_running() { - local p exe - for p in /proc/[0-9]*; do - exe=$(readlink "$p/exe" 2>/dev/null) || continue - case $exe in "$APPDIR"/*) return 0 ;; esac - done - return 1 -} -while image_running; do sleep 2; done -exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run b/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run deleted file mode 100755 index 56466de89..000000000 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-script-run +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/bash -# A script added on the Desktop page (UserApps), run from its own folder: -# bannerlator-script-run /opt/droiddeck-apps/<id> [args...] -set -u -dir=${1:?usage: bannerlator-script-run <added app directory> [args...]} -shift -entry=$(cat "$dir/entry" 2>/dev/null) || { echo "bannerlator-script-run: $dir has no entry" >&2; exit 127; } -cd -P "$dir/files" 2>/dev/null || { echo "bannerlator-script-run: $dir/files is missing; add the script again" >&2; exit 127; } -folder=$PWD -# Shared storage is noexec, so the script is handed to its interpreter rather than executed. -first= -IFS= read -r first < "$entry" -first=${first%$'\r'} -interp=(/bin/bash) -case $first in '#!'*) read -r -a interp <<< "${first#'#!'}" ;; esac -echo "== script $entry in $folder (${interp[*]})" -case ${DBUS_SESSION_BUS_ADDRESS:-} in - unix:*) "${interp[@]}" "./$entry" "$@" ;; - *) unset DBUS_SESSION_BUS_ADDRESS - if command -v dbus-run-session >/dev/null 2>&1; then - dbus-run-session -- "${interp[@]}" "./$entry" "$@" - else - "${interp[@]}" "./$entry" "$@" - fi ;; -esac -rc=$? -# A launcher script often starts the game and returns; the session lasts while it runs. -running() { - local p exe - for p in /proc/[0-9]*; do - exe=$(readlink "$p/exe" 2>/dev/null) || continue - case $exe in "$folder"/*) return 0 ;; esac - done - return 1 -} -while running; do sleep 2; done -exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat deleted file mode 100755 index 1a38c20b8..000000000 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-compat +++ /dev/null @@ -1,641 +0,0 @@ -#!/usr/bin/python3 -# Makes the ARM64 Proton the client already downloaded usable here, and sets it as the default tool -# for Windows titles (CompatToolMapping "0" in config.vdf) when the user has not chosen one. Run -# before the client starts; it rewrites config.vdf on exit. -# -# bannerlator-steam-compat <steam root> -# -# Valve ships a native aarch64 Proton (arm64ec Wine with FEX as the x86 layer) as an ordinary depot, -# which is exactly what this runtime wants. Its toolmanifest declares require_tool_appid 4185400 - -# Steam Linux Runtime 4 for arm64 - so Steam stacks pressure-vessel underneath it, and -# pressure-vessel needs unprivileged user namespaces, which an Android app does not get. Steam then -# drops the launch on the floor: no window, no log, straight back to the library. -# -# So the depot is re-registered here as a compatibility tool of our own: every entry symlinked so -# nothing is copied and Steam still updates the real depot, but with a toolmanifest that leaves out -# require_tool_appid. Proton takes its base directory from dirname(sys.argv[0]) without resolving -# it, so it reads files/ through those symlinks and runs directly on this rootfs. -import os -import re -import shutil -import sys - -TOOL = "bannerlator-proton-arm64" -# Valve's own ARM64 Proton, by the name the client registers it under. Used only to bootstrap a -# fresh install: naming it is what makes the client offer it and fetch the depot. -SEED_TOOL = "proton-experimental-arm64" -# The ARM64 Proton depot the client downloads for itself, as a compatibility-tool appid. -# The session asks the client to install it on the command line the first time (steam://install), -# so a new client has it before a Windows title is first launched. It is deliberately NOT named as -# this tool's dependency: a dependency is composed with its own dependencies, and this depot -# requires 4185400, the arm64 Steam Linux Runtime, whose container needs user namespaces Android -# does not give - the launch would be wrapped in it and die before this tool ever ran. -PROTON_ARM64_APPID = "4427310" -LAUNCHER = "bannerlator-proton" -# The extra Steam library the app mounts the installed games into (see bannerlator-steam-library). -# installed_apps() scans it too; it lives only in the sibling script, so define it here as well. -LIBRARY = "/mnt/bannerlator" # the card library is LIBRARY + "-sd"; internal storage is steam_root itself -# The client lists its own "Proton Experimental (ARM64)" and "Proton 11.0 (ARM64)" in the same -# Compatibility dropdown, and those are the two that cannot start a game here. Ours has to be -# unmistakable at a glance, or a user picks a neighbour and gets a launch that fails silently. -# Newest first: whichever the client has downloaded is used. -SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)") -# Written by us; everything else in the depot is symlinked through. -OWN_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", "bannerlator-proton") -# The client assigns a Proton to a Windows title by itself, as a per-app CompatToolMapping entry at -# priority 250 - which outranks the "0" default, so setting that default alone changes nothing. Every -# Proton it can pick is one this runtime cannot start: the ARM64 ones want pressure-vessel (see -# above) and the x86_64 ones want pressure-vessel and FEX on top. Ours is the only one that runs -# here, so every Proton mapping is repointed at it. Tools that are not Proton are left alone. -COMPAT_TOOL_VDF = """"compatibilitytools" -{ - "compat_tools" - { - "%s" - { - "install_path" "." - "display_name" "Bannerlator Proton (ARM64)" - "from_oslist" "windows" - "to_oslist" "linux" - } - } -} -""" -TOOL_MANIFEST_VDF = """"manifest" -{ - "version" "2" - "commandline" "/%s %%verb%%" - "use_sessions" "1" - "compatmanager_layer_name" "proton" -} -""" % LAUNCHER - - -# Steam runs its own graphics probe through EVERY registered tool at once, ~40 s after logging in, -# all with STEAM_COMPAT_DATA_PATH=.../compatdata/0, and those concurrent Protons can write their -# builtin DLLs through each other's prefix links into each other's trees (seen: GE's FEX DLLs became -# Proton Experimental's). Moving those runs to a prefix per tool was tried and crashed the Steam -# client right after the probe started (segfault, rc 139, first ever in 25 sessions), so the client -# relies on compatdata/0 itself. The launch-time check below is the protection instead. - -# The Components page's choices, enforced in the one place every game launch passes: the app keeps -# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of -# each package in use. A file that no longer matches - whatever changed it - is put back before -# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build -# differs and the Proton's own new files are left alone until the user chooses again. -BL_COMPONENTS_SETUP = """ -bl_components() { - base=$1 - cm_dir="${HOME:-/root}/.local/share/bannerlator-components" - cm_list="$cm_dir/desired.tsv" - # A real game launch only: the verb the client starts a game with and the game's own prefix. Every - # other run - above all the client's own probes right after login, which all share compatdata/0 - - # is left exactly as it was: with this check running through them, the client segfaulted (rc 139) - # within seconds of starting its probes, four sessions out of four. The prefix decides rather than - # the app id because a non-Steam shortcut launches with app id 0 but still gets a prefix of its own. - [ "${verb:-}" = waitforexitandrun ] || return 0 - case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac - [ -f "$cm_list" ] || return 0 - cm_app=${STEAM_COMPAT_DATA_PATH%/} - cm_app=${cm_app##*/} - cm_tool=${base##*/} - cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null) - cm_fixed=0 - cm_checked=0 - while IFS=$'\\t' read -r t want comp rel sum src; do - [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue - cm_checked=$((cm_checked + 1)) - f="$base/$rel" - if [ -f "$f" ] && [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ]; then continue; fi - if [ ! -f "$src" ]; then - echo "bannerlator-proton: components: $comp source missing ($src), left as it is" >&2 - continue - fi - mkdir -p "$(dirname "$f")" - cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue - case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac - cm_fixed=$((cm_fixed + 1)) - done < "$cm_list" - [ "$cm_checked" -gt 0 ] && echo "bannerlator-proton: components: checked $cm_checked file(s) in $cm_tool for app $cm_app" >&2 - if [ "$cm_fixed" -gt 0 ]; then - echo "bannerlator-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2 - printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log" - fi -} -""" - -# Resolves the depot at launch time, so the tool can be registered before the depot exists and -# survives the client updating or replacing it. Proton takes its base directory from -# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. -# Cores for a game the client launches, kept separate from the client's own. One mask suits the -# client's menus and another suits a game, and they are wanted at the same time - so the game's is -# applied here, in the launch path every game goes through, rather than to the session as a whole. -BL_GAME_CPUS_SETUP = """ -bl_game_cpus() { - [ -n "${BL_GAME_CPUS:-}" ] || return 0 - command -v taskset >/dev/null 2>&1 || return 0 - # exec'd through taskset rather than set afterwards: a mask applied to a pid races the process - # into spawning its own threads, and this way every thread inherits it from the first instruction. - BL_TASKSET="taskset -c $BL_GAME_CPUS" - echo "bannerlator-proton: game cores $BL_GAME_CPUS" >&2 -} -""" - -# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under -# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the -# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these -# titles are told to stay windowed at the session's own size - unless the user's launch options -# already say something about the video mode, which are left as they are. -# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for -# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against -# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime -# container that path never reaches Proton either. (WinNative's winnative-proton-launch.) -BL_LIBPATH_SETUP = """ -bl_libpath() { - # A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread - # here, where only PulseAudio answers. (WinNative, winnative-proton-launch.) - export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0} - [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 - local kept= dir old_ifs=$IFS - IFS=: - for dir in $LD_LIBRARY_PATH; do - case $dir in - "" | */steamrtarm64 | */steamrtarm64/*) ;; - *) kept=${kept:+$kept:}$dir ;; - esac - done - IFS=$old_ifs - if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi -} -""" - -BL_IDTECH3_SETUP = """ -bl_idtech3() { - BL_EXTRA_ARGS=() - case "${SteamAppId:-${STEAM_COMPAT_APP_ID:-}}" in - # Quake III Arena, Quake III: Team Arena, Return to Castle Wolfenstein, Jedi Academy. - 2200|2350|9010|6020) ;; - *) return 0 ;; - esac - case " $* " in - *r_fullscreen*|*r_mode*|*r_customwidth*|*r_customheight*) - echo "bannerlator-proton: Quake-engine title; its launch options set the video mode, left as they are" >&2 - return 0 ;; - esac - w=${BL_WIDTH:-1280} - h=${BL_HEIGHT:-720} - BL_EXTRA_ARGS=(+set r_fullscreen 0 +set r_mode -1 +set r_customwidth "$w" +set r_customheight "$h") - echo "bannerlator-proton: Quake-engine title: windowed at ${w}x${h}" >&2 -} -""" - -BL_DIRECTAUDIO_SETUP = """ -bl_directaudio() { - base=$1 - [ -n "${BL_DIRECTAUDIO:-}" ] || return 0 - [ -d "$BL_DIRECTAUDIO/lib/wine" ] || return 0 - version=$("$base/files/bin-arm64/wine" --version 2>/dev/null) - major=${version#wine-} - major=${major%%.*} - if [ "$major" != "11" ]; then - echo "bannerlator-proton: DirectAudio left off - it is built for Wine 11 and this Proton is '${version:-unknown}'. Pairing them would be silent rather than broken, so it is not worth guessing." >&2 - return 0 - fi - # Wine appends the per-architecture subdirectory to each entry, so the directory holding - # aarch64-unix/ and aarch64-windows/ is what goes here. - WINEDLLPATH="$BL_DIRECTAUDIO/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}" - export WINEDLLPATH - # mmdevapi only tries the drivers named in the registry; its built-in list is pulse, alsa, oss and - # coreaudio and knows nothing about this one. The key is appended to the prefix's user.reg rather - # than written with `wine reg`, which would start a wineserver here just to set one value. - reg="${STEAM_COMPAT_DATA_PATH:-}/pfx/user.reg" - if [ -f "$reg" ]; then - if ! grep -q '"Audio"="directaudio"' "$reg" 2>/dev/null; then - printf '\n[Software\\\\Wine\\\\Drivers] %s\n"Audio"="directaudio"\n' "$(date +%s)" >> "$reg" - echo "bannerlator-proton: DirectAudio selected in the prefix" >&2 - fi - else - # A prefix that does not exist yet is created by Proton moments from now, after this runs, so - # the key lands on the NEXT launch of that game. Said out loud because silent audio on a first - # launch would otherwise look like a fault. - echo "bannerlator-proton: no prefix yet - DirectAudio takes effect the next time this game starts" >&2 - fi - echo "bannerlator-proton: DirectAudio ready (Wine $major)" >&2 -} -""" - -# Resolves the depot at launch time, so the tool can be registered before the depot exists and -# survives the client updating or replacing it. Proton takes its base directory from -# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. -LAUNCHER_SH = """#!/bin/bash -# Bannerlator Proton (ARM64): runs the ARM64 Proton the Steam client downloaded, without the -# Steam Linux Runtime container its own manifest asks for (no user namespaces on Android). -verb=$1; shift -steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam} -# Steam's overlay is preloaded into every game Valve launches, and used to be dropped here: it was -# landing in front of open() and read() on /dev/input, letting Wine open the session's pad and -# answering every read of it with nothing - a controller that never reports, and Wine's HID thread -# spinning a core on it. -# -# Removing it was the wrong cure for an ordering problem. glibc processes LD_PRELOAD BEFORE -# /etc/ld.so.preload, and the interposer lives in the latter, so the overlay was simply ahead of it. -# Naming the interposer first in LD_PRELOAD puts it back in front and the overlay loads as Valve -# intends. That matters beyond the overlay itself: every deviation from the launch Valve ships is a -# difference this runtime would otherwise have to justify. -fake=/usr/local/lib/libfakeinput.so -if [ -f "$fake" ]; then - case ":${LD_PRELOAD:-}:" in - *":$fake:"*) ;; - *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; - esac -fi -# The newest of the ARM64 depots the client has, by the build number that opens each one's version -# file: taking the first one found ran a stale Experimental over a newer 11.0. (WinNative.) -depot= -best=0 -for name in %s; do - for library in "$steam_root" /mnt/droiddeck-sd; do - candidate="$library/steamapps/common/$name" - [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue - build=0 - read -r build _ 2>/dev/null < "$candidate/version" - case $build in "" | *[!0-9]*) build=0 ;; esac - if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then - depot=$candidate - best=$build - fi - done -done -if [ -n "$depot" ]; then - bl_components "$depot" - bl_directaudio "$depot" - bl_game_cpus - bl_idtech3 "$@" - bl_libpath - exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" -fi -echo "bannerlator-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 -exit 1 -""" % " ".join('"%s"' % n for n in SOURCES) -# Spliced in after the formatting above, never through it: the function body contains ${major%%.*} -# and a % format would eat that into a single %, leaving a shell expansion that silently does -# something else. -LAUNCHER_SH = LAUNCHER_SH.replace( - "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) - -# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of -# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot -# is: its manifest declares require_tool_appid, and the client's overlay library has to come out of -# LD_PRELOAD. Its own entry point is left untouched and called through this wrapper. -EXTRA_WRAPPER = "bannerlator-proton-wrap" -EXTRA_MANIFEST_VDF = """"manifest" -{ - "version" "2" - "commandline" "/%s %%verb%%" - "use_sessions" "1" - "compatmanager_layer_name" "proton" -} -""" % EXTRA_WRAPPER -# %s is the tool's own entry point, read out of the manifest it shipped with. -EXTRA_WRAPPER_SH = """#!/bin/bash -# Bannerlator: runs a Proton installed into compatibilitytools.d without the Steam Linux Runtime -# container its own manifest asks for - pressure-vessel needs unprivileged user namespaces, which -# an Android app does not get, and the launch dies before Proton is reached. -verb=$1; shift -here=$(dirname "$0") -# See bannerlator-proton: the interposer goes first in LD_PRELOAD rather than the overlay being -# removed, because glibc reads LD_PRELOAD before /etc/ld.so.preload and the overlay was simply -# ahead of it on /dev/input. -fake=/usr/local/lib/libfakeinput.so -if [ -f "$fake" ]; then - case ":${LD_PRELOAD:-}:" in - *":$fake:"*) ;; - *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; - esac -fi -bl_components "$here" -bl_directaudio "$here" -bl_game_cpus -bl_idtech3 "$@" -bl_libpath -# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be -# started from its own tree. -exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" -""" -EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools" -{ - "compat_tools" - { - "%s" - { - "install_path" "." - "display_name" "%s (Bannerlator)" - "from_oslist" "windows" - "to_oslist" "linux" - } - } -} -""" - - -TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)') - - -def tokenize(text): - tokens = [] - pos = 0 - while pos < len(text): - m = TOKEN.match(text, pos) - if not m or m.end() == pos: - break - pos = m.end() - if m.group(2): - tokens.append(m.group(2)) - elif m.group(1) is not None: - tokens.append('"' + m.group(1) + '"') - return tokens - - -def find_block(tokens, path): - """Index of the '{' opening the block at path, or None.""" - depth, want, i = 0, 0, 0 - while i < len(tokens): - t = tokens[i] - if t == "{": - depth += 1 - elif t == "}": - depth -= 1 - if depth < want: - return None - elif depth == want and i + 1 < len(tokens) and tokens[i + 1] == "{": - if t.lower() == '"%s"' % path[want].lower(): - if want == len(path) - 1: - return i + 1 - want += 1 - i += 1 - return None - - -def block_end(tokens, start): - depth = 0 - for i in range(start, len(tokens)): - if tokens[i] == "{": - depth += 1 - elif tokens[i] == "}": - depth -= 1 - if depth == 0: - return i - return len(tokens) - - -def serialize(tokens): - out, depth, i = [], 0, 0 - while i < len(tokens): - t = tokens[i] - if t == "}": - depth -= 1 - out.append("\t" * depth + "}") - i += 1 - elif i + 1 < len(tokens) and tokens[i + 1] == "{": - out.append("\t" * depth + t) - out.append("\t" * depth + "{") - depth += 1 - i += 2 - else: - out.append("\t" * depth + t + "\t\t" + tokens[i + 1]) - i += 2 - return "\n".join(out) + "\n" - - -def write_changed(path, text, mode=None): - """Writes `text` to `path` only when it differs, through a rename so a reader never sees half a - file. The session runs this script every 15 s for as long as the client does; rewriting files - that have not changed cost a python start's worth of writes each time, and config.vdf is the - client's own, which it may be writing at that moment. Returns whether anything was written.""" - try: - with open(path, encoding="utf-8", errors="replace") as f: - same = f.read() == text - except OSError: - same = False - if not same: - tmp = path + ".bannerlator" - with open(tmp, "w", encoding="utf-8") as f: - f.write(text) - if mode is not None: - os.chmod(tmp, mode) - os.replace(tmp, path) - elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode: - os.chmod(path, mode) - return not same - - -def first_key(path, section): - """The single key naming a block inside `section`, or None. Used to keep a third-party tool's - own internal name, so a mapping the user already made against it keeps working.""" - try: - with open(path, encoding="utf-8", errors="replace") as f: - tokens = tokenize(f.read()) - except OSError: - return None - block = find_block(tokens, section) - if block is None: - return None - for i in range(block + 1, block_end(tokens, block)): - if tokens[i] == "}": - break - if i + 1 < len(tokens) and tokens[i + 1] == "{": - return tokens[i].strip('"') - return None - - -def manifest_entry_point(path): - """The script a toolmanifest's commandline runs, relative to the tool directory.""" - try: - with open(path, encoding="utf-8", errors="replace") as f: - tokens = tokenize(f.read()) - except OSError: - return None - for i in range(len(tokens) - 1): - if tokens[i].lower() == '"commandline"': - command = tokens[i + 1].strip('"').split() - if command: - return command[0].lstrip("/") - return None - - -def adopt_extras(tools): - """Makes every other Proton in compatibilitytools.d launchable here, and returns the tool names - to leave alone in CompatToolMapping - the whole point of installing one is to pick it per game, - so register_default must not repoint it back at ours.""" - adopted = {} - try: - entries = sorted(os.listdir(tools)) - except OSError: - return adopted - for entry in entries: - if entry == TOOL or entry.startswith("."): - continue - tool_dir = os.path.join(tools, entry) - manifest = os.path.join(tool_dir, "toolmanifest.vdf") - if not os.path.isdir(tool_dir) or not os.path.isfile(manifest): - continue - # The shipped manifest is kept, once, and stays the source of the entry point: re-running - # must not read back our own rewritten commandline and wrap the wrapper. - original = manifest + ".bannerlator-orig" - if not os.path.isfile(original): - try: - shutil.copy2(manifest, original) - except OSError: - continue - entry_point = manifest_entry_point(original) - if not entry_point or entry_point == EXTRA_WRAPPER: - entry_point = "proton" - if not os.path.exists(os.path.join(tool_dir, entry_point)): - print("bannerlator-steam-compat: %s has no %s; left alone" % (entry, entry_point)) - continue - compat_vdf = os.path.join(tool_dir, "compatibilitytool.vdf") - name = first_key(compat_vdf, ["compatibilitytools", "compat_tools"]) or entry - wrapper = os.path.join(tool_dir, EXTRA_WRAPPER) - try: - # Formatted first, spliced second: the function carries a printf %s and a - # ${major%%.*} of its own, and putting it through the format would eat both. - body = (EXTRA_WRAPPER_SH % entry_point).replace( - 'here=$(dirname "$0")', - 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP - + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) - write_changed(wrapper, body, 0o755) - write_changed(manifest, EXTRA_MANIFEST_VDF) - write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, entry)) - except OSError as e: - print("bannerlator-steam-compat: could not adopt %s (%s)" % (entry, e)) - continue - adopted[name] = entry - return adopted - - -# Steam's own tools and runtimes live in steamapps beside the games; they are not titles and must -# not be mapped to a compatibility tool. -NOT_GAMES = frozenset(( - "228980", # Steamworks Common Redistributables - "1070560", # Steam Linux Runtime 1.0 (scout) - "1391110", # Steam Linux Runtime 2.0 (soldier) - "1493710", # Proton Experimental - "3127680", # FEX - "4183110", # Steam Linux Runtime 4.0 - "4185400", # Steam Linux Runtime 4.0 - Arm64 - "4427310", # Proton Experimental (ARM64) - "4628740", # Proton 11.0 (ARM64) -)) - - -def installed_apps(steam_root): - """App ids of the titles the client has installed, tools and runtimes excluded.""" - apps = set() - for library in (steam_root, LIBRARY + "-sd"): - steamapps = os.path.join(library, "steamapps") - try: - names = os.listdir(steamapps) - except OSError: - continue - for name in names: - m = re.fullmatch(r"appmanifest_(\d+)\.acf", name) - if m and m.group(1) not in NOT_GAMES: - apps.add(m.group(1)) - return sorted(apps, key=int) - - -def register_default(config_path, apps=(), name=None, protect=()): - """Writes the mapping. `name` defaults to our own tool; the first-run seed passes Valve's.""" - tool = name or TOOL - # Tool names the user may deliberately have chosen (see adopt_extras); never repointed. - keep = {n.lower() for n in protect} - with open(config_path, encoding="utf-8", errors="replace") as f: - original = f.read() - tokens = tokenize(original) - steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"]) - if steam is None: - return - mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"]) - entry = ['"0"', "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"75"', "}"] - if mapping is None: - end = block_end(tokens, steam) - tokens[end:end] = ['"CompatToolMapping"', "{"] + entry + ["}"] - else: - mapping += steam + 1 - # Repoint every per-app entry the client aimed at a Proton it cannot actually start. - for i in range(mapping, block_end(tokens, mapping)): - named = tokens[i + 1].strip('"').lower() - if (tokens[i] == '"name"' and named.startswith("proton") - and named != tool.lower() and named not in keep): - tokens[i + 1] = '"%s"' % tool - if find_block(tokens[mapping + 1:], ["0"]) is None: - tokens[mapping + 1:mapping + 1] = entry - # The "0" default only decides how a title is installed, so it settles new downloads and - # leaves anything already installed as a Linux build running as one. An entry per installed - # app is what the client's own Compatibility checkbox writes, and is what makes it switch. - for app in apps: - if find_block(tokens[mapping + 1:], [app]) is not None: - continue - tokens[mapping + 1:mapping + 1] = [ - '"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"250"', "}"] - # Nothing to change is the usual case on the session's beat: the client's own file is left alone. - text = serialize(tokens) - if text != serialize(tokenize(original)): - write_changed(config_path, text) - - -def depot_build(depot): - """The build number a depot's version file starts with; 0 when there is none.""" - try: - with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f: - first = f.read().split(None, 1) - except OSError: - return 0 - return int(first[0]) if first and first[0].isdigit() else 0 - - -def find_source(steam_root): - """The newest ARM64 Proton depot the client has downloaded (the one the launcher runs), or None.""" - # bin-arm64 is what makes it the native build rather than the x86_64 one. - found = [os.path.join(root, "steamapps", "common", name) - for root in (steam_root, "/mnt/droiddeck-sd") for name in SOURCES - if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))] - return max(found, key=depot_build) if found else None - - -def build_tool(tool_dir): - """Our own manifests and the launcher. The depot is found at launch time, not here.""" - if os.path.islink(tool_dir): - os.unlink(tool_dir) - elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES): - # Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding - # only our own files is kept, and each file rewritten only when it changed. - shutil.rmtree(tool_dir) - os.makedirs(tool_dir, exist_ok=True) - write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF) - write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % TOOL) - write_changed(os.path.join(tool_dir, LAUNCHER), LAUNCHER_SH, 0o755) - - -def main(): - steam_root = sys.argv[1] - tools = os.path.join(steam_root, "compatibilitytools.d") - os.makedirs(tools, exist_ok=True) - build_tool(os.path.join(tools, TOOL)) - extras = adopt_extras(tools) - for name, directory in sorted(extras.items()): - print("bannerlator-steam-compat: adopted %s as %s" % (directory, name)) - depot = find_source(steam_root) - print("bannerlator-steam-compat: registered %s (%s)" % ( - TOOL, "depot %s present" % os.path.basename(depot) if depot else - "no ARM64 Proton depot yet; the session asks the client to fetch it")) - config = os.path.join(steam_root, "config", "config.vdf") - if os.path.isfile(config): - apps = installed_apps(steam_root) - register_default(config, apps, protect=extras) - print("bannerlator-steam-compat: default and %d installed title(s) set to %s" % (len(apps), TOOL)) - - -if __name__ == "__main__": - main() diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run new file mode 100755 index 000000000..946a64601 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-appimage-run @@ -0,0 +1,122 @@ +#!/bin/bash +# An AppImage imported from the front end (AppImageManager), extracted once at import into +# /opt/appimages/user/<id>/app +# and started from there: proot has no FUSE to mount the image, and extracting it at every +# start (APPIMAGE_EXTRACT_AND_RUN) would cost the whole image in /tmp each time. +# droiddeck-appimage-run /opt/appimages/user/<id> [args...] +set -u +dir=${1:?usage: droiddeck-appimage-run <imported AppImage directory> [args...]} +shift +export APPDIR=$dir/app +# APPIMAGE is left unset on purpose: with it an app believes it runs from an image file and offers +# to add itself to the menu (DuckStation) or update the file in place - DroidDeck already does the +# one, and there is no image file for the other. +unset APPIMAGE +export ARGV0=$(cat "$dir/name" 2>/dev/null || basename "$dir") +if [ ! -x "$APPDIR/AppRun" ]; then + echo "droiddeck-appimage-run: $APPDIR/AppRun is missing; import the AppImage again" >&2 + exit 127 +fi +# Browsers sandbox their own children with namespaces and a setuid helper, neither of which proot +# can give them (the Flatpak sandboxes get the same; see BwrapSpawner): Firefox and its forks (Zen, +# LibreWolf) by these switches, Electron and Chromium builds by --no-sandbox. Chromium on Wayland +# also asks the render node for a DRM version KGSL cannot give, so with an X server up it stays on +# X11, which it picks from the session type. +export MOZ_DISABLE_CONTENT_SANDBOX=1 MOZ_DISABLE_GMP_SANDBOX=1 MOZ_DISABLE_RDD_SANDBOX=1 +export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 +# sharun images start the app through their own ld.so, patched not to read /etc/ld.so.preload, and +# drop LD_PRELOAD unless allowed: without this the app never gets the session's pads. +if [ -s /etc/ld.so.preload ]; then + preload=$(tr -s '\n' ':' < /etc/ld.so.preload) + export LD_PRELOAD="${preload%:}${LD_PRELOAD:+:$LD_PRELOAD}" SHARUN_ALLOW_LD_PRELOAD=1 +fi +flags=() +if [ -e "$APPDIR/chrome-sandbox" ] || compgen -G "$APPDIR/*/chrome-sandbox" >/dev/null; then + flags+=(--no-sandbox) + if [ -n "${DISPLAY:-}" ]; then + export XDG_SESSION_TYPE=x11 + unset WAYLAND_DISPLAY + flags+=(--ozone-platform=x11) + fi +fi +qt=$(cat "$dir/qt" 2>/dev/null) +case $qt in wayland|xcb) ;; *) qt=auto ;; esac +wayland=${GAMESCOPE_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}} +if [ "$qt" = auto ] && [ -n "$wayland" ]; then + bundled=$(find "$APPDIR" -maxdepth 6 \( -name 'libQt6Quick.so.6*' -o -name libvulkan.so.1 -o -name 'libqwayland*.so' \) -printf '%f\n' 2>/dev/null) + if grep -q '^libQt6Quick\.so\.6' <<<"$bundled"; then + if grep -q '^libqwayland' <<<"$bundled" \ + && { grep -qx libvulkan.so.1 <<<"$bundled" || grep -qs QVulkanInstance "$APPDIR"/usr/bin/* "$APPDIR"/bin/*; }; then + qt=wayland + else + echo "== appimage: Qt Quick without Vulkan or Qt's Wayland plugin stays on X11 ($(tr '\n' ' ' <<<"$bundled"))" + fi + fi +fi +if [ "$qt" = wayland ] && [ -n "$wayland" ]; then + export QT_QPA_PLATFORM='wayland;xcb' WAYLAND_DISPLAY=$wayland SDL_VIDEODRIVER=wayland XDG_SESSION_TYPE=wayland + [ "$wayland" = "${GAMESCOPE_WAYLAND_DISPLAY:-}" ] && export QT_WAYLAND_DISABLE_WINDOWDECORATION=1 DISABLE_GAMESCOPE_WSI=1 +elif [ "$qt" = xcb ] && [ -n "${DISPLAY:-}" ]; then + export QT_QPA_PLATFORM=xcb +fi +fex=$(cat "$dir/fex" 2>/dev/null) +case $fex in on|off) ;; *) fex=auto ;; esac +run=("$APPDIR/AppRun") +if [ -x /usr/local/bin/droiddeck-fex ]; then + case $fex:$(cat "$dir/arch" 2>/dev/null) in + auto:x86_64|auto:i386) run=(/usr/local/bin/droiddeck-fex run --mode on -- "$APPDIR/AppRun") ;; + auto:arm64) ;; + *) run=(/usr/local/bin/droiddeck-fex run --mode "$fex" --for "$APPDIR" -- "$APPDIR/AppRun") ;; + esac +fi +echo "== appimage $ARGV0 ($APPDIR) ${flags[*]} FEX $fex Qt $qt${QT_QPA_PLATFORM:+ ($QT_QPA_PLATFORM)}" +cd "$APPDIR" || exit 1 +started=$(mktemp "${TMPDIR:-/tmp}/droiddeck-appimage.XXXXXX") +# Apps expect a session bus; Electron's main process traps (SIGTRAP) on an address it cannot +# parse. A run mode has none, so one is started for the app, as for Flatpak apps. +case ${DBUS_SESSION_BUS_ADDRESS:-} in + unix:*) "${run[@]}" "${flags[@]}" "$@" ;; + *) unset DBUS_SESSION_BUS_ADDRESS + if command -v dbus-run-session >/dev/null 2>&1; then + dbus-run-session -- "${run[@]}" "${flags[@]}" "$@" + else + "${run[@]}" "${flags[@]}" "$@" + fi ;; +esac +rc=$? +if [ "$rc" -gt 128 ]; then + echo "== appimage $ARGV0: ended by signal $((rc - 128)) ($(kill -l $((rc - 128)) 2>/dev/null))" +else + echo "== appimage $ARGV0: exited with status $rc" +fi +if [ "$rc" -ne 0 ] && [ -n "$started" ]; then + find "${HOME:-/root}/.local/share" "${HOME:-/root}/.config" "${HOME:-/root}/.cache" -maxdepth 4 -type f \ + \( -iname '*.log' -o -iname '*log*.txt' \) -newer "$started" -size -64M 2>/dev/null | head -n 3 | + while IFS= read -r log; do + echo "== appimage $ARGV0: end of $log" + tail -n 40 "$log" | LC_ALL=C tr -cd '\11\12\40-\176' | cut -c1-400 + done +fi +rm -f "$started" +# The program a session was started for may hand over to another process and exit: Electron apps +# relaunch themselves (Obsidian on its first start), launchers fork their real program. The +# session ends with this script, and takes the rest with it - so it waits while anything running +# from the image is still up. +image_running() { + local p exe arg cwd + for p in /proc/[0-9]*; do + exe=$(readlink "$p/exe" 2>/dev/null) || continue + case $exe in "$APPDIR"/*) return 0 ;; esac + case $exe in + */FEX|*/FEXInterpreter) + arg=$(tr '\0' '\n' < "$p/cmdline" 2>/dev/null | sed -n 2p) + cwd=$(readlink "$p/cwd" 2>/dev/null) + case $arg in "$APPDIR"/*) return 0 ;; esac + case $cwd in "$APPDIR"|"$APPDIR"/*) return 0 ;; esac + ;; + esac + done + return 1 +} +while image_running; do sleep 2; done +exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap b/tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap similarity index 100% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-bwrap rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-bwrap diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks similarity index 100% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games similarity index 95% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games index 7690fd219..2d3c32ff4 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-desktop-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-desktop-games @@ -4,7 +4,7 @@ # the app - so an entry of ours exists only for a game the client wrote none for. Plus two links in # the home folder to the game files. Run at every desktop start; stale entries go. # -# bannerlator-desktop-games [steam root] +# droiddeck-desktop-games [steam root] import os import re import sys @@ -90,7 +90,7 @@ def write_entries(): safe = name.replace("\n", " ").strip() or str(appid) body = ( "[Desktop Entry]\nType=Application\nName=%s\nComment=Steam game %d - opens in Steam under gamescope\n" - "Exec=/usr/local/bin/bannerlator-steam-launch desktop %d\nIcon=%s\nTerminal=false\n" + "Exec=/usr/local/bin/droiddeck-steam-launch desktop %d\nIcon=%s\nTerminal=false\n" "Categories=Game;\nX-DroidDeck-AppId=%d\n" % (safe, appid, appid, icon_for(appid), appid) ) path = os.path.join(APPS_DIR, "%s%d.desktop" % (PREFIX, appid)) @@ -123,7 +123,7 @@ def link_libraries(): def main(): wanted = write_entries() link_libraries() - print("bannerlator-desktop-games: %d entr%s of ours, %d the client's" % ( + print("droiddeck-desktop-games: %d entr%s of ours, %d the client's" % ( len(wanted), "y" if len(wanted) == 1 else "ies", len(client_entries()))) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync index 208f4473b..2f0273ae0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-esync @@ -364,11 +364,21 @@ def load_packs(root): return packs +def line_epoch(line): + fields = (line or "").split() + try: + return int(fields[0]) if fields else 0 + except ValueError: + return 0 + + def match_kind(pack, measured): if pack.get("revoked") or pack.get("ignored") or measured["builtin"]: return None stock = pack["stock"] if stock[NTDLL] == measured["ntdll"] and stock[WINESERVER] == measured["wineserver"]: + if pack.get("version") != measured["token"] and line_epoch(pack.get("version_line")) > line_epoch(measured["version_line"]) > 0: + return None return "exact" if (pack.get("source_match") is True and measured["token"] and pack.get("version") == measured["token"] and pack.get("exports") == measured["exports"]): @@ -383,7 +393,7 @@ def find_pack(packs, measured): kind = match_kind(pack, measured) if kind is None: continue - key = (kind == "exact", pack["rev"], pack["id"]) + key = (kind == "exact", bool(measured["token"]) and pack.get("version") == measured["token"], pack["rev"], pack["id"]) if best_key is None or key > best_key: best, best_key = pack, key return best @@ -789,6 +799,18 @@ def keep_live(root, command, env, tool): return None if not os.path.isfile(kept_command) or not server_running(prefix): return None + kept_pack = row["env"].get("BL_SYNC_PACK") + if isinstance(kept_pack, str) and kept_pack and command[1:2] == ["waitforexitandrun"]: + try: + cache = HashCache(root) + try: + pack = find_pack(load_packs(root), measure(tool, cache)) + finally: + cache.save() + except (OSError, ValueError): + pack = {"id": kept_pack} + if pack is None or pack["id"] != kept_pack: + return None command, env = list(command), dict(env) command[0] = kept_command for name in LIVE_KEYS: diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex new file mode 100755 index 000000000..4eb01ca5a --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-fex @@ -0,0 +1,659 @@ +#!/usr/bin/python3 +import contextlib +import fcntl +import filecmp +import gzip +import json +import os +import re +import runpy +import shutil +import struct +import subprocess +import sys + +HOME = os.environ.get("HOME") or "/root" +STEAM_ROOT = os.path.join(HOME, ".local", "share", "Steam") +LIBRARIES = ("/mnt/droiddeck", "/mnt/droiddeck-sd") +STATE = os.path.join(HOME, ".local", "share", "droiddeck-fex") +CONFIG_DIR = os.path.join(STATE, "config") +DATA_DIR = os.path.join(STATE, "data") +CACHE_DIR = os.path.join(HOME, ".cache", "droiddeck-fex") +STATUS_FILE = os.path.join(STATE, "status.json") +WANTED = os.path.join(HOME, ".config", "droiddeck", "fex-wanted") +GAME_ENVIRONMENT = os.path.join(HOME, ".config", "droiddeck", "game-environment.json") +BIN_DIR = os.path.dirname(os.path.abspath(__file__)) +PRELOADS = "/usr/local/lib/droiddeck-fex" +URUNTIME = "/usr/local/lib/droiddeck/uruntime" +HOST_PRELOAD = "/etc/ld.so.preload" +FEX_APPID = "3127680" +SNIPER_APPID = "1628350" +INSTALL_DIRS = {FEX_APPID: "FEX-Emu", SNIPER_APPID: "SteamLinuxRuntime_sniper"} +MARKER = ".droiddeck-fex" +LAYOUT = 1 +GUEST_LIBDIRS = (("x86_64", "lib/x86_64-linux-gnu"), ("i386", "lib/i386-linux-gnu")) +PRELOAD_NAMES = ("libblsession.so", "libfakeinput.so") +FAULT_REPORT = "libfaultreport.so" +FAULT_REPORT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + FAULT_REPORT +THUNK_AUDIT = "libthunkaudit.so" +THUNK_AUDIT_GUEST = "/usr/lib/x86_64-linux-gnu/droiddeck/" + THUNK_AUDIT +VULKAN_PIN = "libvulkan-thunk.so" +VULKAN_THUNK = "libvulkan-guest.so" +RUNTIME_LIBRARIES = (("libc.so.6", b"GLIBC_"), ("libstdc++.so.6", b"GLIBCXX_")) +VERSION_NAME = re.compile(rb"GLIBC(?:XX)?_[0-9][0-9.]*[0-9]") +BUNDLE_DEPTH = 3 +THUNKS = {"GL": 1, "Vulkan": 1} +X86 = {3: "i386", 62: "x86_64"} +ARM = {40: "arm", 183: "arm64"} +SCAN_DEPTH = 4 +SCAN_LIMIT = 4000 +MODES = ("auto", "on", "off") +STEAM_VERBS = ("run", "waitforexitandrun") +ESCAPE = re.compile(rb"\\([0-7]{3})") + + +def say(message): + print("droiddeck-fex: %s" % message, file=sys.stderr, flush=True) + + +def read_text(path): + try: + with open(path, encoding="utf-8", errors="replace") as f: + return f.read() + except OSError: + return None + + +def write_if_changed(path, data, mode=0o644): + if isinstance(data, str): + data = data.encode("utf-8") + try: + with open(path, "rb") as f: + if f.read() == data: + return False + except OSError: + pass + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = "%s.%d.tmp" % (path, os.getpid()) + with open(tmp, "wb") as f: + f.write(data) + os.chmod(tmp, mode) + os.replace(tmp, path) + return True + + +def copy_if_changed(source, target): + if os.path.isfile(target) and filecmp.cmp(source, target, shallow=False): + return False + os.makedirs(os.path.dirname(target), exist_ok=True) + tmp = "%s.%d.tmp" % (target, os.getpid()) + shutil.copyfile(source, tmp) + os.chmod(tmp, 0o755) + os.replace(tmp, target) + return True + + +@contextlib.contextmanager +def locked(): + os.makedirs(STATE, exist_ok=True) + with open(os.path.join(STATE, "lock"), "w") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def libraries(): + roots = [STEAM_ROOT] + text = read_text(os.path.join(STEAM_ROOT, "steamapps", "libraryfolders.vdf")) or "" + roots += [path.replace("\\\\", "\\") for path in re.findall(r'"path"\s+"((?:[^"\\]|\\.)*)"', text)] + roots += LIBRARIES + seen, found = set(), [] + for root in roots: + real = os.path.realpath(root) + if real not in seen and os.path.isdir(os.path.join(root, "steamapps")): + seen.add(real) + found.append(root) + return found + + +def app_dir(appid): + for library in libraries(): + steamapps = os.path.join(library, "steamapps") + match = re.search(r'"installdir"\s+"([^"]+)"', read_text(os.path.join(steamapps, "appmanifest_%s.acf" % appid)) or "") + for name in filter(None, (match.group(1) if match else None, INSTALL_DIRS.get(appid))): + path = os.path.join(steamapps, "common", name) + if os.path.isdir(path): + return path + return None + + +def first_dir(prefix, candidates): + for candidate in candidates: + path = os.path.join(prefix, candidate) + if os.path.isdir(path): + return path + return None + + +def fex_install(): + prefixes = [] + tool = app_dir(FEX_APPID) + if tool: + prefixes += [(os.path.join(tool, sub), True) for sub in ("usr", "", "files/usr", "files")] + prefixes.append(("/usr", False)) + for prefix, portable in prefixes: + binary = next((os.path.join(prefix, "bin", name) for name in ("FEX", "FEXInterpreter") + if os.access(os.path.join(prefix, "bin", name), os.X_OK)), None) + if not binary: + continue + server = os.path.join(prefix, "bin", "FEXServer") + thunks_db = os.path.join(prefix, "share", "fex-emu", "ThunksDB.json") + return { + "bin": binary, + "server": server if os.access(server, os.X_OK) else shutil.which("FEXServer"), + "portable": portable, + "host_thunks": first_dir(prefix, ("lib/aarch64-linux-gnu/fex-emu/HostThunks", "lib/fex-emu/HostThunks", "lib64/fex-emu/HostThunks")), + "guest_thunks": first_dir(prefix, ("share/fex-emu/GuestThunks",)), + "thunks_db": thunks_db if os.path.isfile(thunks_db) else None, + } + return None + + +def platform_version(path): + return tuple(int(part) for part in re.findall(r"\d+", os.path.basename(path))) + + +def runtime_platform(): + runtime = app_dir(SNIPER_APPID) + if not runtime: + return None + try: + entries = os.listdir(runtime) + except OSError: + return None + platforms = [os.path.join(runtime, entry) for entry in entries if entry.startswith("sniper_platform_")] + platforms = [path for path in platforms if os.path.isdir(os.path.join(path, "files")) + and os.path.isfile(os.path.join(path, "usr-mtree.txt.gz"))] + return max(platforms, key=platform_version, default=None) + + +def unescape(raw): + return ESCAPE.sub(lambda match: bytes([int(match.group(1), 8)]), raw) + + +def in_root(root, link, target): + if not target.startswith(b"/"): + return target + relative = target.lstrip(b"/") + if relative == b"usr" or relative.startswith(b"usr/"): + relative = relative[4:] + return os.path.relpath(os.path.join(root, relative), os.path.dirname(link)) + + +def materialize(platform): + mtree = os.path.join(platform, "usr-mtree.txt.gz") + info = os.stat(mtree) + stamp = "%d %d %d\n" % (LAYOUT, info.st_size, info.st_mtime_ns) + root = os.fsencode(os.path.join(platform, "files")) + marker = os.path.join(os.fsdecode(root), MARKER) + if read_text(marker) == stamp: + return False + links = [] + with gzip.open(mtree, "rb") as source: + for line in source: + parts = line.split() + if not parts or parts[0].startswith(b"#"): + continue + fields = dict(part.split(b"=", 1) for part in parts[1:] if b"=" in part) + relative = os.path.normpath(unescape(parts[0])) + if os.path.isabs(relative) or relative == b".." or relative.startswith(b"../"): + continue + dest = os.path.join(root, relative) + kind = fields.get(b"type") + if kind == b"dir": + os.makedirs(dest, exist_ok=True) + elif kind == b"file" and b"contents" in fields: + blob = os.path.join(root, os.path.normpath(unescape(fields[b"contents"]))) + links.append((dest, os.path.relpath(blob, os.path.dirname(dest)))) + elif kind == b"link" and b"link" in fields: + links.append((dest, in_root(root, dest, unescape(fields[b"link"])))) + for dest, target in links: + if os.path.islink(dest): + if os.readlink(dest) == target: + continue + os.unlink(dest) + elif os.path.lexists(dest): + continue + os.makedirs(os.path.dirname(dest), exist_ok=True) + os.symlink(target, dest) + usr = os.path.join(root, b"usr") + if not os.path.lexists(usr): + os.symlink(b".", usr) + write_if_changed(marker, stamp) + return True + + +def host_preloads(): + listed = read_text(HOST_PRELOAD) or "" + return [name for name in PRELOAD_NAMES if name in listed] + + +def install_preloads(root): + shipped = set() + for arch, libdir in GUEST_LIBDIRS: + for name in PRELOAD_NAMES: + source = os.path.join(PRELOADS, arch, name) + if os.path.isfile(source): + copy_if_changed(source, os.path.join(root, libdir, "droiddeck", name)) + shipped.add(name) + for name in (FAULT_REPORT, THUNK_AUDIT, VULKAN_PIN): + source = os.path.join(PRELOADS, "x86_64", name) + if os.path.isfile(source): + copy_if_changed(source, os.path.join(root, "lib/x86_64-linux-gnu/droiddeck", name)) + wanted = [name for name in host_preloads() if name in shipped] + write_if_changed(os.path.join(root, "etc", "ld.so.preload"), "".join("/usr/$LIB/droiddeck/%s\n" % name for name in wanted)) + + +def write_config(fex, root): + config = {"Config": {"RootFS": root}} + if fex["host_thunks"] and fex["guest_thunks"] and fex["thunks_db"]: + config["Config"]["ThunkHostLibs"] = fex["host_thunks"] + config["Config"]["ThunkGuestLibs"] = fex["guest_thunks"] + config["ThunksDB"] = dict(THUNKS) + write_if_changed(os.path.join(CONFIG_DIR, "ThunksConfig.json"), json.dumps({"ThunksDB": dict(THUNKS)}, indent=2) + "\n") + write_if_changed(os.path.join(CONFIG_DIR, "Config.json"), json.dumps(config, indent=2) + "\n") + + +def link_steam_sdk(): + for sdk, candidates in (("sdk64", ("linux64", "ubuntu12_64")), ("sdk32", ("linux32", "ubuntu12_32"))): + link = os.path.join(HOME, ".steam", sdk) + if os.path.lexists(link): + continue + want = "x86_64" if sdk == "sdk64" else "i386" + for candidate in candidates: + directory = os.path.join(STEAM_ROOT, candidate) + if elf_arch(os.path.join(directory, "steamclient.so")) == want: + os.makedirs(os.path.dirname(link), exist_ok=True) + os.symlink(directory, link) + break + + +def steam_running(): + for pid in os.listdir("/proc"): + if not pid.isdigit(): + continue + try: + with open("/proc/%s/cmdline" % pid, "rb") as f: + first = f.read().split(b"\0", 1)[0] + except OSError: + continue + if first.endswith(b"steamrtarm64/steam"): + return True + return False + + +def request_install(): + os.makedirs(os.path.dirname(WANTED), exist_ok=True) + write_if_changed(WANTED, "1\n") + if not steam_running(): + return [] + try: + compat = runpy.run_path(os.path.join(BIN_DIR, "steam-compatibility")) + return compat["request_fex"](STEAM_ROOT, set()) + except Exception as error: + say("could not ask the client to install FEX (%s)" % error) + return [] + + +def describe(fex, platform): + return { + "fex": fex["bin"] if fex else None, + "server": fex["server"] if fex else None, + "thunks": bool(fex and fex["host_thunks"] and fex["guest_thunks"] and fex["thunks_db"]), + "runtime": platform, + "steamclient64": os.path.realpath(os.path.join(HOME, ".steam", "sdk64", "steamclient.so")) + if elf_arch(os.path.join(HOME, ".steam", "sdk64", "steamclient.so")) == "x86_64" else None, + "ready": bool(fex and fex["server"] and platform), + } + + +def prepare(): + with locked(): + fex = fex_install() + platform = runtime_platform() + status = describe(fex, platform) + if not status["ready"]: + missing = [] + tool = app_dir(FEX_APPID) + if not fex and tool: + say("FEX is installed at %s but holds no FEX or FEXInterpreter in usr/bin, bin or files; it has: %s" + % (tool, ", ".join(sorted(os.listdir(tool))[:20]))) + if not fex: + missing.append("FEX (Steam app %s)" % FEX_APPID) + elif not fex["server"]: + missing.append("FEXServer beside %s" % fex["bin"]) + if not platform: + missing.append("Steam Linux Runtime 3.0 (sniper, Steam app %s)" % SNIPER_APPID) + requested = request_install() + say("x86 Linux programs need %s; %s" % (" and ".join(missing), + "the Steam client was asked to install it" if requested else "open Steam once and it is installed for you")) + write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") + return None + root = os.path.join(platform, "files") + if materialize(platform): + say("prepared %s as the x86 runtime" % os.path.basename(platform)) + install_preloads(root) + write_config(fex, root) + fex["rootfs"] = root + try: + link_steam_sdk() + except OSError: + pass + status = describe(fex, platform) + write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") + with contextlib.suppress(OSError): + os.unlink(WANTED) + return fex + + +def elf_arch(path): + try: + with open(path, "rb") as f: + head = f.read(20) + except OSError: + return None + if len(head) < 20 or head[:4] != b"\x7fELF": + return None + machine = struct.unpack_from("<H" if head[5] == 1 else ">H", head, 18)[0] + return X86.get(machine) or ARM.get(machine) or "other" + + +def scan(folder): + counts = {"x86": 0, "arm": 0} + seen = 0 + base = folder.rstrip("/").count("/") + for directory, dirs, files in os.walk(folder): + if directory.count("/") - base >= SCAN_DEPTH: + dirs[:] = [] + for name in files: + seen += 1 + if seen > SCAN_LIMIT: + return counts + arch = elf_arch(os.path.join(directory, name)) + if arch in X86.values(): + counts["x86"] += 1 + elif arch in ARM.values(): + counts["arm"] += 1 + return counts + + +def needs_fex(path): + arch = elf_arch(path) + if arch is not None: + return arch in X86.values() + folder = path if os.path.isdir(path) else os.path.dirname(os.path.abspath(path)) + counts = scan(folder) + return counts["x86"] > 0 and counts["arm"] == 0 + + +def is_arm(path): + return elf_arch(path) in ARM.values() + + +def split_paths(value): + return [part for part in re.split(r"[:\s]+", value or "") if part] + + +def guest_environment(fex, env): + result = dict(env) + for directory in (CONFIG_DIR, DATA_DIR, CACHE_DIR): + os.makedirs(directory, exist_ok=True) + result["FEX_APP_CONFIG_LOCATION"] = CONFIG_DIR + "/" + result["FEX_APP_DATA_LOCATION"] = DATA_DIR + "/" + result["FEX_APP_CACHE_LOCATION"] = CACHE_DIR + "/" + result.setdefault("FEX_SILENTLOG", "0") + result.setdefault("FEX_OUTPUTLOG", "stderr") + if fex["portable"]: + result["FEX_PORTABLE"] = "1" + else: + result.pop("FEX_PORTABLE", None) + for name in ("FEX_ROOTFS", "FEX_THUNKHOSTLIBS", "FEX_THUNKGUESTLIBS"): + if name in result and not os.path.exists(result[name]): + result.pop(name) + if fex.get("rootfs"): + result["FEX_ROOTFS"] = fex["rootfs"] + if fex.get("rootfs") and fex.get("host_thunks") and fex.get("guest_thunks") and fex.get("thunks_db"): + 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)] + library_path = [part for part in (result.get("LD_LIBRARY_PATH") or "").split(":") + if part and "steamrtarm64" not in part and "aarch64" not in part] + for name, parts in (("LD_PRELOAD", preload), ("LD_LIBRARY_PATH", library_path)): + if parts: + result[name] = ":".join(parts) + else: + result.pop(name, None) + return result + + +def version_names(path): + try: + with open(path, "rb") as f: + return set(VERSION_NAME.findall(f.read())) + except OSError: + return set() + + +def bundled_libraries(folder): + wanted = [name for name, _ in RUNTIME_LIBRARIES] + found = {} + seen = 0 + base = folder.rstrip("/").count("/") + for directory, dirs, files in os.walk(folder): + seen += len(files) + if seen > SCAN_LIMIT: + break + if directory.count("/") - base >= BUNDLE_DEPTH: + dirs[:] = [] + for name in wanted: + if name in files and name not in found: + found[name] = os.path.join(directory, name) + return found + + +def vulkan_thunk_loads(fex, folder): + needed = version_names(os.path.join(fex["guest_thunks"], VULKAN_THUNK)) + if not needed: + return False + found = bundled_libraries(folder) + for name, prefix in RUNTIME_LIBRARIES: + library = found.get(name) or os.path.join(fex["rootfs"], "lib/x86_64-linux-gnu", name) + if not {version for version in needed if version.startswith(prefix)} <= version_names(library): + return False + return True + + +def resolve(program, env): + if "/" in program: + return os.path.abspath(program) + return shutil.which(program, path=env.get("PATH")) or program + + +def launch(command, env, mode, decide=None): + program = resolve(command[0], env) + use = mode == "on" or (mode == "auto" and needs_fex(decide or program)) + if not use: + return os.execvpe(command[0], command, env) + fex = prepare() + if fex is None: + return 126 + guest = guest_environment(fex, env) + x86_64 = elf_arch(program) != "i386" + if os.path.isfile(os.path.join(PRELOADS, "x86_64", FAULT_REPORT)) and x86_64: + guest["LD_PRELOAD"] = ":".join([FAULT_REPORT_GUEST] + split_paths(guest.get("LD_PRELOAD"))) + thunks = "on" if "FEX_THUNKCONFIG" in guest else "off" + folder = decide if decide and os.path.isdir(decide) else os.path.dirname(program) + if thunks == "on" and x86_64 and all(os.path.isfile(os.path.join(PRELOADS, "x86_64", name)) for name in (THUNK_AUDIT, VULKAN_PIN)) \ + and vulkan_thunk_loads(fex, folder): + guest["LD_AUDIT"] = ":".join([THUNK_AUDIT_GUEST] + split_paths(guest.get("LD_AUDIT"))) + thunks = "on, its own Vulkan loader too" + say("running %s with %s (runtime %s, thunks %s)" % (program, fex["bin"], guest.get("FEX_ROOTFS", "none"), thunks)) + return os.execve(fex["bin"], [fex["bin"], program] + command[1:], guest) + + +def game_environment(env): + try: + game_env = runpy.run_path(os.path.join(BIN_DIR, "droiddeck-game-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)) + except FileNotFoundError: + return env + except Exception as error: + say("game settings were not applied (%s)" % error) + return env + + +def appimage_offset(path): + with open(path, "rb") as f: + head = f.read(64) + if head[:4] != b"\x7fELF": + raise ValueError("not an ELF file") + order = "<" if head[5] == 1 else ">" + if head[4] == 2: + shoff, = struct.unpack_from(order + "Q", head, 0x28) + shentsize, shnum = struct.unpack_from(order + "HH", head, 0x3A) + else: + shoff, = struct.unpack_from(order + "I", head, 0x20) + shentsize, shnum = struct.unpack_from(order + "HH", head, 0x2E) + return shoff + shentsize * shnum + + +def clear_extracted(): + for stale in ("squashfs-root", "AppDir"): + if os.path.islink(stale): + os.unlink(stale) + else: + shutil.rmtree(stale, ignore_errors=True) + + +def extracted(): + if os.path.isdir("AppDir") and not os.path.lexists("squashfs-root"): + os.symlink("AppDir", "squashfs-root") + return os.path.isdir("squashfs-root") + + +def want_fex(): + with locked(): + status = describe(fex_install(), runtime_platform()) + if not status["ready"]: + request_install() + write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") + + +def extract(image): + image = os.path.abspath(image) + clear_extracted() + x86 = elf_arch(image) in X86.values() + if os.access(URUNTIME, os.X_OK): + status = subprocess.call([URUNTIME, "--appimage-extract"], stdout=subprocess.DEVNULL, + env=dict(os.environ, TARGET_APPIMAGE=image)) + if status == 0 and extracted(): + if x86: + want_fex() + return 0 + say("uruntime could not extract it (status %d)" % status) + clear_extracted() + fex = prepare() if x86 else None + if fex is not None: + status = subprocess.call([fex["bin"], image, "--appimage-extract"], stdout=subprocess.DEVNULL, + env=guest_environment(fex, dict(os.environ))) + if status == 0 and extracted(): + return 0 + say("the image's own runtime could not extract it under FEX (status %d)" % status) + unsquashfs = shutil.which("unsquashfs") + if unsquashfs: + clear_extracted() + status = subprocess.call([unsquashfs, "-q", "-n", "-o", str(appimage_offset(image)), "-d", "squashfs-root", image], + stdout=subprocess.DEVNULL) + if status == 0: + if x86: + want_fex() + return 0 + say("unsquashfs could not extract it (status %d)" % status) + elif fex is None: + say("this AppImage could not be unpacked here: no AppImage runtime for ARM64, no FEX yet and no unsquashfs") + return 1 + + +def parse_run(args): + mode, decide = os.environ.get("DROIDDECK_FEX", "auto"), None + while args and args[0].startswith("--"): + option = args.pop(0) + if option == "--": + break + if option in ("--mode", "--for") and args: + value = args.pop(0) + if option == "--mode": + mode = value + else: + decide = value + else: + raise ValueError("unknown option %s" % option) + if mode not in MODES: + raise ValueError("--mode takes %s" % ", ".join(MODES)) + return mode, decide, args + + +def main(argv): + if not argv: + say("usage: droiddeck-fex steam VERB CMD... | run [--mode auto|on|off] [--for PATH] [--] CMD... | needs PATH | extract IMAGE | prepare | status") + return 2 + action, args = argv[0], list(argv[1:]) + if action == "steam": + if not args: + return 2 + verb, command = args[0], args[1:] + if verb not in STEAM_VERBS: + return 0 + if not command: + return 2 + return launch(command, game_environment(dict(os.environ)), os.environ.get("DROIDDECK_FEX", "auto")) + if action == "run": + try: + mode, decide, command = parse_run(args) + except ValueError as error: + say(str(error)) + return 2 + if not command: + return 2 + return launch(command, dict(os.environ), mode, decide) + if action == "needs": + if len(args) != 1: + return 2 + needed = needs_fex(args[0]) + print("fex" if needed else "native") + return 0 if needed else 1 + if action == "extract": + if len(args) != 1: + return 2 + return extract(args[0]) + if action == "prepare": + return 0 if prepare() else 1 + if action == "status": + with locked(): + status = describe(fex_install(), runtime_platform()) + write_if_changed(STATUS_FILE, json.dumps(status, indent=2) + "\n") + print(json.dumps(status, indent=2)) + return 0 if status["ready"] else 1 + say("unknown action %s" % action) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak similarity index 92% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak index 3b322539f..9be0267a5 100644 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak @@ -3,10 +3,10 @@ # reaches the front end as numbers rather than a terminal's redrawn bar. Per-user installation, # Flathub as the remote, this device's architecture. # -# bannerlator-flatpak install <app-id> the app and whatever runtimes it needs -# bannerlator-flatpak uninstall <app-id> the app, then runtimes nothing uses any more -# bannerlator-flatpak update [<app-id>] one app, or everything with an update -# bannerlator-flatpak updates what has one (a "updates" line) +# droiddeck-flatpak install <app-id> the app and whatever runtimes it needs +# droiddeck-flatpak uninstall <app-id> the app, then runtimes nothing uses any more +# droiddeck-flatpak update [<app-id>] one app, or everything with an update +# droiddeck-flatpak updates what has one (a "updates" line) # # Lines: {"e":"op","i":1,"n":3,"kind":"install","ref":"..."} as each step starts, # {"e":"progress","i":1,"n":3,"percent":45,"status":"..."} while it runs, @@ -15,7 +15,7 @@ import json import os import sys -os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/bannerlator-bwrap") +os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/droiddeck-bwrap") import gi # noqa: E402 @@ -216,7 +216,7 @@ def updates(): def main(): if len(sys.argv) < 2: - sys.stderr.write(__doc__ or "usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|uninstall|update|updates [app-id]\n") return 2 cmd, args = sys.argv[1], sys.argv[2:] if cmd in ("install", "update"): @@ -229,7 +229,7 @@ def main(): return update(args[0] if args else None) if cmd == "updates": return updates() - sys.stderr.write("usage: bannerlator-flatpak install|uninstall|update|updates [app-id]\n") + sys.stderr.write("usage: droiddeck-flatpak install|uninstall|update|updates [app-id]\n") return 2 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run similarity index 93% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run index 203769d13..45a6e37ed 100644 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-run @@ -1,8 +1,8 @@ #!/bin/bash # A Flatpak app from the front end, full screen under gamescope like an emulator: -# bannerlator-flatpak-run <app-id> [args...] +# droiddeck-flatpak-run <app-id> [args...] set -u -export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap case ":${XDG_DATA_DIRS:-}:" in *"/flatpak/exports/share:"*) ;; *) export XDG_DATA_DIRS=$HOME/.local/share/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} ;; diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup similarity index 97% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup index 9020b38fe..2d0b2e790 100644 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-flatpak-setup +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-flatpak-setup @@ -9,7 +9,7 @@ # few packages Flatpak needs on top of this image, verifying each against the Arch Linux ARM # keyring, and they are unpacked here without hooks or scripts, like the desktop package itself. # -# bannerlator-flatpak-setup install or repair; prints "== STEP ..." milestones +# droiddeck-flatpak-setup install or repair; prints "== STEP ..." milestones set -u export LANG=C.UTF-8 step() { echo "== STEP $*"; } @@ -17,7 +17,7 @@ fail() { echo "== FAIL $*"; exit 1; } # Flatpak, the libraries it and appstream add to this image, and the D-Bus proxy flatpak run puts # between an app and the session bus. bubblewrap is not among them: Android gives an app no user -# namespaces, and bannerlator-bwrap stands in for it (FLATPAK_BWRAP). +# namespaces, and droiddeck-bwrap stands in for it (FLATPAK_BWRAP). PACKAGES="flatpak ostree appstream libxmlb libmalcontent libfyaml accountsservice composefs xdg-dbus-proxy" # Libraries Flatpak and the store's helper also need, which the Desktop package brings but a # runtime without it may lack: each is fetched only when its file is not already there. @@ -97,7 +97,7 @@ python3 -c 'import gi; gi.require_version("Flatpak", "1.0"); from gi.repository || fail "the store helper cannot load libflatpak from Python" step "Adding Flathub" -export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap # Flathub's descriptor - its URL and the key its commits are signed with - is carried here rather # than fetched: adding the remote then needs no network, and a descriptor that did not arrive # whole cannot be what failed. Flathub's own copy: https://dl.flathub.org/repo/flathub.flatpakrepo diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env similarity index 62% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env index 5ed00382b..19c88873d 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-game-env +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env @@ -2,9 +2,13 @@ import json import os import re +import runpy import sys from pathlib import Path +SYNC_KEYS = ("BL_SYNC_FALLBACK", "BL_FSYNC_FIRST", "BL_SYNC_MIN_NOFILE", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC") +PACK_KEYS = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "PROTON_NO_FSYNC") + def game_id(env): prefix = env.get("STEAM_COMPAT_DATA_PATH", "").rstrip("/") @@ -144,26 +148,91 @@ def apply_config(env, config, app_id, fixes=None): return result +def sync_config(config, app_id): + games = config.get("games", {}) + return { + "version": config.get("version"), + "shared": {name: value for name, value in config.get("shared", {}).items() if name in SYNC_KEYS}, + "games": {app_id: {name: value for name, value in games.get(app_id, {}).items() if name in SYNC_KEYS}}, + } + + +def load_sync(): + try: + return runpy.run_path(str(Path(__file__).resolve().parent / "droiddeck-esync")) + except Exception as error: + print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr) + return None + + +def select_sync(command, env, sync=None): + try: + sync = sync if sync is not None else load_sync() + if sync is None: + return command, env + chosen, chosen_env = sync["select"](command, env) + if chosen and all(isinstance(value, str) for value in chosen) and isinstance(chosen_env, dict): + return list(chosen), dict(chosen_env) + except Exception as error: + print("droiddeck-game-env: sync selection failed (%s); stock launch" % error, file=sys.stderr) + return command, env + + +def record_stock(sync, command, env): + try: + if sync is not None and "record" in sync: + sync["record"](command, env) + except Exception as error: + print("droiddeck-game-env: stock launch not recorded (%s)" % error, file=sys.stderr) + + +def without_pack(chosen_env, env): + result = dict(chosen_env) + for name in PACK_KEYS: + if name in env: + result[name] = env[name] + else: + result.pop(name, None) + return result + + def main(): command = sys.argv[1:] if not command: return 2 env = os.environ.copy() app_id = game_id(env) - if len(command) > 1 and command[1] == "waitforexitandrun" and app_id: - fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], []) - for note in notes: - print("bannerlator-game-env: " + note, file=sys.stderr) - command += extra + if app_id: + waiting = len(command) > 1 and command[1] == "waitforexitandrun" + fixes = {} + if waiting: + fixes, extra, notes = engine_fixes(command[2], command[3:]) if len(command) > 2 else ({}, [], []) + for note in notes: + print("droiddeck-game-env: " + note, file=sys.stderr) + command += extra path = Path.home() / ".config/droiddeck/game-environment.json" try: with path.open(encoding="utf-8") as source: - env = apply_config(env, json.load(source), app_id, fixes) + config = json.load(source) + if waiting: + env = apply_config(env, config, app_id, fixes) + else: + env = apply_config(env, sync_config(config, app_id), app_id) except FileNotFoundError: - env = apply_config(env, {"version": 1}, app_id, fixes) + if waiting: + env = apply_config(env, {"version": 1}, app_id, fixes) except (OSError, ValueError, TypeError, AttributeError): - print("bannerlator-game-env: invalid configuration; using inherited environment", file=sys.stderr) - os.execvpe(command[0], command, env) + print("droiddeck-game-env: invalid configuration; using inherited environment", file=sys.stderr) + sync = load_sync() + launch, launch_env = select_sync(command, env, sync) + if launch[0] != command[0]: + try: + os.execvpe(launch[0], launch, launch_env) + except OSError as error: + print("droiddeck-game-env: %s could not start (%s); stock launch" % (launch[0], error), file=sys.stderr) + launch, launch_env = command, without_pack(launch_env, env) + record_stock(sync, launch, launch_env) + os.execvpe(launch[0], launch, launch_env) if __name__ == "__main__": diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-login1 b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 similarity index 98% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-login1 rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 index db196b668..3e2f24b77 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-login1 +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-login1 @@ -126,7 +126,7 @@ class LoginManager: def main(): directory = os.environ.get("BL_LAUNCH_DIR") if not directory or not Path(directory).is_dir(): - sys.exit("bannerlator-login1: no DroidDeck session directory") + sys.exit("droiddeck-login1: no DroidDeck session directory") ready = Path(directory) / "steam-sleep-ready" ready.unlink(missing_ok=True) loop = GLib.MainLoop() diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager b/tools/linuxfs/overlay/usr/local/bin/droiddeck-netmanager similarity index 99% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-netmanager index 996cc55c0..7d468e5ff 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-netmanager +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-netmanager @@ -27,8 +27,8 @@ IP6 = ROOT + "/IP6Config/1" AP = ROOT + "/AccessPoint/1" PROFILE = SETTINGS + "/1" UUID = "b1d0c8e4-57f0-4c0e-9d60-77696e6e6174" -SNAPSHOT = Path("/etc/bannerlator-network.json") -SCAN_REQUEST = Path("/etc/bannerlator-wifi-scan-request") +SNAPSHOT = Path("/etc/droiddeck-network.json") +SCAN_REQUEST = Path("/etc/droiddeck-wifi-scan-request") OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager" PROPERTIES = "org.freedesktop.DBus.Properties" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/droiddeck-pad-defaults similarity index 95% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-pad-defaults index c1bf90203..4e15fae07 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-pad-defaults @@ -17,7 +17,7 @@ def seed(path, text, what, done="Player 1 on the controller"): with open(path + ".tmp", "w", encoding="utf-8") as f: f.write(text) os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: {what}: {done} ({path})") + print(f"droiddeck-pad-defaults: {what}: {done} ({path})") return True @@ -97,7 +97,7 @@ def rpcs3_shader_mode(): with open(cfg + ".tmp", "w", encoding="utf-8") as f: f.write(text) os.replace(cfg + ".tmp", cfg) - print(f"bannerlator-pad-defaults: RPCS3: shader mode {RPCS3_RECOMPILER} ({cfg})") + print(f"droiddeck-pad-defaults: RPCS3: shader mode {RPCS3_RECOMPILER} ({cfg})") open(marker, "w").close() @@ -144,7 +144,7 @@ def patch_ini_section(path, section, body, what, needle="SDL-", require=None, do with open(path + ".tmp", "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: {what}: {done} ({path})") + print(f"droiddeck-pad-defaults: {what}: {done} ({path})") # DuckStation: SDL-<n>/<name>, axes signed (src/util/sdl_input_source.cpp); Pad1 keys from @@ -285,12 +285,12 @@ def duckstation(): # One set up by an earlier build still has its first-time setup pending, in the way of a game. if ini_get(ini, "Main", "SetupWizardIncomplete") == "true": ini_set(ini, "Main", "SetupWizardIncomplete", "false") - print("bannerlator-pad-defaults: DuckStation: first-time setup already done here") + print("droiddeck-pad-defaults: DuckStation: first-time setup already done here") # Keeping the screen awake goes through org.freedesktop.ScreenSaver, which nothing in the session # provides (Android decides when the screen sleeps): DuckStation only warns it failed, every game. if (ini_get(ini, "Main", "InhibitScreensaver") or "true") == "true": ini_set(ini, "Main", "InhibitScreensaver", "false") - print("bannerlator-pad-defaults: DuckStation: no screensaver inhibit (no such service here)") + print("droiddeck-pad-defaults: DuckStation: no screensaver inhibit (no such service here)") # Once, for a DuckStation set up before this: its own empty BIOS folder moves to the ROMs folder, # and Automatic becomes Vulkan. A later choice in DuckStation's settings is kept. marker = os.path.join(os.path.dirname(ini), ".droiddeck-setup") @@ -300,10 +300,10 @@ def duckstation(): own = current if os.path.isabs(current) else os.path.join(os.path.dirname(ini), current) if not (os.path.isdir(own) and os.listdir(own)): ini_set(ini, "BIOS", "SearchDirectory", bios_folder(ps1)) - print(f"bannerlator-pad-defaults: DuckStation: BIOS from {bios_folder(ps1)}") + print(f"droiddeck-pad-defaults: DuckStation: BIOS from {bios_folder(ps1)}") if (ini_get(ini, "GPU", "Renderer") or "Automatic") == "Automatic": ini_set(ini, "GPU", "Renderer", "Vulkan") - print("bannerlator-pad-defaults: DuckStation: renderer Vulkan") + print("droiddeck-pad-defaults: DuckStation: renderer Vulkan") open(marker, "w").close() @@ -451,7 +451,7 @@ def armsx2(): src = os.path.join(old, sub) if os.path.isdir(src): shutil.copytree(src, os.path.join(root, sub), dirs_exist_ok=True) - print(f"bannerlator-pad-defaults: ARMSX2: settings and memory cards copied from PCSX2 ({root})") + print(f"droiddeck-pad-defaults: ARMSX2: settings and memory cards copied from PCSX2 ({root})") elif not os.path.isfile(ini): # A new player: everything ARMSX2's first-time setup asks is answered here - the BIOS # folder in the ROMs folder, the ps2 folder as its game list, the controller on Player 1 - @@ -482,7 +482,7 @@ def armsx2(): require="SettingsVersion", done="games listed from the ROMs folder") if ini_get(ini, "UI", "SetupWizardIncomplete") == "true": ini_set(ini, "UI", "SetupWizardIncomplete", "false") - print("bannerlator-pad-defaults: ARMSX2: first-time setup already done here") + print("droiddeck-pad-defaults: ARMSX2: first-time setup already done here") armsx2_bios(ini) @@ -507,7 +507,7 @@ def armsx2_bios(ini): us = [f for f in dumps if any(t in f.lower() for t in ("usa", "(us)", "(us,", "ntsc-u"))] pick = (us or dumps)[0] ini_set(ini, "Filenames", "BIOS", pick) - print(f"bannerlator-pad-defaults: ARMSX2: BIOS {pick}") + print(f"droiddeck-pad-defaults: ARMSX2: BIOS {pick}") # Dolphin (PC) has no controller menu, so the guide button is its Toggle Fullscreen hotkey: out of full @@ -596,7 +596,7 @@ def dolphin_data(): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600) src = os.path.join(work, "squashfs-root/usr/share/dolphin-emu") if not os.path.isfile(os.path.join(src, "Sys/GC/font_western.bin")): - print("bannerlator-pad-defaults: Dolphin: could not unpack its data from the AppImage") + print("droiddeck-pad-defaults: Dolphin: could not unpack its data from the AppImage") return new = DOLPHIN_DATA + ".new" shutil.rmtree(new, ignore_errors=True) @@ -605,9 +605,9 @@ def dolphin_data(): f.write(stamp) shutil.rmtree(DOLPHIN_DATA, ignore_errors=True) os.rename(new, DOLPHIN_DATA) - print("bannerlator-pad-defaults: Dolphin: its data (fonts, per-game fixes) in " + DOLPHIN_DATA) + print("droiddeck-pad-defaults: Dolphin: its data (fonts, per-game fixes) in " + DOLPHIN_DATA) except (OSError, subprocess.SubprocessError) as e: - print("bannerlator-pad-defaults: Dolphin: unpacking its data failed: %s" % e) + print("droiddeck-pad-defaults: Dolphin: unpacking its data failed: %s" % e) finally: shutil.rmtree(work, ignore_errors=True) @@ -622,7 +622,7 @@ def dolphin(): wii_pad = os.path.join(d, "WiimoteNew.ini") if ini_get(wii_pad, "Wiimote1", "Buttons/Home") == "`Guide`": ini_set(wii_pad, "Wiimote1", "Buttons/Home", "`Shoulder R`") - print("bannerlator-pad-defaults: Dolphin (Wii): Home on the right shoulder button (guide = Dolphin's window)") + print("droiddeck-pad-defaults: Dolphin (Wii): Home on the right shoulder button (guide = Dolphin's window)") hotkeys = os.path.join(d, "Hotkeys.ini") if not os.path.isfile(hotkeys): seed(hotkeys, DOLPHIN_HOTKEYS, "Dolphin", "the guide button shows Dolphin's window (Toggle Fullscreen)") @@ -631,7 +631,7 @@ def dolphin(): if current is not None and DOLPHIN_GUIDE not in current: ini_set(hotkeys, "Hotkeys", "General/Toggle Fullscreen", (current + " | " if current else "") + DOLPHIN_GUIDE) - print("bannerlator-pad-defaults: Dolphin: the guide button shows Dolphin's window (Toggle Fullscreen)") + print("droiddeck-pad-defaults: Dolphin: the guide button shows Dolphin's window (Toggle Fullscreen)") # Games from the ROMs folder's gamecube and wii folders, subfolders included; Vulkan; no # NKit warning (it will not let a game start until a box is ticked - games run either way, # the warning is about loading times, netplay and save states); no statistics question. @@ -647,7 +647,7 @@ def dolphin(): "[Core]\nGFXBackend = Vulkan\n" "[General]\nISOPaths = 2\nISOPath0 = %s\nISOPath1 = %s\nRecursiveISOPaths = True\n" "[Interface]\nSkipNKitWarning = True\n" % (gc, wii)) - print("bannerlator-pad-defaults: Dolphin: first-time setup (Vulkan, games from %s and %s)" % (gc, wii)) + print("droiddeck-pad-defaults: Dolphin: first-time setup (Vulkan, games from %s and %s)" % (gc, wii)) return try: count = int(ini_get(ini, "General", "ISOPaths") or "0") @@ -679,7 +679,7 @@ def dolphin(): ini_set(ini, "General", "RecursiveISOPaths", "True") changed.append("subfolders") if changed: - print("bannerlator-pad-defaults: Dolphin: games from " + ", ".join(changed)) + print("droiddeck-pad-defaults: Dolphin: games from " + ", ".join(changed)) # Once, for a Dolphin set up before this: Vulkan unless it was chosen already, and the NKit box. marker = os.path.join(d, ".droiddeck-setup") if os.path.exists(marker): @@ -691,7 +691,7 @@ def dolphin(): ini_set(ini, "Analytics", "Enabled", "False") ini_set(ini, "Analytics", "PermissionAsked", "True") open(marker, "w").close() - print("bannerlator-pad-defaults: Dolphin: Vulkan, no NKit warning") + print("droiddeck-pad-defaults: Dolphin: Vulkan, no NKit warning") # Cemu: an SDLController profile for the GamePad. The uuid is "<index>_<SDL GUID>" and SDL's GUID @@ -954,7 +954,7 @@ def melonds(): with open(path + ".tmp", "w", encoding="utf-8") as f: f.write("\n".join(lines).rstrip("\n") + "\n") os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: melonDS: {'; '.join(done)} ({path})") + print(f"droiddeck-pad-defaults: melonDS: {'; '.join(done)} ({path})") # PPSSPP (SDL): its pad defaults leave the shoulders, the right stick and the triggers unbound on @@ -1041,7 +1041,7 @@ def retroarch(): with open(cfg + ".tmp", "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") os.replace(cfg + ".tmp", cfg) - print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})") + print(f"droiddeck-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})") # yuzu and its forks: Player 1 in [Controls] of <name>/qt-config.ini, which each writes at its first @@ -1090,11 +1090,11 @@ def switch_emulators(): f.write("\n".join(lines) + "\n") os.replace(cfg + ".tmp", cfg) open(marker, "w").close() - print(f"bannerlator-pad-defaults: {name}: Player 1 on the controller ({cfg})") + print(f"droiddeck-pad-defaults: {name}: Player 1 on the controller ({cfg})") for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch, switch_emulators): try: step() except Exception as e: - print(f"bannerlator-pad-defaults: {step.__name__}: {e}") + print(f"droiddeck-pad-defaults: {step.__name__}: {e}") diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-proton-extra b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra similarity index 82% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-proton-extra rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra index fd63f6320..415e49a18 100644 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-proton-extra +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-extra @@ -1,12 +1,12 @@ #!/usr/bin/python3 # Installs a third-party Proton build into the client's compatibilitytools.d, so it appears in the -# Compatibility dropdown beside Bannerlator Proton (ARM64) and can be picked per game. +# Compatibility dropdown beside our own Proton ARM64 and can be picked per game. # -# bannerlator-proton-extra <steam root> ge [--tag GE-Proton11-7] -# bannerlator-proton-extra <steam root> cachyos [--tag cachyos-11.0-20260703-slr] -# bannerlator-proton-extra <steam root> <url of an aarch64 tarball> -# bannerlator-proton-extra <steam root> /path/to/an/aarch64/tarball -# bannerlator-proton-extra <steam root> --list what is installed now +# droiddeck-proton-extra <steam root> ge [--tag GE-Proton11-7] +# droiddeck-proton-extra <steam root> cachyos [--tag cachyos-11.0-20260703-slr] +# droiddeck-proton-extra <steam root> <url of an aarch64 tarball> +# droiddeck-proton-extra <steam root> /path/to/an/aarch64/tarball +# droiddeck-proton-extra <steam root> --list what is installed now # # Both projects publish native ARM64 builds - GloriousEggroll as "*-aarch64.tar.gz", CachyOS as # "*-arm64.tar.xz" - and both are ordinary Proton trees: a python entry point beside a files/ @@ -16,7 +16,7 @@ # What it cannot use is their toolmanifest. Like Valve's ARM64 depot they declare # require_tool_appid, which makes Steam stack pressure-vessel underneath - and pressure-vessel # needs unprivileged user namespaces, which an Android app does not get. Patching that out is -# bannerlator-steam-compat's job (adopt_extras); this script only puts the tree in place and then +# steam-compatibility's job (adopt_extras); this script only puts the tree in place and then # calls it, so an install is usable without restarting the client. import hashlib import json @@ -36,14 +36,14 @@ SOURCES = { "ge": ("GloriousEggroll/proton-ge-custom", re.compile(r"aarch64\.tar\.(gz|xz)$")), "cachyos": ("CachyOS/proton-cachyos", re.compile(r"arm64\.tar\.(gz|xz)$")), } -USER_AGENT = "bannerlator-proton-extra" +USER_AGENT = "droiddeck-proton-extra" # Room for the tarball and the tree it unpacks to. GE-Proton's aarch64 build is ~650 MB packed and # unpacks to about three times that; refusing early beats a half-written tool directory. NEED_BYTES = 4 * 1024 * 1024 * 1024 def log(*args): - print("bannerlator-proton-extra:", *args, flush=True) + print("droiddeck-proton-extra:", *args, flush=True) def fetch_json(url): @@ -77,7 +77,7 @@ def release_asset(source, tag): sums = [u for n, u in assets.items() if n.startswith(name.split(".tar")[0]) and "sha512" in n] return tag, assets[name], (sums[0] if sums else None) - raise SystemExit("bannerlator-proton-extra: %s has no ARM64 asset" % tag) + raise SystemExit("droiddeck-proton-extra: %s has no ARM64 asset" % tag) def download(url, path): @@ -88,7 +88,7 @@ def download(url, path): "curl", "-fL", "--retry", "5", "--retry-delay", "3", "--retry-connrefused", "-C", "-", "-A", USER_AGENT, "-o", path, url]) if rc != 0: - raise SystemExit("bannerlator-proton-extra: download failed (curl %d)" % rc) + raise SystemExit("droiddeck-proton-extra: download failed (curl %d)" % rc) log("downloaded %.0f MB" % (os.path.getsize(path) / 1048576.0)) @@ -109,7 +109,7 @@ def verify(path, sums_url): h.update(chunk) if h.hexdigest() != want: os.unlink(path) - raise SystemExit("bannerlator-proton-extra: checksum mismatch; the download was deleted") + raise SystemExit("droiddeck-proton-extra: checksum mismatch; the download was deleted") log("checksum ok") @@ -118,7 +118,7 @@ def extract(tarball, into): log("unpacking (a few minutes; nothing is shown while tar runs)") rc = subprocess.call(["tar", "-xf", tarball, "-C", into]) if rc != 0: - raise SystemExit("bannerlator-proton-extra: tar failed (%d)" % rc) + raise SystemExit("droiddeck-proton-extra: tar failed (%d)" % rc) # A Proton tarball holds one top-level directory. Take whichever entry has the entry point so a # stray ._ file or a nested layout does not decide it. entries = [e for e in sorted(os.listdir(into)) if not e.startswith(".")] @@ -128,7 +128,7 @@ def extract(tarball, into): return path if len(entries) == 1 and os.path.isdir(os.path.join(into, entries[0])): return os.path.join(into, entries[0]) - raise SystemExit("bannerlator-proton-extra: no Proton tree in the archive") + raise SystemExit("droiddeck-proton-extra: no Proton tree in the archive") def installed(tools): @@ -144,7 +144,7 @@ def installed(tools): def main(): argv = sys.argv[1:] if not argv: - raise SystemExit(__doc__ or "usage: bannerlator-proton-extra <steam root> ge|cachyos|<url>") + raise SystemExit(__doc__ or "usage: droiddeck-proton-extra <steam root> ge|cachyos|<url>") steam_root = argv.pop(0) tools = os.path.join(steam_root, "compatibilitytools.d") os.makedirs(tools, exist_ok=True) @@ -167,7 +167,7 @@ def main(): if what in SOURCES: tag, url, sums = release_asset(what, tag) if tag else pick_asset(what) if not url: - raise SystemExit("bannerlator-proton-extra: no ARM64 build published for %s" % what) + raise SystemExit("droiddeck-proton-extra: no ARM64 build published for %s" % what) log("%s -> %s" % (what, tag)) elif what.startswith("http"): url, sums = what, None @@ -176,14 +176,15 @@ def main(): log("using %s" % what) else: raise SystemExit( - "bannerlator-proton-extra: expected ge, cachyos, a URL or a tarball path, got %r" % what) + "droiddeck-proton-extra: expected ge, cachyos, a URL or a tarball path, got %r" % what) free = shutil.disk_usage(tools).free if free < NEED_BYTES: - raise SystemExit("bannerlator-proton-extra: needs about %d GB free, has %.1f GB" % ( + raise SystemExit("droiddeck-proton-extra: needs about %d GB free, has %.1f GB" % ( NEED_BYTES // (1024 ** 3), free / float(1024 ** 3))) - staging = os.path.join(tools, ".bannerlator-download") + shutil.rmtree(os.path.join(tools, ".bannerlator-download"), ignore_errors=True) + staging = os.path.join(tools, ".droiddeck-download") os.makedirs(staging, exist_ok=True) if local: tarball = local @@ -210,7 +211,7 @@ def main(): # Patches out require_tool_appid and wraps the entry point, which is what makes it launchable # here. Doing it now means the client picks the tool up on its next restart with no extra step. - subprocess.call(["bannerlator-steam-compat", steam_root]) + subprocess.call(["steam-compatibility", steam_root]) if __name__ == "__main__": diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-script-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-script-run new file mode 100755 index 000000000..f512be638 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-script-run @@ -0,0 +1,51 @@ +#!/bin/bash +# A script added on the Desktop page (UserApps), run from its own folder: +# droiddeck-script-run /opt/droiddeck-apps/<id> [args...] +set -u +dir=${1:?usage: droiddeck-script-run <added app directory> [args...]} +shift +entry=$(cat "$dir/entry" 2>/dev/null) || { echo "droiddeck-script-run: $dir has no entry" >&2; exit 127; } +cd -P "$dir/files" 2>/dev/null || { echo "droiddeck-script-run: $dir/files is missing; add the script again" >&2; exit 127; } +folder=$PWD +# Shared storage is noexec, so the script is handed to its interpreter rather than executed. +first= +IFS= read -r first < "$entry" +first=${first%$'\r'} +interp=(/bin/bash) +case $first in '#!'*) read -r -a interp <<< "${first#'#!'}" ;; esac +fex=$(cat "$dir/fex" 2>/dev/null) +case $fex in on|off) ;; *) fex=auto ;; esac +run=("${interp[@]}" "./$entry") +if [ -x /usr/local/bin/droiddeck-fex ]; then + run=(/usr/local/bin/droiddeck-fex run --mode "$fex" --for "$folder" -- "${interp[@]}" "./$entry") +fi +echo "== script $entry in $folder (${interp[*]}, FEX $fex)" +case ${DBUS_SESSION_BUS_ADDRESS:-} in + unix:*) "${run[@]}" "$@" ;; + *) unset DBUS_SESSION_BUS_ADDRESS + if command -v dbus-run-session >/dev/null 2>&1; then + dbus-run-session -- "${run[@]}" "$@" + else + "${run[@]}" "$@" + fi ;; +esac +rc=$? +# A launcher script often starts the game and returns; the session lasts while it runs. +running() { + local p exe arg cwd + for p in /proc/[0-9]*; do + exe=$(readlink "$p/exe" 2>/dev/null) || continue + case $exe in "$folder"/*) return 0 ;; esac + case $exe in + */FEX|*/FEXInterpreter) + arg=$(tr '\0' '\n' < "$p/cmdline" 2>/dev/null | sed -n 2p) + cwd=$(readlink "$p/cwd" 2>/dev/null) + case $arg in "$folder"/*) return 0 ;; esac + case $cwd in "$folder"|"$folder"/*) return 0 ;; esac + ;; + esac + done + return 1 +} +while running; do sleep 2; done +exit $rc diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists similarity index 95% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists index ea1d413f5..9007596a8 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-seed-redists +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-seed-redists @@ -81,7 +81,11 @@ for reg in "$steam_root"/steamapps/common/Proton*/files/share/default_pfx*/syste "$steam_root"/steamapps/compatdata/*/pfx/system.reg \ /mnt/droiddeck-sd/steamapps/compatdata/*/pfx/system.reg; do [ -f "$reg" ] || continue - seeded=$(dirname "$reg")/.bannerlator-redists + seeded=$(dirname "$reg")/.droiddeck-redists + legacy=$(dirname "$reg")/.bannerlator-redists + if [ -f "$legacy" ] && [ ! -f "$seeded" ]; then + mv -f "$legacy" "$seeded" + fi [ "$(cat "$seeded" 2>/dev/null)" = "$stamp" ] && continue dated=$(mktemp) || continue touch -r "$reg" "$dated" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session similarity index 89% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-session rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-session index d3f93b8e4..838a58136 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -1,10 +1,10 @@ #!/bin/bash -# The Linux runtime's session. Bannerlator execs this under proot with the output size and mode; +# The Linux runtime's session. DroidDeck execs this under proot with the output size and mode; # the first pass starts gamescope, which execs this script again inside it for the program: -# bannerlator-session desktop the file manager -# bannerlator-session run <path> a Linux program (AppImage, script or ELF) -# bannerlator-session steam [steam-url] the native Steam client -# bannerlator-session lxqt the LXQt/labwc desktop (DroidDeck's desktop package) +# droiddeck-session desktop the file manager +# droiddeck-session run <path> a Linux program (AppImage, script or ELF) +# droiddeck-session steam [steam-url] the native Steam client +# droiddeck-session lxqt the LXQt/labwc desktop (DroidDeck's desktop package) # BL_WIDTH / BL_HEIGHT size the output, BL_FPS limits it, BL_LOG collects everything said here. set -u export HOME=${HOME:-/root} @@ -22,8 +22,8 @@ if [ -n "${BL_LOG:-}" ] && [ -z "${BL_INSIDE:-}" ]; then # A guest looping on an error writes gigabytes in minutes (an Xwayland proot had lost track of # managed 9 GB of one line in 19 min): the log keeps its first BL_LOG_CAP bytes and the rest is # drained, not refused - a writer that filled it must not die of SIGPIPE. - exec > >({ head -c "${BL_LOG_CAP:-268435456}" >>"$BL_LOG"; cat >/dev/null; }) 2>&1 - echo "== bannerlator-session $(date) mode=$mode $*" + exec > >({ dd of="$BL_LOG" oflag=append conv=notrunc iflag=count_bytes count="${BL_LOG_CAP:-268435456}" bs=65536 status=none; cat >/dev/null; }) 2>&1 + echo "== droiddeck-session $(date) mode=$mode $*" echo "== uid=$(id -u) size=${BL_WIDTH:-?}x${BL_HEIGHT:-?} fps=${BL_FPS:-0} icd=${VK_ICD_FILENAMES:-none}" echo "== gamescope: $(gamescope --version 2>&1 | head -1) at $(command -v gamescope)" echo "== rootfs: $(cat /.version 2>/dev/null || echo unversioned)" @@ -42,6 +42,16 @@ collect_steam_logs() { [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null # NOT copied, ever: loginusers.vdf, config.vdf and the ssfn* files, which hold the credentials # themselves rather than a mention of them. + local sync_store=$HOME/.local/share/droiddeck-esync sync_file + if [ -d "$sync_store" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-esync" 2>/dev/null; then + for sync_file in launches.log tools.tsv wanted.tsv prefixes.json; do + [ -f "$sync_store/$sync_file" ] && cp "$sync_store/$sync_file" "$BL_DEBUG_DIR/droiddeck-esync/" 2>/dev/null + done + fi + local fex_state=$HOME/.local/share/droiddeck-fex + if [ -d "$fex_state" ] && mkdir -p "$BL_DEBUG_DIR/droiddeck-fex" 2>/dev/null; then + cp "$fex_state/status.json" "$fex_state/config/Config.json" "$BL_DEBUG_DIR/droiddeck-fex/" 2>/dev/null + fi echo "== steam logs copied to $BL_DEBUG_DIR/steam" } # A session that is killed never reaches the copy below, and those are the sessions worth reading. @@ -93,11 +103,11 @@ export XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} export XDG_CACHE_HOME=${XDG_CACHE_HOME:-$HOME/.cache} mkdir -p "$XDG_DATA_HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" if [ "$mode" = lxqt ] || [ "$mode" = desktop ]; then - /usr/local/bin/bannerlator-desktop-bookmarks || true + /usr/local/bin/droiddeck-desktop-bookmarks || true fi # Flatpak (the store's apps): its sandboxes through the app instead of bubblewrap, which needs the # user namespaces Android keeps from apps, and its apps' menu entries and icons on the desktop. -export FLATPAK_BWRAP=/usr/local/bin/bannerlator-bwrap +export FLATPAK_BWRAP=/usr/local/bin/droiddeck-bwrap export XDG_DATA_DIRS=$XDG_DATA_HOME/flatpak/exports/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share} # The cores a program runs on outside Steam (the desktop and everything started from it, a @@ -238,32 +248,41 @@ case $mode in taskset -cp "$cores" $$ >/dev/null 2>&1 || true fi # Player 1 on the controller in an emulator that has no profile of its own yet. - python3 /usr/local/bin/bannerlator-pad-defaults 2>&1 || true + python3 /usr/local/bin/droiddeck-pad-defaults 2>&1 || true + fex=() + if [ -x /usr/local/bin/droiddeck-fex ]; then + case $(od -An -tu2 -j18 -N2 "$target" 2>/dev/null | tr -d ' ') in + 3|62) fex=(/usr/local/bin/droiddeck-fex run --mode on --) ;; + esac + fi case ${target,,} in *.appimage) export APPIMAGE_EXTRACT_AND_RUN=1 chmod +x "$target" 2>/dev/null - exec "$target" "$@" + exec "${fex[@]}" "$target" "$@" ;; *.sh) + if [ -x /usr/local/bin/droiddeck-fex ]; then + exec /usr/local/bin/droiddeck-fex run --for "$target" -- bash "$target" "$@" + fi exec bash "$target" "$@" ;; *) chmod +x "$target" 2>/dev/null - cd "$(dirname "$target")" && exec "$target" "$@" + cd "$(dirname "$target")" && exec "${fex[@]}" "$target" "$@" ;; esac ;; steam) step "checking the Steam client (first run downloads it, ~1-2 min with nothing on screen)" - bannerlator-steam-install || exit $? + droiddeck-steam-install || exit $? step "Steam client ready" steam_root=$XDG_DATA_HOME/Steam # Register the card library (bound at /mnt/droiddeck-sd) with the client - internal storage is # its main library already - and the ARM64 Proton that lets it launch the Windows ones. # A request left in ~/.bl-proton-extra installs a third-party Proton before the client starts, # so it is in the Compatibility dropdown the first time the user looks. One per line, each the - # arguments to bannerlator-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL. + # arguments to droiddeck-proton-extra: "ge", "cachyos", "ge --tag GE-Proton11-7", or a URL. # Hundreds of megabytes each, so it is only ever done on request, and the line is dropped once # it has succeeded - a failed download stays and is retried next session. if [ -s "$HOME/.bl-proton-extra" ]; then @@ -272,7 +291,7 @@ case $mode in while read -r _bl_extra || [ -n "$_bl_extra" ]; do case ${_bl_extra# } in ""|"#"*) continue ;; esac step "installing Proton '$_bl_extra' (large download, several minutes)" - if bannerlator-proton-extra "$steam_root" $_bl_extra; then + if droiddeck-proton-extra "$steam_root" $_bl_extra; then step "installed Proton '$_bl_extra'" else step "Proton '$_bl_extra' failed; will retry next session" @@ -323,27 +342,27 @@ case $mode in fi step "registering the app's game library with Steam" - bannerlator-steam-library "$steam_root" || true + droiddeck-steam-library "$steam_root" || true step "setting up the ARM64 Proton compatibility tool" - bannerlator-steam-compat "$steam_root" || true + steam-compatibility "$steam_root" || true if [ -z "${BL_DESKTOP:-}" ] && [ "${BL_STEAM_UI:-}" != desktop ]; then - bannerlator-steam-ui-scale "$steam_root" "${BL_WIDTH:-1280}" "${BL_HEIGHT:-720}" || true + droiddeck-steam-ui-scale "$steam_root" "${BL_WIDTH:-1280}" "${BL_HEIGHT:-720}" || true fi # The user's own games, as non-Steam shortcuts under that tool; the list is written by the app # each session (empty = the app's earlier entries are removed, the client's own are kept). if [ -n "${BL_ADDED_GAMES:-}" ]; then step "adding the app's own games to the client's library" - bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true fi # Steam runs a game's shared redistributables inside its prefix on first launch; marking # them as already run keeps those x86 installers from starting under emulation at all. - bannerlator-seed-redists "$steam_root" || true + droiddeck-seed-redists "$steam_root" || true # The compatibility tool runs Valve's arm64 Proton, which the client fetches like any depot. # Asked for on the command line the first time, so a Windows title has it before it is needed. if [ ! -f "$steam_root/steamapps/appmanifest_4427310.acf" ]; then step "asking the client to fetch the ARM64 Proton" set -- "$@" steam://install/4427310 - # Ported from Bannerlator (bannerlator-session, device-proven 2026-09-22). The client decides + # Ported from Bannerlator's session script (device-proven 2026-09-22). The client decides # a title's compatibility tool when the title is installed and keeps that decision for the # rest of its run, and the tool is set up before the client comes up. So once the depot has # fully landed, restart the client once - its own -shutdown, then the loop below, which knows @@ -357,7 +376,7 @@ case $mode in # StateFlags 4 = fully installed; a moment more for the client to finish its bookkeeping. if grep -q '"StateFlags"[[:space:]]*"4"' "$_bl_acf" 2>/dev/null; then sleep 10 - bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true + steam-compatibility "$steam_root" >/dev/null 2>&1 || true step "compatibility layer installed: restarting the Steam client once" : > "$HOME/.bl-restart-once" "$steam_root/steamrtarm64/steam" -shutdown >/dev/null 2>&1 || true @@ -514,10 +533,10 @@ case $mode in nap 5 beat=$((beat + 1)) [ "$((beat % 3))" -eq 0 ] || continue - bannerlator-seed-redists "$steam_root" >/dev/null 2>&1 || true + droiddeck-seed-redists "$steam_root" >/dev/null 2>&1 || true if compat_due; then : > "$compat_stamp" - bannerlator-steam-compat "$steam_root" >/dev/null 2>&1 || true + steam-compatibility "$steam_root" >/dev/null 2>&1 || true fi done ) & @@ -542,9 +561,9 @@ case $mode in # refuses what it cannot do, since the app has no say over Wi-Fi - answer what the client asks. # The bus config is written here rather than shipped, so a runtime installed earlier gets this # from the app's copy of the session scripts without a re-host. (WinNative, maxjivi05, e7a224ae.) - if command -v dbus-daemon >/dev/null 2>&1 && command -v bannerlator-netmanager >/dev/null 2>&1; then - mkdir -p /run/dbus /usr/local/share/bannerlator - cat > /usr/local/share/bannerlator/system-bus.conf <<'BUSCONF' + if command -v dbus-daemon >/dev/null 2>&1 && command -v droiddeck-netmanager >/dev/null 2>&1; then + mkdir -p /run/dbus /usr/local/share/droiddeck + cat > /usr/local/share/droiddeck/system-bus.conf <<'BUSCONF' <!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN" "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd"> <busconfig> @@ -560,21 +579,21 @@ case $mode in </busconfig> BUSCONF rm -f /run/dbus/system_bus_socket - dbus-daemon --config-file=/usr/local/share/bannerlator/system-bus.conf --nofork --nopidfile & + dbus-daemon --config-file=/usr/local/share/droiddeck/system-bus.conf --nofork --nopidfile & system_bus=$! for _ in $(seq 1 50); do [ -S /run/dbus/system_bus_socket ] && break sleep 0.1 done if [ -S /run/dbus/system_bus_socket ]; then - bannerlator-netmanager & + droiddeck-netmanager & netmanager=$! echo "== network: system bus up, NetworkManager stand-in running" # Steam blanks its main UI before calling login1.Suspend. Without the # matching resume notification it stays black although the menus work. # Own the name before Steam constructs its login1 proxy at startup. rm -f "$BL_LAUNCH_DIR/steam-sleep-ready" - bannerlator-login1 & + droiddeck-login1 & login_manager=$! for ((_i = 0; _i < 50; _i++)); do [ -f "$BL_LAUNCH_DIR/steam-sleep-ready" ] && break @@ -598,26 +617,20 @@ BUSCONF export STEAM_RUNTIME=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 - # Native-Linux x86 games go through Valve's FEX compat tool, which runs FEX with FEX_PORTABLE=1 - # - that makes FEX look for its config next to the binary, while the tool writes one into the - # app's compatdata instead. FEX therefore starts with no rootfs, every x86_64 library lookup - # falls through to the host, and the guest shell dies on libreadline before the game is reached. - # FEX_ROOTFS names it outright and is read whatever the config situation. - [ -d /usr/share/guestos/fex-mesa ] && export FEX_ROOTFS=/usr/share/guestos/fex-mesa # Decky is optional and session scoped: the app's marker opts in, and this supervisor is # reaped with the client session. Native ARM64 runs directly. A legacy x86_64 asset is used - # only when its recorded architecture and this guest's existing FEX installation agree. + # only when its recorded architecture says so and droiddeck-fex has FEX and its runtime ready. decky_loader=$HOME/homebrew/services/PluginLoader if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ -x "$decky_loader" ]; then decky_arch=$(cat "$HOME/homebrew/services/.droiddeck-decky-arch" 2>/dev/null || echo unknown) - if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && [ -x /usr/bin/FEX ] && [ -n "${FEX_ROOTFS:-}" ]; }; then + if [ "$decky_arch" = arm64 ] || { [ "$decky_arch" = x86_64 ] && /usr/local/bin/droiddeck-fex prepare >/dev/null; }; then ( decky_child= decky_stop() { [ -z "$decky_child" ] || kill "$decky_child" 2>/dev/null; [ -z "$decky_child" ] || wait "$decky_child" 2>/dev/null; exit 0; } trap decky_stop TERM INT HUP decky_exits=0 while :; do - if [ "$decky_arch" = x86_64 ]; then /usr/bin/FEX "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 & + if [ "$decky_arch" = x86_64 ]; then /usr/local/bin/droiddeck-fex run --mode on -- "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 & else "$decky_loader" >>"$HOME/homebrew/services/PluginLoader.log" 2>&1 & fi decky_child=$! @@ -640,6 +653,20 @@ BUSCONF echo "== Decky: installed loader architecture is unsupported by this guest; not starting" fi fi + _bl_cdp=() + if [ -f "$steam_root/.cef-enable-remote-debugging" ]; then + export BL_CDP_PORT=8080 + else + export BL_CDP_PORT=$((20000 + RANDOM % 30000)) + _bl_cdp=(-cef-enable-debugging -devtools-port "$BL_CDP_PORT") + fi + if [ -f "$HOME/.droiddeck-decky-enabled" ] && [ "${decky_arch:-}" = x86_64 ]; then + echo "== steam: devtools port $BL_CDP_PORT left open for the x86_64 Decky loader" + else + export BL_CDP_GUARD=$BL_CDP_PORT + echo "== steam: devtools port $BL_CDP_PORT, guest processes only" + fi + droiddeck-steam-compat "$steam_root" 2>&1 & # The client exits 42 when it has updated itself and wants a fresh start (steam.sh does this). # BL_STEAM_UI=desktop = the client's desktop UI (a window, the library as a list), which the # desktop's launchers ask for; gamescope shows one window at a time and the game takes over. @@ -682,7 +709,7 @@ BUSCONF steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta} steam_beta=$steam_root/package/beta if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then - bannerlator-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & + droiddeck-steam-games watch "$steam_root" "$BL_ADDED_GAMES" & games_watcher=$! fi # Lossless Scaling (Steam app 993090): whether this account owns it, asked of the signed-in @@ -694,7 +721,7 @@ BUSCONF for ((_i = 0; _i < 60; _i++)); do nap 30 [ -e "$owned" ] && exit 0 - answer=$(bannerlator-steam-games probe "$steam_root" 993090 2>/dev/null) || continue + answer=$(droiddeck-steam-games probe "$steam_root" 993090 2>/dev/null) || continue case $answer in *'"993090": true'*) mkdir -p "${owned%/*}" && : > "$owned" && echo "== Lossless Scaling: owned" ;; *) echo "== Lossless Scaling: not owned by this account" ;; @@ -703,7 +730,7 @@ BUSCONF done ) & fi - echo "== steam args: ${_bl_ui[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" + echo "== steam args: ${_bl_ui[*]} ${_bl_cdp[*]} -clientbeta $steam_channel -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 -cef-use-gl=angle -cef-use-angle=vulkan $*" # An update that cannot install asks for a restart (rc=42) every time; the count is capped so a # session can never again loop in silence, which reads as a menu opening and closing by itself. steam_updates=0 @@ -749,14 +776,17 @@ BUSCONF echo "$steam_channel" > "$steam_beta" fi step "starting the Steam client${BL_STEAM_UI:+ ($BL_STEAM_UI UI)}" - "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" -clientbeta "$steam_channel" \ + "$steam_root/steamrtarm64/steam" "${_bl_ui[@]}" "${_bl_cdp[@]}" -clientbeta "$steam_channel" \ -no-cef-sandbox -cef-force-gpu -cef-ozone-platform=x11 \ -cef-use-gl=angle -cef-use-angle=vulkan "$@" rc=$? echo "== steam exited rc=$rc" collect_steam_logs + if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then + /usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true + fi if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then - bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true + droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true fi if [ -f "$HOME/.bl-stop-requested" ]; then rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once" @@ -787,7 +817,7 @@ BUSCONF wait "$games_watcher" 2>/dev/null || true fi if [ -n "${BL_DESKTOP:-}" ]; then - # Started from the desktop (bannerlator-steam-launch): the desktop's own programs share this + # Started from the desktop (droiddeck-steam-launch): the desktop's own programs share this # script's parent, so reap_orphans - right under gamescope, where everything is the client's - # is not for here. The helpers this run started (the cores and redistributables beats, the # system bus and the NetworkManager stand-in) go with the client instead. @@ -812,7 +842,7 @@ BUSCONF exit "$rc" ;; *) - echo "bannerlator-session: unknown mode '$mode'" >&2 + echo "droiddeck-session: unknown mode '$mode'" >&2 exit 64 ;; esac diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat new file mode 100755 index 000000000..e3520f4d9 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -0,0 +1,495 @@ +#!/usr/bin/python3 +import base64 +import ctypes +import json +import os +import runpy +import select +import signal +import socket +import struct +import sys +import time + +HERE = os.path.dirname(os.path.realpath(__file__)) +COMPAT = runpy.run_path(os.path.join(HERE, "steam-compatibility")) +BINDING = "droiddeckCompatChanged" +INSTALL_JS = r""" +(() => { + if (window.__droiddeckCompat) return "present"; + const enc = new TextEncoder(), dec = new TextDecoder(); + const varint = (value, out) => { let n = BigInt(value); do { let b = Number(n & 0x7fn); n >>= 7n; if (n) b |= 0x80; out.push(b); } while (n); }; + const key = (field, wire, out) => varint(field * 8 + wire, out); + const str = (field, s, out) => { const b = enc.encode(s); key(field, 2, out); varint(b.length, out); for (const x of b) out.push(x); }; + const uint = (field, v, out) => { key(field, 0, out); varint(v, out); }; + const fixed64 = (field, v, out) => { key(field, 1, out); let n = BigInt(v); for (let i = 0; i < 8; i++) { out.push(Number(n & 0xffn)); n >>= 8n; } }; + const parse = (bytes) => { + const fields = {}; + let i = 0; + const read = () => { let r = 0n, s = 0n, b; do { b = bytes[i++]; r |= BigInt(b & 0x7f) << s; s += 7n; } while (b & 0x80); return r; }; + while (i < bytes.length) { + const k = Number(read()), field = k >> 3, wire = k & 7; + let v; + if (wire === 0) v = read(); + else if (wire === 1) { v = 0n; for (let j = 0; j < 8; j++) v |= BigInt(bytes[i + j]) << BigInt(8 * j); i += 8; } + else if (wire === 2) { const n = Number(read()); v = bytes.subarray(i, i + n); i += n; } + else if (wire === 5) { v = bytes.subarray(i, i + 4); i += 4; } + else throw new Error("unexpected wire type " + wire); + (fields[field] = fields[field] || []).push(v); + } + return fields; + }; + const text = (f, n) => f[n] ? dec.decode(f[n][0]) : ""; + const self = { seq: 0, pending: new Map(), ws: null, opening: null, changes: 0 }; + const frame = (method, body, auth) => { + const h = []; + fixed64(10, ++self.seq, h); + str(12, method, h); + if (auth) str(40, auth, h); + const out = new Uint8Array(8 + h.length + body.length); + const view = new DataView(out.buffer); + view.setUint32(0, (0x80000000 | 146) >>> 0, true); + view.setUint32(4, h.length, true); + out.set(h, 8); + out.set(body, 8 + h.length); + return [self.seq, out]; + }; + const onMessage = (event) => { + const bytes = new Uint8Array(event.data); + const length = new DataView(event.data).getUint32(4, true); + const header = parse(bytes.subarray(8, 8 + length)); + const target = header[11] ? Number(header[11][0] & 0xffffffffn) : -1; + if (self.pending.has(target)) { + const done = self.pending.get(target); + self.pending.delete(target); + const result = header[13] ? Number(header[13][0]) : 2; + done(result === 1 ? { ok: true, body: parse(bytes.subarray(8 + length)) } : { ok: false, eresult: result, error: text(header, 14) }); + return; + } + if (text(header, 12).startsWith("CompatManager.NotifyStateChanged")) { + self.changes++; + if (typeof window.__BINDING__ === "function") window.__BINDING__(String(self.changes)); + } + }; + const send = (method, body, auth) => new Promise((resolve, reject) => { + const [id, out] = frame(method, Uint8Array.from(body), auth); + const timer = setTimeout(() => { self.pending.delete(id); reject(new Error(method + " timed out")); }, 10000); + self.pending.set(id, (r) => { clearTimeout(timer); resolve(r); }); + self.ws.send(out); + }); + const open = async () => { + const info = await SteamClient.WebUITransport.GetTransportInfo(); + const ws = new WebSocket("ws://127.0.0.1:" + info.portClientdll + "/transportsocket/"); + ws.binaryType = "arraybuffer"; + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error("the client's transport refused the connection")); }); + ws.onmessage = onMessage; + ws.onclose = () => { + if (self.ws === ws) self.ws = null; + for (const done of self.pending.values()) done({ ok: false, eresult: 0, error: "connection closed" }); + self.pending.clear(); + }; + self.ws = ws; + const body = []; + str(1, info.authKeyClientdll, body); + const r = await send("TransportAuth.Authenticate#1", body, info.authKeyClientdll); + if (!r.ok) { ws.close(); throw new Error("the client's transport refused authentication (" + r.eresult + ")"); } + }; + const ready = async () => { + if (self.ws && self.ws.readyState === 1) return; + if (!self.opening) self.opening = open().finally(() => { self.opening = null; }); + await self.opening; + }; + self.get = async (appid) => { + await ready(); + const body = []; + uint(1, appid, body); + const r = await send("CompatManager.GetCompatTools#1", body); + if (!r.ok) throw new Error("GetCompatTools " + appid + " failed (" + r.eresult + " " + r.error + ")"); + return { selected: text(r.body, 3), fallback: text(r.body, 4), tools: (r.body[1] || []).map((t) => text(parse(t), 1)) }; + }; + self.set = async (appid, name) => { + await ready(); + const body = []; + uint(1, appid, body); + str(2, name, body); + const r = await send("CompatManager.SpecifyCompatTool#1", body); + if (!r.ok) throw new Error("SpecifyCompatTool " + appid + " " + name + " failed (" + r.eresult + " " + r.error + ")"); + return true; + }; + self.many = async (apps) => { + const out = {}; + for (const app of apps) out[app] = (await self.get(Number(app))).selected; + return out; + }; + window.__droiddeckCompat = self; + return "installed"; +})() +""".replace("__BINDING__", BINDING) + + +class Closed(Exception): + pass + + +def say(message): + print("droiddeck-steam-compat: %s" % message, flush=True) + + +def connect(port, timeout=5): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.bind(("127.0.0.1", 0)) + sock.connect(("127.0.0.1", port)) + except OSError: + sock.close() + raise + return sock + + +def http_get(port, path): + sock = connect(port) + try: + sock.sendall(("GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nConnection: close\r\n\r\n" % (path, port)).encode()) + data = b"" + length = None + while length is None or len(data) < length: + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + if length is None and b"\r\n\r\n" in data: + head, _, rest = data.partition(b"\r\n\r\n") + for line in head.split(b"\r\n")[1:]: + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + length = int(value.strip()) + if length is None: + length = float("inf") + data = rest + if not head.startswith(b"HTTP/1.1 200"): + raise OSError("devtools answered %r" % head[:40]) + finally: + sock.close() + return json.loads(data.decode("utf-8", "replace")) + + +class WebSocket: + def __init__(self, port, path): + self.sock = connect(port) + key = base64.b64encode(os.urandom(16)).decode() + self.sock.sendall(("GET %s HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n" % (path, port, key)).encode()) + self.buffer = b"" + while b"\r\n\r\n" not in self.buffer: + self.fill() + head, _, self.buffer = self.buffer.partition(b"\r\n\r\n") + if not head.startswith(b"HTTP/1.1 101"): + raise OSError("devtools refused the websocket: %r" % head[:60]) + self.sock.settimeout(None) + + def fileno(self): + return self.sock.fileno() + + def fill(self): + chunk = self.sock.recv(65536) + if not chunk: + raise Closed("devtools closed the connection") + self.buffer += chunk + + def take(self, n): + while len(self.buffer) < n: + self.fill() + data, self.buffer = self.buffer[:n], self.buffer[n:] + return data + + def send(self, text, opcode=1): + payload = text.encode() if isinstance(text, str) else text + mask = os.urandom(4) + n = len(payload) + if n < 126: + head = struct.pack("!BB", 0x80 | opcode, 0x80 | n) + elif n < 65536: + head = struct.pack("!BBH", 0x80 | opcode, 0xFE, n) + else: + head = struct.pack("!BBQ", 0x80 | opcode, 0xFF, n) + self.sock.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))) + + def pending(self): + return len(self.buffer) > 0 + + def recv(self): + message = b"" + while True: + first, second = self.take(2) + n = second & 0x7F + if n == 126: + n = struct.unpack("!H", self.take(2))[0] + elif n == 127: + n = struct.unpack("!Q", self.take(8))[0] + mask = self.take(4) if second & 0x80 else None + payload = self.take(n) + if mask: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + opcode = first & 0x0F + if opcode == 8: + raise Closed("devtools closed the websocket") + if opcode == 9: + self.send(payload, 10) + continue + if opcode == 10: + continue + message += payload + if first & 0x80: + return message.decode("utf-8", "replace") + + def close(self): + try: + self.sock.close() + except OSError: + pass + + +class Page: + def __init__(self, port, path): + self.ws = WebSocket(port, path) + self.next_id = 0 + self.events = [] + + def call(self, method, params=None, timeout=30): + self.next_id += 1 + mine = self.next_id + self.ws.send(json.dumps({"id": mine, "method": method, "params": params or {}})) + deadline = time.monotonic() + timeout + while True: + if not self.ws.pending(): + left = deadline - time.monotonic() + if left <= 0 or not select.select([self.ws], [], [], left)[0]: + raise TimeoutError("%s timed out" % method) + message = json.loads(self.ws.recv()) + if message.get("id") == mine: + if "error" in message: + raise RuntimeError("%s: %s" % (method, message["error"].get("message"))) + return message.get("result", {}) + if "method" in message: + self.events.append(message) + + def evaluate(self, expression, timeout=30): + result = self.call("Runtime.evaluate", {"expression": expression, "awaitPromise": True, "returnByValue": True}, timeout) + if "exceptionDetails" in result: + details = result["exceptionDetails"] + raise RuntimeError((details.get("exception") or {}).get("description") or details.get("text") or "script failed") + return result.get("result", {}).get("value") + + def compat(self, call): + return self.evaluate("%s;\nwindow.__droiddeckCompat.%s" % (INSTALL_JS, call)) + + def drain(self): + while True: + if not self.ws.pending() and not select.select([self.ws], [], [], 0)[0]: + break + message = json.loads(self.ws.recv()) + if "method" in message: + self.events.append(message) + events, self.events = self.events, [] + return events + + +def shared_context(port): + for target in http_get(port, "/json/list"): + if target.get("title") == "SharedJSContext" and target.get("webSocketDebuggerUrl"): + path = target["webSocketDebuggerUrl"].split("/", 3)[3] + return "/" + path + return None + + +class Watcher: + MASK = 0x00000008 | 0x00000080 | 0x00000100 | 0x00000200 + + def __init__(self, files): + self.files = files + self.fd = -1 + try: + libc = ctypes.CDLL(None, use_errno=True) + fd = libc.inotify_init1(os.O_NONBLOCK | os.O_CLOEXEC) + dirs = sorted({os.path.dirname(f) for f in files if os.path.isdir(os.path.dirname(f))}) + if fd >= 0 and dirs and all(libc.inotify_add_watch(fd, d.encode(), self.MASK) >= 0 for d in dirs): + self.fd = fd + elif fd >= 0: + os.close(fd) + except (OSError, AttributeError): + self.fd = -1 + self.stamp = self.snapshot() + + def snapshot(self): + stamp = [] + for f in self.files: + for p in (f, os.path.dirname(f)): + try: + stamp.append(os.stat(p).st_mtime_ns) + except OSError: + stamp.append(0) + return stamp + + def fileno(self): + return self.fd + + def changed(self): + if self.fd < 0: + stamp = self.snapshot() + moved, self.stamp = stamp != self.stamp, stamp + return moved + names = set() + try: + while True: + data = os.read(self.fd, 65536) + if not data: + break + at = 0 + while at + 16 <= len(data): + _, _, _, size = struct.unpack_from("iIII", data, at) + names.add(data[at + 16:at + 16 + size].split(b"\0", 1)[0].decode("utf-8", "replace")) + at += 16 + size + except BlockingIOError: + pass + wanted = {os.path.basename(f) for f in self.files} + return any(n in wanted or n.startswith("appmanifest_") for n in names) + + +class Helper: + def __init__(self, steam_root, port): + self.steam_root = steam_root + self.port = port + self.page = None + self.known = set() + self.installed = None + self.fex_asked = set() + + def attach(self): + path = shared_context(self.port) + if path is None: + raise LookupError("no SharedJSContext yet") + page = Page(self.port, path) + try: + if page.evaluate("typeof SteamClient === 'object' && typeof SteamClient.WebUITransport === 'object'") is not True: + raise LookupError("SteamClient is not ready") + page.call("Runtime.addBinding", {"name": BINDING}) + page.compat("get(0)") + except Exception: + page.ws.close() + raise + self.page = page + return True + + def reconcile(self): + page = self.page + with COMPAT["locked"](): + state_file = COMPAT["state_path"]("state.json") + state = COMPAT["read_json"](state_file) + request = COMPAT["read_json"](COMPAT["state_path"]("request.json")) + catalog = COMPAT["tool_catalog"](self.steam_root) + top = page.compat("get(0)") + self.known = set(top["tools"]) + live = [t for t in catalog if t["name"] in self.known] + wanted = COMPAT["requested_tool"](request, catalog) + newer = isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0) + waiting = newer and wanted is not None and wanted not in self.known + default, state = COMPAT["choose_default"](top["selected"], state, request, live, apply_request=not waiting) + installed = COMPAT["installed_apps"](self.steam_root) + self.installed = installed + apps = sorted(set(installed) | set(state.get("auto", {})), key=COMPAT["app_order"]) + current = dict(page.compat("many(%s)" % json.dumps(apps)) or {}) + current["0"] = top["selected"] + native = COMPAT["native_targets"](self.steam_root, installed) + changes, auto = COMPAT["plan_mapping"](current, installed, [t["name"] for t in live], default, COMPAT["followers"](state, current), native) + for app, tool in sorted(changes.items(), key=lambda item: COMPAT["app_order"](item[0])): + page.compat("set(%d, %s)" % (int(app), json.dumps(tool))) + say("%s -> %s" % ("default" if app == "0" else "app %s" % app, tool)) + state.update(auto=auto, tools=catalog, live=True) + if waiting: + state["pending"] = wanted + say("%s is new to the running client; it applies when Steam next starts" % wanted) + else: + state.pop("pending", None) + COMPAT["write_json"](state_file, state) + if native or os.path.exists(COMPAT["fex_wanted_marker"]()): + for app in COMPAT["request_fex"](self.steam_root, self.fex_asked): + say("asked the client to install %s for x86 Linux titles and programs" % app) + + def request_waiting(self): + request = COMPAT["read_json"](COMPAT["state_path"]("request.json")) + state = COMPAT["read_json"](COMPAT["state_path"]("state.json")) + return isinstance(request.get("seq"), int) and request["seq"] > int(state.get("applied", 0) or 0) + + def serve(self, watcher): + with open(COMPAT["state_path"]("live.pid"), "w") as f: + f.write("%d\n" % os.getpid()) + say("attached to the client on port %d" % self.port) + self.reconcile() + while True: + sources = [self.page.ws] + ([watcher] if watcher.fileno() >= 0 else []) + ready = [self.page.ws] if self.page.ws.pending() else select.select(sources, [], [], 30 if watcher.fileno() >= 0 else 2)[0] + due = False + if self.page.ws in ready: + due = any(e.get("method") == "Runtime.bindingCalled" and e.get("params", {}).get("name") == BINDING + for e in self.page.drain()) + if (watcher in ready or watcher.fileno() < 0) and watcher.changed(): + due = self.installed != COMPAT["installed_apps"](self.steam_root) or self.request_waiting() + if due: + time.sleep(0.3) + if watcher.fileno() >= 0 and select.select([watcher], [], [], 0)[0]: + watcher.changed() + self.page.drain() + self.reconcile() + + +def detach(): + try: + os.unlink(COMPAT["state_path"]("live.pid")) + except OSError: + pass + with COMPAT["locked"](): + state_file = COMPAT["state_path"]("state.json") + state = COMPAT["read_json"](state_file) + if state.get("live"): + state["live"] = False + COMPAT["write_json"](state_file, state) + + +def main(): + steam_root = sys.argv[1] + try: + port = int(os.environ.get("BL_CDP_PORT", "")) + except ValueError: + say("no devtools port for this session; the client's mapping is kept through config.vdf") + return 0 + os.makedirs(COMPAT["COMPAT_DIR"], exist_ok=True) + watcher = Watcher([COMPAT["state_path"]("request.json")] + [os.path.join(lib, "steamapps", "libraryfolders.vdf") for lib in (steam_root, COMPAT["LIBRARY"] + "-sd")]) + helper = Helper(steam_root, port) + failures = 0 + last = None + while True: + try: + helper.attach() + failures = 0 + last = None + helper.serve(watcher) + except Exception as e: + if helper.page is not None: + say("lost the client (%s); reattaching" % e) + helper.page.ws.close() + helper.page = None + detach() + elif str(e) != last: + say("waiting for the client (%s)" % e) + last = str(e) + failures += 1 + time.sleep(min(2 * max(failures, 1), 10)) + + +if __name__ == "__main__": + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + try: + sys.exit(main()) + finally: + detach() diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games similarity index 97% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games index 46dcdb83e..50802187c 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-games +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-games @@ -239,13 +239,13 @@ def watch(steam_root, listing): status = last_status if pending and pending != requested and not game_running() and downloads_idle(steam_root): requested = pending - print("bannerlator-steam-games: restarting the client once to import %s" % pending, flush=True) + print("droiddeck-steam-games: restarting the client once to import %s" % pending, flush=True) try: request_restart(steam_root) except (OSError, subprocess.TimeoutExpired) as error: - print("bannerlator-steam-games: restart unavailable: %s" % error, flush=True) + print("droiddeck-steam-games: restart unavailable: %s" % error, flush=True) if status != last_status: - print("bannerlator-steam-games: " + status, flush=True) + print("droiddeck-steam-games: " + status, flush=True) last_status = status time.sleep(30) @@ -357,7 +357,7 @@ def route(steam_root, acct, games): routes[str(game["appid"])] = appid continue except OSError as error: - print(f"bannerlator-steam-games: import unavailable ({error})") + print(f"droiddeck-steam-games: import unavailable ({error})") shortcuts.append(game) save_json(Path(acct) / "config/.droiddeck-routes.json", routes) return shortcuts, routes diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install similarity index 87% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install index ae02ad1ee..a5722f7d0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-install +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-install @@ -6,11 +6,15 @@ cdn=https://client-update.fastly.steamstatic.com steam_channel=${BL_STEAM_CHANNEL:-steamdeck_publicbeta} case "$steam_channel" in publicbeta|steamdeck_publicbeta) ;; - *) echo "bannerlator-steam-install: unsupported Steam channel: $steam_channel" >&2; exit 1 ;; + *) echo "droiddeck-steam-install: unsupported Steam channel: $steam_channel" >&2; exit 1 ;; esac manifest=steam_client_${steam_channel}_linuxarm64 steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam -stamp=$steam_root/package/bannerlator-installed +stamp=$steam_root/package/droiddeck-installed +legacy_stamp=$steam_root/package/bannerlator-installed +if [ -f "$legacy_stamp" ] && [ ! -f "$stamp" ]; then + mv -f "$legacy_stamp" "$stamp" || true +fi [ -x "$steam_root/steamrtarm64/steam" ] && [ -f "$stamp" ] && exit 0 work=$(mktemp -d) @@ -24,7 +28,7 @@ awk -F'"' ' $2 == "sha2" { sha[block] = $4 } END { for (b in file) if (b ~ /_all$/ || b ~ /_linuxarm64_linuxarm64$/) print b, file[b], sha[b] } ' "$work/manifest" | sort > "$work/parts" -[ -s "$work/parts" ] || { echo "bannerlator-steam-install: no client components in $manifest" >&2; exit 1; } +[ -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) mkdir -p "$steam_root/package" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch similarity index 75% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch index 92d1c9d22..121c77d90 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-launch +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-launch @@ -1,12 +1,12 @@ #!/bin/bash # Starts the Steam client from inside a session (the desktop's menu entries and the per-game -# launchers bannerlator-desktop-games writes): -# bannerlator-steam-launch desktop [appid|steam-url] Steam's desktop UI, optionally a game -# bannerlator-steam-launch bigpicture [appid|steam-url] Big Picture +# launchers droiddeck-desktop-games writes): +# droiddeck-steam-launch desktop [appid|steam-url] Steam's desktop UI, optionally a game +# droiddeck-steam-launch bigpicture [appid|steam-url] Big Picture # # On a desktop that draws with the GPU (labwc on vulkan or gles2 - the default since the GPU # desktop) the client runs right there: a window on the desktop, through the desktop's Xwayland, -# with the same set-up a Steam session gives it (bannerlator-session steam, minus gamescope), and +# with the same set-up a Steam session gives it (droiddeck-session steam, minus gamescope), and # its games open in windows of their own. A client already running there gets the request instead # of a second one being started. # @@ -35,24 +35,24 @@ if [ "${BL_DESKTOP:-}" = 1 ] && [ -n "${WLR_RENDERER:-}" ] && [ "$WLR_RENDERER" # One client at a time: the running one takes the request (a second start hands its command # line over and exits). No game asked for = switch it to the UI that was clicked. [ -n "$url" ] || { [ "$ui" = bigpicture ] && url=steam://open/bigpicture || url=steam://open/main; } - echo "bannerlator-steam-launch: the client is running on the desktop; handing it $url" | tee -a "$log" + echo "droiddeck-steam-launch: the client is running on the desktop; handing it $url" | tee -a "$log" exec "$client" "$url" fi - echo "bannerlator-steam-launch: starting the Steam client on the desktop ($ui${url:+, $url}); log: $log" + echo "droiddeck-steam-launch: starting the Steam client on the desktop ($ui${url:+, $url}); log: $log" # BL_INSIDE: the session script's Steam start-up without gamescope in front of it. The desktop UI # unless Big Picture was asked for; never Deck mode (its Quick Access Menu needs gamescope). export BL_INSIDE=1 BL_STEAMDECK=0 if [ "$ui" = desktop ]; then export BL_STEAM_UI=desktop; else unset BL_STEAM_UI; fi - setsid /usr/local/bin/bannerlator-session steam ${url:+"$url"} >>"$log" 2>&1 < /dev/null & + setsid /usr/local/bin/droiddeck-session steam ${url:+"$url"} >>"$log" 2>&1 < /dev/null & exit 0 fi # The software desktop, or outside the desktop: ask the app for a Steam session. dir=${BL_LAUNCH_DIR:-} if [ -z "$dir" ] || [ ! -d "$dir" ]; then - echo "bannerlator-steam-launch: not inside a DroidDeck session (BL_LAUNCH_DIR unset)" >&2 + echo "droiddeck-steam-launch: not inside a DroidDeck session (BL_LAUNCH_DIR unset)" >&2 exit 69 fi # Written whole, then renamed: the app reads it on close, never half-written. printf 'ui=%s\nurl=%s\n' "$ui" "$url" > "$dir/steam-launch.tmp" && mv -f "$dir/steam-launch.tmp" "$dir/steam-launch" -echo "bannerlator-steam-launch: asked the app for Steam ($ui${url:+, $url})" +echo "droiddeck-steam-launch: asked the app for Steam ($ui${url:+, $url})" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library similarity index 94% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library index d065db6b5..f5d2b59c5 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-library +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-library @@ -2,7 +2,7 @@ # Registers the card the app installs to (bound at /mnt/droiddeck-sd) as a library folder of the # native Steam client, so they show as installed there instead of being downloaded a second time. # -# bannerlator-steam-library <steam root> +# droiddeck-steam-library <steam root> # # Ported from WinNative's gamescope runtime (GPL-3.0). import os @@ -61,7 +61,7 @@ def register(steam_root, library, label): os.makedirs(steamapps_dir, exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(text) - print("bannerlator-steam-library: registered %s with %d game(s)" % (library, len(apps))) + print("droiddeck-steam-library: registered %s with %d game(s)" % (library, len(apps))) return len(apps) @@ -85,7 +85,7 @@ def rename(steam_root, old, new): return with open(path, "w", encoding="utf-8") as f: f.write(old_path.sub(lambda m: m.group(1) + new + m.group(2), text)) - print("bannerlator-steam-library: moved %s to %s" % (old, new)) + print("droiddeck-steam-library: moved %s to %s" % (old, new)) def forget(steam_root, library): @@ -115,7 +115,7 @@ def forget(steam_root, library): if text != "\n".join(lines): with open(path, "w", encoding="utf-8") as f: f.write(text) - print("bannerlator-steam-library: forgot %s" % library) + print("droiddeck-steam-library: forgot %s" % library) def relabel(steam_root, target, label): @@ -151,7 +151,7 @@ def relabel(steam_root, target, label): if changed: with open(path, "w", encoding="utf-8") as f: f.write("\n".join(out)) - print("bannerlator-steam-library: labelled %s %r" % (target, label)) + print("droiddeck-steam-library: labelled %s %r" % (target, label)) # A library also carries its own libraryfolder.vdf, and the client takes the label from there # back into the list above - so editing the list alone is undone on the next start. The # client's own library has no such file, which is why that one kept the new name and the card @@ -165,7 +165,7 @@ def relabel(steam_root, target, label): if new != text: with open(marker, "w", encoding="utf-8") as f: f.write(new) - print("bannerlator-steam-library: labelled %s in its own marker too" % target) + print("droiddeck-steam-library: labelled %s in its own marker too" % target) def main(): diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim similarity index 78% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim index a30c2c8d8..03cc4c959 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shim +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shim @@ -1,7 +1,7 @@ #!/bin/bash # `steam` on the desktop. The client writes a menu entry per installed game that runs # `steam steam://rungameid/<id>`, and its own launchers say `steam`. On the desktop that goes -# through bannerlator-steam-launch - which runs the client on the desktop itself when it draws with +# through droiddeck-steam-launch - which runs the client on the desktop itself when it draws with # the GPU, hands the URL to a client already running there, and asks the app for a Steam session # on a software desktop. Anywhere else it is the client itself. set -u @@ -14,6 +14,6 @@ if [ "${BL_DESKTOP:-}" = 1 ]; then -gamepadui|-bigpicture) ui=bigpicture ;; esac done - exec /usr/local/bin/bannerlator-steam-launch "$ui" ${url:+"$url"} + exec /usr/local/bin/droiddeck-steam-launch "$ui" ${url:+"$url"} fi exec "${HOME:-/root}/.local/share/Steam/steamrtarm64/steam" "$@" diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts similarity index 89% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts index 0abb8b719..a08ccb7be 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-shortcuts @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Import added games while Steam is closed, preserving user-created shortcuts. -Usage: bannerlator-steam-shortcuts <steam_root> <added-games.json> +Usage: droiddeck-steam-shortcuts <steam_root> <added-games.json> """ import importlib.machinery import importlib.util @@ -163,7 +163,7 @@ def install_art(acct, games): os.replace(dst + ".tmp", dst) copied += 1 except OSError as e: - print("bannerlator-steam-shortcuts: could not copy art %s (%s)" % (name, e)) + print("droiddeck-steam-shortcuts: could not copy art %s (%s)" % (name, e)) for name in list(manifest): if name not in wanted: try: @@ -173,7 +173,7 @@ def install_art(acct, games): with open(manifest_path, "w", encoding="utf-8") as f: json.dump(wanted, f) if copied: - print("bannerlator-steam-shortcuts: %d piece(s) of art placed" % copied) + print("droiddeck-steam-shortcuts: %d piece(s) of art placed" % copied) def main(): @@ -185,11 +185,11 @@ def main(): with open(listing, encoding="utf-8") as f: games = json.load(f) except (OSError, ValueError) as e: - print("bannerlator-steam-shortcuts: no list (%s)" % e) + print("droiddeck-steam-shortcuts: no list (%s)" % e) return 0 acct = account_dir(steam_root) if not acct: - print("bannerlator-steam-shortcuts: no signed-in account yet; nothing written") + print("droiddeck-steam-shortcuts: no signed-in account yet; nothing written") return 0 config = os.path.join(acct, "config") os.makedirs(config, exist_ok=True) @@ -200,9 +200,9 @@ def main(): with open(path, "rb") as f: existing = parse(f.read()).get("shortcuts", {}) except (ValueError, IndexError, struct.error) as e: - print("bannerlator-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e) + print("droiddeck-steam-shortcuts: could not read the existing file (%s); keeping it unchanged" % e) return 1 - helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bannerlator-steam-games") + helper = os.path.join(os.path.dirname(os.path.abspath(__file__)), "droiddeck-steam-games") loader = importlib.machinery.SourceFileLoader("steam_games", helper) spec = importlib.util.spec_from_loader(loader.name, loader) routing = importlib.util.module_from_spec(spec) @@ -225,25 +225,26 @@ def main(): entry["LaunchOptions"] = previous["LaunchOptions"] ours.append(entry) entries = {str(i): v for i, v in enumerate(kept + ours)} - tmp = path + ".bannerlator" + tmp = path + ".droiddeck" with open(tmp, "wb") as f: f.write(write({"shortcuts": entries})) os.replace(tmp, path) - print("bannerlator-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept))) + print("droiddeck-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept))) install_art(acct, [g for g in games if g.get("exe") and g.get("name")]) # Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer. try: # No .py suffix on the registrar, so the loader has to be named. - compat_path = "/usr/local/bin/bannerlator-steam-compat" + compat_path = "/usr/local/bin/steam-compatibility" spec = importlib.util.spec_from_file_location("compat", compat_path, loader=importlib.machinery.SourceFileLoader("compat", compat_path)) compat = importlib.util.module_from_spec(spec) spec.loader.exec_module(compat) cfg = os.path.join(steam_root, "config", "config.vdf") if os.path.isfile(cfg) and (ours or routes): - compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())]) - print("bannerlator-steam-shortcuts: mapped them to %s" % compat.TOOL) + compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")] + [str(appid) for appid in set(routes.values())], + protect=compat.adopt_extras(os.path.join(steam_root, "compatibilitytools.d"))) + print("droiddeck-steam-shortcuts: mapped them to %s" % compat.TOOL) except Exception as e: # noqa: BLE001 - the games still show; the client falls back to the default tool - print("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e) + print("droiddeck-steam-shortcuts: compat mapping skipped (%s)" % e) return 0 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-ui-scale b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale similarity index 100% rename from tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-ui-scale rename to tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-ui-scale diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility new file mode 100755 index 000000000..f3600e557 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -0,0 +1,1112 @@ +#!/usr/bin/python3 +# Makes the ARM64 Proton the client already downloaded usable here, and sets it as the default tool +# for Windows titles (CompatToolMapping "0" in config.vdf) when the user has not chosen one. Run +# before the client starts; it rewrites config.vdf on exit. +# +# steam-compatibility <steam root> +# +# Valve ships a native aarch64 Proton (arm64ec Wine with FEX as the x86 layer) as an ordinary depot, +# which is exactly what this runtime wants. Its toolmanifest declares require_tool_appid 4185400 - +# Steam Linux Runtime 4 for arm64 - so Steam stacks pressure-vessel underneath it, and +# pressure-vessel needs unprivileged user namespaces, which an Android app does not get. Steam then +# drops the launch on the floor: no window, no log, straight back to the library. +# +# So the depot is re-registered here as a compatibility tool of our own: every entry symlinked so +# nothing is copied and Steam still updates the real depot, but with a toolmanifest that leaves out +# require_tool_appid. Proton takes its base directory from dirname(sys.argv[0]) without resolving +# it, so it reads files/ through those symlinks and runs directly on this rootfs. +import json +import os +import re +import runpy +import shutil +import sys + +TOOL = "droiddeck-proton-arm64" +TOOL_11 = "droiddeck-proton-11-arm64" +LEGACY_TOOL = "bannerlator-proton-arm64" +LABEL_FILE = "/etc/steam-compatibility-label" +# The ARM64 Proton depot the client downloads for itself, as a compatibility-tool appid. +# The session asks the client to install it on the command line the first time (steam://install), +# so a new client has it before a Windows title is first launched. It is deliberately NOT named as +# this tool's dependency: a dependency is composed with its own dependencies, and this depot +# requires 4185400, the arm64 Steam Linux Runtime, whose container needs user namespaces Android +# does not give - the launch would be wrapped in it and die before this tool ever ran. +PROTON_ARM64_APPID = "4427310" +LAUNCHER = "droiddeck-proton" +LEGACY_LAUNCHER = "bannerlator-proton" +# The extra Steam library the app mounts the installed games into (see droiddeck-steam-library). +# installed_apps() scans it too; it lives only in the sibling script, so define it here as well. +LIBRARY = "/mnt/droiddeck" # the card library is LIBRARY + "-sd"; internal storage is steam_root itself +# The client lists its own "Proton Experimental (ARM64)" and "Proton 11.0 (ARM64)" in the same +# Compatibility dropdown, and those are the two that cannot start a game here. Ours has to be +# unmistakable at a glance, or a user picks a neighbour and gets a launch that fails silently. +# Newest first: whichever the client has downloaded is used. +SOURCES = ("Proton Experimental (ARM64)", "Proton 11.0 (ARM64)") +OWN_TOOLS = ((TOOL, SOURCES), (TOOL_11, ("Proton 11.0 (ARM64)",))) +FEX_TOOL = "droiddeck-fex" +FEX_LAUNCHER = "droiddeck-fex-tool" +FEX_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", FEX_LAUNCHER) +FEX_APPID = "3127680" +SNIPER_APPID = "1628350" +FEX_DEPENDENCIES = (FEX_APPID, SNIPER_APPID) +REQUEST_HANDOFF_SECONDS = 30 +PLAYABLE_TYPES = frozenset(("game", "demo", "application", "beta")) +COMPAT_DIR = os.path.join(os.environ.get("HOME", "/root"), ".local", "share", "droiddeck-compat") +# Written by us; everything else in the depot is symlinked through. +OWN_FILES = ("toolmanifest.vdf", "compatibilitytool.vdf", LAUNCHER) +# The client assigns a Proton to a Windows title by itself, as a per-app CompatToolMapping entry at +# priority 250 - which outranks the "0" default, so setting that default alone changes nothing. Every +# Proton it can pick is one this runtime cannot start: the ARM64 ones want pressure-vessel (see +# above) and the x86_64 ones want pressure-vessel and FEX on top. Ours is the only one that runs +# here, so every Proton mapping is repointed at it. Tools that are not Proton are left alone. +COMPAT_TOOL_VDF = """"compatibilitytools" +{ + "compat_tools" + { + "%s" + { + "install_path" "." + "display_name" "%s" + "from_oslist" "windows" + "to_oslist" "linux" + } + } +} +""" +TOOL_MANIFEST_VDF = """"manifest" +{ + "version" "2" + "commandline" "/%s %%verb%%" + "use_sessions" "1" + "compatmanager_layer_name" "proton" +} +""" % LAUNCHER + + +# Steam runs its own graphics probe through EVERY registered tool at once, ~40 s after logging in, +# all with STEAM_COMPAT_DATA_PATH=.../compatdata/0, and those concurrent Protons can write their +# builtin DLLs through each other's prefix links into each other's trees (seen: GE's FEX DLLs became +# Proton Experimental's). Moving those runs to a prefix per tool was tried and crashed the Steam +# client right after the probe started (segfault, rc 139, first ever in 25 sessions), so the client +# relies on compatdata/0 itself. The launch-time check below is the protection instead. + +# The Components page's choices, enforced in the one place every game launch passes: the app keeps +# desired.tsv (tool dir, Proton build, component, file, sha256, source copy) and an unpacked copy of +# each package in use. A file that no longer matches - whatever changed it - is put back before +# Proton starts. Only for the Proton build the choice was made for: after a Steam update the build +# differs and the Proton's own new files are left alone until the user chooses again. +BL_COMPONENTS_SETUP = """ +bl_components() { + base=$1 + cm_dir="${HOME:-/root}/.local/share/droiddeck-components" + cm_list="$cm_dir/desired.tsv" + # A real game launch only: the verb the client starts a game with and the game's own prefix. Every + # other run - above all the client's own probes right after login, which all share compatdata/0 - + # is left exactly as it was: with this check running through them, the client segfaulted (rc 139) + # within seconds of starting its probes, four sessions out of four. The prefix decides rather than + # the app id because a non-Steam shortcut launches with app id 0 but still gets a prefix of its own. + [ "${verb:-}" = waitforexitandrun ] || return 0 + case "${STEAM_COMPAT_DATA_PATH:-}" in ''|*/compatdata/0|*/compatdata/0/|*/compatdata/0-*) return 0 ;; esac + [ -f "$cm_list" ] || return 0 + cm_app=${STEAM_COMPAT_DATA_PATH%/} + cm_app=${cm_app##*/} + cm_tool=${base##*/} + cm_build=$(awk 'NR == 1 { print $2 }' "$base/version" 2>/dev/null) + cm_fixed=0 + cm_checked=0 + while IFS=$'\\t' read -r t want comp rel sum src; do + [ "$t" = "$cm_tool" ] && [ "$want" = "$cm_build" ] || continue + cm_checked=$((cm_checked + 1)) + f="$base/$rel" + if [ -f "$f" ] && [ "$(sha256sum "$f" | cut -d' ' -f1)" = "$sum" ]; then continue; fi + if [ ! -f "$src" ]; then + echo "droiddeck-proton: components: $comp source missing ($src), left as it is" >&2 + continue + fi + mkdir -p "$(dirname "$f")" + cp -f "$src" "$f.bl-new" && mv -f "$f.bl-new" "$f" || continue + case "$rel" in *.so) chmod 755 "$f" ;; *) chmod 644 "$f" ;; esac + cm_fixed=$((cm_fixed + 1)) + done < "$cm_list" + [ "$cm_checked" -gt 0 ] && echo "droiddeck-proton: components: checked $cm_checked file(s) in $cm_tool for app $cm_app" >&2 + if [ "$cm_fixed" -gt 0 ]; then + echo "droiddeck-proton: components: re-applied $cm_fixed of $cm_checked file(s) in $cm_tool before this launch" >&2 + printf '%s\\t%s\\t%s\\n' "$(date +%s)" "$cm_tool" "$cm_fixed" >> "$cm_dir/reapplied.log" + fi +} +""" + +# Resolves the depot at launch time, so the tool can be registered before the depot exists and +# survives the client updating or replacing it. Proton takes its base directory from +# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. +# Cores for a game the client launches, kept separate from the client's own. One mask suits the +# client's menus and another suits a game, and they are wanted at the same time - so the game's is +# applied here, in the launch path every game goes through, rather than to the session as a whole. +BL_GAME_CPUS_SETUP = """ +bl_game_cpus() { + [ -n "${BL_GAME_CPUS:-}" ] || return 0 + command -v taskset >/dev/null 2>&1 || return 0 + # exec'd through taskset rather than set afterwards: a mask applied to a pid races the process + # into spawning its own threads, and this way every thread inherits it from the first instruction. + BL_TASKSET="taskset -c $BL_GAME_CPUS" + echo "droiddeck-proton: game cores $BL_GAME_CPUS" >&2 +} +""" + +# Quake III engine games (id Tech 3) set their own resolution and switch the display to it. Under +# gamescope with the ARM64 Proton that switch is refused ("calling CDS: failed, bad mode") and the +# game never gets OpenGL; left at its own size in a window, it draws small in a corner. So these +# titles are told to stay windowed at the session's own size - unless the user's launch options +# already say something about the video mode, which are left as they are. +# The session puts the client's own libraries (steamrtarm64, its panorama/) ahead of everything for +# the client, and a game inherits that: Wine then resolved gnutls, SDL, FFmpeg and the rest against +# the client's private copies instead of the rootfs it was built for. Inside Steam's runtime +# container that path never reaches Proton either. (WinNative's winnative-proton-launch.) +BL_LIBPATH_SETUP = """ +bl_libpath() { + # A Proton built to prefer PipeWire (CachyOS's among the adopted ones) hung its audio thread + # here, where only PulseAudio answers. (WinNative, winnative-proton-launch.) + export PROTON_USE_PIPEWIRE=${PROTON_USE_PIPEWIRE:-0} + [ -n "${LD_LIBRARY_PATH:-}" ] || return 0 + local kept= dir old_ifs=$IFS + IFS=: + for dir in $LD_LIBRARY_PATH; do + case $dir in + "" | */steamrtarm64 | */steamrtarm64/*) ;; + *) kept=${kept:+$kept:}$dir ;; + esac + done + IFS=$old_ifs + if [ -n "$kept" ]; then export LD_LIBRARY_PATH="$kept"; else unset LD_LIBRARY_PATH; fi +} +""" + +BL_IDTECH3_SETUP = """ +bl_idtech3() { + BL_EXTRA_ARGS=() + case "${SteamAppId:-${STEAM_COMPAT_APP_ID:-}}" in + # Quake III Arena, Quake III: Team Arena, Return to Castle Wolfenstein, Jedi Academy. + 2200|2350|9010|6020) ;; + *) return 0 ;; + esac + case " $* " in + *r_fullscreen*|*r_mode*|*r_customwidth*|*r_customheight*) + echo "droiddeck-proton: Quake-engine title; its launch options set the video mode, left as they are" >&2 + return 0 ;; + esac + w=${BL_WIDTH:-1280} + h=${BL_HEIGHT:-720} + BL_EXTRA_ARGS=(+set r_fullscreen 0 +set r_mode -1 +set r_customwidth "$w" +set r_customheight "$h") + echo "droiddeck-proton: Quake-engine title: windowed at ${w}x${h}" >&2 +} +""" + +BL_DIRECTAUDIO_SETUP = """ +bl_directaudio() { + base=$1 + [ -n "${BL_DIRECTAUDIO:-}" ] || return 0 + [ -d "$BL_DIRECTAUDIO/lib/wine" ] || return 0 + version=$("$base/files/bin-arm64/wine" --version 2>/dev/null) + major=${version#wine-} + major=${major%%.*} + if [ "$major" != "11" ]; then + echo "droiddeck-proton: DirectAudio left off - it is built for Wine 11 and this Proton is '${version:-unknown}'. Pairing them would be silent rather than broken, so it is not worth guessing." >&2 + return 0 + fi + # Wine appends the per-architecture subdirectory to each entry, so the directory holding + # aarch64-unix/ and aarch64-windows/ is what goes here. + WINEDLLPATH="$BL_DIRECTAUDIO/lib/wine${WINEDLLPATH:+:$WINEDLLPATH}" + export WINEDLLPATH + # mmdevapi only tries the drivers named in the registry; its built-in list is pulse, alsa, oss and + # coreaudio and knows nothing about this one. The key is appended to the prefix's user.reg rather + # than written with `wine reg`, which would start a wineserver here just to set one value. + pfx="${STEAM_COMPAT_DATA_PATH:-}/pfx" + reg="$pfx/user.reg" + if [ -f "$reg" ]; then + for pair in system32:aarch64-windows syswow64:i386-windows; do + dir="$pfx/drive_c/windows/${pair%%:*}" + src="$BL_DIRECTAUDIO/lib/wine/${pair#*:}/winedirectaudio.drv" + if [ -d "$dir" ] && [ -f "$src" ] && [ "$(readlink "$dir/winedirectaudio.drv" 2>/dev/null)" != "$src" ]; then + ln -sfn "$src" "$dir/winedirectaudio.drv.bl-new" && mv -f "$dir/winedirectaudio.drv.bl-new" "$dir/winedirectaudio.drv" + fi + done + key='[Software\\\\Wine\\\\Drivers]' + if [ "$(readlink "$pfx/drive_c/windows/system32/winedirectaudio.drv" 2>/dev/null)" != "$BL_DIRECTAUDIO/lib/wine/aarch64-windows/winedirectaudio.drv" ]; then + echo "droiddeck-proton: DirectAudio could not be placed in the prefix; Proton's own audio stays" >&2 + elif ! BL_KEY="$key" awk 'index($0, "[") == 1 { inside = index($0, ENVIRON["BL_KEY"] " ") == 1 } inside && index($0, "\\"Audio\\"=") == 1 { last = $0 } END { exit last != "\\"Audio\\"=\\"directaudio,pulse\\"" }' "$reg" 2>/dev/null; then + printf '\\n%s %s\\n"Audio"="directaudio,pulse"\\n' "$key" "$(date +%s)" >> "$reg" + echo "droiddeck-proton: DirectAudio selected in the prefix" >&2 + fi + else + # A prefix that does not exist yet is created by Proton moments from now, after this runs, so + # the key lands on the NEXT launch of that game. Said out loud because silent audio on a first + # launch would otherwise look like a fault. + echo "droiddeck-proton: no prefix yet - DirectAudio takes effect the next time this game starts" >&2 + fi + echo "droiddeck-proton: DirectAudio ready (Wine $major)" >&2 +} +""" + +# Resolves the depot at launch time, so the tool can be registered before the depot exists and +# survives the client updating or replacing it. Proton takes its base directory from +# dirname(sys.argv[0]) without resolving it, so it is run from the depot itself. +LAUNCHER_SH = """#!/bin/bash +# DroidDeck's Proton ARM64: runs the ARM64 Proton the Steam client downloaded, without the +# Steam Linux Runtime container its own manifest asks for (no user namespaces on Android). +verb=$1; shift +steam_root=${STEAM_COMPAT_CLIENT_INSTALL_PATH:-${XDG_DATA_HOME:-$HOME/.local/share}/Steam} +# Steam's overlay is preloaded into every game Valve launches, and used to be dropped here: it was +# landing in front of open() and read() on /dev/input, letting Wine open the session's pad and +# answering every read of it with nothing - a controller that never reports, and Wine's HID thread +# spinning a core on it. +# +# Removing it was the wrong cure for an ordering problem. glibc processes LD_PRELOAD BEFORE +# /etc/ld.so.preload, and the interposer lives in the latter, so the overlay was simply ahead of it. +# Naming the interposer first in LD_PRELOAD puts it back in front and the overlay loads as Valve +# intends. That matters beyond the overlay itself: every deviation from the launch Valve ships is a +# difference this runtime would otherwise have to justify. +for fake in /usr/local/lib/libfakeinput.so /usr/local/lib/libblsession.so; do + if [ -f "$fake" ]; then + case ":${LD_PRELOAD:-}:" in + *":$fake:"*) ;; + *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; + esac + fi +done +# The depot this tool is named after, newest across the libraries that hold it; the others in the +# list stand in only when it is missing. +depot= +for name in %s; do + best=0 + for library in "$steam_root" /mnt/droiddeck-sd; do + candidate="$library/steamapps/common/$name" + [ -d "$candidate/files/bin-arm64" ] && [ -x "$candidate/proton" ] || continue + build=0 + read -r build _ 2>/dev/null < "$candidate/version" + case $build in "" | *[!0-9]*) build=0 ;; esac + if [ -z "$depot" ] || [ "$build" -gt "$best" ]; then + depot=$candidate + best=$build + fi + done + [ -n "$depot" ] && break +done +if [ -n "$depot" ]; then + bl_components "$depot" + bl_directaudio "$depot" + bl_game_cpus + bl_idtech3 "$@" + bl_libpath + exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env "$depot/proton" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" +fi +echo "droiddeck-proton: no ARM64 Proton depot yet - the client downloads one as this tool's dependency; retry the launch once it has" >&2 +exit 1 +""" +LAUNCHER_TEMPLATE = LAUNCHER_SH + + +def launcher_sh(sources=SOURCES): + # Spliced in after the formatting, never through it: the function body contains ${major%%.*} + # and a % format would eat that into a single %, leaving a shell expansion that silently does + # something else. + return (LAUNCHER_TEMPLATE % " ".join('"%s"' % n for n in sources)).replace( + "verb=$1; shift", "verb=$1; shift\n" + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) + + +LAUNCHER_SH = launcher_sh() + +# A third-party Proton dropped into compatibilitytools.d - GE-Proton, proton-cachyos, anything of +# that shape - is adopted rather than used as shipped, for the same two reasons Valve's ARM64 depot +# is: its manifest declares require_tool_appid, and the client's overlay library has to come out of +# LD_PRELOAD. Its own entry point is left untouched and called through this wrapper. +EXTRA_WRAPPER = "droiddeck-proton-wrap" +LEGACY_EXTRA_WRAPPER = "bannerlator-proton-wrap" +ORIGINAL_SUFFIX = ".droiddeck-orig" +LEGACY_ORIGINAL_SUFFIX = ".bannerlator-orig" +EXTRA_MANIFEST_VDF = """"manifest" +{ + "version" "2" + "commandline" "/%s %%verb%%" + "use_sessions" "1" + "compatmanager_layer_name" "proton" +} +""" % EXTRA_WRAPPER +# %s is the tool's own entry point, read out of the manifest it shipped with. +EXTRA_WRAPPER_SH = """#!/bin/bash +# DroidDeck: runs a Proton installed into compatibilitytools.d without the Steam Linux Runtime +# container its own manifest asks for - pressure-vessel needs unprivileged user namespaces, which +# an Android app does not get, and the launch dies before Proton is reached. +verb=$1; shift +here=$(dirname "$0") +# See droiddeck-proton: the interposer goes first in LD_PRELOAD rather than the overlay being +# removed, because glibc reads LD_PRELOAD before /etc/ld.so.preload and the overlay was simply +# ahead of it on /dev/input. +for fake in /usr/local/lib/libfakeinput.so /usr/local/lib/libblsession.so; do + if [ -f "$fake" ]; then + case ":${LD_PRELOAD:-}:" in + *":$fake:"*) ;; + *) LD_PRELOAD="$fake${LD_PRELOAD:+:$LD_PRELOAD}"; export LD_PRELOAD ;; + esac + fi +done +bl_components "$here" +bl_directaudio "$here" +bl_game_cpus +bl_idtech3 "$@" +bl_libpath +# Proton takes its base directory from dirname(sys.argv[0]) without resolving it, so it has to be +# started from its own tree. +exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env "$here/%s" "$verb" "$@" "${BL_EXTRA_ARGS[@]}" +""" +EXTRA_COMPAT_TOOL_VDF = """"compatibilitytools" +{ + "compat_tools" + { + "%s" + { + "install_path" "." + "display_name" "%s" + "from_oslist" "windows" + "to_oslist" "linux" + } + } +} +""" + + +FEX_COMPAT_TOOL_VDF = """"compatibilitytools" +{ + "compat_tools" + { + "%s" + { + "install_path" "." + "display_name" "%s" + "from_oslist" "linux" + "to_oslist" "linux" + } + } +} +""" +FEX_MANIFEST_VDF = """"manifest" +{ + "version" "2" + "commandline" "/%s %%verb%%" + "use_tool_subprocess_reaper" "1" +} +""" % FEX_LAUNCHER +FEX_LAUNCHER_SH = """#!/bin/bash +verb=$1; shift +case $verb in + getcompatpath | getnativepath) printf '%s\\n' "$1"; exit 0 ;; +esac +exec /usr/local/bin/droiddeck-fex steam "$verb" "$@" +""" + + +TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)') + + +def tokenize(text): + tokens = [] + pos = 0 + while pos < len(text): + m = TOKEN.match(text, pos) + if not m or m.end() == pos: + break + pos = m.end() + if m.group(2): + tokens.append(m.group(2)) + elif m.group(1) is not None: + tokens.append('"' + m.group(1) + '"') + return tokens + + +def find_block(tokens, path): + """Index of the '{' opening the block at path, or None.""" + depth, want, i = 0, 0, 0 + while i < len(tokens): + t = tokens[i] + if t == "{": + depth += 1 + elif t == "}": + depth -= 1 + if depth < want: + return None + elif depth == want and i + 1 < len(tokens) and tokens[i + 1] == "{": + if t.lower() == '"%s"' % path[want].lower(): + if want == len(path) - 1: + return i + 1 + want += 1 + i += 1 + return None + + +def block_end(tokens, start): + depth = 0 + for i in range(start, len(tokens)): + if tokens[i] == "{": + depth += 1 + elif tokens[i] == "}": + depth -= 1 + if depth == 0: + return i + return len(tokens) + + +def serialize(tokens): + out, depth, i = [], 0, 0 + while i < len(tokens): + t = tokens[i] + if t == "}": + depth -= 1 + out.append("\t" * depth + "}") + i += 1 + elif i + 1 < len(tokens) and tokens[i + 1] == "{": + out.append("\t" * depth + t) + out.append("\t" * depth + "{") + depth += 1 + i += 2 + else: + out.append("\t" * depth + t + "\t\t" + tokens[i + 1]) + i += 2 + return "\n".join(out) + "\n" + + +def write_changed(path, text, mode=None): + """Writes `text` to `path` only when it differs, through a rename so a reader never sees half a + file. The session runs this script every 15 s for as long as the client does; rewriting files + that have not changed cost a python start's worth of writes each time, and config.vdf is the + client's own, which it may be writing at that moment. Returns whether anything was written.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + same = f.read() == text + except OSError: + same = False + if not same: + tmp = path + ".droiddeck" + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + if mode is not None: + os.chmod(tmp, mode) + os.replace(tmp, path) + elif mode is not None and (os.stat(path).st_mode & 0o7777) != mode: + os.chmod(path, mode) + return not same + + +def display_name(base): + try: + with open(LABEL_FILE, encoding="utf-8") as f: + label = f.read().strip().replace('"', "'").replace("\\", "") + except OSError: + label = "" + return "%s (%s)" % (base, label) if label else base + + +def first_key(path, section): + """The single key naming a block inside `section`, or None. Used to keep a third-party tool's + own internal name, so a mapping the user already made against it keeps working.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + tokens = tokenize(f.read()) + except OSError: + return None + block = find_block(tokens, section) + if block is None: + return None + for i in range(block + 1, block_end(tokens, block)): + if tokens[i] == "}": + break + if i + 1 < len(tokens) and tokens[i + 1] == "{": + return tokens[i].strip('"') + return None + + +def manifest_entry_point(path): + """The script a toolmanifest's commandline runs, relative to the tool directory.""" + try: + with open(path, encoding="utf-8", errors="replace") as f: + tokens = tokenize(f.read()) + except OSError: + return None + for i in range(len(tokens) - 1): + if tokens[i].lower() == '"commandline"': + command = tokens[i + 1].strip('"').split() + if command: + return command[0].lstrip("/") + return None + + +def adopt_extras(tools): + """Makes every other Proton in compatibilitytools.d launchable here, and returns the tool names + to leave alone in CompatToolMapping - the whole point of installing one is to pick it per game, + so register_default must not repoint it back at ours.""" + adopted = {} + try: + entries = sorted(os.listdir(tools)) + except OSError: + return adopted + for entry in entries: + if entry in dict(OWN_TOOLS) or entry == FEX_TOOL or entry.startswith("."): + continue + tool_dir = os.path.join(tools, entry) + manifest = os.path.join(tool_dir, "toolmanifest.vdf") + if not os.path.isdir(tool_dir) or not os.path.isfile(manifest): + continue + # The shipped manifest is kept, once, and stays the source of the entry point: re-running + # must not read back our own rewritten commandline and wrap the wrapper. + original = manifest + ORIGINAL_SUFFIX + legacy = manifest + LEGACY_ORIGINAL_SUFFIX + if not os.path.isfile(original) and os.path.isfile(legacy): + try: + os.replace(legacy, original) + except OSError: + continue + if not os.path.isfile(original): + try: + shutil.copy2(manifest, original) + except OSError: + continue + entry_point = manifest_entry_point(original) + if not entry_point or entry_point in (EXTRA_WRAPPER, LEGACY_EXTRA_WRAPPER): + entry_point = "proton" + if not os.path.exists(os.path.join(tool_dir, entry_point)): + print("steam-compatibility: %s has no %s; left alone" % (entry, entry_point)) + continue + compat_vdf = os.path.join(tool_dir, "compatibilitytool.vdf") + name = first_key(compat_vdf, ["compatibilitytools", "compat_tools"]) or entry + wrapper = os.path.join(tool_dir, EXTRA_WRAPPER) + try: + # Formatted first, spliced second: the function carries a printf %s and a + # ${major%%.*} of its own, and putting it through the format would eat both. + body = (EXTRA_WRAPPER_SH % entry_point).replace( + 'here=$(dirname "$0")', + 'here=$(dirname "$0")\n' + BL_DIRECTAUDIO_SETUP + BL_GAME_CPUS_SETUP + BL_IDTECH3_SETUP + + BL_COMPONENTS_SETUP + BL_LIBPATH_SETUP, 1) + write_changed(wrapper, body, 0o755) + write_changed(manifest, EXTRA_MANIFEST_VDF) + write_changed(compat_vdf, EXTRA_COMPAT_TOOL_VDF % (name, display_name(entry))) + legacy_wrapper = os.path.join(tool_dir, LEGACY_EXTRA_WRAPPER) + if os.path.isfile(legacy_wrapper): + os.unlink(legacy_wrapper) + except OSError as e: + print("steam-compatibility: could not adopt %s (%s)" % (entry, e)) + continue + adopted[name] = entry + return adopted + + +# Steam's own tools and runtimes live in steamapps beside the games; they are not titles and must +# not be mapped to a compatibility tool. +NOT_GAMES = frozenset(( + "228980", # Steamworks Common Redistributables + "1070560", # Steam Linux Runtime 1.0 (scout) + "1391110", # Steam Linux Runtime 2.0 (soldier) + "1628350", # Steam Linux Runtime 3.0 (sniper) + "1493710", # Proton Experimental + "3127680", # FEX + "4183110", # Steam Linux Runtime 4.0 + "4185400", # Steam Linux Runtime 4.0 - Arm64 + "4427310", # Proton Experimental (ARM64) + "4628740", # Proton 11.0 (ARM64) +)) + + +def installed_apps(steam_root): + """App ids of the titles the client has installed, tools and runtimes excluded.""" + apps = set() + for library in (steam_root, LIBRARY + "-sd"): + steamapps = os.path.join(library, "steamapps") + try: + names = os.listdir(steamapps) + except OSError: + continue + for name in names: + m = re.fullmatch(r"appmanifest_(\d+)\.acf", name) + if m and m.group(1) not in NOT_GAMES: + apps.add(m.group(1)) + return sorted(apps, key=int) + + +APPINFO_CACHE = {} + + +def appinfo_platforms(path, wanted): + import struct + try: + stamp = os.stat(path).st_mtime_ns + except OSError: + return {} + cached = APPINFO_CACHE.get(path) + if cached and cached[0] == stamp and wanted <= cached[1].keys() | cached[2]: + return {app: cached[1][app] for app in wanted if app in cached[1]} + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return {} + if len(data) < 8: + return {} + magic = struct.unpack_from("<I", data, 0)[0] + if magic not in (0x07564427, 0x07564428, 0x07564429): + return {} + offset = 8 + strings = None + if magic == 0x07564429: + table = struct.unpack_from("<q", data, offset)[0] + offset += 8 + count = struct.unpack_from("<I", data, table)[0] + position = table + 4 + strings = [] + for _ in range(count): + end = data.index(b"\0", position) + strings.append(data[position:end].decode("utf-8", "replace")) + position = end + 1 + header = 4 + 4 + 8 + 20 + 4 + (20 if magic >= 0x07564428 else 0) + + def section(position): + result = {} + while True: + kind = data[position] + position += 1 + if kind == 8: + return result, position + if strings is not None: + key = strings[struct.unpack_from("<I", data, position)[0]] + position += 4 + else: + end = data.index(b"\0", position) + key = data[position:end].decode("utf-8", "replace") + position = end + 1 + if kind == 0: + result[key], position = section(position) + elif kind == 1: + end = data.index(b"\0", position) + result[key] = data[position:end].decode("utf-8", "replace") + position = end + 1 + elif kind == 2: + result[key] = struct.unpack_from("<i", data, position)[0] + position += 4 + elif kind == 7: + result[key] = struct.unpack_from("<Q", data, position)[0] + position += 8 + else: + raise ValueError("unknown appinfo field type %d" % kind) + + found = {} + try: + while offset + 8 <= len(data): + app, size = struct.unpack_from("<II", data, offset) + if app == 0: + break + start = offset + 8 + offset = start + size + if str(app) not in wanted: + continue + info = section(start + header)[0].get("appinfo", {}) + common = info.get("common", {}) if isinstance(info, dict) else {} + oslist = {part.strip().lower() for part in str(common.get("oslist", "")).split(",") if part.strip()} + found[str(app)] = (oslist, str(common.get("osarch", "")).lower(), str(common.get("type", "")).lower()) + except (ValueError, IndexError, struct.error): + return {} + APPINFO_CACHE[path] = (stamp, found, set(wanted)) + return {app: found[app] for app in wanted if app in found} + + +def native_targets(steam_root, apps): + wanted = {str(app) for app in apps if str(app) not in NOT_GAMES} + if not wanted: + return {} + targets = {} + for app, (oslist, osarch, kind) in appinfo_platforms(os.path.join(steam_root, "appcache", "appinfo.vdf"), wanted).items(): + if kind not in PLAYABLE_TYPES or "windows" in oslist or "linux" not in oslist: + continue + if "arm64" in osarch or "aarch64" in osarch: + continue + targets[app] = FEX_TOOL + return targets + + +def linux_runtime_tool(name): + low = name.lower() + return low == "fex" or low.startswith("steamlinuxruntime") + + +def fex_missing(steam_root): + missing = [] + for app in FEX_DEPENDENCIES: + if not any(os.path.isfile(os.path.join(library, "steamapps", "appmanifest_%s.acf" % app)) + for library in (steam_root, LIBRARY + "-sd")): + missing.append(app) + return missing + + +def fex_wanted_marker(): + return os.path.join(os.environ.get("HOME", "/root"), ".config", "droiddeck", "fex-wanted") + + +def request_fex(steam_root, asked): + import subprocess + client = os.path.join(steam_root, "steamrtarm64", "steam") + missing = fex_missing(steam_root) + if not os.access(client, os.X_OK) or any(app in asked for app in missing): + return [] + for app in missing: + asked.add(app) + try: + process = subprocess.Popen([client, "steam://install/%s" % app], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + except OSError: + continue + try: + process.wait(timeout=REQUEST_HANDOFF_SECONDS) + except subprocess.TimeoutExpired: + pass + return [app] + return [] + + +def read_json(path): + try: + with open(path, encoding="utf-8") as f: + value = json.load(f) + except (OSError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def write_json(path, value): + os.makedirs(os.path.dirname(path), exist_ok=True) + return write_changed(path, json.dumps(value, indent=1, sort_keys=True) + "\n") + + +class locked: + def __init__(self, directory=None): + self.path = os.path.join(directory or COMPAT_DIR, "lock") + + def __enter__(self): + import fcntl + os.makedirs(os.path.dirname(self.path), exist_ok=True) + self.fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o644) + fcntl.flock(self.fd, fcntl.LOCK_EX) + return self + + def __exit__(self, *exc): + os.close(self.fd) + + +def state_path(name, directory=None): + return os.path.join(directory or COMPAT_DIR, name) + + +def unrunnable(name, runnable): + low = name.lower() + return low not in runnable and (low.startswith("proton") or low == LEGACY_TOOL or low.startswith("droiddeck-proton")) + + +def app_order(app): + return (len(app), app) + + +def plan_mapping(current, installed, runnable, default, auto, native=None): + runnable = {n.lower() for n in runnable} + installed = set(installed) + native = native or {} + changes, kept = {}, {} + if current.get("0", "") != default: + changes["0"] = default + for app in sorted((set(current) | installed | set(auto)) - {"0"}, key=app_order): + name = current.get(app, "") + mine = auto.get(app) + target = native.get(app, default) + stale = unrunnable(name, runnable) or (app in native and linux_runtime_tool(name)) + if name and not stale and (mine is None or name != mine): + continue + if not name and app not in installed: + continue + if name != target: + changes[app] = target + kept[app] = target + return changes, kept + + +def followers(state, current): + if "auto" in state: + return state["auto"] + return {app: name for app, name in current.items() if app != "0" and name == TOOL} + + +def tool_entry(name, display, path, valve): + return {"name": name, "display": display, "dir": os.path.basename(path) if path else "", "valve": valve} + + +def tool_catalog(steam_root, extras=None): + tools = os.path.join(steam_root, "compatibilitytools.d") + catalog = [] + for name, sources in OWN_TOOLS: + depot = find_source(steam_root, sources) + if depot is None and name != TOOL: + continue + catalog.append(tool_entry(name, display_name( + os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64"), depot, True)) + if extras is None: + extras = {} + try: + entries = sorted(os.listdir(tools)) + except OSError: + entries = [] + for entry in entries: + tool_dir = os.path.join(tools, entry) + if entry in dict(OWN_TOOLS) or not any(os.path.isfile(os.path.join(tool_dir, w)) for w in (EXTRA_WRAPPER, LEGACY_EXTRA_WRAPPER)): + continue + extras[first_key(os.path.join(tool_dir, "compatibilitytool.vdf"), ["compatibilitytools", "compat_tools"]) or entry] = entry + for name, entry in sorted(extras.items()): + catalog.append(tool_entry(name, display_name(entry), os.path.join(tools, entry), False)) + return catalog + + +def requested_tool(request, catalog): + name = request.get("name") + if isinstance(name, str) and any(t["name"] == name for t in catalog): + return name + for tool in catalog: + if tool["dir"] and tool["dir"] == request.get("dir") and tool["valve"] == bool(request.get("valve")): + return tool["name"] + return None + + +def now_ms(): + import time + return int(time.time() * 1000) + + +def choose_default(steam_default, state, request, catalog, adopt_steam=True, apply_request=True, now=None): + state = dict(state) + names = [t["name"] for t in catalog] + seq = request.get("seq") + seq = seq if isinstance(seq, int) else 0 + if apply_request and seq > int(state.get("applied", 0) or 0): + wanted = requested_tool(request, catalog) + state["applied"] = seq + if wanted: + if wanted != state.get("default"): + state.update(default=wanted, seq=now or now_ms(), source="app") + else: + print("steam-compatibility: the app asked for %s, which is not installed; kept %s" % ( + request.get("name") or request.get("dir"), state.get("default") or TOOL)) + elif adopt_steam and steam_default in names and steam_default != state.get("default"): + state.update(default=steam_default, seq=now or now_ms(), source="steam") + if state.get("default") not in names: + state["default"] = TOOL + return state["default"], state + + +def mapping_block(tokens): + steam = find_block(tokens, ["InstallConfigStore", "Software", "Valve", "Steam"]) + if steam is None: + return None + mapping = find_block(tokens[steam + 1:], ["CompatToolMapping"]) + if mapping is None: + end = block_end(tokens, steam) + tokens[end:end] = ['"CompatToolMapping"', "{", "}"] + return end + 1 + return mapping + steam + 1 + + +def read_mapping(tokens, mapping): + current = {} + end = block_end(tokens, mapping) + i = mapping + 1 + while i < end: + if i + 1 < end and tokens[i + 1] == "{": + close = block_end(tokens, i + 1) + name = "" + for j in range(i + 2, close - 1): + if tokens[j] == '"name"': + name = tokens[j + 1].strip('"') + break + current[tokens[i].strip('"')] = name + i = close + 1 + else: + i += 2 + return current + + +def set_mapping(tokens, mapping, app, tool): + block = find_block(tokens[mapping + 1:], [app]) + if block is None: + tokens[mapping + 1:mapping + 1] = [ + '"%s"' % app, "{", '"name"', '"%s"' % tool, '"config"', '""', '"priority"', '"75"' if app == "0" else '"250"', "}"] + return + block += mapping + 1 + end = block_end(tokens, block) + for i in range(block + 1, end): + if tokens[i] == '"name"': + tokens[i + 1] = '"%s"' % tool + return + tokens[block + 1:block + 1] = ['"name"', '"%s"' % tool] + + +def steam_running(): + try: + pids = [p for p in os.listdir("/proc") if p.isdigit()] + except OSError: + return False + for pid in pids: + try: + with open("/proc/%s/cmdline" % pid, "rb") as f: + argv0 = f.read().split(b"\0", 1)[0] + except OSError: + continue + if argv0.endswith(b"steamrtarm64/steam"): + return True + return False + + +def live_helper_running(directory=None): + try: + with open(state_path("live.pid", directory)) as f: + pid = int(f.read().split()[0]) + os.kill(pid, 0) + return True + except (OSError, ValueError, IndexError): + return False + + +def register_default(config_path, apps=(), protect=(), default=None, catalog=None, directory=None, settle=False): + """Writes the mapping: the "0" default and every title that follows it (see plan_mapping). + With settle, the default itself is decided first (see choose_default): before the client + starts, config.vdf holds what the client last kept, menu changes included.""" + with locked(directory): + state = read_json(state_path("state.json", directory)) + with open(config_path, encoding="utf-8", errors="replace") as f: + original = f.read() + tokens = tokenize(original) + mapping = mapping_block(tokens) + if mapping is None: + return state.get("default") or TOOL + current = read_mapping(tokens, mapping) + if catalog is None: + catalog = tool_catalog(os.path.dirname(os.path.dirname(os.path.abspath(config_path))), + dict(protect) if isinstance(protect, dict) else {name: name for name in protect}) + names = [t["name"] for t in catalog] + if default is None: + if settle: + default, state = choose_default(current.get("0", ""), state, read_json(state_path("request.json", directory)), catalog) + 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))) + changes, auto = plan_mapping(current, apps, names, default, followers(state, current), native_targets(steam_root, apps)) + for app, tool in changes.items(): + set_mapping(tokens, mapping, app, tool) + text = serialize(tokens) + if text != serialize(tokenize(original)): + write_changed(config_path, text) + state["auto"] = auto + state["tools"] = catalog + state.setdefault("default", default) + write_json(state_path("state.json", directory), state) + return default + + +def depot_build(depot): + """The build number a depot's version file starts with; 0 when there is none.""" + try: + with open(os.path.join(depot, "version"), encoding="utf-8", errors="replace") as f: + first = f.read().split(None, 1) + except OSError: + return 0 + return int(first[0]) if first and first[0].isdigit() else 0 + + +def find_source(steam_root, sources=SOURCES): + """The first of `sources` the client has downloaded, newest across the libraries holding it.""" + for name in sources: + found = [os.path.join(root, "steamapps", "common", name) for root in (steam_root, "/mnt/droiddeck-sd") + if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))] + if found: + return max(found, key=depot_build) + return None + + +def build_tool(tool_dir, depot, sources=SOURCES, name=TOOL): + """Our own manifests and the launcher, named after `depot`. The depot is run at launch time.""" + if os.path.islink(tool_dir): + os.unlink(tool_dir) + elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(OWN_FILES): + # Earlier versions mirrored the depot in here as symlinks; start clean. A directory holding + # only our own files is kept, and each file rewritten only when it changed. + shutil.rmtree(tool_dir) + os.makedirs(tool_dir, exist_ok=True) + write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), TOOL_MANIFEST_VDF) + write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), COMPAT_TOOL_VDF % (name, display_name( + os.path.basename(depot).replace(" (ARM64)", " ARM64") if depot else "Proton ARM64"))) + write_changed(os.path.join(tool_dir, LAUNCHER), launcher_sh(sources), 0o755) + + +def build_fex_tool(tool_dir): + if os.path.islink(tool_dir): + os.unlink(tool_dir) + elif os.path.isdir(tool_dir) and set(os.listdir(tool_dir)) - set(FEX_FILES): + shutil.rmtree(tool_dir) + os.makedirs(tool_dir, exist_ok=True) + write_changed(os.path.join(tool_dir, "toolmanifest.vdf"), FEX_MANIFEST_VDF) + write_changed(os.path.join(tool_dir, "compatibilitytool.vdf"), FEX_COMPAT_TOOL_VDF % (FEX_TOOL, display_name("Linux x86 (FEX)"))) + write_changed(os.path.join(tool_dir, FEX_LAUNCHER), FEX_LAUNCHER_SH, 0o755) + + +def remove_tool(tool_dir): + if os.path.isdir(tool_dir) and not os.path.islink(tool_dir) and not set(os.listdir(tool_dir)) - set(OWN_FILES) - {LEGACY_LAUNCHER}: + shutil.rmtree(tool_dir) + + +def main(): + steam_root = sys.argv[1] + tools = os.path.join(steam_root, "compatibilitytools.d") + os.makedirs(tools, exist_ok=True) + legacy = os.path.join(tools, LEGACY_TOOL) + if os.path.islink(legacy): + os.unlink(legacy) + elif os.path.isdir(legacy): + shutil.rmtree(legacy) + for name, sources in OWN_TOOLS: + depot = find_source(steam_root, sources) + if depot is None and name != TOOL: + remove_tool(os.path.join(tools, name)) + continue + build_tool(os.path.join(tools, name), depot, sources, name) + print("steam-compatibility: registered %s (%s)" % ( + name, "depot %s present" % os.path.basename(depot) if depot else + "no ARM64 Proton depot yet; the session asks the client to fetch it")) + build_fex_tool(os.path.join(tools, FEX_TOOL)) + print("steam-compatibility: registered %s" % FEX_TOOL) + extras = adopt_extras(tools) + for name, directory in sorted(extras.items()): + print("steam-compatibility: adopted %s as %s" % (directory, name)) + catalog = tool_catalog(steam_root, extras) + config = os.path.join(steam_root, "config", "config.vdf") + if live_helper_running(): + with locked(): + state = read_json(state_path("state.json")) + state["tools"] = catalog + write_json(state_path("state.json"), state) + print("steam-compatibility: the live helper keeps the client's mapping; tools listed") + elif os.path.isfile(config): + apps = installed_apps(steam_root) + default = register_default(config, apps, protect=extras, catalog=catalog, settle=not steam_running()) + print("steam-compatibility: default and %d installed title(s) set to %s" % (len(apps), default)) + sync = os.path.join(os.path.dirname(os.path.realpath(__file__)), "droiddeck-esync") + if os.path.isfile(sync): + try: + summary = runpy.run_path(sync)["reconcile"](steam_root, os.environ) + except Exception as e: + summary = "droiddeck-esync: reconcile failed (%s)" % e + if summary: + print("steam-compatibility: %s" % summary) + + +if __name__ == "__main__": + main() diff --git a/tools/linuxfs/preload/fsync.c b/tools/linuxfs/preload/fsync.c new file mode 100644 index 000000000..4d765d905 --- /dev/null +++ b/tools/linuxfs/preload/fsync.c @@ -0,0 +1,653 @@ +/* droiddeck-fsync: futex_waitv(2) in userspace, so Proton's own fsync runs where the app sandbox refuses the syscall. + * Credit: fsync, the futex-based synchronization in Proton, by Elizabeth Figura and Paul Gofman; + * futex_waitv(2) by André Almeida. */ +#define _GNU_SOURCE +#include <dlfcn.h> +#include <errno.h> +#include <fcntl.h> +#include <linux/futex.h> +#include <pthread.h> +#include <signal.h> +#include <stdlib.h> +#include <stdint.h> +#include <stdio.h> +#include <string.h> +#include <sys/mman.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <time.h> +#include <unistd.h> + +#ifndef SYS_futex_waitv +#define SYS_futex_waitv 449 +#endif + +#define FS_SLOTS 2048u +#define FS_WORDS (FS_SLOTS / 64u) +#define FS_BUCKETS 4096u +#define FS_KEYS 128u +#define FS_NOKEY 0xffffffffu +#define FS_SIZE32 2u +#define FS_PRIVATE 128u +#define FS_MAGIC 0x434e595346444444ull +#define FS_SWEEP_NS 1000000000ull +#define FS_POLL_NS 500000l +#define FS_NAP_MIN 10000000ull +#define FS_NAP_MAX 1000000000ull +#define FS_HEAD_SIZE 4096ul + +struct fs_waitv { + uint64_t val; + uint64_t uaddr; + uint32_t flags; + uint32_t reserved; +}; + +struct fs_bucket { + uint64_t bits[FS_WORDS]; +}; + +struct fs_slot { + uint32_t futex; + uint32_t count; + uint64_t owner; + uint32_t keys[FS_KEYS]; +}; + +struct fs_head { + uint64_t magic; + uint32_t sweeper; + uint32_t pad; + uint64_t sweep_at; + uint64_t used[FS_WORDS]; +}; + +#define FS_BUCKET_OFF FS_HEAD_SIZE +#define FS_SLOT_OFF (FS_BUCKET_OFF + (size_t)FS_BUCKETS * sizeof(struct fs_bucket)) +#define FS_REGION (FS_SLOT_OFF + (size_t)FS_SLOTS * sizeof(struct fs_slot)) + +_Static_assert(sizeof(struct fs_head) <= FS_HEAD_SIZE, "fsync registry head"); + +struct fs_range { + uintptr_t lo; + uintptr_t hi; + uint64_t off; +}; + +struct fs_map { + uint32_t n; + struct fs_range r[]; +}; + +static int fs_state = -1; +static char *fs_reg; +static int fs_fd = -1; +static struct fs_map *fs_cur; +static uint32_t fs_pid; +static pthread_mutex_t fs_mx = PTHREAD_MUTEX_INITIALIZER; +static pthread_key_t fs_key; +static pthread_once_t fs_once = PTHREAD_ONCE_INIT; +static __thread int32_t fs_tslot = -1; +static __thread int fs_busy; +static __thread uint32_t fs_tid; +static __thread uint32_t fs_nap_key; +static __thread uint32_t fs_nap_n; +static __thread uint64_t fs_nap_ns; +static __thread sigset_t fs_fork_mask; + +static long (*fs_real_syscall)(long, ...); +static int (*fs_real_shm_open)(const char *, int, mode_t); +static int (*fs_real_shm_unlink)(const char *); +static void *(*fs_real_mmap)(void *, size_t, int, int, int, off_t); +static void *(*fs_real_mmap64)(void *, size_t, int, int, int, off64_t); +static int (*fs_real_munmap)(void *, size_t); + +static int fs_on(void) { + if (fs_state < 0) { + const char *e = getenv("BL_FSYNC"); + fs_state = e && e[0] == '1'; + } + return fs_state; +} + +static long fs_sys(long n, long a, long b, long c, long d, long e, long f) { + if (!fs_real_syscall) fs_real_syscall = (long (*)(long, ...))dlsym(RTLD_NEXT, "syscall"); + return fs_real_syscall(n, a, b, c, d, e, f); +} + +static void *fs_anon(size_t size) { + if (!fs_real_mmap) fs_real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap"); + return fs_real_mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); +} + +static uint32_t fs_self(void) { + if (!fs_tid) fs_tid = (uint32_t)fs_sys(SYS_gettid, 0, 0, 0, 0, 0, 0); + return fs_tid; +} + +static uint32_t fs_getpid(void) { + if (!fs_pid) fs_pid = (uint32_t)getpid(); + return fs_pid; +} + +static uint64_t fs_now(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} + +static int fs_dead(uint32_t pid, uint32_t tid) { + int saved = errno, dead; + dead = pid && fs_sys(SYS_tgkill, pid, tid, 0, 0, 0, 0) == -1 && errno == ESRCH; + errno = saved; + return dead; +} + +static uint64_t fs_owner(void) { + return (uint64_t)fs_getpid() << 32 | fs_self(); +} + +static void fs_block(sigset_t *old) { + sigset_t all; + sigfillset(&all); + pthread_sigmask(SIG_SETMASK, &all, old); +} + +static inline struct fs_head *fs_head(void) { + return (struct fs_head *)fs_reg; +} + +static inline struct fs_bucket *fs_bucket(uint32_t key) { + return (struct fs_bucket *)(fs_reg + FS_BUCKET_OFF) + ((key >> 4) & (FS_BUCKETS - 1)); +} + +static inline struct fs_slot *fs_slot(uint32_t s) { + return (struct fs_slot *)(fs_reg + FS_SLOT_OFF) + s; +} + +static int fs_key_of(uint64_t addr, uint32_t *key) { + struct fs_map *m = __atomic_load_n(&fs_cur, __ATOMIC_ACQUIRE); + uint32_t lo = 0, hi; + if (!m) return 0; + hi = m->n; + while (lo < hi) { + uint32_t mid = (lo + hi) / 2; + if (addr < m->r[mid].lo) { + hi = mid; + } else if (addr >= m->r[mid].hi) { + lo = mid + 1; + } else { + uint64_t k = m->r[mid].off + (addr - m->r[mid].lo); + if (k >= FS_NOKEY) return 0; + *key = (uint32_t)k; + return 1; + } + } + return 0; +} + +static int fs_overlaps(uintptr_t lo, uintptr_t hi) { + struct fs_map *m = __atomic_load_n(&fs_cur, __ATOMIC_ACQUIRE); + if (!m) return 0; + for (uint32_t i = 0; i < m->n; i++) + if (m->r[i].lo < hi && m->r[i].hi > lo) return 1; + return 0; +} + +static void fs_rebuild_locked(uintptr_t lo, uintptr_t hi, const struct fs_range *add) { + struct fs_map *old, *m; + uint32_t n, k = 0; + int added = 0; + old = fs_cur; + n = old ? old->n : 0; + m = fs_anon(sizeof(*m) + (n + 1) * sizeof(struct fs_range)); + if (m == MAP_FAILED) return; + for (uint32_t i = 0; i < n; i++) { + const struct fs_range *r = &old->r[i]; + if (r->lo < hi && r->hi > lo) continue; + if (add && !added && add->lo < r->lo) { + m->r[k++] = *add; + added = 1; + } + m->r[k++] = *r; + } + if (add && !added) m->r[k++] = *add; + m->n = k; + __atomic_store_n(&fs_cur, m, __ATOMIC_RELEASE); +} + +static void fs_rebuild(uintptr_t lo, uintptr_t hi, const struct fs_range *add) { + sigset_t old; + fs_block(&old); + pthread_mutex_lock(&fs_mx); + fs_rebuild_locked(lo, hi, add); + pthread_mutex_unlock(&fs_mx); + pthread_sigmask(SIG_SETMASK, &old, NULL); +} + +static void fs_track(void *p, size_t len, int flags, int fd, off_t off) { + uintptr_t lo = (uintptr_t)p, hi = lo + len; + int saved = errno; + if (fs_fd >= 0 && fd == fs_fd && (flags & MAP_SHARED) && off >= 0) { + struct fs_range r = {lo, hi, (uint64_t)off}; + fs_rebuild(lo, hi, &r); + } else if (fs_overlaps(lo, hi)) { + fs_rebuild(lo, hi, NULL); + } + errno = saved; +} + +static void fs_unregister(uint32_t s, const uint32_t *keys, uint32_t n) { + uint64_t bit = 1ull << (s & 63); + for (uint32_t i = 0; i < n; i++) { + struct fs_bucket *b; + if (keys[i] == FS_NOKEY) continue; + b = fs_bucket(keys[i]); + __atomic_and_fetch(&b->bits[s >> 6], ~bit, __ATOMIC_SEQ_CST); + } +} + +static void fs_release(uint32_t s, uint64_t owner) { + struct fs_slot *sl = fs_slot(s); + uint32_t n, keys[FS_KEYS]; + if (!owner || !__atomic_compare_exchange_n(&sl->owner, &owner, 0, 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED)) return; + n = __atomic_load_n(&sl->count, __ATOMIC_RELAXED); + if (n > FS_KEYS) n = FS_KEYS; + for (uint32_t i = 0; i < n; i++) keys[i] = __atomic_load_n(&sl->keys[i], __ATOMIC_RELAXED); + fs_unregister(s, keys, n); + __atomic_store_n(&sl->count, 0, __ATOMIC_RELAXED); + __atomic_and_fetch(&fs_head()->used[s >> 6], ~(1ull << (s & 63)), __ATOMIC_RELEASE); +} + +static void fs_sweep(int force) { + struct fs_head *h = fs_head(); + uint64_t now = fs_now(); + uint32_t me = fs_getpid(), cur = 0; + if (!force && now < __atomic_load_n(&h->sweep_at, __ATOMIC_RELAXED)) return; + if (!__atomic_compare_exchange_n(&h->sweeper, &cur, me, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) { + if (!fs_dead(cur, cur) || !__atomic_compare_exchange_n(&h->sweeper, &cur, me, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) + return; + } + __atomic_store_n(&h->sweep_at, now + FS_SWEEP_NS, __ATOMIC_RELAXED); + for (uint32_t w = 0; w < FS_WORDS; w++) { + uint64_t u = __atomic_load_n(&h->used[w], __ATOMIC_ACQUIRE); + while (u) { + uint32_t s = w * 64 + (uint32_t)__builtin_ctzll(u); + struct fs_slot *sl = fs_slot(s); + u &= u - 1; + uint64_t owner = __atomic_load_n(&sl->owner, __ATOMIC_ACQUIRE); + if (fs_dead((uint32_t)(owner >> 32), (uint32_t)owner)) fs_release(s, owner); + } + } + __atomic_store_n(&h->sweeper, 0, __ATOMIC_RELEASE); +} + +static int32_t fs_alloc(void) { + struct fs_head *h = fs_head(); + fs_sweep(0); + for (int pass = 0; pass < 2; pass++) { + for (uint32_t w = 0; w < FS_WORDS; w++) { + uint64_t u = __atomic_load_n(&h->used[w], __ATOMIC_RELAXED); + while (~u) { + uint32_t b = (uint32_t)__builtin_ctzll(~u); + if (__atomic_compare_exchange_n(&h->used[w], &u, u | (1ull << b), 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED)) { + uint32_t s = w * 64 + b; + struct fs_slot *sl = fs_slot(s); + __atomic_store_n(&sl->count, 0, __ATOMIC_RELAXED); + __atomic_store_n(&sl->owner, fs_owner(), __ATOMIC_RELEASE); + return (int32_t)s; + } + } + } + fs_sweep(1); + } + return -1; +} + +static void fs_thread_gone(void *p) { + uint32_t s = (uint32_t)((uintptr_t)p - 1); + if (fs_reg && s < FS_SLOTS && fs_tslot == (int32_t)s) { + fs_tslot = -1; + fs_busy = 0; + fs_release(s, fs_owner()); + } +} + +static void fs_key_init(void) { + pthread_key_create(&fs_key, fs_thread_gone); +} + +static void fs_fork_prepare(void) { + fs_block(&fs_fork_mask); + pthread_mutex_lock(&fs_mx); +} + +static void fs_fork_parent(void) { + pthread_mutex_unlock(&fs_mx); + pthread_sigmask(SIG_SETMASK, &fs_fork_mask, NULL); +} + +static void fs_child(void) { + pthread_mutex_init(&fs_mx, NULL); + pthread_sigmask(SIG_SETMASK, &fs_fork_mask, NULL); + fs_pid = 0; + fs_tid = 0; + fs_busy = 0; + if (fs_tslot >= 0) { + fs_tslot = -1; + pthread_setspecific(fs_key, NULL); + } +} + +__attribute__((constructor)) static void fs_init(void) { + if (!fs_real_mmap) fs_real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap"); + if (!fs_real_mmap64) fs_real_mmap64 = (void *(*)(void *, size_t, int, int, int, off64_t))dlsym(RTLD_NEXT, "mmap64"); + if (!fs_real_munmap) fs_real_munmap = (int (*)(void *, size_t))dlsym(RTLD_NEXT, "munmap"); + if (fs_on()) pthread_atfork(fs_fork_prepare, fs_fork_parent, fs_child); +} + +static int32_t fs_take(int *temp) { + if (fs_busy) { + *temp = 1; + return fs_alloc(); + } + *temp = 0; + fs_busy = 1; + __atomic_signal_fence(__ATOMIC_SEQ_CST); + if (fs_tslot < 0) { + int32_t s = fs_alloc(); + if (s < 0) { + fs_busy = 0; + return -1; + } + pthread_once(&fs_once, fs_key_init); + pthread_setspecific(fs_key, (void *)(uintptr_t)(s + 1)); + fs_tslot = s; + } + return fs_tslot; +} + +static void fs_give(int32_t s, int temp) { + if (temp) { + fs_release((uint32_t)s, fs_owner()); + } else { + __atomic_signal_fence(__ATOMIC_SEQ_CST); + fs_busy = 0; + } +} + +static int fs_changed(const struct fs_waitv *v, uint32_t n) { + for (uint32_t i = 0; i < n; i++) + if (__atomic_load_n((uint32_t *)(uintptr_t)v[i].uaddr, __ATOMIC_SEQ_CST) != (uint32_t)v[i].val) return (int)i; + return -1; +} + +static int fs_past(const struct timespec *ts, int clk) { + struct timespec now; + if (!ts) return 0; + clock_gettime(clk, &now); + return now.tv_sec > ts->tv_sec || (now.tv_sec == ts->tv_sec && now.tv_nsec >= ts->tv_nsec); +} + +static uint64_t fs_clock_ns(int clk) { + struct timespec now; + clock_gettime(clk, &now); + return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec; +} + +static uint64_t fs_ts_ns(const struct timespec *ts) { + return (uint64_t)ts->tv_sec * 1000000000ull + (uint64_t)ts->tv_nsec; +} + +static long fs_poll(const struct fs_waitv *v, uint32_t n, const struct timespec *ts, int clk) { + int i = fs_changed(v, n); + if (i >= 0) { + errno = EAGAIN; + return -1; + } + for (;;) { + struct timespec nap = {0, FS_POLL_NS}; + if (fs_past(ts, clk)) { + errno = ETIMEDOUT; + return -1; + } + if (nanosleep(&nap, NULL) == -1 && errno == EINTR) return -1; + if ((i = fs_changed(v, n)) >= 0) return i; + } +} + +static long fs_waitv(const struct fs_waitv *v, uint32_t n, uint32_t flags, const struct timespec *ts, int clk) { + uint32_t keys[FS_KEYS]; + struct fs_slot *sl; + struct timespec nap_ts; + const struct timespec *until = ts; + int temp, err = 0, op, napping = 0, wclk = clk, saved = errno; + int32_t s; + uint64_t bit, nap; + if (!v || !n || n > FS_KEYS || flags || (ts && clk != CLOCK_MONOTONIC && clk != CLOCK_REALTIME)) { + errno = EINVAL; + return -1; + } + for (uint32_t i = 0; i < n; i++) { + if ((v[i].flags & ~FS_PRIVATE) != FS_SIZE32 || v[i].reserved || (v[i].uaddr & 3)) { + errno = EINVAL; + return -1; + } + if ((v[i].flags & FS_PRIVATE) || !fs_key_of(v[i].uaddr, &keys[i])) keys[i] = FS_NOKEY; + } + if (!fs_reg || (s = fs_take(&temp)) < 0) return fs_poll(v, n, ts, clk); + sl = fs_slot((uint32_t)s); + bit = 1ull << (s & 63); + for (uint32_t i = 0; i < n; i++) __atomic_store_n(&sl->keys[i], keys[i], __ATOMIC_RELAXED); + __atomic_store_n(&sl->count, n, __ATOMIC_RELAXED); + __atomic_store_n(&sl->futex, 0, __ATOMIC_RELAXED); + for (uint32_t i = 0; i < n; i++) { + struct fs_bucket *b; + if (keys[i] == FS_NOKEY) continue; + b = fs_bucket(keys[i]); + __atomic_or_fetch(&b->bits[s >> 6], bit, __ATOMIC_SEQ_CST); + } + __atomic_thread_fence(__ATOMIC_SEQ_CST); + if (fs_changed(v, n) >= 0) err = EAGAIN; + nap = fs_nap_ns && fs_nap_n == n && fs_nap_key == keys[0] ? fs_nap_ns * 2 : FS_NAP_MIN; + if (nap > FS_NAP_MAX) nap = FS_NAP_MAX; + if (!ts || fs_ts_ns(ts) > fs_clock_ns(clk) + nap) { + uint64_t at = fs_clock_ns(CLOCK_MONOTONIC) + nap; + nap_ts.tv_sec = (time_t)(at / 1000000000ull); + nap_ts.tv_nsec = (long)(at % 1000000000ull); + until = &nap_ts; + wclk = CLOCK_MONOTONIC; + napping = 1; + } + op = FUTEX_WAIT_BITSET | (until && wclk == CLOCK_REALTIME ? FUTEX_CLOCK_REALTIME : 0); + while (!err && !__atomic_load_n(&sl->futex, __ATOMIC_ACQUIRE)) { + if (fs_sys(SYS_futex, (long)&sl->futex, op, 0, (long)until, 0, FUTEX_BITSET_MATCH_ANY) == -1 && errno != EAGAIN + && !__atomic_load_n(&sl->futex, __ATOMIC_ACQUIRE)) + err = errno; + } + fs_unregister((uint32_t)s, keys, n); + if (err && __atomic_exchange_n(&sl->futex, 1, __ATOMIC_ACQ_REL)) err = 0; + fs_give(s, temp); + if (err == ETIMEDOUT && napping) { + fs_nap_key = keys[0]; + fs_nap_n = n; + fs_nap_ns = nap; + errno = EINTR; + return -1; + } + if (err != EINTR) fs_nap_ns = 0; + if (err) { + errno = err; + return -1; + } + errno = saved; + s = fs_changed(v, n); + return s < 0 ? 0 : s; +} + +static long fs_wake(uint32_t key, int n) { + struct fs_bucket *b = fs_bucket(key); + long woken = 0; + __atomic_thread_fence(__ATOMIC_SEQ_CST); + if (n <= 0) return 0; + for (uint32_t w = 0; w < FS_WORDS; w++) { + uint64_t bits = __atomic_load_n(&b->bits[w], __ATOMIC_SEQ_CST); + while (bits) { + uint32_t s = w * 64 + (uint32_t)__builtin_ctzll(bits), c; + struct fs_slot *sl = fs_slot(s); + bits &= bits - 1; + c = __atomic_load_n(&sl->count, __ATOMIC_ACQUIRE); + if (c > FS_KEYS) c = FS_KEYS; + for (uint32_t i = 0; i < c; i++) { + if (__atomic_load_n(&sl->keys[i], __ATOMIC_RELAXED) != key) continue; + if (!__atomic_exchange_n(&sl->futex, 1, __ATOMIC_SEQ_CST)) { + int saved = errno; + fs_sys(SYS_futex, (long)&sl->futex, FUTEX_WAKE, 1, 0, 0, 0); + errno = saved; + if (++woken >= n) return woken; + } + break; + } + } + } + return woken; +} + +void bl_fsync_fds_closed(unsigned int first, unsigned int last) __attribute__((visibility("hidden"))); +void bl_fsync_fds_closed(unsigned int first, unsigned int last) { + int fd = __atomic_load_n(&fs_fd, __ATOMIC_ACQUIRE); + if (fd >= 0 && (unsigned int)fd >= first && (unsigned int)fd <= last) __atomic_store_n(&fs_fd, -1, __ATOMIC_RELEASE); +} + +int bl_fsync_syscall(long number, const long *args, long *ret) __attribute__((visibility("hidden"))); +int bl_fsync_syscall(long number, const long *args, long *ret) { + if (number == SYS_futex_waitv) { + if (!fs_on()) return 0; + if (!args[0] && !args[1]) { + errno = EINVAL; + *ret = -1; + return 1; + } + *ret = fs_waitv((const struct fs_waitv *)args[0], (uint32_t)args[1], (uint32_t)args[2], + (const struct timespec *)args[3], (int)args[4]); + return 1; + } + if (number == SYS_futex && (int)args[1] == FUTEX_WAKE && fs_reg && __atomic_load_n(&fs_cur, __ATOMIC_RELAXED)) { + uint32_t key; + if (!fs_key_of((uint64_t)args[0], &key)) return 0; + *ret = fs_wake(key, (int)args[2]); + return 1; + } + return 0; +} + +static int fs_fsync_name(const char *name) { + size_t n; + if (!name) return 0; + if (*name == '/') name++; + n = strlen(name); + return n > 11 && !strncmp(name, "wine-", 5) && !strcmp(name + n - 6, "-fsync"); +} + +static void fs_attach(const char *name, int fd, int create) { + char path[256]; + struct stat st; + void *p; + int rfd; + if (fs_reg) { + fs_fd = fd; + return; + } + if (snprintf(path, sizeof(path), "%s-droiddeck", name) >= (int)sizeof(path)) return; + if (create) fs_real_shm_unlink(path); + rfd = fs_real_shm_open(path, O_RDWR | O_CREAT, 0644); + if (rfd < 0) { + fprintf(stderr, "droiddeck-fsync: cannot open %s: %s\n", path, strerror(errno)); + return; + } + if (fstat(rfd, &st) == 0 && (size_t)st.st_size < FS_REGION && ftruncate(rfd, (off_t)FS_REGION) != 0) { + fprintf(stderr, "droiddeck-fsync: cannot size %s: %s\n", path, strerror(errno)); + close(rfd); + return; + } + if (!fs_real_mmap) fs_real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap"); + p = fs_real_mmap(NULL, FS_REGION, PROT_READ | PROT_WRITE, MAP_SHARED, rfd, 0); + close(rfd); + if (p == MAP_FAILED) { + fprintf(stderr, "droiddeck-fsync: cannot map %s: %s\n", path, strerror(errno)); + return; + } + fs_reg = p; + fs_fd = fd; + if (create) { + __atomic_store_n(&fs_head()->magic, FS_MAGIC, __ATOMIC_RELEASE); + fprintf(stderr, "droiddeck-fsync: up and running.\n"); + } +} + +int shm_open(const char *name, int oflag, mode_t mode) { + int fd, saved; + if (!fs_real_shm_open) fs_real_shm_open = (int (*)(const char *, int, mode_t))dlsym(RTLD_NEXT, "shm_open"); + if (!fs_real_shm_unlink) fs_real_shm_unlink = (int (*)(const char *))dlsym(RTLD_NEXT, "shm_unlink"); + if (!fs_real_shm_open) { + errno = ENOSYS; + return -1; + } + fd = fs_real_shm_open(name, oflag, mode); + if (fd >= 0 && fs_on() && fs_fsync_name(name) && fs_real_shm_unlink) { + saved = errno; + fs_attach(name, fd, oflag & O_CREAT); + errno = saved; + } + return fd; +} + +int shm_unlink(const char *name) { + int r, saved; + if (!fs_real_shm_unlink) fs_real_shm_unlink = (int (*)(const char *))dlsym(RTLD_NEXT, "shm_unlink"); + if (!fs_real_shm_unlink) { + errno = ENOSYS; + return -1; + } + r = fs_real_shm_unlink(name); + if (fs_on() && fs_fsync_name(name)) { + char path[256]; + saved = errno; + if (snprintf(path, sizeof(path), "%s-droiddeck", name) < (int)sizeof(path)) fs_real_shm_unlink(path); + errno = saved; + } + return r; +} + +void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t off) { + void *p; + if (!fs_real_mmap) fs_real_mmap = (void *(*)(void *, size_t, int, int, int, off_t))dlsym(RTLD_NEXT, "mmap"); + p = fs_real_mmap(addr, len, prot, flags, fd, off); + if (p != MAP_FAILED && __builtin_expect(fs_fd >= 0, 0)) fs_track(p, len, flags, fd, off); + return p; +} + +void *mmap64(void *addr, size_t len, int prot, int flags, int fd, off64_t off) { + void *p; + if (!fs_real_mmap64) fs_real_mmap64 = (void *(*)(void *, size_t, int, int, int, off64_t))dlsym(RTLD_NEXT, "mmap64"); + p = fs_real_mmap64(addr, len, prot, flags, fd, off); + if (p != MAP_FAILED && __builtin_expect(fs_fd >= 0, 0)) fs_track(p, len, flags, fd, off); + return p; +} + +int munmap(void *addr, size_t len) { + uintptr_t lo = (uintptr_t)addr, hi = lo + len; + sigset_t old; + int r, saved; + if (!fs_real_munmap) fs_real_munmap = (int (*)(void *, size_t))dlsym(RTLD_NEXT, "munmap"); + if (__builtin_expect(fs_fd < 0, 1) || !fs_overlaps(lo, hi)) return fs_real_munmap(addr, len); + fs_block(&old); + pthread_mutex_lock(&fs_mx); + r = fs_real_munmap(addr, len); + saved = errno; + if (r == 0) fs_rebuild_locked(lo, hi, NULL); + pthread_mutex_unlock(&fs_mx); + pthread_sigmask(SIG_SETMASK, &old, NULL); + errno = saved; + return r; +} diff --git a/tools/linuxfs/preload/inputudev.c b/tools/linuxfs/preload/inputudev.c index c1d04f029..b4ff0b3ef 100644 --- a/tools/linuxfs/preload/inputudev.c +++ b/tools/linuxfs/preload/inputudev.c @@ -89,7 +89,7 @@ static void discover_pads(void) { if (pad->devnode[0] == '\0') { snprintf(pad->sysname, sizeof(pad->sysname), "event%ld", slot); - snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%ld", + snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/droiddeck%ld", slot); snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%ld", slot); snprintf(pad->uevent, sizeof(pad->uevent), PAD_UEVENT_FORMAT, (int)slot, (int)slot); @@ -153,7 +153,7 @@ static void refresh_pad(int node) { if (read_virtual_uevent(node, uevent, sizeof(uevent))) { snprintf(pad->sysname, sizeof(pad->sysname), "event%d", node); - snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/bannerlator%d", node); + snprintf(pad->syspath, sizeof(pad->syspath), "/sys/devices/virtual/input/droiddeck%d", node); memcpy(pad->uevent, uevent, sizeof(uevent)); pad->devnum = makedev(13, EVENT_MINOR_BASE + (unsigned int)node); snprintf(pad->devnode, sizeof(pad->devnode), "/dev/input/event%d", node); diff --git a/tools/linuxfs/preload/net.c b/tools/linuxfs/preload/net.c index babe95bdd..44de4809b 100644 --- a/tools/linuxfs/preload/net.c +++ b/tools/linuxfs/preload/net.c @@ -20,6 +20,8 @@ #include <sys/stat.h> #include <sys/types.h> #include <errno.h> +#include <signal.h> +#include <time.h> #include <unistd.h> static const char *net_dir(void) { @@ -123,9 +125,75 @@ int listen(int fd, int backlog) { return ret; } +static unsigned local_port(int fd) { + struct sockaddr_storage ss; + socklen_t len = sizeof(ss); + if (getsockname(fd, (struct sockaddr *)&ss, &len) != 0) { + return 0; + } + return addr_port((struct sockaddr *)&ss, len); +} + +static void bind_before_loopback_connect(int fd, const struct sockaddr *addr, socklen_t len) { + if (!addr || addr->sa_family != AF_INET || len < sizeof(struct sockaddr_in) || + ((const struct sockaddr_in *)addr)->sin_addr.s_addr != htonl(INADDR_LOOPBACK)) { + return; + } + struct sockaddr_storage ss; + socklen_t slen = sizeof(ss); + if (getsockname(fd, (struct sockaddr *)&ss, &slen) != 0 || ss.ss_family != AF_INET || + ((struct sockaddr_in *)&ss)->sin_port != 0) { + return; + } + struct sockaddr_in local; + memset(&local, 0, sizeof(local)); + local.sin_family = AF_INET; + local.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + int saved = errno; + bind(fd, (struct sockaddr *)&local, sizeof(local)); + errno = saved; +} + +static unsigned guarded_port(void) { + static int port = -1; + if (port < 0) { + const char *value = getenv("BL_CDP_GUARD"); + port = value && *value ? atoi(value) : 0; + if (port < 0 || port > 65535) port = 0; + } + return (unsigned)port; +} + +static int guest_peer(int fd) { + unsigned peer = peer_port(fd); + if (peer == 0) { + return 0; + } + char path[300]; + snprintf(path, sizeof(path), "%s/p%u", net_dir(), peer); + struct stat st; + if (stat(path, &st) != 0 || time(NULL) - st.st_mtime > 30) { + return 0; + } + int in = open(path, O_RDONLY); + if (in < 0) { + return 0; + } + char buf[64]; + int n = read(in, buf, sizeof(buf) - 1); + close(in); + if (n <= 0) { + return 0; + } + buf[n] = '\0'; + int pid = atoi(buf); + return pid > 0 && (kill(pid, 0) == 0 || errno != ESRCH); +} + int connect(int fd, const struct sockaddr *addr, socklen_t len) { static connect_fn real; if (!real) real = (connect_fn)dlsym(RTLD_NEXT, "connect"); + bind_before_loopback_connect(fd, addr, len); int ret = real(fd, addr, len); if (ret == 0 || errno == EINPROGRESS) { /* The caller goes on to test for EINPROGRESS, which the bookkeeping must not disturb. */ @@ -139,9 +207,17 @@ int connect(int fd, const struct sockaddr *addr, socklen_t len) { int accept4(int fd, struct sockaddr *addr, socklen_t *len, int flags) { static accept4_fn real; if (!real) real = (accept4_fn)dlsym(RTLD_NEXT, "accept4"); - int ret = real(fd, addr, len, flags); - if (ret >= 0) record_port(ret, peer_port(ret)); - return ret; + for (;;) { + int ret = real(fd, addr, len, flags); + if (ret < 0) return ret; + unsigned guarded = guarded_port(); + if (guarded && local_port(ret) == guarded && !guest_peer(ret)) { + close(ret); + continue; + } + record_port(ret, peer_port(ret)); + return ret; + } } int accept(int fd, struct sockaddr *addr, socklen_t *len) { diff --git a/tools/linuxfs/preload/netif.c b/tools/linuxfs/preload/netif.c index 078f6a301..0de77eed5 100644 --- a/tools/linuxfs/preload/netif.c +++ b/tools/linuxfs/preload/netif.c @@ -8,7 +8,7 @@ * address ioctl, and every table under /proc/net. Wine builds its adapter, address, route and * neighbour tables from exactly those, so a game saw a machine with no adapter, no address, no * gateway and no MAC, and one that checks its adapters before going online waited for good. - * The app knows the real link from ConnectivityManager and writes it to /etc/bannerlator-net; + * The app knows the real link from ConnectivityManager and writes it to /etc/droiddeck-net; * whatever the kernel refuses is answered from that file. Anything the kernel does answer wins. */ #define _GNU_SOURCE 1 @@ -30,7 +30,7 @@ #include <sys/socket.h> #include <unistd.h> -#define LINK_FILE "/etc/bannerlator-net" +#define LINK_FILE "/etc/droiddeck-net" #define LO_NAME "lo" #define LO_INDEX 1 #define LO_MTU 65536 @@ -352,6 +352,7 @@ static int is_link_query(unsigned long request) { void bl_kgsl_chip_id_fixup(unsigned long request, void *arg, int rc); /* kgslid.c */ int bl_kgsl_poll(int (*real)(int, unsigned long, void *), int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); +int bl_ntsync_ioctl(int fd, unsigned long request, void *arg, int *rc) __attribute__((visibility("hidden"))); /* Every DRM and evdev call comes through here, so the lookup is done once. Two threads racing * to store it store the same pointer. */ @@ -363,8 +364,9 @@ int ioctl(int fd, unsigned long request, ...) { void *arg = va_arg(ap, void *); va_end(ap); - if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); int rc; + if (bl_ntsync_ioctl(fd, request, arg, &rc)) return rc; + if (real == NULL) real = (ioctl_fn) dlsym(RTLD_NEXT, "ioctl"); if (!bl_kgsl_poll(real, fd, request, arg, &rc)) rc = real(fd, request, arg); bl_kgsl_chip_id_fixup(request, arg, rc); if (arg == NULL || !is_link_query(request)) return rc; @@ -466,7 +468,7 @@ static const char *denied_table(const char *path) { static FILE *synthesize(const char *table) { struct link link; if (!load_link(&link)) return NULL; - int fd = memfd_create("bannerlator-net", MFD_CLOEXEC); + int fd = memfd_create("droiddeck-net", MFD_CLOEXEC); if (fd < 0) return NULL; FILE *out = fdopen(fd, "w+"); if (out == NULL) { diff --git a/tools/linuxfs/preload/ntsync.c b/tools/linuxfs/preload/ntsync.c new file mode 100644 index 000000000..4b8256fc1 --- /dev/null +++ b/tools/linuxfs/preload/ntsync.c @@ -0,0 +1,1333 @@ +/* droiddeck-ntsync: a userspace implementation of the /dev/ntsync interface for kernels without the driver. + * Credit: ntsync, the kernel driver and its Wine client, by Elizabeth Figura. */ +#define _GNU_SOURCE +#include <dlfcn.h> +#include <errno.h> +#include <fcntl.h> +#include <linux/futex.h> +#include <linux/ioctl.h> +#include <pthread.h> +#include <signal.h> +#include <stddef.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> +#include <sys/random.h> +#include <sys/socket.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <sys/timerfd.h> +#include <time.h> +#include <unistd.h> + +struct ns_sem_args { + uint32_t count; + uint32_t max; +}; + +struct ns_mutex_args { + uint32_t owner; + uint32_t count; +}; + +struct ns_event_args { + uint32_t manual; + uint32_t signaled; +}; + +struct ns_wait_args { + uint64_t timeout; + uint64_t objs; + uint32_t count; + uint32_t index; + uint32_t flags; + uint32_t owner; + uint32_t alert; + uint32_t pad; +}; + +#define NS_CREATE_SEM _IOW('N', 0x80, struct ns_sem_args) +#define NS_SEM_RELEASE _IOWR('N', 0x81, uint32_t) +#define NS_WAIT_ANY _IOWR('N', 0x82, struct ns_wait_args) +#define NS_WAIT_ALL _IOWR('N', 0x83, struct ns_wait_args) +#define NS_CREATE_MUTEX _IOW('N', 0x84, struct ns_mutex_args) +#define NS_MUTEX_UNLOCK _IOWR('N', 0x85, struct ns_mutex_args) +#define NS_MUTEX_KILL _IOW('N', 0x86, uint32_t) +#define NS_CREATE_EVENT _IOW('N', 0x87, struct ns_event_args) +#define NS_EVENT_SET _IOR('N', 0x88, uint32_t) +#define NS_EVENT_RESET _IOR('N', 0x89, uint32_t) +#define NS_EVENT_PULSE _IOR('N', 0x8a, uint32_t) +#define NS_SEM_READ _IOR('N', 0x8b, struct ns_sem_args) +#define NS_MUTEX_READ _IOR('N', 0x8c, struct ns_mutex_args) +#define NS_EVENT_READ _IOR('N', 0x8d, struct ns_event_args) + +#define NS_WAIT_REALTIME 1u +#define NS_MAX_WAIT 64u +#define NS_ENTS (NS_MAX_WAIT + 1u) +#define NS_SLOTS 65536u +#define NS_QS 16384u +#define NS_PROCS 256u +#define NS_FDS 65536 +#define NS_DEVS 8 +#define NS_VERSION 1u +#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_DEAD 0xffffffffu + +#ifndef CLOSE_RANGE_CLOEXEC +#define CLOSE_RANGE_CLOEXEC (1u << 2) +#endif + +#define NS_E_VALID (1ull << 63) +#define NS_E_DEVICE (1ull << 62) + +enum { NS_FREE, NS_SEM, NS_MUTEX, NS_EVENT }; + +struct ns_proc { + uint32_t pid; + uint32_t pad; +}; + +struct ns_head { + uint64_t magic; + uint32_t version; + uint32_t tag; + uint32_t all_lock; + uint32_t proc_lock; + uint64_t slot_free; + uint64_t q_free; + uint32_t slot_hwm; + uint32_t q_hwm; + uint64_t sweep_at; + struct ns_proc procs[NS_PROCS]; +}; + +struct ns_obj { + uint32_t lock; + uint32_t type; + uint32_t gen; + uint32_t all_hint; + uint32_t a; + uint32_t b; + uint32_t ownerdead; + uint32_t busy; + uint32_t any_head; + uint32_t any_tail; + uint32_t all_head; + uint32_t all_tail; + uint32_t next_free; + uint32_t pad[3]; + uint64_t holders[NS_PROCS / 64]; + uint64_t pad2[4]; +}; + +struct ns_ent { + uint32_t next; + uint32_t prev; + uint32_t obj; + uint32_t index; + uint32_t linked; +}; + +struct ns_q { + int32_t signaled; + uint32_t owner; + uint32_t tid; + uint32_t pid; + uint32_t proc; + uint32_t count; + uint32_t total; + uint32_t all; + uint32_t ownerdead; + uint32_t waiting; + uint32_t inuse; + uint32_t next_free; + struct ns_ent ent[NS_ENTS]; +}; + +#define NS_HEAD_SIZE 65536ul +#define NS_OBJ_OFF NS_HEAD_SIZE +#define NS_Q_OFF (NS_OBJ_OFF + (size_t)NS_SLOTS * sizeof(struct ns_obj)) +#define NS_REGION (NS_Q_OFF + (size_t)NS_QS * sizeof(struct ns_q)) + +_Static_assert(sizeof(struct ns_head) <= NS_HEAD_SIZE, "ntsync head"); +_Static_assert(sizeof(struct ns_obj) == 128, "ntsync object"); + +struct ns_dev { + struct ns_head *h; + uint64_t ino; + uint32_t tag; + int proc; + uint32_t *counts; + uint32_t refs; +}; + +struct ns_tls { + struct ns_dev *d; + uint32_t q; + int waiting; +}; + +static struct ns_dev ns_devs[NS_DEVS]; +static int ns_ndev; +static uint64_t *ns_fdt; +static pthread_mutex_t ns_mx = PTHREAD_MUTEX_INITIALIZER; +static pid_t ns_pid; +static int ns_state = -1; +static pthread_key_t ns_key; +static pthread_once_t ns_once = PTHREAD_ONCE_INIT; +static __thread uint32_t ns_tid; +static __thread struct ns_tls ns_tq; + +static int (*ns_real_close)(int); + +static int ns_on(void) { + if (ns_state < 0) { + const char *e = getenv("BL_SYNC"); + ns_state = e && e[0] == '1'; + } + return ns_state; +} + +static int ns_close_fd(int fd) { + if (!ns_real_close) ns_real_close = (int (*)(int))dlsym(RTLD_NEXT, "close"); + return ns_real_close(fd); +} + +static inline void ns_relax(void) { +#if defined(__aarch64__) + __asm__ volatile("yield" ::: "memory"); +#elif defined(__x86_64__) || defined(__i386__) + __asm__ volatile("pause" ::: "memory"); +#endif +} + +static uint32_t ns_self(void) { + if (!ns_tid) ns_tid = (uint32_t)syscall(SYS_gettid); + return ns_tid; +} + +static uint32_t ns_getpid(void) { + if (!ns_pid) ns_pid = getpid(); + return (uint32_t)ns_pid; +} + +static long ns_futex(void *w, int op, uint32_t val, const struct timespec *ts, uint32_t bits) { + return syscall(SYS_futex, w, op, val, ts, NULL, bits); +} + +static int ns_dead(uint32_t id) { + return id && kill((pid_t)id, 0) == -1 && errno == ESRCH; +} + +static void ns_lock(uint32_t *w) { + uint32_t me = ns_self(), want = me; + for (unsigned spin = 0;; spin++) { + uint32_t v = 0; + if (__atomic_compare_exchange_n(w, &v, want, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) return; + if (spin < 100) { + ns_relax(); + continue; + } + if (!(v & NS_WAITERS)) { + if (!__atomic_compare_exchange_n(w, &v, v | NS_WAITERS, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) continue; + v |= NS_WAITERS; + } + want = me | NS_WAITERS; + struct timespec ts = {0, 20000000}; + int saved = errno; + if (ns_futex(w, FUTEX_WAIT, v, &ts, 0) == -1 && errno == ETIMEDOUT && ns_dead(v & ~NS_WAITERS) + && __atomic_compare_exchange_n(w, &v, want, 0, __ATOMIC_ACQUIRE, __ATOMIC_RELAXED)) { + errno = saved; + return; + } + errno = saved; + } +} + +static void ns_unlock(uint32_t *w) { + if (__atomic_exchange_n(w, 0, __ATOMIC_RELEASE) & NS_WAITERS) { + int saved = errno; + ns_futex(w, FUTEX_WAKE, 1, NULL, 0); + errno = saved; + } +} + +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); +} + +static void ns_restore(const sigset_t *old) { + pthread_sigmask(SIG_SETMASK, old, NULL); +} + +static inline struct ns_obj *ns_obj(struct ns_head *h, uint32_t s) { + return (struct ns_obj *)((char *)h + NS_OBJ_OFF) + s; +} + +static inline struct ns_q *ns_q(struct ns_head *h, uint32_t i) { + return (struct ns_q *)((char *)h + NS_Q_OFF) + i; +} + +static inline uint32_t ns_node_id(uint32_t q, uint32_t i) { + return q * NS_ENTS + i + 1; +} + +static inline struct ns_ent *ns_node(struct ns_head *h, uint32_t id) { + return &ns_q(h, (id - 1) / NS_ENTS)->ent[(id - 1) % NS_ENTS]; +} + +static inline struct ns_q *ns_node_q(struct ns_head *h, uint32_t id) { + return ns_q(h, (id - 1) / NS_ENTS); +} + +static void ns_link(struct ns_head *h, uint32_t *head, uint32_t *tail, uint32_t id) { + struct ns_ent *e = ns_node(h, id); + e->next = 0; + e->prev = *tail; + if (*tail) ns_node(h, *tail)->next = id; + else *head = id; + *tail = id; + e->linked = 1; +} + +static void ns_unlink(struct ns_head *h, uint32_t *head, uint32_t *tail, uint32_t id) { + struct ns_ent *e = ns_node(h, id); + if (!e->linked) return; + if (e->prev) ns_node(h, e->prev)->next = e->next; + else *head = e->next; + if (e->next) ns_node(h, e->next)->prev = e->prev; + else *tail = e->prev; + e->next = e->prev = 0; + e->linked = 0; +} + +static int ns_no_holders(const struct ns_obj *o) { + for (unsigned i = 0; i < NS_PROCS / 64; i++) + if (o->holders[i]) return 0; + return 1; +} + +static void ns_push(uint64_t *head, uint32_t *next, uint32_t index) { + uint64_t old = __atomic_load_n(head, __ATOMIC_ACQUIRE), nh; + do { + __atomic_store_n(next, (uint32_t)old, __ATOMIC_RELAXED); + nh = (((old >> 32) + 1) << 32) | (index + 1); + } while (!__atomic_compare_exchange_n(head, &old, nh, 1, __ATOMIC_RELEASE, __ATOMIC_ACQUIRE)); +} + +static int ns_pop(struct ns_head *h, uint64_t *head, int objs, uint32_t *out) { + uint64_t old = __atomic_load_n(head, __ATOMIC_ACQUIRE); + while ((uint32_t)old) { + uint32_t i = (uint32_t)old - 1; + uint32_t next = __atomic_load_n(objs ? &ns_obj(h, i)->next_free : &ns_q(h, i)->next_free, __ATOMIC_RELAXED); + uint64_t nh = (((old >> 32) + 1) << 32) | next; + if (__atomic_compare_exchange_n(head, &old, nh, 1, __ATOMIC_ACQUIRE, __ATOMIC_ACQUIRE)) { + *out = i; + return 1; + } + } + return 0; +} + +static int ns_grow(uint32_t *hwm, uint32_t limit, uint32_t *out) { + uint32_t v = __atomic_load_n(hwm, __ATOMIC_RELAXED); + while (v < limit) { + if (__atomic_compare_exchange_n(hwm, &v, v + 1, 1, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED)) { + *out = v; + return 1; + } + } + return 0; +} + +static void ns_maybe_free(struct ns_head *h, struct ns_obj *o) { + if (o->type == NS_FREE || o->busy || !ns_no_holders(o)) return; + o->type = NS_FREE; + o->gen = (o->gen + 1) & 0xfffff; + ns_push(&h->slot_free, &o->next_free, (uint32_t)(o - ns_obj(h, 0))); +} + +static int ns_lock_obj(struct ns_head *h, struct ns_obj *o) { + ns_lock(&o->lock); + if (!o->all_hint) return 0; + ns_unlock(&o->lock); + ns_lock(&h->all_lock); + ns_lock(&o->lock); + return 1; +} + +static void ns_unlock_obj(struct ns_head *h, struct ns_obj *o, int all) { + ns_unlock(&o->lock); + if (all) ns_unlock(&h->all_lock); +} + +static int ns_claim(struct ns_q *q, int32_t index) { + if (q->pid != ns_getpid()) { + int saved = errno, dead = ns_dead(q->pid); + errno = saved; + if (dead) return 0; + } + int32_t want = -1; + return __atomic_compare_exchange_n(&q->signaled, &want, index, 0, __ATOMIC_ACQ_REL, __ATOMIC_RELAXED); +} + +static void ns_wake(struct ns_q *q) { + int saved = errno; + ns_futex(&q->signaled, FUTEX_WAKE, 1, NULL, 0); + errno = saved; +} + +static int ns_signaled(const struct ns_obj *o, uint32_t owner) { + switch (o->type) { + case NS_SEM: + return o->a != 0; + case NS_MUTEX: + if (o->b && o->b != owner) return 0; + return o->a < UINT32_MAX; + case NS_EVENT: + return o->b != 0; + } + return 0; +} + +static void ns_take(struct ns_obj *o, struct ns_q *q) { + switch (o->type) { + case NS_SEM: + o->a--; + break; + case NS_MUTEX: + if (o->ownerdead) q->ownerdead = 1; + o->ownerdead = 0; + o->a++; + o->b = q->owner; + break; + case NS_EVENT: + if (!o->a) o->b = 0; + break; + } +} + +static void ns_wake_any(struct ns_head *h, struct ns_obj *o) { + uint32_t id = o->any_head; + while (id) { + struct ns_ent *e = ns_node(h, id); + struct ns_q *q = ns_node_q(h, id); + uint32_t next = e->next; + if (!ns_signaled(o, q->owner)) { + if (o->type != NS_MUTEX || o->a == UINT32_MAX) return; + } else if (ns_claim(q, (int32_t)e->index)) { + ns_take(o, q); + ns_wake(q); + } + id = next; + } +} + +static void ns_wake_all_q(struct ns_head *h, struct ns_q *q, struct ns_obj *locked) { + uint32_t n = q->count; + int ok = 1; + for (uint32_t i = 0; i < n; i++) { + struct ns_obj *o = ns_obj(h, q->ent[i].obj); + if (o != locked) ns_lock(&o->lock); + } + for (uint32_t i = 0; i < n && ok; i++) + ok = ns_signaled(ns_obj(h, q->ent[i].obj), q->owner); + if (ok && ns_claim(q, 0)) { + for (uint32_t i = 0; i < n; i++) ns_take(ns_obj(h, q->ent[i].obj), q); + ns_wake(q); + } + for (uint32_t i = 0; i < n; i++) { + struct ns_obj *o = ns_obj(h, q->ent[i].obj); + if (o != locked) ns_unlock(&o->lock); + } +} + +static void ns_wake_all_obj(struct ns_head *h, struct ns_obj *o) { + uint32_t id = o->all_head; + while (id) { + uint32_t next = ns_node(h, id)->next; + ns_wake_all_q(h, ns_node_q(h, id), o); + id = next; + } +} + +static void ns_drop_q(struct ns_head *h, uint32_t qi) { + struct ns_q *q = ns_q(h, qi); + if (q->waiting) { + if (q->all) ns_lock(&h->all_lock); + for (uint32_t i = 0; i < q->total; i++) { + struct ns_ent *e = &q->ent[i]; + if (e->obj == UINT32_MAX) continue; + struct ns_obj *o = ns_obj(h, e->obj); + int all = 0; + if (q->all && i < q->count) { + ns_lock(&o->lock); + } else { + if (q->all) ns_lock(&o->lock); + else all = ns_lock_obj(h, o); + } + if (e->linked) { + if (q->all && i < q->count) { + ns_unlink(h, &o->all_head, &o->all_tail, ns_node_id(qi, i)); + o->all_hint--; + } else { + ns_unlink(h, &o->any_head, &o->any_tail, ns_node_id(qi, i)); + } + } + o->busy--; + e->obj = UINT32_MAX; + ns_maybe_free(h, o); + if (q->all) ns_unlock(&o->lock); + else ns_unlock_obj(h, o, all); + } + if (q->all) ns_unlock(&h->all_lock); + q->waiting = 0; + } +} + +static void ns_free_q(struct ns_head *h, uint32_t qi) { + struct ns_q *q = ns_q(h, qi); + ns_drop_q(h, qi); + q->inuse = 0; + ns_push(&h->q_free, &q->next_free, qi); +} + +static void ns_sweep_proc(struct ns_head *h, uint32_t p) { + uint32_t qn = __atomic_load_n(&h->q_hwm, __ATOMIC_ACQUIRE); + for (uint32_t i = 0; i < qn; i++) { + struct ns_q *q = ns_q(h, i); + if (q->inuse && q->proc == p) ns_free_q(h, i); + } + uint32_t sn = __atomic_load_n(&h->slot_hwm, __ATOMIC_ACQUIRE); + uint64_t bit = 1ull << (p % 64); + for (uint32_t s = 0; s < sn; s++) { + struct ns_obj *o = ns_obj(h, s); + if (!(__atomic_load_n(&o->holders[p / 64], __ATOMIC_RELAXED) & bit)) continue; + ns_lock(&o->lock); + o->holders[p / 64] &= ~bit; + ns_maybe_free(h, o); + ns_unlock(&o->lock); + } + __atomic_store_n(&h->procs[p].pid, 0, __ATOMIC_RELEASE); +} + +static void ns_sweep_dead(struct ns_head *h) { + int saved = errno; + ns_lock(&h->proc_lock); + for (uint32_t i = 0; i < NS_PROCS; i++) { + uint32_t pid = h->procs[i].pid; + if (pid && pid != ns_getpid() && ns_dead(pid)) ns_sweep_proc(h, i); + } + ns_unlock(&h->proc_lock); + errno = saved; +} + +static void ns_maybe_sweep(struct ns_head *h) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + uint64_t now = (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; + uint64_t last = __atomic_load_n(&h->sweep_at, __ATOMIC_RELAXED); + if (now - last < NS_SWEEP_NS) return; + if (!__atomic_compare_exchange_n(&h->sweep_at, &last, now, 0, __ATOMIC_RELAXED, __ATOMIC_RELAXED)) return; + ns_sweep_dead(h); +} + +static int ns_attach(struct ns_dev *d) { + if (d->proc >= 0) return d->proc; + struct ns_head *h = d->h; + uint32_t pid = ns_getpid(); + int slot = -1; + if (!d->counts) { + void *m = mmap(NULL, (size_t)NS_SLOTS * sizeof(uint32_t), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (m == MAP_FAILED) return -1; + d->counts = m; + } + int saved = errno; + ns_lock(&h->proc_lock); + for (uint32_t i = 0; i < NS_PROCS && slot < 0; i++) + if (h->procs[i].pid == pid) { + ns_sweep_proc(h, i); + slot = (int)i; + } + for (uint32_t i = 0; i < NS_PROCS && slot < 0; i++) + if (!h->procs[i].pid) slot = (int)i; + for (uint32_t i = 0; i < NS_PROCS && slot < 0; i++) + if (ns_dead(h->procs[i].pid)) { + ns_sweep_proc(h, i); + slot = (int)i; + } + if (slot >= 0) __atomic_store_n(&h->procs[slot].pid, pid, __ATOMIC_RELEASE); + ns_unlock(&h->proc_lock); + errno = saved; + __atomic_store_n(&d->proc, slot, __ATOMIC_RELEASE); + return slot; +} + +static int ns_table(void) { + if (ns_fdt) return 1; + void *m = mmap(NULL, (size_t)NS_FDS * sizeof(uint64_t), PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (m == MAP_FAILED) return 0; + __atomic_store_n(&ns_fdt, (uint64_t *)m, __ATOMIC_RELEASE); + return 1; +} + +static struct ns_dev *ns_find(uint32_t tag) { + for (int i = 0; i < ns_ndev; i++) + if (ns_devs[i].h && ns_devs[i].tag == tag) return &ns_devs[i]; + return NULL; +} + +static int ns_dev_get(struct ns_dev *d) { + uint32_t r = __atomic_load_n(&d->refs, __ATOMIC_ACQUIRE); + while (r && r != NS_DEAD) + if (__atomic_compare_exchange_n(&d->refs, &r, r + 1, 1, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) return 1; + return 0; +} + +static void ns_release(struct ns_dev *d) { + struct ns_head *h = d->h; + __atomic_store_n(&d->tag, 0, __ATOMIC_RELEASE); + if (d->proc >= 0) { + ns_lock(&h->proc_lock); + ns_sweep_proc(h, (uint32_t)d->proc); + ns_unlock(&h->proc_lock); + } + if (d->counts) munmap(d->counts, (size_t)NS_SLOTS * sizeof(uint32_t)); + munmap(h, NS_REGION); + d->counts = NULL; + d->proc = -1; + d->ino = 0; + __atomic_store_n(&d->h, NULL, __ATOMIC_RELEASE); +} + +static void ns_dev_put_locked(struct ns_dev *d) { + if (__atomic_sub_fetch(&d->refs, 1, __ATOMIC_ACQ_REL)) return; + 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); +} + +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); + 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); + errno = saved; +} + +static struct ns_dev *ns_map(int fd, uint32_t tag, uint64_t ino) { + struct ns_dev *d = ns_find(tag); + if (d) return d->ino == ino ? d : NULL; + for (int i = 0; i < ns_ndev && !d; i++) + if (!ns_devs[i].h) d = &ns_devs[i]; + if (!d && ns_ndev == NS_DEVS) return NULL; + struct ns_head *h = mmap(NULL, NS_REGION, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (h == MAP_FAILED) return NULL; + if (h->magic != NS_HEAD_MAGIC || h->version != NS_VERSION || h->tag != tag) { + munmap(h, NS_REGION); + return NULL; + } + if (!d) d = &ns_devs[ns_ndev]; + d->ino = ino; + d->proc = -1; + d->counts = NULL; + d->refs = 0; + __atomic_store_n(&d->h, h, __ATOMIC_RELEASE); + __atomic_store_n(&d->tag, tag, __ATOMIC_RELEASE); + if (d == &ns_devs[ns_ndev]) __atomic_store_n(&ns_ndev, ns_ndev + 1, __ATOMIC_RELEASE); + return d; +} + +static uint64_t ns_entry_dev(struct ns_dev *d) { + return NS_E_VALID | NS_E_DEVICE | ((uint64_t)(d - ns_devs) << 56); +} + +static uint64_t ns_entry_obj(struct ns_dev *d, uint32_t gen, uint32_t s) { + return NS_E_VALID | ((uint64_t)(d - ns_devs) << 56) | ((uint64_t)gen << 20) | s; +} + +static uint64_t ns_token(uint32_t tag, uint32_t gen, uint32_t s) { + return (NS_OBJ_MAGIC << 56) | ((uint64_t)(tag & 0xffff) << 40) | ((uint64_t)(gen & 0xfffff) << 20) | s; +} + +static void ns_forget_locked(int fd) { + uint64_t e = __atomic_exchange_n(&ns_fdt[fd], 0, __ATOMIC_ACQ_REL); + if (!e) return; + struct ns_dev *d = &ns_devs[(e >> 56) & 7]; + if (!(e & NS_E_DEVICE)) { + uint32_t s = (uint32_t)(e & 0xfffff); + if (d->proc >= 0 && d->counts && d->counts[s] && !--d->counts[s]) { + struct ns_obj *o = ns_obj(d->h, s); + ns_lock(&o->lock); + o->holders[d->proc / 64] &= ~(1ull << (d->proc % 64)); + ns_maybe_free(d->h, o); + ns_unlock(&o->lock); + } + } + ns_dev_put_locked(d); +} + +static void ns_forget(int fd) { + sigset_t old; + int saved = errno; + ns_block(&old); + pthread_mutex_lock(&ns_mx); + ns_forget_locked(fd); + pthread_mutex_unlock(&ns_mx); + ns_restore(&old); + 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); + 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; + struct ns_obj *o = ns_obj(d->h, s); + ns_lock(&o->lock); + if (o->type != NS_FREE && o->gen == gen) { + if (!d->counts[s]++) o->holders[d->proc / 64] |= 1ull << (d->proc % 64); + e = ns_entry_obj(d, gen, s); + d->refs++; + __atomic_store_n(&ns_fdt[fd], e, __ATOMIC_RELEASE); + } + ns_unlock(&o->lock); +out: + pthread_mutex_unlock(&ns_mx); + ns_restore(&old); + return e; +} + +static uint64_t ns_note_device(int fd) { + struct stat st; + 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); + 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) { + e = __atomic_load_n(&ns_fdt[fd], __ATOMIC_ACQUIRE); + if (!e) { + e = ns_entry_dev(d); + d->refs++; + __atomic_store_n(&ns_fdt[fd], e, __ATOMIC_RELEASE); + } + } + pthread_mutex_unlock(&ns_mx); + ns_restore(&old); + return e; +} + +static uint64_t ns_lookup(int fd) { + if (fd < 0 || fd >= NS_FDS) return 0; + uint64_t *t = __atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE); + if (t) { + uint64_t e = __atomic_load_n(&t[fd], __ATOMIC_ACQUIRE); + if (e) return e; + } + int saved = errno; + struct itimerspec its; + uint64_t e = 0; + if (timerfd_gettime(fd, &its) == 0) { + uint64_t tok = (uint64_t)its.it_interval.tv_sec * 1000000000ull + (uint64_t)its.it_interval.tv_nsec; + if (its.it_value.tv_sec == 0 && its.it_value.tv_nsec == 0 && (tok >> 56) == NS_OBJ_MAGIC) { + struct ns_dev *d = NULL; + uint32_t tag = (uint32_t)(tok >> 40) & 0xffff; + int n = __atomic_load_n(&ns_ndev, __ATOMIC_ACQUIRE); + for (int i = 0; i < n && !d; i++) + if (__atomic_load_n(&ns_devs[i].tag, __ATOMIC_ACQUIRE) == tag) d = &ns_devs[i]; + if (d) e = ns_adopt(d, tag, fd, (uint32_t)(tok >> 20) & 0xfffff, (uint32_t)tok & 0xfffff); + } + } else { + e = ns_note_device(fd); + } + errno = saved; + return e; +} + +static int ns_ready(struct ns_dev *d) { + if (__atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE) && __atomic_load_n(&d->proc, __ATOMIC_ACQUIRE) >= 0) return 1; + pthread_mutex_lock(&ns_mx); + int ok = ns_table() && ns_attach(d) >= 0; + pthread_mutex_unlock(&ns_mx); + return ok; +} + +static void ns_thread_gone(void *p) { + struct ns_tls *t = p; + if (!t || !t->d || !t->q) return; + sigset_t old; + struct ns_dev *d = t->d; + ns_block(&old); + ns_free_q(d->h, t->q - 1); + t->q = 0; + t->waiting = 0; + t->d = NULL; + ns_restore(&old); + ns_dev_put(d); +} + +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); + ns_tq.q = 0; + ns_tq.d = NULL; + ns_dev_put(old); + } + pthread_once(&ns_once, ns_key_init); + uint32_t i; + if (!ns_pop(h, &h->q_free, 0, &i) && !ns_grow(&h->q_hwm, NS_QS, &i)) { + ns_sweep_dead(h); + if (!ns_pop(h, &h->q_free, 0, &i)) return NULL; + } + struct ns_q *q = ns_q(h, i); + q->proc = (uint32_t)d->proc; + q->waiting = 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 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); + pthread_mutex_lock(&ns_mx); + if (!ns_table() || ns_attach(d) < 0) { + err = ENOMEM; + goto out; + } + if (!ns_pop(h, &h->slot_free, 1, &s) && !ns_grow(&h->slot_hwm, NS_SLOTS, &s)) { + ns_sweep_dead(h); + if (!ns_pop(h, &h->slot_free, 1, &s)) { + err = ENOMEM; + goto out; + } + } + struct ns_obj *o = ns_obj(h, s); + ns_lock(&o->lock); + o->a = a; + o->b = b; + o->ownerdead = 0; + o->all_hint = 0; + o->any_head = o->any_tail = o->all_head = o->all_tail = 0; + memset(o->holders, 0, sizeof(o->holders)); + o->holders[d->proc / 64] = 1ull << (d->proc % 64); + o->type = (uint32_t)type; + uint32_t gen = o->gen; + ns_unlock(&o->lock); + fd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC); + if (fd >= 0) { + uint64_t tok = ns_token(d->tag, gen, s); + struct itimerspec its = {{(time_t)(tok / 1000000000ull), (long)(tok % 1000000000ull)}, {0, 0}}; + if (fd >= NS_FDS || timerfd_settime(fd, 0, &its, NULL) != 0) { + err = fd >= NS_FDS ? EMFILE : errno; + ns_close_fd(fd); + fd = -1; + } + } else { + err = errno; + } + ns_lock(&o->lock); + if (fd < 0) { + o->holders[d->proc / 64] &= ~(1ull << (d->proc % 64)); + ns_maybe_free(h, o); + } else { + d->counts[s] = 1; + d->refs++; + __atomic_store_n(&ns_fdt[fd], ns_entry_obj(d, gen, s), __ATOMIC_RELEASE); + } + ns_unlock(&o->lock); +out: + pthread_mutex_unlock(&ns_mx); + ns_restore(&old); + if (fd < 0) errno = err; + return fd; +} + +static int ns_expired(const struct ns_wait_args *args) { + struct timespec now; + if (clock_gettime((args->flags & NS_WAIT_REALTIME) ? CLOCK_REALTIME : CLOCK_MONOTONIC, &now)) return 0; + return (uint64_t)now.tv_sec * 1000000000ull + (uint64_t)now.tv_nsec >= args->timeout; +} + +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); + if (args->timeout != UINT64_MAX) { + ts.tv_sec = (time_t)(args->timeout / 1000000000ull); + 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; + } + } + return 0; +} + +static int ns_wait(struct ns_dev *d, struct ns_wait_args *ua, int all) { + struct ns_head *h = d->h; + struct ns_wait_args args; + int fds[NS_ENTS]; + uint32_t slots[NS_ENTS], gens[NS_ENTS]; + memcpy(&args, ua, sizeof(args)); + if (args.pad || (args.flags & ~NS_WAIT_REALTIME) || !args.owner || args.count > NS_MAX_WAIT) return EINVAL; + uint32_t total = args.count + (args.alert ? 1 : 0); + if (args.count) memcpy(fds, (const void *)(uintptr_t)args.objs, args.count * sizeof(int)); + if (args.alert) fds[args.count] = (int)args.alert; + for (uint32_t i = 0; i < total; i++) { + uint64_t e = ns_lookup(fds[i]); + if (!e || (e & NS_E_DEVICE) || &ns_devs[(e >> 56) & 7] != d) return EINVAL; + slots[i] = (uint32_t)(e & 0xfffff); + gens[i] = (uint32_t)(e >> 20) & 0xfffff; + if (all && i < args.count) + for (uint32_t j = 0; j < i; j++) + if (slots[j] == slots[i]) return EINVAL; + } + sigset_t old; + ns_block(&old); + uint32_t qi; + struct ns_q *q = ns_ready(d) ? ns_thread_q(d, &qi) : NULL; + if (!q) { + ns_restore(&old); + return ENOMEM; + } + ns_maybe_sweep(h); + __atomic_store_n(&q->signaled, -1, __ATOMIC_RELAXED); + q->owner = args.owner; + q->tid = ns_self(); + q->pid = ns_getpid(); + q->count = args.count; + q->total = 0; + q->all = (uint32_t)all; + q->ownerdead = 0; + for (uint32_t i = 0; i < total; i++) { + q->ent[i].obj = UINT32_MAX; + q->ent[i].index = i; + q->ent[i].linked = 0; + q->ent[i].next = q->ent[i].prev = 0; + } + q->total = total; + q->waiting = 1; + ns_tq.waiting = 1; + int bad = 0; + if (!all) { + for (uint32_t i = 0; i < total && !bad; i++) { + struct ns_obj *o = ns_obj(h, slots[i]); + int a = ns_lock_obj(h, o); + if (o->type == NS_FREE || o->gen != gens[i]) { + bad = 1; + } else { + o->busy++; + q->ent[i].obj = slots[i]; + ns_link(h, &o->any_head, &o->any_tail, ns_node_id(qi, i)); + } + ns_unlock_obj(h, o, a); + } + for (uint32_t i = 0; i < total && !bad; i++) { + if (__atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) != -1) break; + struct ns_obj *o = ns_obj(h, slots[i]); + int a = ns_lock_obj(h, o); + ns_wake_any(h, o); + ns_unlock_obj(h, o, a); + } + } else { + ns_lock(&h->all_lock); + for (uint32_t i = 0; i < total && !bad; i++) { + struct ns_obj *o = ns_obj(h, slots[i]); + ns_lock(&o->lock); + if (o->type == NS_FREE || o->gen != gens[i]) { + bad = 1; + } else { + o->busy++; + q->ent[i].obj = slots[i]; + if (i < args.count) { + o->all_hint++; + ns_link(h, &o->all_head, &o->all_tail, ns_node_id(qi, i)); + } else { + ns_link(h, &o->any_head, &o->any_tail, ns_node_id(qi, i)); + } + } + ns_unlock(&o->lock); + } + if (!bad) ns_wake_all_q(h, q, NULL); + ns_unlock(&h->all_lock); + if (!bad && args.alert && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { + struct ns_obj *o = ns_obj(h, slots[args.count]); + int a = ns_lock_obj(h, o); + ns_wake_any(h, o); + ns_unlock_obj(h, o, a); + } + } + int ret = 0; + if (!bad && __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE) == -1) { + sigset_t held; + ns_restore(&old); + ret = ns_sleep(q, &args); + ns_block(&held); + } + ns_drop_q(h, qi); + ns_tq.waiting = 0; + int32_t got = __atomic_load_n(&q->signaled, __ATOMIC_ACQUIRE); + int dead = (int)q->ownerdead; + ns_restore(&old); + if (bad) return EINVAL; + if (got != -1) { + ua->index = (uint32_t)got; + return dead ? EOWNERDEAD : 0; + } + return ret ? ret : ETIMEDOUT; +} + +static int ns_obj_ioctl(struct ns_dev *d, uint64_t e, unsigned long req, void *arg) { + struct ns_head *h = d->h; + uint32_t s = (uint32_t)(e & 0xfffff), gen = (uint32_t)(e >> 20) & 0xfffff; + struct ns_obj *o = ns_obj(h, s); + uint32_t type = __atomic_load_n(&o->type, __ATOMIC_ACQUIRE), u32 = 0, prev = 0; + struct ns_mutex_args margs = {0, 0}; + int want, ret = 0; + switch (req) { + case NS_SEM_RELEASE: + memcpy(&u32, arg, sizeof(u32)); + want = NS_SEM; + break; + case NS_SEM_READ: + want = NS_SEM; + break; + case NS_MUTEX_UNLOCK: + memcpy(&margs, arg, sizeof(margs)); + if (!margs.owner) return EINVAL; + want = NS_MUTEX; + break; + case NS_MUTEX_KILL: + memcpy(&u32, arg, sizeof(u32)); + if (!u32) return EINVAL; + if (type == NS_MUTEX && __atomic_load_n(&o->b, __ATOMIC_ACQUIRE) != u32) return EPERM; + want = NS_MUTEX; + break; + case NS_MUTEX_READ: + want = NS_MUTEX; + break; + case NS_EVENT_SET: + case NS_EVENT_RESET: + case NS_EVENT_PULSE: + case NS_EVENT_READ: + want = NS_EVENT; + break; + default: + return ENOTTY; + } + if (type != (uint32_t)want) return EINVAL; + sigset_t old; + ns_block(&old); + 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); + return EINVAL; + } + switch (req) { + case NS_SEM_RELEASE: { + prev = o->a; + uint32_t sum = o->a + u32; + if (sum < o->a || sum > o->b) { + ret = EOVERFLOW; + break; + } + o->a = sum; + if (all) ns_wake_all_obj(h, o); + ns_wake_any(h, o); + break; + } + case NS_SEM_READ: { + struct ns_sem_args r = {o->a, o->b}; + memcpy(arg, &r, sizeof(r)); + break; + } + case NS_MUTEX_UNLOCK: + prev = o->a; + if (o->b != margs.owner) { + ret = EPERM; + break; + } + if (!--o->a) o->b = 0; + if (all) ns_wake_all_obj(h, o); + ns_wake_any(h, o); + break; + case NS_MUTEX_KILL: + if (o->b != u32) { + ret = EPERM; + break; + } + o->ownerdead = 1; + o->b = 0; + o->a = 0; + if (all) ns_wake_all_obj(h, o); + ns_wake_any(h, o); + break; + case NS_MUTEX_READ: { + struct ns_mutex_args r = {o->b, o->a}; + memcpy(arg, &r, sizeof(r)); + if (o->ownerdead) ret = EOWNERDEAD; + break; + } + case NS_EVENT_SET: + case NS_EVENT_PULSE: + prev = o->b; + o->b = 1; + if (all) ns_wake_all_obj(h, o); + ns_wake_any(h, o); + if (req == NS_EVENT_PULSE) o->b = 0; + break; + case NS_EVENT_RESET: + prev = o->b; + o->b = 0; + break; + case NS_EVENT_READ: { + struct ns_event_args r = {o->a, o->b}; + memcpy(arg, &r, sizeof(r)); + break; + } + } + ns_unlock_obj(h, o, all); + ns_restore(&old); + 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)); + else if (req == NS_MUTEX_UNLOCK) + memcpy((char *)arg + offsetof(struct ns_mutex_args, count), &prev, sizeof(prev)); + return 0; +} + +static int ns_dev_ioctl(struct ns_dev *d, unsigned long req, void *arg, int *fd) { + switch (req) { + case NS_CREATE_SEM: { + struct ns_sem_args a; + memcpy(&a, arg, sizeof(a)); + if (a.count > a.max) return EINVAL; + return (*fd = ns_create(d, NS_SEM, a.count, a.max)) < 0 ? errno : 0; + } + case NS_CREATE_MUTEX: { + struct ns_mutex_args a; + memcpy(&a, arg, sizeof(a)); + if (!a.owner != !a.count) return EINVAL; + return (*fd = ns_create(d, NS_MUTEX, a.count, a.owner)) < 0 ? errno : 0; + } + case NS_CREATE_EVENT: { + struct ns_event_args a; + memcpy(&a, arg, sizeof(a)); + return (*fd = ns_create(d, NS_EVENT, a.manual, a.signaled)) < 0 ? errno : 0; + } + case NS_WAIT_ANY: + return ns_wait(d, arg, 0); + case NS_WAIT_ALL: + return ns_wait(d, arg, 1); + } + return ENOTTY; +} + +int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) __attribute__((visibility("hidden"))); +int bl_ntsync_ioctl(int fd, unsigned long req, void *arg, int *rc) { + if (_IOC_TYPE(req) != 'N' || _IOC_NR(req) < 0x80 || _IOC_NR(req) > 0x8d) return 0; + if (!__atomic_load_n(&ns_ndev, __ATOMIC_ACQUIRE) && !ns_on()) return 0; + 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 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 (err) { + errno = err; + *rc = -1; + } else { + *rc = out; + } + return 1; +} + +int bl_ntsync_open(const char *path, int flags) __attribute__((visibility("hidden"))); +int bl_ntsync_open(const char *path, int flags) { + if (!path || strcmp(path, "/dev/ntsync") != 0 || !ns_on()) return -2; + int fd = (int)syscall(SYS_openat, AT_FDCWD, path, flags, 0); + if (fd >= 0) return fd; + fd = memfd_create("droiddeck-ntsync", (flags & O_CLOEXEC) ? MFD_CLOEXEC : 0); + if (fd < 0) return -1; + uint32_t tag = 0; + for (int tries = 0; tries < 64 && (!tag || ns_find(tag)); tries++) { + uint16_t r = 0; + if (getrandom(&r, sizeof(r), GRND_NONBLOCK) != sizeof(r)) r = (uint16_t)(ns_getpid() * 40503u + (uint32_t)tries * 9973u + (uint32_t)time(NULL)); + tag = r; + } + struct ns_head *h = MAP_FAILED; + if (fd >= NS_FDS || ftruncate(fd, (off_t)(NS_DEV_MAGIC | tag)) != 0 + || (h = mmap(NULL, NS_REGION, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == MAP_FAILED) { + int err = fd >= NS_FDS ? EMFILE : errno; + ns_close_fd(fd); + errno = err; + return -1; + } + h->version = NS_VERSION; + h->tag = tag; + __atomic_store_n(&h->magic, NS_HEAD_MAGIC, __ATOMIC_RELEASE); + munmap(h, NS_REGION); + if (!ns_note_device(fd)) { + ns_close_fd(fd); + errno = ENOMEM; + return -1; + } + return fd; +} + +static void ns_received(struct msghdr *msg) { + for (struct cmsghdr *c = CMSG_FIRSTHDR(msg); c; c = CMSG_NXTHDR(msg, c)) { + if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue; + size_t n = (c->cmsg_len - CMSG_LEN(0)) / sizeof(int); + int *fds = (int *)CMSG_DATA(c); + for (size_t i = 0; i < n; i++) { + int fd = fds[i]; + if (fd < 0 || fd >= NS_FDS) continue; + if (ns_fdt && __atomic_load_n(&ns_fdt[fd], __ATOMIC_ACQUIRE)) ns_forget(fd); + ns_note_device(fd); + } + } +} + +ssize_t recvmsg(int fd, struct msghdr *msg, int flags) { + static ssize_t (*real)(int, struct msghdr *, int); + if (!real) real = (ssize_t (*)(int, struct msghdr *, int))dlsym(RTLD_NEXT, "recvmsg"); + ssize_t r = real(fd, msg, flags); + if (r >= 0 && msg && msg->msg_control && msg->msg_controllen && ns_on()) { + int saved = errno; + ns_received(msg); + errno = saved; + } + return r; +} + +void bl_fsync_fds_closed(unsigned int first, unsigned int last) __attribute__((visibility("hidden"))); + +int close(int fd) { + if (fd >= 0) bl_fsync_fds_closed((unsigned int)fd, (unsigned int)fd); + uint64_t *t = __atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE); + if (t && fd >= 0 && fd < NS_FDS && __atomic_load_n(&t[fd], __ATOMIC_ACQUIRE)) ns_forget(fd); + return ns_close_fd(fd); +} + +int dup2(int oldfd, int newfd) { + static int (*real)(int, int); + if (!real) real = (int (*)(int, int))dlsym(RTLD_NEXT, "dup2"); + if (oldfd != newfd && newfd >= 0) bl_fsync_fds_closed((unsigned int)newfd, (unsigned int)newfd); + uint64_t *t = __atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE); + if (t && oldfd != newfd && newfd >= 0 && newfd < NS_FDS && __atomic_load_n(&t[newfd], __ATOMIC_ACQUIRE)) ns_forget(newfd); + return real(oldfd, newfd); +} + +int dup3(int oldfd, int newfd, int flags) { + static int (*real)(int, int, int); + if (!real) real = (int (*)(int, int, int))dlsym(RTLD_NEXT, "dup3"); + if (oldfd != newfd && newfd >= 0) bl_fsync_fds_closed((unsigned int)newfd, (unsigned int)newfd); + uint64_t *t = __atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE); + if (t && oldfd != newfd && newfd >= 0 && newfd < NS_FDS && __atomic_load_n(&t[newfd], __ATOMIC_ACQUIRE)) ns_forget(newfd); + return real(oldfd, newfd, flags); +} + +static void ns_forget_range(unsigned int first, unsigned int last) { + uint64_t *t = __atomic_load_n(&ns_fdt, __ATOMIC_ACQUIRE); + if (!t) return; + for (unsigned int fd = first; fd <= last && fd < (unsigned int)NS_FDS; fd++) + if (__atomic_load_n(&t[fd], __ATOMIC_ACQUIRE)) ns_forget((int)fd); +} + +int close_range(unsigned int first, unsigned int last, int flags) { + static int (*real)(unsigned int, unsigned int, int); + if (!real) real = (int (*)(unsigned int, unsigned int, int))dlsym(RTLD_NEXT, "close_range"); + if (!(flags & CLOSE_RANGE_CLOEXEC)) { + bl_fsync_fds_closed(first, last); + ns_forget_range(first, last); + } + if (!real) { + errno = ENOSYS; + return -1; + } + return real(first, last, flags); +} + +void closefrom(int lowfd) { + static void (*real)(int); + if (!real) real = (void (*)(int))dlsym(RTLD_NEXT, "closefrom"); + if (lowfd >= 0) { + bl_fsync_fds_closed((unsigned int)lowfd, ~0u); + ns_forget_range((unsigned int)lowfd, ~0u); + } + if (real) real(lowfd); +} + +static void ns_fork_prepare(void) { + pthread_mutex_lock(&ns_mx); +} + +static void ns_fork_parent(void) { + pthread_mutex_unlock(&ns_mx); +} + +static void ns_fork_child(void) { + pthread_mutex_init(&ns_mx, NULL); + ns_pid = 0; + ns_tid = 0; + ns_fdt = NULL; + memset(&ns_tq, 0, sizeof(ns_tq)); + for (int i = 0; i < ns_ndev; i++) { + ns_devs[i].proc = -1; + ns_devs[i].counts = NULL; + ns_devs[i].refs = 0; + } +} + +__attribute__((constructor)) static void ns_init(void) { + pthread_atfork(ns_fork_prepare, ns_fork_parent, ns_fork_child); +} diff --git a/tools/linuxfs/preload/opens.c b/tools/linuxfs/preload/opens.c index 4c8b6e165..1e3a945a5 100644 --- a/tools/linuxfs/preload/opens.c +++ b/tools/linuxfs/preload/opens.c @@ -35,6 +35,7 @@ int bl_status_without_tracer(const char *path, int flags) __attribute__((visibility("hidden"))); int bl_writable_retry(int dirfd, const char *path, int flags) __attribute__((visibility("hidden"))); +int bl_ntsync_open(const char *path, int flags) __attribute__((visibility("hidden"))); static int retry_without_noatime(int fd, int flags) { return fd < 0 && errno == EPERM && (flags & O_NOATIME); @@ -61,6 +62,7 @@ int open(const char *path, int flags, ...) { return -1; } if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd; + if ((fd = bl_ntsync_open(path, flags)) != -2) return fd; if (real_open == NULL) real_open = dlsym(RTLD_NEXT, "open"); if (real_open == NULL) { errno = ENOSYS; @@ -87,6 +89,7 @@ int openat(int dirfd, const char *path, int flags, ...) { return -1; } if ((fd = bl_status_without_tracer(path, flags)) >= 0) return fd; + if ((fd = bl_ntsync_open(path, flags)) != -2) return fd; if (real_openat == NULL) real_openat = dlsym(RTLD_NEXT, "openat"); if (real_openat == NULL) { errno = ENOSYS; diff --git a/tools/linuxfs/preload/robust.c b/tools/linuxfs/preload/robust.c index 0f0b581ca..97bb9eec9 100644 --- a/tools/linuxfs/preload/robust.c +++ b/tools/linuxfs/preload/robust.c @@ -9,6 +9,7 @@ #define _GNU_SOURCE #include <dlfcn.h> #include <errno.h> +#include <fcntl.h> #include <linux/futex.h> #include <pthread.h> #include <stdarg.h> @@ -18,7 +19,23 @@ #include <sys/uio.h> #include <unistd.h> +#ifndef SYS_openat2 +#define SYS_openat2 437 +#endif + +#define BL_RESOLVE_BENEATH 0x08 +#define BL_RESOLVE_IN_ROOT 0x10 + +struct bl_open_how { + uint64_t flags; + uint64_t mode; + uint64_t resolve; +}; + static long (*real_syscall)(long, ...); +static int openat2_missing; + +int bl_fsync_syscall(long number, const long *args, long *ret) __attribute__((visibility("hidden"))); static struct robust_list_head *thread_robust_head(void) { pthread_mutexattr_t attr; @@ -54,6 +71,35 @@ static struct robust_list_head *thread_robust_head(void) { return head; } +static long openat2_via_openat(const long *args) { + const char *path = (const char *)args[1]; + const struct bl_open_how *how = (const struct bl_open_how *)args[2]; + if (!path || !how || (size_t)args[3] < sizeof(*how)) { + errno = EINVAL; + return -1; + } + if (how->resolve & BL_RESOLVE_IN_ROOT) { + while (*path == '/') { + path++; + } + if (!*path) { + path = "."; + } + } else if ((how->resolve & BL_RESOLVE_BENEATH) && *path == '/') { + errno = EXDEV; + return -1; + } + return real_syscall(SYS_openat, args[0], (long)path, (long)(int)how->flags, (long)how->mode); +} + +static int execveat_is_execve(const long *args) { + const char *path = (const char *)args[1]; + if (!path || (args[4] & (AT_EMPTY_PATH | AT_SYMLINK_NOFOLLOW))) { + return 0; + } + return path[0] == '/' || (int)args[0] == AT_FDCWD; +} + long syscall(long number, ...) { va_list ap; long args[6]; @@ -72,8 +118,25 @@ long syscall(long number, ...) { *(size_t *)args[2] = sizeof(*head); return 0; } + long handled; + if (bl_fsync_syscall(number, args, &handled)) { + return handled; + } if (!real_syscall) { real_syscall = (long (*)(long, ...)) dlsym(RTLD_NEXT, "syscall"); } + if (number == SYS_openat2) { + if (!__atomic_load_n(&openat2_missing, __ATOMIC_RELAXED)) { + long fd = real_syscall(number, args[0], args[1], args[2], args[3]); + if (fd != -1 || errno != ENOSYS) { + return fd; + } + __atomic_store_n(&openat2_missing, 1, __ATOMIC_RELAXED); + } + return openat2_via_openat(args); + } + if (number == SYS_execveat && execveat_is_execve(args)) { + return real_syscall(SYS_execve, args[1], args[2], args[3]); + } return real_syscall(number, args[0], args[1], args[2], args[3], args[4], args[5]); } diff --git a/tools/linuxfs/uruntime.env b/tools/linuxfs/uruntime.env new file mode 100644 index 000000000..f1b445d86 --- /dev/null +++ b/tools/linuxfs/uruntime.env @@ -0,0 +1,3 @@ +URUNTIME_VERSION=v0.8.1 +URUNTIME_ASSET=uruntime-appimage-lite-aarch64 +URUNTIME_SHA256=8485ae20eb0bfd4eaeac38d7fd7f78e153b391c718cf33ff07879605683be1ea diff --git a/tools/proot/bench/equiv.py b/tools/proot/bench/equiv.py index 209d005ce..f624bc0a7 100644 --- a/tools/proot/bench/equiv.py +++ b/tools/proot/bench/equiv.py @@ -2,7 +2,7 @@ import os, sys paths = ["/etc/os-release", "/root/abs-link", "/root/to-bind", "/root/to-bind/inbind.txt", "/root/rel-to-bind", "/root/rel-to-bind/inbind.txt", "/root/dotdot", "/root/dotdot/libc.so.6", "/root/dangling", "/root/loop", "/usr/lib/../lib/libc.so.6", "/usr//lib/./libc.so.6", "/lib", "/lib/libc.so.6", "/bin/env", "/nope", "/nope/x", - "/etc/os-release/x", "/mnt/droiddeck-sd", "/mnt/droiddeck-sd/inbind.txt", "/mnt/droiddeck-sd/../bannerlator", + "/etc/os-release/x", "/mnt/droiddeck-sd", "/mnt/droiddeck-sd/inbind.txt", "/mnt/droiddeck-sd/../droiddeck", "/dev/null", "/dev/shm", "/dev/shm/inbind.txt", "/dev/random", "/proc/self/exe", "/proc/version", "/proc", "/sys/class/thermal/thermal_zone28/temp", "/sdcard", "/", "/tmp", "/root/in-root-abs-dir/os-release", "/data/local/tmp/dd/tmp/inbind.txt", "/data/local/tmp/dd/mini/etc/os-release", "/root/to-bind/missing/x", "/root/to-bind/inbind.txt/x", "/usr/lib/nope/deeper/x.so", "/mnt/droiddeck-sd/nope/x", "/root/dangling/x", "/root/abs-link/x", "/nope/a/b/c", "/sdcard/nope/x"] diff --git a/tools/proot/bench/run-device.sh b/tools/proot/bench/run-device.sh index 953a07af6..058780a0b 100644 --- a/tools/proot/bench/run-device.sh +++ b/tools/proot/bench/run-device.sh @@ -13,7 +13,7 @@ D=/data/local/tmp/dd V=$1; shift U=$(id -u) REL="\\Linux\\DroidDeck\\$(uname -r)\\$(uname -v)\\aarch64\\localdomain\\-1\\" -BINDS="-b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -b /proc/self/fd:/dev/fd -b /proc/self/fd/0:/dev/stdin -b /proc/self/fd/1:/dev/stdout -b /proc/self/fd/2:/dev/stderr -b $D/tmp:/dev/shm -b $D -b /sdcard -b /data/local/tmp/dd/mini/etc/os-release:/proc/version -b $D/mini/etc/os-release:/proc/loadavg -b $D/mini/etc/os-release:/proc/uptime -b /sys/class/thermal/thermal_zone0/temp:/sys/class/thermal/thermal_zone28/temp -b $D/tmp:/mnt/droiddeck-sd -b $D/tmp:/mnt/bannerlator" +BINDS="-b /dev -b /proc -b /sys -b /dev/urandom:/dev/random -b /proc/self/fd:/dev/fd -b /proc/self/fd/0:/dev/stdin -b /proc/self/fd/1:/dev/stdout -b /proc/self/fd/2:/dev/stderr -b $D/tmp:/dev/shm -b $D -b /sdcard -b /data/local/tmp/dd/mini/etc/os-release:/proc/version -b $D/mini/etc/os-release:/proc/loadavg -b $D/mini/etc/os-release:/proc/uptime -b /sys/class/thermal/thermal_zone0/temp:/sys/class/thermal/thermal_zone28/temp -b $D/tmp:/mnt/droiddeck-sd -b $D/tmp:/mnt/droiddeck" export PROOT_TMP_DIR=$D/tmp case $V in patched) export PROOT_LOADER=$D/loader; exec $D/proot --kill-on-exit --kernel-release="$REL" -i $U:$U -r $D/mini -w /root $BINDS /usr/bin/env -i HOME=/root PATH=/usr/bin LANG=C.UTF-8 "$@" ;; diff --git a/tools/release/check_session_assets.py b/tools/release/check_session_assets.py index b71f42e41..8b3b81ca7 100644 --- a/tools/release/check_session_assets.py +++ b/tools/release/check_session_assets.py @@ -8,7 +8,8 @@ OVERLAY = Path(__file__).resolve().parents[1] / 'linuxfs/overlay' def check(apk, overlay=OVERLAY): - sources = list((overlay / 'usr/local/bin').glob('bannerlator-*')) + sources = list((overlay / 'usr/local/bin').glob('droiddeck-*')) + sources += [path for path in [overlay / 'usr/local/bin/steam-compatibility'] if path.is_file()] sources += [path for path in (overlay / 'usr/bin').rglob('*') if path.is_file()] errors = [] with ZipFile(apk) as package: diff --git a/tools/tests/test_appimage_run.py b/tools/tests/test_appimage_run.py new file mode 100644 index 000000000..5e0c5dd65 --- /dev/null +++ b/tools/tests/test_appimage_run.py @@ -0,0 +1,132 @@ +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +RUN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-appimage-run" +KEYS = ("QT_QPA_PLATFORM", "WAYLAND_DISPLAY", "SDL_VIDEODRIVER", "XDG_SESSION_TYPE", "QT_WAYLAND_DISABLE_WINDOWDECORATION", "DISABLE_GAMESCOPE_WSI") +QUICK = ("usr/lib/libQt6Quick.so.6", "usr/lib/libvulkan.so.1", "usr/plugins/platforms/libqwayland-generic.so") +RUN_MODE = {"GAMESCOPE_WAYLAND_DISPLAY": "gamescope-0", "WAYLAND_DISPLAY": "gamescope-0", "DISPLAY": ":0", + "QT_QPA_PLATFORM": "xcb", "SDL_VIDEODRIVER": "x11", "XDG_SESSION_TYPE": "x11"} +DESKTOP = {"WAYLAND_DISPLAY": "wayland-1", "DISPLAY": ":1", "QT_QPA_PLATFORM": "wayland;xcb", + "SDL_VIDEODRIVER": "wayland,x11", "XDG_SESSION_TYPE": "wayland"} + + +class AppImageRunTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.dir = self.tmp / "opennow" + self.out = self.tmp / "env" + app = self.dir / "app" + app.mkdir(parents=True) + run = app / "AppRun" + run.write_text("#!/bin/sh\nfor k in %s; do eval \"echo $k=\\${$k-unset}\"; done > %s\n" % (" ".join(KEYS), self.out)) + run.chmod(0o755) + + def bundle(self, *paths): + for path in paths: + lib = self.dir / "app" / path + lib.parent.mkdir(parents=True, exist_ok=True) + lib.write_bytes(b"") + + def launch(self, session): + env = {k: v for k, v in os.environ.items() if k not in KEYS + ("GAMESCOPE_WAYLAND_DISPLAY", "DISPLAY")} + env.update(session, HOME=str(self.tmp / "home"), DBUS_SESSION_BUS_ADDRESS="unix:path=/dev/null") + result = subprocess.run(["bash", str(RUN), str(self.dir)], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(0, result.returncode, result.stderr) + return dict(line.split("=", 1) for line in self.out.read_text().splitlines()), result.stdout + + def test_a_vulkan_qt_quick_app_draws_on_gamescope_wayland(self): + self.bundle(*QUICK) + env, log = self.launch(RUN_MODE) + self.assertEqual("wayland;xcb", env["QT_QPA_PLATFORM"]) + self.assertEqual("gamescope-0", env["WAYLAND_DISPLAY"]) + self.assertEqual("wayland", env["SDL_VIDEODRIVER"]) + self.assertEqual("wayland", env["XDG_SESSION_TYPE"]) + self.assertEqual("1", env["QT_WAYLAND_DISABLE_WINDOWDECORATION"]) + self.assertEqual("1", env["DISABLE_GAMESCOPE_WSI"]) + self.assertIn("Qt wayland (wayland;xcb)", log) + + def test_a_quick_app_that_loads_vulkan_itself_is_found_by_its_binary(self): + self.bundle(QUICK[0], QUICK[2]) + binary = self.dir / "app/usr/bin/opennow-qt" + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_bytes(b"\x7fELF\0_ZN7QWindow17setVulkanInstanceEP15QVulkanInstance\0") + env, _ = self.launch(RUN_MODE) + self.assertEqual("wayland;xcb", env["QT_QPA_PLATFORM"]) + self.assertEqual("1", env["DISABLE_GAMESCOPE_WSI"]) + + def test_other_apps_stay_on_xwayland(self): + self.bundle("usr/lib/libQt6Widgets.so.6", "usr/lib/libvulkan.so.1", "usr/plugins/platforms/libqxcb.so") + env, _ = self.launch(RUN_MODE) + self.assertEqual("xcb", env["QT_QPA_PLATFORM"]) + self.assertEqual("x11", env["SDL_VIDEODRIVER"]) + self.assertEqual("unset", env["QT_WAYLAND_DISABLE_WINDOWDECORATION"]) + self.assertEqual("unset", env["DISABLE_GAMESCOPE_WSI"]) + + def test_a_quick_app_without_the_wayland_plugin_stays_on_xwayland(self): + self.bundle(*QUICK[:2]) + env, log = self.launch(RUN_MODE) + self.assertEqual("xcb", env["QT_QPA_PLATFORM"]) + self.assertIn("stays on X11", log) + + def test_the_desktop_keeps_its_decorations(self): + self.bundle(*QUICK) + env, _ = self.launch(DESKTOP) + self.assertEqual("wayland;xcb", env["QT_QPA_PLATFORM"]) + self.assertEqual("wayland-1", env["WAYLAND_DISPLAY"]) + self.assertEqual("wayland", env["SDL_VIDEODRIVER"]) + self.assertEqual("unset", env["QT_WAYLAND_DISABLE_WINDOWDECORATION"]) + self.assertEqual("unset", env["DISABLE_GAMESCOPE_WSI"]) + + def test_how_the_program_ended_is_logged(self): + run = self.dir / "app/AppRun" + run.write_text("#!/bin/sh\nexit 3\n") + env = {k: v for k, v in os.environ.items() if k not in KEYS} + env.update(RUN_MODE, HOME=str(self.tmp / "home"), DBUS_SESSION_BUS_ADDRESS="unix:path=/dev/null") + result = subprocess.run(["bash", str(RUN), str(self.dir)], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(3, result.returncode) + self.assertIn("exited with status 3", result.stdout) + run.write_text("#!/bin/sh\nkill -SEGV $$\n") + result = subprocess.run(["bash", str(RUN), str(self.dir)], env=env, capture_output=True, text=True, timeout=30) + self.assertEqual(139, result.returncode) + self.assertIn("ended by signal 11 (SEGV)", result.stdout) + + def test_a_failed_program_shows_the_log_it_wrote(self): + home = self.tmp / "home" + old = home / ".local/share/emulator/log/old.log" + old.parent.mkdir(parents=True) + old.write_text("from an earlier run\n") + os.utime(old, (1, 1)) + (self.dir / "app/AppRun").write_text( + "#!/bin/sh\nmkdir -p \"$HOME/.local/share/emulator/log\"\n" + "printf 'Assertion failed: vulkan device\\n\\345\\001\\n' > \"$HOME/.local/share/emulator/log/emulator_log.txt\"\n" + "kill -ILL $$\n") + env = {k: v for k, v in os.environ.items() if k not in KEYS} + env.update(RUN_MODE, HOME=str(home), DBUS_SESSION_BUS_ADDRESS="unix:path=/dev/null") + result = subprocess.run(["bash", str(RUN), str(self.dir)], env=env, capture_output=True, text=True, timeout=30) + self.assertIn("ended by signal 4 (ILL)", result.stdout) + self.assertIn("end of %s" % (home / ".local/share/emulator/log/emulator_log.txt"), result.stdout) + self.assertIn("Assertion failed: vulkan device", result.stdout) + self.assertNotIn("from an earlier run", result.stdout) + (self.dir / "app/AppRun").write_text("#!/bin/sh\necho again > \"$HOME/.local/share/emulator/log/emulator_log.txt\"\n") + result = subprocess.run(["bash", str(RUN), str(self.dir)], env=env, capture_output=True, text=True, timeout=30) + self.assertNotIn("end of", result.stdout) + + def test_the_per_app_choice_wins(self): + self.bundle(*QUICK) + (self.dir / "qt").write_text("xcb\n") + env, log = self.launch(DESKTOP) + self.assertEqual("xcb", env["QT_QPA_PLATFORM"]) + self.assertIn("Qt xcb (xcb)", log) + (self.dir / "qt").write_text("wayland\n") + (self.dir / "app/usr/lib/libQt6Quick.so.6").unlink() + env, _ = self.launch(RUN_MODE) + self.assertEqual("wayland;xcb", env["QT_QPA_PLATFORM"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_desktop_bookmarks.py b/tools/tests/test_desktop_bookmarks.py index 366a8939a..113730bc6 100644 --- a/tools/tests/test_desktop_bookmarks.py +++ b/tools/tests/test_desktop_bookmarks.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch -HELPER = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-desktop-bookmarks" +HELPER = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-desktop-bookmarks" MODULE = runpy.run_path(str(HELPER)) diff --git a/tools/tests/test_droiddeck_fex.py b/tools/tests/test_droiddeck_fex.py new file mode 100644 index 000000000..a79c7567f --- /dev/null +++ b/tools/tests/test_droiddeck_fex.py @@ -0,0 +1,515 @@ +import contextlib +import gzip +import io +import json +import os +import runpy +import shutil +import struct +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +FEX = runpy.run_path(str(BIN / "droiddeck-fex")) +G = FEX["main"].__globals__ + + +def elf(machine, data=1, size=64): + head = bytearray(size) + head[0:4] = b"\x7fELF" + head[4] = 2 + head[5] = data + struct.pack_into("<H" if data == 1 else ">H", head, 18, machine) + return bytes(head) + + +class FexTestCase(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.home = self.tmp / "home" + self.steam = self.home / ".local/share/Steam" + (self.steam / "steamapps/common").mkdir(parents=True) + state = self.home / ".local/share/droiddeck-fex" + patches = { + "HOME": str(self.home), + "STEAM_ROOT": str(self.steam), + "LIBRARIES": (str(self.tmp / "sd"),), + "STATE": str(state), + "CONFIG_DIR": str(state / "config"), + "DATA_DIR": str(state / "data"), + "CACHE_DIR": str(self.home / ".cache/droiddeck-fex"), + "STATUS_FILE": str(state / "status.json"), + "WANTED": str(self.home / ".config/droiddeck/fex-wanted"), + "PRELOADS": str(self.tmp / "preloads"), + "HOST_PRELOAD": str(self.tmp / "ld.so.preload"), + } + patcher = mock.patch.dict(G, patches) + patcher.start() + self.addCleanup(patcher.stop) + + def write(self, path, data=b"", mode=0o644): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data if isinstance(data, bytes) else data.encode()) + path.chmod(mode) + return path + + def fex_tool(self, library=None, server=True, thunks=True): + tool = Path(library or self.steam) / "steamapps/common/FEX-Emu" + self.write(tool / "usr/bin/FEX", elf(183), 0o755) + if server: + self.write(tool / "usr/bin/FEXServer", elf(183), 0o755) + if thunks: + (tool / "usr/lib/aarch64-linux-gnu/fex-emu/HostThunks").mkdir(parents=True) + (tool / "usr/share/fex-emu/GuestThunks").mkdir(parents=True) + self.write(tool / "usr/share/fex-emu/ThunksDB.json", "{}") + self.write(Path(library or self.steam) / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }') + return tool + + def runtime(self, version="3.0.20260805.254768", lines=None): + runtime = self.steam / "steamapps/common/SteamLinuxRuntime_sniper" + platform = runtime / ("sniper_platform_" + version) + files = platform / "files" + self.write(files / "lib/x86_64-linux-gnu/libc.so.6", elf(62)) + self.write(files / "ab/cdef-1.bin", b"blob") + self.write(files / "share/ca/real.crt", b"cert") + mtree = lines or [ + "#mtree", + ". type=dir", + "./bin type=dir", + "./lib64 type=dir", + "./lib64/ld-linux-x86-64.so.2 type=link link=/usr/lib/x86_64-linux-gnu/ld-2.31.so", + "./lib/x86_64-linux-gnu/libc.so.6 type=file mode=644 size=64", + "./bin/\\133 type=file mode=755 size=4 contents=./ab/cdef-1.bin", + "./share/ca/Na\\075me.crt type=link link=real.crt", + "./etc/ssl/cert.pem type=link link=/etc/ssl/certs/ca.pem", + ] + with gzip.open(platform / "usr-mtree.txt.gz", "wt") as out: + out.write("\n".join(mtree) + "\n") + self.write(self.steam / "steamapps/appmanifest_1628350.acf", '"AppState" { "installdir" "SteamLinuxRuntime_sniper" }') + return platform + + def quiet(self, function, *args): + with contextlib.redirect_stderr(io.StringIO()) as err: + return function(*args), err.getvalue() + + +class ArchitectureTest(FexTestCase): + def test_elf_machines(self): + self.assertEqual(FEX["elf_arch"](self.write(self.tmp / "a", elf(62))), "x86_64") + self.assertEqual(FEX["elf_arch"](self.write(self.tmp / "b", elf(3))), "i386") + self.assertEqual(FEX["elf_arch"](self.write(self.tmp / "c", elf(183))), "arm64") + self.assertEqual(FEX["elf_arch"](self.write(self.tmp / "d", elf(62, data=2))), "x86_64") + self.assertEqual(FEX["elf_arch"](self.write(self.tmp / "e", elf(8))), "other") + self.assertIsNone(FEX["elf_arch"](self.write(self.tmp / "f", "#!/bin/sh\n"))) + self.assertIsNone(FEX["elf_arch"](self.tmp / "missing")) + + def test_a_script_follows_the_programs_beside_it(self): + game = self.tmp / "game" + script = self.write(game / "start.sh", "#!/bin/bash\n./bin/game\n") + self.assertFalse(FEX["needs_fex"](str(script))) + self.write(game / "bin/game.x86_64", elf(62), 0o755) + self.assertTrue(FEX["needs_fex"](str(script))) + self.assertTrue(FEX["needs_fex"](str(game))) + self.write(game / "bin/game.arm64", elf(183), 0o755) + self.assertFalse(FEX["needs_fex"](str(script))) + self.assertTrue(FEX["needs_fex"](str(game / "bin/game.x86_64"))) + self.assertFalse(FEX["needs_fex"](str(game / "bin/game.arm64"))) + + def test_the_scan_stops_at_its_depth(self): + game = self.tmp / "deep" + self.write(game / "a/b/c/d/e/game", elf(62), 0o755) + self.assertFalse(FEX["needs_fex"](str(game))) + self.write(game / "a/b/game", elf(62), 0o755) + self.assertTrue(FEX["needs_fex"](str(game))) + + def test_appimage_offset_is_the_end_of_the_section_headers(self): + head = bytearray(elf(62)) + struct.pack_into("<Q", head, 0x28, 1000) + struct.pack_into("<HH", head, 0x3A, 64, 30) + self.assertEqual(FEX["appimage_offset"](self.write(self.tmp / "x.AppImage", bytes(head))), 1000 + 64 * 30) + head = bytearray(elf(3)) + head[4] = 1 + struct.pack_into("<I", head, 0x20, 500) + struct.pack_into("<HH", head, 0x2E, 40, 10) + self.assertEqual(FEX["appimage_offset"](self.write(self.tmp / "y.AppImage", bytes(head))), 900) + + def test_run_options(self): + self.assertEqual(FEX["parse_run"](["--mode", "on", "--for", "/x", "--", "a", "--mode"]), ("on", "/x", ["a", "--mode"])) + self.assertEqual(FEX["parse_run"](["a"]), ("auto", None, ["a"])) + with self.assertRaises(ValueError): + FEX["parse_run"](["--mode", "maybe", "a"]) + with self.assertRaises(ValueError): + FEX["parse_run"](["--bogus", "a"]) + + +class LocateTest(FexTestCase): + def test_tools_are_found_in_any_library(self): + self.assertIsNone(FEX["fex_install"]() if not os.access("/usr/bin/FEX", os.X_OK) else None) + sd = self.tmp / "sd" + (sd / "steamapps/common").mkdir(parents=True) + tool = self.fex_tool(sd) + fex = FEX["fex_install"]() + self.assertEqual((fex["bin"], fex["server"], fex["portable"]), (str(tool / "usr/bin/FEX"), str(tool / "usr/bin/FEXServer"), True)) + self.assertTrue(fex["host_thunks"].endswith("HostThunks") and fex["guest_thunks"].endswith("GuestThunks") and fex["thunks_db"]) + + def test_library_folders_are_read(self): + other = self.tmp / "other" + (other / "steamapps/common").mkdir(parents=True) + self.write(self.steam / "steamapps/libraryfolders.vdf", '"libraryfolders" { "1" { "path" "%s" } }' % other) + tool = self.fex_tool(other) + self.assertEqual(FEX["app_dir"]("3127680"), str(tool)) + + def test_the_newest_runtime_platform_wins(self): + self.runtime("3.0.20260805.254768") + newer = self.runtime("3.0.20261001.1") + self.assertEqual(FEX["runtime_platform"](), str(newer)) + + +class PrepareTest(FexTestCase): + def test_materialize_links_the_tree_in_place(self): + platform = self.runtime() + files = platform / "files" + self.assertTrue(FEX["materialize"](str(platform))) + self.assertEqual(os.readlink(files / "lib64/ld-linux-x86-64.so.2"), "../lib/x86_64-linux-gnu/ld-2.31.so") + self.assertEqual(os.readlink(files / "bin/["), "../ab/cdef-1.bin") + self.assertEqual((files / "bin/[").read_bytes(), b"blob") + self.assertEqual((files / "share/ca/Na=me.crt").read_bytes(), b"cert") + self.assertEqual(os.readlink(files / "etc/ssl/cert.pem"), "certs/ca.pem") + self.assertEqual(os.readlink(files / "usr"), ".") + self.assertEqual((files / "usr/lib/x86_64-linux-gnu/libc.so.6").read_bytes()[:4], b"\x7fELF") + self.assertFalse(FEX["materialize"](str(platform))) + os.unlink(files / "bin/[") + os.utime(platform / "usr-mtree.txt.gz", ns=(1, 1)) + self.assertTrue(FEX["materialize"](str(platform))) + self.assertTrue(os.path.islink(files / "bin/[")) + + def test_materialize_ignores_paths_outside_the_tree(self): + platform = self.runtime(lines=["./../escape type=link link=x", "/abs type=dir", "./ok type=link link=x"]) + FEX["materialize"](str(platform)) + self.assertFalse((platform / "escape").exists() or os.path.islink(platform / "escape")) + self.assertTrue(os.path.islink(platform / "files/ok")) + + def test_preloads_mirror_the_host_list(self): + root = self.tmp / "root" + for arch in ("x86_64", "i386"): + for name in ("libblsession.so", "libfakeinput.so"): + self.write(self.tmp / "preloads" / arch / name, arch.encode() + name.encode()) + self.write(self.tmp / "preloads/x86_64/libfaultreport.so", b"report") + self.write(self.tmp / "preloads/x86_64/libthunkaudit.so", b"audit") + self.write(self.tmp / "preloads/x86_64/libvulkan-thunk.so", b"pin") + self.write(self.tmp / "ld.so.preload", "/usr/local/lib/libblsession.so\n/usr/local/lib/libfakeinput.so\n") + FEX["install_preloads"](str(root)) + self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/libfaultreport.so").read_bytes(), b"report") + self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/libthunkaudit.so").read_bytes(), b"audit") + self.assertEqual((root / "lib/x86_64-linux-gnu/droiddeck/libvulkan-thunk.so").read_bytes(), b"pin") + self.assertEqual((root / "etc/ld.so.preload").read_text(), + "/usr/$LIB/droiddeck/libblsession.so\n/usr/$LIB/droiddeck/libfakeinput.so\n") + self.assertEqual((root / "lib/i386-linux-gnu/droiddeck/libfakeinput.so").read_bytes(), b"i386libfakeinput.so") + self.write(self.tmp / "ld.so.preload", "/usr/local/lib/libblsession.so\n") + FEX["install_preloads"](str(root)) + self.assertEqual((root / "etc/ld.so.preload").read_text(), "/usr/$LIB/droiddeck/libblsession.so\n") + + def test_config_turns_thunks_on_only_when_complete(self): + FEX["write_config"]({"host_thunks": "/h", "guest_thunks": "/g", "thunks_db": "/db"}, "/root") + config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text()) + self.assertEqual(config, {"Config": {"RootFS": "/root", "ThunkHostLibs": "/h", "ThunkGuestLibs": "/g"}, "ThunksDB": {"GL": 1, "Vulkan": 1}}) + FEX["write_config"]({"host_thunks": None, "guest_thunks": "/g", "thunks_db": "/db"}, "/root") + config = json.loads((self.home / ".local/share/droiddeck-fex/config/Config.json").read_text()) + self.assertEqual(config, {"Config": {"RootFS": "/root"}}) + + def test_missing_pieces_are_asked_for(self): + with mock.patch.dict(G, {"fex_install": lambda: None, "steam_running": lambda: False}): + fex, said = self.quiet(FEX["prepare"]) + self.assertIsNone(fex) + self.assertIn("FEX (Steam app 3127680)", said) + self.assertIn("Steam Linux Runtime 3.0", said) + self.assertTrue(said.startswith("droiddeck-fex: x86 Linux programs need ")) + self.assertTrue((self.home / ".config/droiddeck/fex-wanted").is_file()) + status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) + self.assertFalse(status["ready"]) + + def test_a_ready_install_is_prepared(self): + tool = self.fex_tool() + platform = self.runtime() + self.write(self.home / ".config/droiddeck/fex-wanted", "1\n") + fex, said = self.quiet(FEX["prepare"]) + self.assertEqual(fex["bin"], str(tool / "usr/bin/FEX")) + self.assertIn("prepared sniper_platform_", said) + self.assertTrue(os.path.islink(platform / "files/usr")) + status = json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text()) + self.assertEqual((status["ready"], status["runtime"], status["thunks"]), (True, str(platform), True)) + self.assertFalse((self.home / ".config/droiddeck/fex-wanted").exists()) + self.assertEqual(self.quiet(FEX["prepare"])[1], "") + + def test_status_refreshes_what_the_app_reads_once_steam_installed_fex(self): + with mock.patch.dict(G, {"steam_running": lambda: False}): + self.quiet(FEX["prepare"]) + status_file = self.home / ".local/share/droiddeck-fex/status.json" + self.assertFalse(json.loads(status_file.read_text())["ready"]) + self.fex_tool() + self.runtime() + with contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(0, FEX["main"](["status"])) + self.assertTrue(json.loads(out.getvalue())["ready"]) + self.assertTrue(json.loads(status_file.read_text())["ready"]) + + def test_fex_is_found_at_the_tool_root_too(self): + tool = self.steam / "steamapps/common/FEX-Emu" + self.write(tool / "bin/FEX", elf(183), 0o755) + self.write(tool / "bin/FEXServer", elf(183), 0o755) + self.write(self.steam / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }') + fex = FEX["fex_install"]() + self.assertEqual((fex["bin"], fex["server"], fex["portable"]), (str(tool / "bin/FEX"), str(tool / "bin/FEXServer"), True)) + + def test_an_installed_tool_without_fex_says_what_it_holds(self): + tool = self.steam / "steamapps/common/FEX-Emu" + self.write(tool / "toolmanifest.vdf", "") + self.write(self.steam / "steamapps/appmanifest_3127680.acf", '"AppState" { "installdir" "FEX-Emu" }') + with mock.patch.dict(G, {"steam_running": lambda: False}), mock.patch("shutil.which", return_value=None), \ + mock.patch("os.access", lambda path, mode: False): + fex, said = self.quiet(FEX["prepare"]) + self.assertIsNone(fex) + self.assertIn("holds no FEX or FEXInterpreter", said) + self.assertIn("toolmanifest.vdf", said) + + def test_a_missing_server_is_not_ready(self): + self.fex_tool(server=False) + self.runtime() + with mock.patch.dict(G, {"steam_running": lambda: False}), mock.patch("shutil.which", return_value=None): + fex, said = self.quiet(FEX["prepare"]) + self.assertIsNone(fex) + self.assertIn("FEXServer beside", said) + + +class ExtractTest(FexTestCase): + def setUp(self): + super().setUp() + self.work = self.tmp / "work" + self.work.mkdir() + self.image = self.write(self.work / "image.AppImage", elf(62) + b"payload", 0o755) + chdir = contextlib.chdir(self.work) + chdir.__enter__() + self.addCleanup(chdir.__exit__, None, None, None) + + def uruntime(self, body): + return str(self.write(self.tmp / "uruntime", "#!/bin/sh\n" + body, 0o755)) + + def test_uruntime_unpacks_an_x86_image_without_fex(self): + tool = self.uruntime('[ "$1" = --appimage-extract ] && [ "$TARGET_APPIMAGE" = "%s" ] || exit 9\nmkdir -p AppDir && touch AppDir/AppRun\n' % self.image) + with mock.patch.dict(G, {"URUNTIME": tool, "fex_install": lambda: None, "steam_running": lambda: False}): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertEqual((status, said), (0, "")) + self.assertEqual(os.readlink("squashfs-root"), "AppDir") + self.assertTrue(Path("squashfs-root/AppRun").is_file()) + self.assertTrue((self.home / ".config/droiddeck/fex-wanted").is_file()) + self.assertFalse(json.loads((self.home / ".local/share/droiddeck-fex/status.json").read_text())["ready"]) + + def test_an_arm64_image_does_not_ask_for_fex(self): + self.write(self.image, elf(183) + b"payload", 0o755) + tool = self.uruntime("mkdir -p squashfs-root\n") + with mock.patch.dict(G, {"URUNTIME": tool}): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertEqual((status, said), (0, "")) + self.assertFalse((self.home / ".config/droiddeck/fex-wanted").exists()) + + def test_nothing_to_unpack_with_says_so(self): + tool = self.uruntime("mkdir -p squashfs-root\nexit 1\n") + with mock.patch.dict(G, {"URUNTIME": tool, "fex_install": lambda: None, "steam_running": lambda: False}), \ + mock.patch("shutil.which", return_value=None): + status, said = self.quiet(FEX["extract"], "image.AppImage") + self.assertEqual(status, 1) + self.assertIn("uruntime could not extract it (status 1)", said) + self.assertIn("no AppImage runtime for ARM64, no FEX yet and no unsquashfs", said) + self.assertFalse(os.path.lexists("squashfs-root")) + + +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"), + "LD_LIBRARY_PATH": "/steam/steamrtarm64:/usr/lib/aarch64-linux-gnu:/game/lib", + "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.assertNotIn("FEX_ROOTFS", guest) + self.assertEqual(guest["FEX_TSOENABLED"], "1") + self.assertTrue(guest["FEX_APP_CONFIG_LOCATION"].endswith("/config/")) + self.assertEqual((guest["FEX_SILENTLOG"], guest["FEX_OUTPUTLOG"]), ("0", "stderr")) + guest = FEX["guest_environment"]({"portable": False}, {"LD_PRELOAD": str(arm), "FEX_PORTABLE": "1", "FEX_OUTPUTLOG": "/tmp/fex.log"}) + self.assertEqual(guest["FEX_OUTPUTLOG"], "/tmp/fex.log") + self.assertNotIn("LD_PRELOAD", guest) + self.assertNotIn("FEX_PORTABLE", guest) + + def test_the_runtime_and_thunks_are_passed_to_fex(self): + fex = {"portable": True, "rootfs": "/rt/files", "host_thunks": "/h", "guest_thunks": "/g", "thunks_db": "/db"} + guest = FEX["guest_environment"](fex, {}) + self.assertEqual((guest["FEX_ROOTFS"], guest["FEX_THUNKHOSTLIBS"], guest["FEX_THUNKGUESTLIBS"]), ("/rt/files", "/h", "/g")) + self.assertTrue(guest["FEX_THUNKCONFIG"].endswith("/config/ThunksConfig.json")) + guest = FEX["guest_environment"](dict(fex, host_thunks=None), {}) + self.assertEqual(guest["FEX_ROOTFS"], "/rt/files") + self.assertNotIn("FEX_THUNKCONFIG", guest) + FEX["write_config"](fex, "/rt/files") + thunks = json.loads((self.home / ".local/share/droiddeck-fex/config/ThunksConfig.json").read_text()) + self.assertEqual(thunks, {"ThunksDB": {"GL": 1, "Vulkan": 1}}) + + def test_native_programs_run_directly(self): + program = self.write(self.tmp / "native", elf(183), 0o755) + with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated: + FEX["launch"]([str(program), "--flag"], {"PATH": "/usr/bin"}, "auto") + native.assert_called_once_with(str(program), [str(program), "--flag"], {"PATH": "/usr/bin"}) + emulated.assert_not_called() + + def test_x86_programs_run_under_fex(self): + tool = self.fex_tool() + self.runtime() + program = self.write(self.tmp / "game.x86_64", elf(62), 0o755) + with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(program), "-w"], {"PATH": "/usr/bin"}, "auto") + native.assert_not_called() + binary, argv, env = emulated.call_args[0] + self.assertEqual((binary, argv), (str(tool / "usr/bin/FEX"), [str(tool / "usr/bin/FEX"), str(program), "-w"])) + self.assertEqual(env["FEX_PORTABLE"], "1") + self.assertNotIn("LD_PRELOAD", env) + self.assertEqual(env["FEX_ROOTFS"], str(self.steam / "steamapps/common/SteamLinuxRuntime_sniper/sniper_platform_3.0.20260805.254768/files")) + self.write(self.tmp / "preloads/x86_64/libfaultreport.so", elf(62)) + x86 = self.write(self.tmp / "x86.so", elf(62)) + with mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(program)], {"LD_PRELOAD": str(x86)}, "auto") + self.assertEqual(emulated.call_args[0][2]["LD_PRELOAD"], "/usr/lib/x86_64-linux-gnu/droiddeck/libfaultreport.so:%s" % x86) + old = self.write(self.tmp / "game.i386", elf(3), 0o755) + with mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(old)], {}, "auto") + self.assertNotIn("LD_PRELOAD", emulated.call_args[0][2]) + with mock.patch("os.execvpe") as native, mock.patch("os.execve") as emulated: + self.quiet(FEX["launch"], [str(program)], {}, "off") + native.assert_called_once() + emulated.assert_not_called() + + def test_programs_that_bring_a_new_enough_runtime_get_the_vulkan_thunk(self): + tool = self.fex_tool() + self.runtime() + self.write(tool / "usr/share/fex-emu/GuestThunks/libvulkan-guest.so", + elf(62) + b"\0libstdc++.so.6\0GLIBCXX_3.4\0GLIBCXX_3.4.29\0libc.so.6\0GLIBC_2.2.5\0GLIBC_2.34\0") + self.write(self.tmp / "preloads/x86_64/libthunkaudit.so", elf(62)) + pin = self.write(self.tmp / "preloads/x86_64/libvulkan-thunk.so", elf(62)) + audit = "/usr/lib/x86_64-linux-gnu/droiddeck/libthunkaudit.so" + + def app(name, glibc=b"GLIBC_2.34\0GLIBC_2.44", glibcxx=b"GLIBCXX_3.4.29\0GLIBCXX_3.4.36", bundled=True): + run = self.write(self.tmp / name / "AppRun", elf(62), 0o755) + if bundled: + self.write(self.tmp / name / "shared/lib/libc.so.6", elf(62) + b"\0GLIBC_2.2.5\0" + glibc + b"\0") + self.write(self.tmp / name / "shared/lib/libstdc++.so.6", + elf(62) + b"\0GLIBCXX_3.4\0" + glibcxx + b"\0GLIBC_2.34\0") + return run + + def started(program, env=None, decide=None): + with mock.patch("os.execve") as emulated: + _, said = self.quiet(FEX["launch"], [str(program)], dict(env or {}), "on", decide) + return emulated.call_args[0][2], said + + env, said = started(app("new")) + self.assertEqual(env["LD_AUDIT"], audit) + self.assertIn("thunks on, its own Vulkan loader too", said) + env, _ = started(app("kept"), {"LD_AUDIT": "/opt/audit.so"}) + self.assertEqual(env["LD_AUDIT"], audit + ":/opt/audit.so") + env, _ = started(self.write(self.tmp / "elsewhere/run.sh", "#!/bin/sh\n", 0o755), decide=str(self.tmp / "new")) + self.assertEqual(env["LD_AUDIT"], audit) + env, said = started(app("old-cxx", glibcxx=b"GLIBCXX_3.4.28")) + self.assertNotIn("LD_AUDIT", env) + self.assertIn("thunks on)", said) + self.assertNotIn("LD_AUDIT", started(app("old-libc", glibc=b"GLIBC_2.31"))[0]) + self.assertNotIn("LD_AUDIT", started(app("runtime-only", bundled=False))[0]) + i386 = self.write(self.tmp / "new/game.i386", elf(3), 0o755) + self.assertNotIn("LD_AUDIT", started(i386)[0]) + pin.unlink() + self.assertNotIn("LD_AUDIT", started(app("no-pin"))[0]) + self.write(pin, elf(62)) + (tool / "usr/share/fex-emu/GuestThunks/libvulkan-guest.so").unlink() + self.assertNotIn("LD_AUDIT", started(app("no-thunk"))[0]) + + def test_steam_verbs(self): + with mock.patch.dict(G, {"launch": mock.Mock(return_value=0)}): + self.assertEqual(FEX["main"](["steam", "getcompatpath", "/x"]), 0) + G["launch"].assert_not_called() + FEX["main"](["steam", "waitforexitandrun", "/game/run.sh", "-a"]) + command, env, mode = G["launch"].call_args[0] + self.assertEqual((command, mode), (["/game/run.sh", "-a"], "auto")) + + +class AppInfoTest(unittest.TestCase): + COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) + + @staticmethod + def appinfo(apps, magic=0x07564429): + strings = [] + + def key(name): + if magic != 0x07564429: + return name.encode() + b"\0" + if name not in strings: + strings.append(name) + return struct.pack("<I", strings.index(name)) + + def section(items): + out = b"" + for name, value in items.items(): + if isinstance(value, dict): + out += b"\x00" + key(name) + section(value) + else: + out += b"\x01" + key(name) + value.encode() + b"\0" + return out + b"\x08" + + body = b"" + for app, (oslist, osarch, kind) in apps.items(): + data = section({"appinfo": {"appid": str(app), "common": {"type": kind, "oslist": oslist, "osarch": osarch}}}) + header = struct.pack("<IIQ", 2, 0, 0) + b"\0" * 20 + struct.pack("<I", 1) + (b"\0" * 20 if magic >= 0x07564428 else b"") + body += struct.pack("<II", app, len(header) + len(data)) + header + data + body += struct.pack("<I", 0) + if magic != 0x07564429: + return struct.pack("<II", magic, 1) + body + table_at = 16 + len(body) + table = struct.pack("<I", len(strings)) + b"".join(s.encode() + b"\0" for s in strings) + return struct.pack("<IIq", magic, 1, table_at) + body + table + + def test_linux_only_titles_are_sent_to_fex(self): + for magic in (0x07564427, 0x07564428, 0x07564429): + with tempfile.TemporaryDirectory() as tmp: + steam = Path(tmp) + (steam / "appcache").mkdir() + (steam / "appcache/appinfo.vdf").write_bytes(self.appinfo({ + 10: ("windows", "", "Game"), 20: ("linux", "64", "Game"), 30: ("windows,linux,macos", "", "game"), + 40: ("linux", "arm64", "game"), 50: ("linux", "", "Demo"), 70: ("linux", "", "Tool"), 80: ("linux", "", "config"), + 90: ("linux", "", ""), + }, magic)) + self.COMPAT["APPINFO_CACHE"].clear() + targets = self.COMPAT["native_targets"](str(steam), ["10", "20", "30", "40", "50", "70", "80", "90", "1628350", "60"]) + self.assertEqual(targets, {"20": "droiddeck-fex", "50": "droiddeck-fex"}, hex(magic)) + + def test_the_mapping_sends_native_titles_to_fex_and_keeps_picks(self): + tool, fex = self.COMPAT["TOOL"], self.COMPAT["FEX_TOOL"] + changes, auto = self.COMPAT["plan_mapping"]( + {"0": tool, "20": "fex", "21": "steamlinuxruntime_sniper", "22": "GE-Proton11-7"}, + ["20", "21", "22", "23", "24"], [tool, "GE-Proton11-7"], tool, {}, + {"20": fex, "21": fex, "22": fex, "23": fex}) + self.assertEqual(changes, {"20": fex, "21": fex, "23": fex, "24": tool}) + self.assertEqual(auto, {"20": fex, "21": fex, "23": fex, "24": tool}) + + def test_the_tool_files(self): + with tempfile.TemporaryDirectory() as tmp: + tool = Path(tmp) / "droiddeck-fex" + self.COMPAT["build_fex_tool"](str(tool)) + self.assertEqual(sorted(os.listdir(tool)), sorted(self.COMPAT["FEX_FILES"])) + self.assertIn('"commandline" "/droiddeck-fex-tool %verb%"', (tool / "toolmanifest.vdf").read_text()) + self.assertTrue(os.access(tool / "droiddeck-fex-tool", os.X_OK)) + vdf = (tool / "compatibilitytool.vdf").read_text() + self.assertIn('"from_oslist" "linux"', vdf) + self.assertIn('"to_oslist" "linux"', vdf) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_esync_packs.py b/tools/tests/test_esync_packs.py new file mode 100644 index 000000000..a793f1bee --- /dev/null +++ b/tools/tests/test_esync_packs.py @@ -0,0 +1,1161 @@ +import contextlib +import hashlib +import io +import json +import multiprocessing +import os +from pathlib import Path +import runpy +import shutil +import stat +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from unittest import mock + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +SYNC = runpy.run_path(str(BIN / "droiddeck-esync")) +COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) +GLOBALS = SYNC["select"].__globals__ +NTDLL = SYNC["NTDLL"] +WINESERVER = SYNC["WINESERVER"] +COPY = list(SYNC["DEFAULT_COPY"]) +REAL_DIRS = SYNC["REAL_DIRS"] +RECORD = SYNC["RECORD"] +VALVE_LINE = "1789159687 experimental-11.0-20260910b-arm64" +GE_LINE = "1789520806 GE-Proton11-7" +EXPORTS = ("NtClose", "NtCreateEvent", "wine_server_call") +WATCHED = ("WINESERVER", "BL_SYNC_PACK", "WINEESYNC", "PROTON_NO_ESYNC", "BL_SYNC", "CUSTOM") +PROTON = ("#!/usr/bin/python3\nimport json, os, sys\n" + "print(json.dumps([sys.argv[0], sys.argv[1:], {k: os.environ.get(k) for k in %r}]))\n" % (WATCHED,)) +MANIFEST = '"manifest" { "commandline" "/proton %verb%" }' + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def elf(exported=(), weak=(), local=(), undefined=(), salt=b""): + strings = b"\0" + offsets = {} + for name in list(exported) + list(weak) + list(local) + list(undefined): + if name not in offsets: + offsets[name] = len(strings) + strings += name.encode() + b"\0" + symbols = [bytes(24)] + for group, binding, shndx in ((exported, 1, 7), (weak, 2, 7), (local, 0, 7), (undefined, 1, 0)): + symbols += [struct.pack("<IBBHQQ", offsets[name], binding << 4 | 2, 0, shndx, 0, 0) for name in group] + dynsym = b"".join(symbols) + body = dynsym + strings + body += bytes(-len(body) % 8) + header = b"\x7fELF" + bytes((2, 1, 1)) + bytes(9) + struct.pack( + "<HHIQQQIHHHHHH", 3, 183, 1, 0, 0, 64 + len(body), 0, 64, 0, 0, 64, 3, 0) + sections = (bytes(64) + struct.pack("<IIQQQQIIQQ", 0, 11, 2, 0, 64, len(dynsym), 2, 1, 8, 24) + + struct.pack("<IIQQQQIIQQ", 0, 3, 2, 0, 64 + len(dynsym), len(strings), 0, 0, 1, 0)) + return header + body + sections + salt + + +def make_tool(parent, name, line=VALVE_LINE, salt=b"", builtin=False, ge=False, decorated=False, fsync=False, ntsync=True): + tool = Path(parent) / name + for folder in ("files/lib/wine/aarch64-unix", "files/lib/wine/aarch64-windows", "files/bin-arm64", "files/share/wine"): + (tool / folder).mkdir(parents=True, exist_ok=True) + (tool / NTDLL).write_bytes(elf(EXPORTS, salt=salt)) + (tool / WINESERVER).write_bytes(b"wineserver " + salt + (b" esync: up and running" if builtin else b"") + + (b" fsync: up and running" if fsync else b"") + (b" /dev/ntsync" if ntsync else b"")) + for rel in COPY: + (tool / rel).write_bytes(b"stock " + rel.encode() + salt) + (tool / "files/lib/wine/aarch64-unix/winevulkan.so").write_bytes(b"vulkan") + (tool / "files/lib/wine/aarch64-windows/ntdll.dll").write_bytes(b"pe") + os.symlink("wine", tool / "files/bin-arm64/msidb") + if ge: + os.symlink("bin-arm64", tool / "files/bin") + (tool / "version").write_text(line + "\n") + (tool / "proton").write_text(PROTON) + (tool / "proton").chmod(0o755) + if decorated: + (tool / "toolmanifest.vdf").write_text(MANIFEST) + (tool / "toolmanifest.vdf.droiddeck-orig").write_text(MANIFEST) + (tool / "toolmanifest.vdf.bannerlator-orig").write_text(MANIFEST) + (tool / "compatibilitytool.vdf").write_text("compat") + (tool / "droiddeck-proton-wrap").write_text("wrap") + (tool / "bannerlator-proton-wrap").write_text("wrap") + return tool + + +def make_pack(root, tool, pack_id, rev=1, source_match=False, stock=None, version=None, exports=None, complete=True, **extra): + tool = Path(tool) + pack_dir = Path(root) / "packs" / pack_id + (pack_dir / "files/lib/wine/aarch64-unix").mkdir(parents=True) + (pack_dir / "files/bin-arm64").mkdir(parents=True) + (pack_dir / NTDLL).write_bytes(b"patched ntdll " + pack_id.encode()) + (pack_dir / WINESERVER).write_bytes(b"patched wineserver " + pack_id.encode()) + line = (tool / "version").read_text().strip() + data = { + "format": 1, "id": pack_id, "flavor": "valve", "rev": rev, "version": version or line.split()[1], + "version_line": line, "stock": stock or {NTDLL: sha(tool / NTDLL), WINESERVER: sha(tool / WINESERVER)}, + "exports": exports or SYNC["exports_hash"](tool / NTDLL), "source_match": source_match, + "files": {NTDLL: sha(pack_dir / NTDLL), WINESERVER: sha(pack_dir / WINESERVER)}, "copy": COPY, + "protocol": 931, "source": {"repo": "r", "ref": "t", "commit": "c", "wine_commit": "w", "sdk_image": "i"}, + "patch": {"series": "valve-11", "sha256": "0" * 64}, + } + data.update(extra) + (pack_dir / "pack.json").write_text(json.dumps(data)) + if complete: + (pack_dir / ".complete").write_text("") + return data + + +def files_under(path): + return sorted(str(Path(folder, name).relative_to(path)) for folder, dirs, names in os.walk(path) for name in dirs + names) + + +def concurrent_worker(barrier, queue, root, tool, pack, steam): + barrier.wait() + try: + with contextlib.redirect_stderr(io.StringIO()): + path = SYNC["ensure_dist"](root, tool, pack) + SYNC["reconcile"](steam, {"HOME": str(Path(root).parents[2]), "BL_SYNC_FALLBACK": "1"}) + queue.put(path) + except Exception as error: + queue.put("error: %r" % error) + + +class SyncTestCase(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.home = self.tmp / "home" + self.home.mkdir() + self.root = self.home / ".local/share/droiddeck-esync" + self.tools = self.tmp / "tools" + self.tools.mkdir() + + def store(self): + self.root.mkdir(parents=True, exist_ok=True) + return self.root + + def env(self, **extra): + env = {"HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "BL_SYNC_FALLBACK": "1", "BL_SYNC_MIN_NOFILE": "1"} + env.update(extra) + return {name: value for name, value in env.items() if value is not None} + + def select(self, tool, env, verb="waitforexitandrun"): + command = [str(Path(tool) / "proton"), verb, "game.exe"] + output = io.StringIO() + with contextlib.redirect_stderr(output): + chosen, chosen_env = SYNC["select"](command, env) + return chosen, chosen_env, output.getvalue() + + def dist_of(self, pack_id, tool): + return self.root / "dist" / SYNC["dist_name"](pack_id, os.path.realpath(tool)) + + def quiet(self, function, *args): + with contextlib.redirect_stderr(io.StringIO()): + return function(*args) + + +class ExportsHashTest(SyncTestCase): + def test_keeps_defined_global_and_weak_names(self): + path = self.tmp / "lib.so" + path.write_bytes(elf(("beta", "alpha", "beta"), weak=("weak",), local=("hidden",), undefined=("imported",), salt=b"x")) + self.assertEqual(SYNC["exports_hash"](path), hashlib.sha256(b"alpha\nbeta\nweak").hexdigest()) + other = self.tmp / "other.so" + other.write_bytes(elf(("alpha", "beta"), weak=("weak",), salt=b"different")) + self.assertEqual(SYNC["exports_hash"](other), SYNC["exports_hash"](path)) + for data in (b"not an elf", elf(("alpha",))[:80], b"\x7fELF\x01\x01" + bytes(60)): + path.write_bytes(data) + with self.assertRaises(ValueError): + SYNC["exports_hash"](path) + + @unittest.skipUnless(shutil.which("cc") and shutil.which("nm"), "needs cc and nm") + def test_matches_nm_on_a_compiled_library(self): + source = self.tmp / "lib.c" + source.write_text("int alpha(void) { return 1; }\n__attribute__((weak)) int beta(void) { return 2; }\n" + "static int gamma_(void) { return 3; }\nint delta = 4;\nint use(void) { return gamma_(); }\n" + "extern int outside(void);\nint call(void) { return outside(); }\n") + library = self.tmp / "lib.so" + if subprocess.run(["cc", "-shared", "-fPIC", "-o", str(library), str(source)], capture_output=True).returncode: + self.skipTest("cc cannot build a shared library here") + listing = subprocess.run(["nm", "-D", "--defined-only", str(library)], capture_output=True, text=True, check=True).stdout + names = {fields[2].split("@")[0] for fields in (line.split() for line in listing.splitlines()) + if len(fields) == 3 and fields[1].isupper()} + self.assertIn("alpha", names) + self.assertNotIn("outside", names) + self.assertEqual(SYNC["exports_hash"](library), hashlib.sha256("\n".join(sorted(names)).encode()).hexdigest()) + + +class ShadowTest(SyncTestCase): + def test_layout_links_and_real_files(self): + root = self.store() + tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True, decorated=True) + os.symlink(self.tools, self.tmp / "launch") + launched = self.tmp / "launch" / "GE-Proton11-7" + pack = make_pack(root, tool, "ge-GE-Proton11-7-1789520806-abcdefabcdef-r1") + dist = Path(SYNC["ensure_dist"](root, launched, pack)) + self.assertEqual(dist, self.dist_of(pack["id"], tool)) + self.assertEqual(dist.name, "%s~%s" % (pack["id"], hashlib.sha256(str(tool.resolve()).encode()).hexdigest()[:8])) + for folder in REAL_DIRS: + self.assertTrue((dist / folder).is_dir() and not (dist / folder).is_symlink(), folder) + real_files = {NTDLL, WINESERVER, *COPY, RECORD} + for rel in files_under(dist): + if rel not in REAL_DIRS and rel not in real_files: + self.assertTrue((dist / rel).is_symlink(), rel) + self.assertEqual(os.readlink(dist / "files/bin"), "bin-arm64") + self.assertEqual(os.readlink(dist / "files/bin-arm64/msidb"), "wine") + for rel in ("proton", "version", "files/share", "files/lib/wine/aarch64-windows", "files/lib/wine/aarch64-unix/winevulkan.so"): + self.assertEqual(os.readlink(dist / rel), str(launched / rel)) + for rel in ("dist.lock", "files/steampipe_fixups_mtime"): + self.assertEqual(os.readlink(dist / rel), str(launched / rel)) + self.assertFalse((tool / rel).exists()) + for name in ("toolmanifest.vdf", "compatibilitytool.vdf", "droiddeck-proton-wrap", "toolmanifest.vdf.droiddeck-orig", + "bannerlator-proton-wrap", "toolmanifest.vdf.bannerlator-orig"): + self.assertFalse(os.path.lexists(dist / name), name) + pack_dir = root / "packs" / pack["id"] + for rel in (NTDLL, WINESERVER): + self.assertEqual((dist / rel).read_bytes(), (pack_dir / rel).read_bytes()) + for rel in COPY: + self.assertEqual((dist / rel).read_bytes(), (tool / rel).read_bytes()) + for rel in (NTDLL, WINESERVER, *COPY): + info = os.lstat(dist / rel) + self.assertTrue(stat.S_ISREG(info.st_mode), rel) + self.assertEqual(stat.S_IMODE(info.st_mode), 0o755, rel) + self.assertEqual(info.st_nlink, 1, rel) + self.assertEqual((dist / "files/bin/wineserver").read_bytes(), (pack_dir / WINESERVER).read_bytes()) + self.assertEqual((dist / "files/bin-arm64/msidb").resolve(), (dist / "files/bin-arm64/wine").resolve()) + record = json.loads((dist / RECORD).read_text()) + self.assertEqual(record["pack"], pack["id"]) + self.assertEqual(record["stock"], str(tool.resolve())) + self.assertEqual(record["tool"], str(launched)) + self.assertEqual(record["version"], GE_LINE) + self.assertEqual(sorted(record["copies"]), sorted(COPY)) + for rel in COPY: + info = os.stat(tool / rel) + self.assertEqual(record["copies"][rel], [info.st_size, info.st_mtime_ns, sha(tool / rel)]) + inodes = {rel: os.lstat(dist / rel).st_ino for rel in (NTDLL, WINESERVER, *COPY)} + self.assertEqual(Path(SYNC["ensure_dist"](root, tool, pack)), dist) + self.assertEqual(inodes, {rel: os.lstat(dist / rel).st_ino for rel in inodes}) + self.assertEqual(os.readlink(dist / "proton"), str(launched / "proton")) + self.assertEqual([name for name in os.listdir(root / "dist") if ".tmp-" in name], []) + + def test_reconcile_follows_stock_changes(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + dist = Path(SYNC["ensure_dist"](root, tool, pack)) + unix_wine = "files/lib/wine/aarch64-unix/wine" + preloader = "files/lib/wine/aarch64-unix/wine-preloader" + (tool / "files/new.txt").write_text("new") + (tool / "files/lib/wine/aarch64-unix/new.so").write_text("new") + (tool / "files/lib/wine/aarch64-unix/winevulkan.so").unlink() + (dist / "files/share").unlink() + os.symlink("/nonexistent", dist / "files/share") + (dist / "files/stray").symlink_to("/nowhere") + for rel in (NTDLL, WINESERVER, "files/bin-arm64/wine"): + os.chmod(dist / rel, 0o444) + os.chmod(dist / unix_wine, 0o555) + kept = os.lstat(dist / unix_wine).st_ino + (tool / "files/bin-arm64/wine").write_bytes(b"an updated stock wine loader") + (tool / preloader).unlink() + self.assertEqual(Path(SYNC["ensure_dist"](root, tool, pack)), dist) + self.assertEqual(os.readlink(dist / "files/new.txt"), str(tool / "files/new.txt")) + self.assertEqual(os.readlink(dist / "files/lib/wine/aarch64-unix/new.so"), str(tool / "files/lib/wine/aarch64-unix/new.so")) + self.assertFalse(os.path.lexists(dist / "files/lib/wine/aarch64-unix/winevulkan.so")) + self.assertFalse(os.path.lexists(dist / "files/stray")) + self.assertEqual(os.readlink(dist / "files/share"), str(tool / "files/share")) + self.assertEqual((dist / "files/bin-arm64/wine").read_bytes(), b"an updated stock wine loader") + for rel in (NTDLL, WINESERVER, "files/bin-arm64/wine"): + self.assertEqual(stat.S_IMODE(os.lstat(dist / rel).st_mode), 0o755, rel) + self.assertEqual((dist / NTDLL).read_bytes(), (root / "packs" / pack["id"] / NTDLL).read_bytes()) + self.assertEqual(os.lstat(dist / unix_wine).st_ino, kept) + self.assertTrue((dist / preloader).is_file() and not (dist / preloader).is_symlink()) + record = json.loads((dist / RECORD).read_text()) + self.assertEqual(record["copies"]["files/bin-arm64/wine"][2], sha(tool / "files/bin-arm64/wine")) + self.assertIn(preloader, record["copies"]) + + def test_tampered_pack_falls_back_to_stock(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + (root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered") + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn("WINESERVER", env) + self.assertIn("unusable", said) + self.assertIn(f"pack {pack['id']} is damaged; the app fetches it again", said) + self.assertFalse((root / "packs" / pack["id"] / ".complete").exists()) + self.assertEqual([name for name in os.listdir(root / "dist") if not name.endswith(".lock")], []) + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertIn("no pack for Proton Experimental (ARM64)", said) + self.assertIn(pack["stock"][NTDLL], (root / "wanted.tsv").read_text()) + + def test_a_missing_tool_file_does_not_condemn_the_pack(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + with mock.patch.dict(GLOBALS, {"ensure_dist": mock.Mock(side_effect=OSError("disk full"))}): + command, _, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertIn("unusable", said) + self.assertNotIn("damaged", said) + self.assertTrue((root / "packs" / pack["id"] / ".complete").exists()) + + def test_concurrent_ensure_dist_builds_one_dist(self): + root = self.store() + steam = self.tmp / "steam" + tool = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + context = multiprocessing.get_context("fork") + barrier = context.Barrier(4) + queue = context.Queue() + workers = [context.Process(target=concurrent_worker, args=(barrier, queue, str(root), str(tool), pack, str(steam))) + for _ in range(4)] + with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}): + for worker in workers: + worker.start() + results = [queue.get(timeout=60) for _ in workers] + for worker in workers: + worker.join(60) + dist = self.dist_of(pack["id"], tool) + self.assertEqual(results, [str(dist)] * 4) + self.assertEqual(sorted(os.listdir(root / "dist")), sorted([dist.name, dist.name + ".lock"])) + record = json.loads((dist / RECORD).read_text()) + self.assertEqual(record["pack"], pack["id"]) + for rel in (NTDLL, WINESERVER): + self.assertEqual(sha(dist / rel), pack["files"][rel]) + for rel in COPY: + self.assertEqual(sha(dist / rel), sha(tool / rel)) + self.assertEqual((root / "tools.tsv").read_text().count("\n"), 1) + self.assertFalse([name for name in os.listdir(root) if ".tmp-" in name]) + + def test_build_shadow_command_line(self): + root = self.store() + tool = make_tool(self.tools, "proton-cachyos-11", "1790000000 cachyos-11.0-1") + pack = make_pack(root, tool, "cachyos-11-r1") + out = self.tmp / "shadow" / "dist" + for _ in range(2): + result = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "build-shadow", str(tool), + str(root / "packs" / pack["id"]), str(out)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(out)) + self.assertEqual(os.listdir(out.parent), ["dist"]) + self.assertEqual(json.loads((out / RECORD).read_text())["pack"], pack["id"]) + self.assertEqual(os.readlink(out / "proton"), str(tool / "proton")) + status = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "status"], capture_output=True, text=True, + env={**os.environ, "HOME": str(self.home)}, check=True).stdout + self.assertIn("packs: 1 installed", status) + usage = subprocess.run([sys.executable, str(BIN / "droiddeck-esync"), "build-shadow"], capture_output=True, text=True) + self.assertEqual(usage.returncode, 2) + + +class SelectTest(SyncTestCase): + def test_store_missing_is_inert(self): + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + before = files_under(self.tmp) + env = self.env() + command, chosen_env, said = self.select(tool, env) + self.assertEqual((command, chosen_env, said), ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env, "")) + command, chosen_env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1")) + self.assertEqual(chosen_env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0")) + self.assertIsNone(self.quiet(SYNC["reconcile"], str(self.tmp / "steam"), env)) + self.assertEqual(SYNC["gc"](None), 0) + self.assertIsNone(SYNC["store_root"](env)) + self.assertEqual(files_under(self.tmp), before) + + def test_probe_prefixes_and_other_tools_are_left_alone(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + make_pack(root, tool, "valve-experimental-r1") + for prefix in ("", "/steam/compatdata/0", "/steam/compatdata/0/", "/steam/compatdata/0-1234"): + env = self.env(STEAM_COMPAT_DATA_PATH=prefix, PROTON_NO_NTSYNC="1") + self.assertEqual(self.select(tool, env), ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env, "")) + env = self.env(PROTON_USE_ARM64="0") + self.assertEqual(self.select(tool, env)[:2], ([str(tool / "proton"), "waitforexitandrun", "game.exe"], env)) + other = self.tmp / "not-proton" + other.mkdir() + self.assertEqual(self.select(other, self.env())[1], self.env()) + self.assertFalse((root / "dist").exists()) + self.assertFalse((root / "launches.log").exists()) + + def test_fallback_switch_off(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + make_pack(root, tool, "valve-experimental-r1") + for value in ("0", None, "yes"): + env = self.env(BL_SYNC_FALLBACK=value) + command, chosen_env, said = self.select(tool, env) + self.assertEqual((command[0], chosen_env), (str(tool / "proton"), SYNC["wineserver_only"](env))) + self.assertEqual(said, "droiddeck-esync: off; wineserver sync only\n") + self.assertFalse((root / "dist").exists()) + self.assertFalse((root / "wanted.tsv").exists()) + self.assertEqual([line.split("\t")[4] for line in (root / "launches.log").read_text().splitlines()], ["-"] * 3) + + def test_pack_is_used_and_logged(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + dist = self.dist_of(pack["id"], tool) + command, env, said = self.select(tool, self.env(BL_SYNC="1")) + self.assertEqual(command, [str(dist / "proton"), "waitforexitandrun", "game.exe"]) + self.assertEqual(env["WINESERVER"], str(dist / WINESERVER)) + self.assertEqual(env["BL_SYNC_PACK"], pack["id"]) + self.assertEqual(env["WINEESYNC"], "1") + self.assertNotIn("PROTON_NO_ESYNC", env) + self.assertTrue(said.startswith("droiddeck-esync: pack %s for Proton Experimental (ARM64) (%s, exact match); " + "droiddeck-ntsync on, droiddeck-esync on, fds " % (pack["id"], VALVE_LINE)), said) + self.assertEqual(said.count("\n"), 1) + fields = (root / "launches.log").read_text().splitlines()[-1].split("\t") + self.assertEqual(fields[1:7], ["42", "waitforexitandrun", str(tool), pack["id"], "1", "1"]) + self.assertRegex(fields[7], r"^(\d+|unlimited)/(\d+|unlimited)$") + command, env, said = self.select(tool, self.env(WINEESYNC="2")) + self.assertEqual((env["WINEESYNC"], command[0]), ("2", str(dist / "proton"))) + self.assertIn("droiddeck-ntsync off", said) + for _ in range(SYNC["LOG_LINES"] + 5): + SYNC["log_launch"](str(root), command, env, str(tool), None) + self.assertEqual(len((root / "launches.log").read_text().splitlines()), SYNC["LOG_LINES"]) + + def test_fallback_switched_off_per_game(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + cases = ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"SteamAppId": "1060210"}, + {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/414740"}, {"STEAM_COMPAT_APP_ID": "201510", "WINEESYNC": "1"}, + {"STEAM_COMPAT_DATA_PATH": "/steam/compatdata/1233880/"}, {"WINEESYNC": "0"}, + {"WINEESYNC": "0", "PROTON_NO_ESYNC": "0"}) + for extra in cases: + command, env, _ = self.select(tool, self.env(**extra)) + self.assertEqual((env["WINEESYNC"], env["PROTON_NO_ESYNC"], env["BL_SYNC_PACK"]), ("0", "1", pack["id"]), extra) + self.assertNotIn("PROTON_NO_FSYNC", env, extra) + self.assertEqual(command[0], str(self.dist_of(pack["id"], tool) / "proton")) + for extra in ({"PROTON_NO_ESYNC": "0"}, {"PROTON_NO_ESYNC": ""}, {"STEAM_COMPAT_APP_ID": "42", "SteamAppId": "2630"}, + {"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_ESYNC": "0"}, {"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_ESYNC": ""}): + env = self.select(tool, self.env(**extra))[1] + self.assertEqual((env["WINEESYNC"], env["PROTON_NO_FSYNC"]), ("1", "1"), extra) + + def test_fallback_comes_before_fsync(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + make_pack(root, tool, "valve-experimental-r1") + for extra, expected in (({}, "1"), ({"WINEESYNC": "2"}, "1"), ({"PROTON_NO_FSYNC": "0"}, "0"), + ({"PROTON_NO_FSYNC": ""}, ""), ({"PROTON_NO_FSYNC": "1"}, "1")): + env = self.select(tool, self.env(**extra))[1] + self.assertEqual(env.get("PROTON_NO_FSYNC"), expected, extra) + stock = self.select(tool, self.env(BL_SYNC_FALLBACK="0"))[1] + self.assertEqual((stock["PROTON_NO_FSYNC"], stock["WINEFSYNC"]), ("1", "0")) + + def test_open_file_limit_threshold(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + make_pack(root, tool, "valve-experimental-r1") + cases = (((1024, 4096), None, "0"), ((1024, 4096), "4096", "1"), ((1024, 4096), "4097", "0"), + ((1024, 4096), "junk", "0"), ((1024, 8192), None, "1"), ((1024, -1), "999999", "1")) + for limits, minimum, expected in cases: + with mock.patch("resource.getrlimit", return_value=limits): + command, env, said = self.select(tool, self.env(BL_SYNC_MIN_NOFILE=minimum)) + self.assertEqual(env["WINEESYNC"], expected, (limits, minimum)) + self.assertIn("fds 1024/unlimited", said) + + def test_proton_no_ntsync_turns_emulation_off(self): + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1"))[1]["BL_SYNC"], "0") + self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="1"))[1]["BL_SYNC"], "0") + self.assertEqual(self.select(tool, self.env(PROTON_NO_NTSYNC="0", BL_SYNC="1"))[1]["BL_SYNC"], "1") + root = self.store() + _, env, _ = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1")) + self.assertEqual(env["BL_SYNC"], "0") + pack = make_pack(root, tool, "valve-experimental-r1") + _, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1")) + self.assertEqual((env["BL_SYNC"], env["BL_SYNC_PACK"]), ("0", pack["id"])) + self.assertIn("droiddeck-ntsync off", said) + + def test_internal_failure_launches_stock(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + make_pack(root, tool, "valve-experimental-r1") + + def broken(*args, **kwargs): + raise RuntimeError("boom") + + for name in ("measure", "load_packs", "ensure_dist", "find_pack"): + with mock.patch.dict(GLOBALS, {name: broken}): + command, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1")) + self.assertEqual(command[0], str(tool / "proton"), name) + self.assertEqual(env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0"), name) + self.assertIn("boom", said) + + def test_droiddeck_ntsync_stands_in_without_a_pack(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + command, env, said = self.select(tool, self.env(BL_SYNC="0")) + self.assertEqual(command[0], str(tool / "proton")) + self.assertEqual(env, self.env(BL_SYNC="1")) + self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), said) + self.assertEqual((root / "launches.log").read_text().split("\n")[-2].split("\t")[6], "1") + command, env, said = self.select(tool, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="1")) + self.assertEqual(env, self.env(PROTON_NO_NTSYNC="1", BL_SYNC="0")) + self.assertTrue(said.endswith("; stock binaries\n"), said) + pack = make_pack(root, tool, "valve-experimental-r1") + (root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered") + command, env, said = self.select(tool, self.env(BL_SYNC="0")) + self.assertEqual(command[0], str(tool / "proton")) + self.assertEqual(env, self.env(BL_SYNC="1")) + self.assertIn("unusable", said) + self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), said) + + def test_droiddeck_fsync_stands_in_without_a_pack(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True) + command, env, said = self.select(tool, self.env(BL_SYNC="0")) + self.assertEqual(command, [str(tool / "proton"), "waitforexitandrun", "game.exe"]) + self.assertEqual(env, self.env(BL_SYNC="0", BL_FSYNC="1", WINEFSYNC="1")) + self.assertEqual(said, "droiddeck-fsync: no pack for Proton Experimental (ARM64) (%s, ntdll %s); " + "stock binaries with Proton's fsync\n" % (VALVE_LINE, sha(tool / NTDLL)[:12])) + fields = (root / "launches.log").read_text().splitlines()[-1].split("\t") + self.assertEqual((fields[4], fields[6], fields[8]), ("-", "0", "1")) + self.assertEqual(len((root / "wanted.tsv").read_text().splitlines()), 1) + for extra in ({"BL_SYNC": "1"}, {"PROTON_NO_FSYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"SteamAppId": "1233880"}): + command, env, said = self.select(tool, self.env(**extra)) + self.assertNotIn("BL_FSYNC", env, extra) + self.assertEqual(env["BL_SYNC"], "1", extra) + self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), (extra, said)) + for extra in ({"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_FSYNC": "0"}, {"PROTON_NO_FSYNC": ""}, {"BL_SYNC": "0"}): + self.assertEqual(self.select(tool, self.env(**extra))[1]["BL_FSYNC"], "1", extra) + env = self.select(tool, self.env(PROTON_NO_FSYNC="1", PROTON_NO_NTSYNC="1"))[1] + self.assertEqual((env["BL_SYNC"], "BL_FSYNC" in env), ("0", False)) + pack = make_pack(root, tool, "valve-experimental-r1") + (root / "packs" / pack["id"] / NTDLL).write_bytes(b"tampered") + command, env, said = self.select(tool, self.env()) + self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1")) + self.assertIn("\ndroiddeck-fsync: pack %s unusable for Proton Experimental (ARM64) (" % pack["id"], said) + self.assertTrue(said.endswith("; stock binaries with Proton's fsync\n"), said) + + def test_droiddeck_fsync_first(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True) + pack = make_pack(root, tool, "valve-experimental-r1") + for extra in ({}, {"BL_SYNC_FALLBACK": None}, {"BL_SYNC_FALLBACK": "0"}, {"BL_FSYNC": "0"}): + command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", **extra)) + self.assertEqual(command[0], str(tool / "proton"), extra) + self.assertEqual((env["BL_FSYNC"], env["BL_SYNC"], env["WINEFSYNC"]), ("1", "0", "1"), extra) + self.assertNotIn("BL_SYNC_PACK", env, extra) + self.assertEqual(said, "droiddeck-fsync: chosen for Proton Experimental (ARM64); stock binaries with Proton's fsync\n") + self.assertFalse((root / "wanted.tsv").exists()) + command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_SYNC="1")) + self.assertEqual((command[0], env["BL_SYNC_PACK"], env["BL_SYNC"]), (str(self.dist_of(pack["id"], tool) / "proton"), pack["id"], "1")) + self.assertNotIn("BL_FSYNC", env) + command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_FSYNC="1", STEAM_COMPAT_APP_ID="414740")) + self.assertEqual((env["BL_FSYNC"], env["BL_SYNC_PACK"], env["PROTON_NO_ESYNC"]), ("0", pack["id"], "1")) + command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1", BL_FSYNC="1", BL_SYNC_FALLBACK="0", PROTON_NO_FSYNC="1")) + self.assertEqual((command[0], env["BL_FSYNC"], said), (str(tool / "proton"), "0", "droiddeck-esync: off\n")) + plain = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True) + command, env, said = self.select(plain, self.env(BL_FSYNC_FIRST="1")) + self.assertEqual((command[0], said), (str(plain / "proton"), "droiddeck-esync: GE-Proton11-9 has esync built in\n")) + self.assertNotIn("BL_FSYNC", env) + + def test_fsync_takes_over_when_esync_is_off_for_a_game(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True) + pack = make_pack(root, tool, "valve-experimental-r1") + for extra in ({"PROTON_NO_ESYNC": "1"}, {"WINEESYNC": "0"}): + command, env, said = self.select(tool, self.env(**extra)) + self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1"), extra) + self.assertEqual(said, "droiddeck-fsync: esync is off for this game; stock binaries with Proton's fsync\n") + with mock.patch("resource.getrlimit", return_value=(1024, 4096)): + env = self.select(tool, self.env(BL_SYNC_MIN_NOFILE=None))[1] + self.assertEqual(env["BL_FSYNC"], "1") + for extra in ({"STEAM_COMPAT_APP_ID": "2630"}, {"PROTON_NO_ESYNC": "1", "PROTON_NO_FSYNC": "1"}, {"WINEESYNC": "0", "BL_SYNC": "1"}): + command, env, said = self.select(tool, self.env(**extra)) + self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"]), (pack["id"], "0"), extra) + self.assertNotIn("BL_FSYNC", env, extra) + command, env, said = self.select(tool, self.env()) + self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env["PROTON_NO_FSYNC"]), (pack["id"], "1", "1")) + self.assertNotIn("BL_FSYNC", env) + + def test_the_hash_cache_remembers_fsync(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True) + self.select(tool, self.env()) + cache = (root / "hash-cache.tsv").read_text().splitlines() + rows = {line.split("\t")[0]: line.split("\t") for line in cache} + server = rows[os.path.realpath(tool / WINESERVER)] + self.assertEqual((len(server), server[7], server[9], server[10]), (11, "0", "1", "1")) + self.assertEqual(rows[os.path.realpath(tool / NTDLL)][9], "0") + (root / "hash-cache.tsv").write_text("\n".join("\t".join(row[:9]) for row in rows.values()) + "\n") + self.assertEqual(SYNC["parse_cache"]((root / "hash-cache.tsv").read_text()), {}) + self.assertEqual(self.select(tool, self.env())[1]["BL_FSYNC"], "1") + + def test_esync_off_games_get_droiddeck_ntsync_on_a_pack(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + for extra in ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}, {"WINEESYNC": "0"}): + command, env, said = self.select(tool, self.env(**extra)) + self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env["BL_SYNC"]), (pack["id"], "0", "1"), extra) + self.assertIn("droiddeck-ntsync on, droiddeck-esync off", said) + env = self.select(tool, self.env(PROTON_NO_ESYNC="1", PROTON_NO_NTSYNC="1"))[1] + self.assertEqual((env["WINEESYNC"], env["BL_SYNC"]), ("0", "0")) + env = self.select(tool, self.env(BL_SYNC="0"))[1] + self.assertEqual((env["WINEESYNC"], env["BL_SYNC"]), ("1", "0")) + + def test_a_running_prefix_keeps_its_sync_choice(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)", fsync=True) + prefix = os.path.realpath("/compatdata/42/pfx") + command, first, _ = self.select(tool, self.env()) + self.assertEqual(first["BL_FSYNC"], "1") + rows = json.loads((root / "prefixes.json").read_text()) + self.assertEqual(rows[prefix]["env"]["BL_FSYNC"], "1") + self.assertIsNone(rows[prefix]["env"]["BL_SYNC_PACK"]) + pack = make_pack(root, tool, "valve-experimental-r1") + dist = self.dist_of(pack["id"], tool) + running = [] + with mock.patch.dict(GLOBALS, {"server_running": lambda path: running.append(path) or True}): + command, env, said = self.select(tool, self.env(BL_SYNC="0", WINEFSYNC=None)) + self.assertEqual(running, [prefix]) + self.assertEqual(command[0], str(tool / "proton")) + self.assertEqual(env, self.env(BL_SYNC="0", BL_FSYNC="1", WINEFSYNC="1")) + self.assertEqual(said, "droiddeck-esync: Wine is still running in this prefix; keeping its sync choice (droiddeck-fsync)\n") + command, env, said = self.select(tool, self.env()) + self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"])) + self.assertNotIn("BL_FSYNC", env) + with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}): + command, env, said = self.select(tool, self.env(BL_FSYNC_FIRST="1")) + self.assertEqual((command[0], env["BL_SYNC_PACK"], env["WINESERVER"]), (str(dist / "proton"), pack["id"], str(dist / WINESERVER))) + self.assertNotIn("BL_FSYNC", env) + self.assertIn("(droiddeck-esync)", said) + other = make_tool(self.tools, "Proton 11.0 (ARM64)", salt=b"11", fsync=True) + self.assertEqual(self.select(other, self.env())[1]["BL_FSYNC"], "1") + shutil.rmtree(self.root / "dist") + command, env, said = self.select(tool, self.env()) + self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"])) + self.assertNotIn("keeping", said) + (root / "prefixes.json").write_text("not json") + with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}): + self.assertEqual(self.select(tool, self.env())[1]["BL_SYNC_PACK"], pack["id"]) + self.assertEqual(json.loads((root / "prefixes.json").read_text())[prefix]["env"]["BL_SYNC_PACK"], pack["id"]) + + def test_a_running_prefix_drops_a_pack_that_no_longer_fits(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + dist = self.dist_of(pack["id"], tool) + self.assertEqual(self.select(tool, self.env())[0][0], str(dist / "proton")) + with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}): + command, env, said = self.select(tool, self.env()) + self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"])) + self.assertIn("keeping its sync choice", said) + (tool / NTDLL).write_bytes(elf(EXPORTS, salt=b"updated in place")) + (tool / "version").write_text("1791000000 experimental-11.0-20261008-arm64\n") + command, env, said = self.select(tool, self.env(), verb="run") + self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(dist / "proton"), pack["id"])) + self.assertIn("keeping its sync choice", said) + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn("BL_SYNC_PACK", env) + self.assertNotIn("keeping", said) + self.assertIn("no pack for Proton Experimental (ARM64)", said) + other = make_tool(self.tools, "Proton 11.0 (ARM64)", "1788504814 proton-11.0-2c-arm64", salt=b"2c") + pack = make_pack(root, other, "valve-proton-11.0-2c-arm64-r1") + dist = self.dist_of(pack["id"], other) + self.assertEqual(self.select(other, self.env())[1]["BL_SYNC_PACK"], pack["id"]) + rev2 = make_pack(root, other, "valve-proton-11.0-2c-arm64-r2", rev=2) + with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}): + self.assertEqual(self.select(other, self.env(), verb="run")[1]["BL_SYNC_PACK"], pack["id"]) + self.assertEqual(self.select(other, self.env())[1]["BL_SYNC_PACK"], rev2["id"]) + for gone in (pack, rev2): + data = json.loads((root / "packs" / gone["id"] / "pack.json").read_text()) + (root / "packs" / gone["id"] / "pack.json").write_text(json.dumps(dict(data, revoked=True))) + with mock.patch.dict(GLOBALS, {"server_running": lambda path: True}): + self.assertEqual(self.select(other, self.env(), verb="run")[1]["BL_SYNC_PACK"], rev2["id"]) + command, env, said = self.select(other, self.env()) + self.assertEqual(command[0], str(other / "proton")) + self.assertNotIn("keeping", said) + + def test_droiddeck_ntsync_does_not_stand_in_when_esync_is_off(self): + self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + env = self.env(BL_SYNC="0") + env.pop("BL_SYNC_FALLBACK", None) + command, chosen, said = self.select(tool, env) + self.assertEqual(chosen, SYNC["wineserver_only"](env)) + self.assertEqual(said, "droiddeck-esync: off; wineserver sync only\n") + + def test_the_wineserver_tab_turns_proton_sync_off(self): + self.store() + tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True, fsync=True) + command, env, said = self.select(tool, self.env(BL_SYNC_FALLBACK="0")) + self.assertEqual(command[0], str(tool / "proton")) + self.assertEqual({name: env.get(name) for name in ("PROTON_NO_ESYNC", "PROTON_NO_FSYNC", "PROTON_NO_NTSYNC", "WINEESYNC", "WINEFSYNC", "BL_SYNC")}, + {"PROTON_NO_ESYNC": "1", "PROTON_NO_FSYNC": "1", "PROTON_NO_NTSYNC": "1", "WINEESYNC": "0", "WINEFSYNC": "0", "BL_SYNC": "0"}) + env = self.select(tool, self.env(BL_SYNC_FALLBACK="0", PROTON_NO_FSYNC="0"))[1] + self.assertEqual((env["PROTON_NO_FSYNC"], "WINEFSYNC" in env, env["PROTON_NO_ESYNC"]), ("0", False, "1")) + self.assertEqual(SYNC["describe"](SYNC["wineserver_only"]({})), "wineserver") + + def test_the_ntsync_tab_needs_a_wine_with_ntsync(self): + root = self.store() + tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3", fsync=True, ntsync=False) + command, env, said = self.select(tool, self.env(BL_SYNC="1")) + self.assertEqual((command[0], env["BL_SYNC"], env["BL_FSYNC"], env["WINEFSYNC"]), (str(tool / "proton"), "0", "1", "1")) + self.assertIn("droiddeck-ntsync: Proton 11.0 (ARM64) has no ntsync; trying droiddeck-fsync\n", said) + plain = make_tool(self.tools, "Proton 10.0 (ARM64)", "1780000000 proton-10.0-1", salt=b"p", ntsync=False) + command, env, said = self.select(plain, self.env(BL_SYNC="1")) + self.assertEqual((env["BL_SYNC"], "BL_FSYNC" in env), ("0", False)) + self.assertTrue(said.endswith("; stock binaries (this Wine has no ntsync)\n"), said) + pack = make_pack(root, plain, "valve-10-r1") + command, env, said = self.select(plain, self.env(PROTON_NO_ESYNC="1")) + self.assertEqual((env["BL_SYNC_PACK"], env["WINEESYNC"], env.get("BL_SYNC")), (pack["id"], "0", None)) + + def test_built_in_esync_still_falls_back_when_esync_is_off(self): + self.store() + tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True, fsync=True) + for extra in ({"PROTON_NO_ESYNC": "1"}, {"WINEESYNC": "0"}, {"STEAM_COMPAT_APP_ID": "2630", "PROTON_NO_FSYNC": "0"}): + command, env, said = self.select(tool, self.env(**extra)) + self.assertEqual((command[0], env["BL_FSYNC"]), (str(tool / "proton"), "1"), extra) + plain = make_tool(self.tools, "GE-Proton11-8", "1789900000 GE-Proton11-8", salt=b"g", builtin=True, ge=True) + for extra in ({"PROTON_NO_ESYNC": "1"}, {"STEAM_COMPAT_APP_ID": "2630"}): + command, env, said = self.select(plain, self.env(**extra)) + self.assertEqual((env["BL_SYNC"], env["PROTON_NO_ESYNC"], env["WINEESYNC"]), ("1", "1", "0"), extra) + self.assertTrue(said.endswith("; stock binaries with droiddeck-ntsync\n"), (extra, said)) + command, env, said = self.select(plain, self.env()) + self.assertEqual(said, "droiddeck-esync: GE-Proton11-8 has esync built in\n") + + def test_a_stock_fallback_replaces_the_recorded_pack(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-r1") + command, env, said = self.select(tool, self.env()) + prefix = os.path.realpath("/compatdata/42/pfx") + rows = json.loads((root / "prefixes.json").read_text()) + self.assertEqual(rows[prefix]["env"]["BL_SYNC_PACK"], pack["id"]) + stock = [str(tool / "proton"), "waitforexitandrun", "game.exe"] + self.quiet(SYNC["record"], stock, self.env()) + rows = json.loads((root / "prefixes.json").read_text()) + self.assertEqual((rows[prefix]["command"], rows[prefix]["env"]["BL_SYNC_PACK"]), (stock[0], None)) + + def test_built_in_esync_never_uses_a_pack(self): + root = self.store() + tool = make_tool(self.tools, "GE-Proton11-9", "1790000000 GE-Proton11-9", builtin=True, ge=True) + make_pack(root, tool, "ge-builtin-r1") + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn("WINEESYNC", env) + self.assertEqual(said, "droiddeck-esync: GE-Proton11-9 has esync built in\n") + self.assertFalse((root / "wanted.tsv").exists()) + self.assertFalse((root / "dist").exists()) + + def test_wanted_rows_are_deduplicated(self): + root = self.store() + tool = make_tool(self.tools, "GE-Proton11-7", GE_LINE, ge=True) + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertEqual(said, "droiddeck-esync: no pack for GE-Proton11-7 (%s, ntdll %s); stock binaries with droiddeck-ntsync\n" + % (GE_LINE, sha(tool / NTDLL)[:12])) + self.assertEqual(env["BL_SYNC"], "1") + wanted = root / "wanted.tsv" + rows = [line.split("\t") for line in wanted.read_text().splitlines()] + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0][:5], [sha(tool / NTDLL), sha(tool / WINESERVER), "GE-Proton11-7", + SYNC["exports_hash"](tool / NTDLL), str(tool)]) + self.assertTrue(rows[0][5].isdigit()) + inode = os.stat(wanted).st_ino + self.select(tool, self.env()) + self.assertEqual(os.stat(wanted).st_ino, inode) + copy = self.tmp / "elsewhere" / "GE-Proton11-7" + shutil.copytree(tool, copy, symlinks=True) + self.select(copy, self.env()) + rows_after = [line.split("\t") for line in wanted.read_text().splitlines()] + self.assertEqual(len(rows_after), 1) + self.assertEqual((rows_after[0][4], rows_after[0][5]), (str(copy), rows[0][5])) + other = make_tool(self.tools, "GE-Proton11-8", "1790000001 GE-Proton11-8", salt=b"8", ge=True) + self.select(other, self.env()) + self.assertEqual(len(wanted.read_text().splitlines()), 2) + + +class MatchingTest(SyncTestCase): + def test_exact_beats_source_then_rev_then_id(self): + root = self.store() + tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3") + other = {NTDLL: "1" * 64, WINESERVER: "2" * 64} + find = SYNC["find_pack"] + exact1 = make_pack(root, tool, "a-exact-r1", rev=1) + source9 = make_pack(root, tool, "b-source-r9", rev=9, source_match=True, stock=other) + measured = SYNC["measure"](tool) + self.assertIs(find([exact1, source9], measured), exact1) + exact2 = make_pack(root, tool, "c-exact-r2", rev=2) + self.assertIs(find([exact1, source9, exact2], measured), exact2) + exact2b = make_pack(root, tool, "d-exact-r2", rev=2) + self.assertIs(find([exact2b, exact1, exact2, source9], measured), exact2b) + source3 = make_pack(root, tool, "e-source-r3", rev=3, source_match=True, stock=other) + self.assertIs(find([source3, source9], measured), source9) + self.assertIsNone(find([make_pack(root, tool, "f-nosource", rev=5, stock=other)], measured)) + self.assertIsNone(find([make_pack(root, tool, "g-exports", source_match=True, stock=other, exports="3" * 64)], measured)) + self.assertIsNone(find([make_pack(root, tool, "h-version", source_match=True, stock=other, version="proton-11.0-4")], measured)) + self.assertIsNone(find([dict(exact2, revoked=True)], measured)) + loaded = {pack["id"] for pack in SYNC["load_packs"](root)} + self.assertEqual(loaded, {"a-exact-r1", "b-source-r9", "c-exact-r2", "d-exact-r2", "e-source-r3", "f-nosource", + "g-exports", "h-version"}) + self.assertEqual(SYNC["find_pack"](SYNC["load_packs"](root), SYNC["measure"](tool))["id"], "d-exact-r2") + + def test_identical_releases_prefer_the_pack_built_for_the_installed_one(self): + root = self.store() + tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1787950357 proton-11.0-2b-arm64") + find = SYNC["find_pack"] + own = make_pack(root, tool, "valve-proton-11.0-2b-arm64-r1") + newer = make_pack(root, tool, "valve-proton-11.0-2c-arm64-r1", version="proton-11.0-2c-arm64", + version_line="1788504814 proton-11.0-2c-arm64") + older = make_pack(root, tool, "valve-proton-11.0-2a-arm64-r1", version="proton-11.0-2a-arm64", + version_line="1787000000 proton-11.0-2a-arm64") + measured = SYNC["measure"](tool) + self.assertIs(find([newer, own], measured), own) + self.assertIsNone(find([newer], measured)) + self.assertIsNone(SYNC["match_kind"](newer, measured)) + self.assertIs(find([newer, older], measured), older) + own2 = make_pack(root, tool, "valve-proton-11.0-2b-arm64-r2", rev=2) + older2 = make_pack(root, tool, "valve-proton-11.0-2a-arm64-r2", rev=2, version="proton-11.0-2a-arm64", + version_line="1787000000 proton-11.0-2a-arm64") + self.assertIs(find([own, older2], measured), own) + self.assertIs(find([own, older2, own2], measured), own2) + self.assertEqual(find(SYNC["load_packs"](root), measured)["id"], "valve-proton-11.0-2b-arm64-r2") + unknown = make_tool(self.tools, "Proton Hotfix", "proton-hotfix", salt=b"hotfix") + sibling = make_pack(root, unknown, "valve-hotfix-sibling-r1", version="proton-11.0-2c-arm64", + version_line="1788504814 proton-11.0-2c-arm64") + self.assertIs(find([sibling], SYNC["measure"](unknown)), sibling) + + def test_exports_are_measured_only_when_needed(self): + root = self.store() + tool = make_tool(self.tools, "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3") + exact = make_pack(root, tool, "a-exact-r1") + measured = SYNC["measure"](tool) + self.assertEqual(SYNC["find_pack"]([exact], measured), exact) + self.assertNotIn("exports", measured) + other = {NTDLL: "1" * 64, WINESERVER: "2" * 64} + unrelated = make_pack(root, tool, "b-source-other", source_match=True, stock=other, version="proton-10.0-1") + self.assertIsNone(SYNC["find_pack"]([unrelated], measured)) + self.assertNotIn("exports", measured) + source = make_pack(root, tool, "c-source", source_match=True, stock=other) + self.assertEqual(SYNC["find_pack"]([source], measured), source) + self.assertEqual(measured["exports"], SYNC["exports_hash"](tool / NTDLL)) + + def test_only_complete_valid_packs_load(self): + root = self.store() + tool = make_tool(self.tools, "Proton 11.0 (ARM64)") + make_pack(root, tool, "good") + make_pack(root, tool, "incomplete", complete=False) + make_pack(root, tool, "revoked", revoked=True) + make_pack(root, tool, "badcopy", copy=["../escape"]) + make_pack(root, tool, "badrev", rev="1") + make_pack(root, tool, "badhash", files={NTDLL: "x", WINESERVER: "y"}) + make_pack(root, tool, "ignored") + (root / "packs/ignored/.ignored").write_text("") + make_pack(root, tool, "nofiles") + (root / "packs/nofiles" / WINESERVER).unlink() + make_pack(root, tool, "renamed") + (root / "packs/renamed").rename(root / "packs/other-name") + make_pack(root, tool, "badformat", format=True) + make_pack(root, tool, ".hidden") + (root / "packs/broken").mkdir() + (root / "packs/broken/.complete").write_text("") + (root / "packs/broken/pack.json").write_text("{") + self.assertEqual([pack["id"] for pack in SYNC["load_packs"](root)], ["good"]) + + +class ReconcileTest(SyncTestCase): + def test_states_tables_and_prebuilt_dists(self): + root = self.store() + steam = self.tmp / "steam" + sd = self.tmp / "sd" + valve = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") + builtin = make_tool(sd / "steamapps/common", "Proton 11.0 (ARM64)", "1789000000 proton-11.0-3", salt=b"b", builtin=True) + tools = steam / "compatibilitytools.d" + ge = make_tool(tools, "GE-Proton11-7", GE_LINE, salt=b"ge", ge=True, decorated=True) + make_tool(tools, "droiddeck-proton-arm64", salt=b"own") + make_tool(tools, ".hidden", salt=b"hidden") + (make_tool(tools, "incomplete", salt=b"inc") / WINESERVER).unlink() + loose = make_tool(self.tmp / "loose", "GE-Proton10-1", "1700000000 GE-Proton10-1", salt=b"loose") + gone = make_tool(self.tmp / "gone", "GE-Proton9-1", "1600000000 GE-Proton9-1", salt=b"gone") + pack = make_pack(root, valve, "valve-experimental-r1") + with contextlib.redirect_stderr(io.StringIO()): + self.select(loose, self.env()) + self.select(gone, self.env()) + shutil.rmtree(gone) + with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(sd)}): + summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)}) + self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 2 wanted") + self.assertFalse((root / "dist").exists()) + rows = {row[0]: row[1:] for row in (line.split("\t") for line in (root / "tools.tsv").read_text().splitlines())} + self.assertEqual(rows, { + str(valve): [VALVE_LINE, sha(valve / NTDLL), sha(valve / WINESERVER), "pack", pack["id"]], + str(builtin): ["1789000000 proton-11.0-3", sha(builtin / NTDLL), sha(builtin / WINESERVER), "builtin", "-"], + str(ge): [GE_LINE, sha(ge / NTDLL), sha(ge / WINESERVER), "wanted", "-"], + }) + wanted = [line.split("\t") for line in (root / "wanted.tsv").read_text().splitlines()] + self.assertEqual(sorted(row[4] for row in wanted), sorted([str(loose), str(ge)])) + self.assertTrue((root / "hash-cache.tsv").is_file()) + summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home), "BL_SYNC_FALLBACK": "1"}) + self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 2 wanted") + dist = self.dist_of(pack["id"], valve) + self.assertEqual(json.loads((dist / RECORD).read_text())["tool"], str(valve)) + make_pack(root, ge, "ge-GE-Proton11-7-r1", flavor="ge") + make_pack(root, loose, "ge-GE-Proton10-1-r1", flavor="ge") + summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home), "BL_SYNC_FALLBACK": "1"}) + self.assertEqual(summary, "droiddeck-esync: 2 pack(s) in use, 0 wanted") + self.assertEqual((root / "wanted.tsv").read_text(), "") + self.assertTrue(self.dist_of("ge-GE-Proton11-7-r1", ge).is_dir()) + self.assertIn("ge-GE-Proton11-7-r1", (root / "tools.tsv").read_text()) + + def test_steam_compatibility_reports_sync(self): + steam = self.tmp / "steam" + make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") + env = {"PATH": os.defpath, "HOME": str(self.home), "BL_SYNC_FALLBACK": "1"} + result = subprocess.run([sys.executable, str(BIN / "steam-compatibility"), str(steam)], env=env, + capture_output=True, text=True, check=True) + self.assertNotIn("droiddeck-esync", result.stdout + result.stderr) + self.assertFalse(self.root.exists()) + root = self.store() + make_pack(root, steam / "steamapps/common/Proton Experimental (ARM64)", "valve-experimental-r1") + result = subprocess.run([sys.executable, str(BIN / "steam-compatibility"), str(steam)], env=env, + capture_output=True, text=True, check=True) + self.assertIn("steam-compatibility: droiddeck-esync: 1 pack(s) in use, 0 wanted\n", result.stdout) + self.assertTrue(self.dist_of("valve-experimental-r1", steam / "steamapps/common/Proton Experimental (ARM64)").is_dir()) + + + def test_an_updated_proton_drops_its_pack_until_one_is_built_for_it(self): + root = self.store() + steam = self.tmp / "steam" + tool = make_tool(steam / "steamapps/common", "Proton Experimental (ARM64)") + pack = make_pack(root, tool, "valve-experimental-20261001-r1") + dist = self.dist_of(pack["id"], tool) + self.assertEqual(self.select(tool, self.env())[0][0], str(dist / "proton")) + (tool / NTDLL).write_bytes(elf(EXPORTS, salt=b"20261008")) + (tool / WINESERVER).write_bytes(b"wineserver 20261008 /dev/ntsync") + line = "1791000000 experimental-11.0-20261008-arm64" + (tool / "version").write_text(line + "\n") + command, env, said = self.select(tool, self.env()) + self.assertEqual(command[0], str(tool / "proton")) + self.assertNotIn(pack["id"], said) + with mock.patch.dict(GLOBALS, {"SD_LIBRARY": str(self.tmp / "sd")}): + summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)}) + self.assertEqual(summary, "droiddeck-esync: 0 pack(s) in use, 1 wanted") + rows = {row[0]: row[1:] for row in (line.split("\t") for line in (root / "tools.tsv").read_text().splitlines())} + self.assertEqual(rows, {str(tool): [line, sha(tool / NTDLL), sha(tool / WINESERVER), "wanted", "-"]}) + wanted = [row.split("\t") for row in (root / "wanted.tsv").read_text().splitlines()] + self.assertEqual([(row[0], row[1], row[2], row[4]) for row in wanted], + [(sha(tool / NTDLL), sha(tool / WINESERVER), "experimental-11.0-20261008-arm64", str(tool))]) + old = time.time() - 2 * SYNC["GC_GRACE"] + os.utime(dist, (old, old)) + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}): + SYNC["gc"](root) + self.assertFalse(dist.exists()) + new = make_pack(root, tool, "valve-experimental-20261008-r1") + command, env, said = self.select(tool, self.env()) + self.assertEqual((command[0], env["BL_SYNC_PACK"]), (str(self.dist_of(new["id"], tool) / "proton"), new["id"])) + summary = self.quiet(SYNC["reconcile"], str(steam), {"HOME": str(self.home)}) + self.assertEqual(summary, "droiddeck-esync: 1 pack(s) in use, 0 wanted") + + +class GcTest(SyncTestCase): + def test_removes_stale_dists_unless_in_use(self): + root = self.store() + keep = make_tool(self.tools, "keep", salt=b"keep") + uninstalled = make_tool(self.tools, "uninstalled", salt=b"uninstalled") + updated = make_tool(self.tools, "updated", salt=b"updated") + running = make_tool(self.tools, "running", salt=b"running") + recent = make_tool(self.tools, "recent", salt=b"recent") + dists = {} + for tool in (keep, uninstalled, updated, running, recent): + pack = make_pack(root, tool, "pack-" + tool.name) + dists[tool.name] = Path(SYNC["ensure_dist"](root, tool, pack)) + for name in ("uninstalled", "running", "recent"): + shutil.rmtree(root / "packs" / ("pack-" + name)) + (updated / NTDLL).write_bytes(elf(EXPORTS, salt=b"new build")) + old = time.time() - 2 * SYNC["GC_GRACE"] + for name in ("keep", "uninstalled", "updated", "running"): + os.utime(dists[name], (old, old)) + stale_temp = root / "dist" / ".pack-x~00000000.tmp-123" + stale_temp.mkdir() + os.utime(stale_temp, (time.time() - 2 * SYNC["TEMP_AGE"],) * 2) + fresh_temp = root / "dist" / ".pack-y~00000000.tmp-456" + fresh_temp.mkdir() + (root / "dist" / "ghost~00000000.lock").write_text("") + cmdline = b"/usr/bin/python3\0" + str(dists["running"] / "proton").encode() + b"\0waitforexitandrun\0" + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: None}): + self.assertEqual(SYNC["gc"](root), 0) + self.assertTrue(dists["uninstalled"].exists()) + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: [b"bash\0", cmdline]}): + self.assertEqual(SYNC["gc"](root), 2) + remaining = sorted(os.listdir(root / "dist")) + self.assertEqual(remaining, sorted([dists["keep"].name, dists["keep"].name + ".lock", dists["running"].name, + dists["running"].name + ".lock", dists["recent"].name, dists["recent"].name + ".lock", + fresh_temp.name])) + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}): + os.utime(dists["recent"], (old, old)) + self.assertEqual(SYNC["gc"](root), 2) + self.assertEqual(sorted(os.listdir(root / "dist")), sorted([dists["keep"].name, dists["keep"].name + ".lock", + fresh_temp.name])) + + def test_superseded_pack_revisions_are_collected(self): + root = self.store() + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + first = make_pack(root, tool, "valve-experimental-r1", rev=1) + first_dist = Path(SYNC["ensure_dist"](root, tool, first)) + second = make_pack(root, tool, "valve-experimental-r2", rev=2) + second_dist = Path(SYNC["ensure_dist"](root, tool, second)) + self.assertNotEqual(first_dist, second_dist) + old = time.time() - 2 * SYNC["GC_GRACE"] + for dist in (first_dist, second_dist): + os.utime(dist, (old, old)) + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: [str(first_dist / "proton").encode() + b"\0run\0"]}): + self.assertEqual(SYNC["gc"](root), 0) + with mock.patch.dict(GLOBALS, {"process_cmdlines": lambda: []}): + self.assertEqual(SYNC["gc"](root), 1) + self.assertEqual(sorted(os.listdir(root / "dist")), [second_dist.name, second_dist.name + ".lock"]) + self.assertTrue((root / "packs" / first["id"] / ".complete").is_file()) + _, env, _ = self.select(tool, self.env()) + self.assertEqual(env["BL_SYNC_PACK"], second["id"]) + + +class LauncherTest(SyncTestCase): + def test_game_env_applies_sync_keys_for_every_verb(self): + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + config = self.home / ".config/droiddeck/game-environment.json" + config.parent.mkdir(parents=True) + config.write_text(json.dumps({"version": 1, "shared": {"PROTON_NO_NTSYNC": "1", "CUSTOM": "shared"}, + "games": {"42": {"WINEESYNC": "3", "BL_SYNC_FALLBACK": "1"}}})) + env = {"PATH": os.defpath, "HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "BL_SYNC": "1", + "BL_SYNC_MIN_NOFILE": "1"} + + def launch(verb): + output = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(tool / "proton"), verb, "a b"], + env=env, capture_output=True, text=True, check=True).stdout + return json.loads(output) + + argv0, args, seen = launch("run") + self.assertEqual((argv0, args), (str(tool / "proton"), ["run", "a b"])) + self.assertEqual((seen["CUSTOM"], seen["BL_SYNC"], seen["WINEESYNC"]), (None, "0", "3")) + self.assertEqual(launch("waitforexitandrun")[2]["CUSTOM"], "shared") + root = self.store() + pack = make_pack(root, tool, "valve-experimental-r1") + dist = self.dist_of(pack["id"], tool) + for verb in ("run", "waitforexitandrun", "getcompatpath"): + argv0, args, seen = launch(verb) + self.assertEqual((argv0, args), (str(dist / "proton"), [verb, "a b"])) + self.assertEqual((seen["WINEESYNC"], seen["BL_SYNC_PACK"], seen["WINESERVER"]), ("3", pack["id"], str(dist / WINESERVER))) + + def test_failed_dist_exec_keeps_the_sync_choice(self): + tool = make_tool(self.tools, "Proton Experimental (ARM64)") + pack = make_pack(self.store(), tool, "valve-experimental-r1") + dist = self.dist_of(pack["id"], tool) + game_env = runpy.run_path(str(BIN / "droiddeck-game-env")) + command = [str(tool / "proton"), "waitforexitandrun", "game.exe"] + env = self.env(STEAM_COMPAT_APP_ID="2630", PROTON_NO_NTSYNC="1", BL_SYNC="1", WINEESYNC="1") + calls = [] + + def execvpe(path, args, environment): + calls.append((path, list(args), dict(environment))) + if len(calls) == 1: + raise PermissionError("denied") + raise SystemExit(0) + + with mock.patch.dict(os.environ, env, clear=True), mock.patch.object(sys, "argv", ["droiddeck-game-env"] + command), \ + mock.patch("os.execvpe", execvpe), contextlib.redirect_stderr(io.StringIO()) as said: + with self.assertRaises(SystemExit): + game_env["main"]() + self.assertEqual([call[:2] for call in calls], [(str(dist / "proton"), [str(dist / "proton")] + command[1:]), + (str(tool / "proton"), command)]) + self.assertEqual((calls[0][2]["WINEESYNC"], calls[0][2]["PROTON_NO_ESYNC"], calls[0][2]["BL_SYNC_PACK"]), ("0", "1", pack["id"])) + self.assertEqual(calls[1][2], self.env(STEAM_COMPAT_APP_ID="2630", PROTON_NO_NTSYNC="1", BL_SYNC="0", WINEESYNC="1")) + self.assertIn("could not start (", said.getvalue()) + + def test_generated_launchers_run_the_dist_proton(self): + root = self.store() + steam = self.tmp / "Steam" + tools = steam / "compatibilitytools.d" + depot = make_tool(steam / "steamapps/common", COMPAT["SOURCES"][0]) + extra = make_tool(tools, "GE-Proton11-7", GE_LINE, salt=b"ge", ge=True) + (extra / "toolmanifest.vdf").write_text(MANIFEST) + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(depot)) + COMPAT["adopt_extras"](str(tools)) + packs = {depot: make_pack(root, depot, "valve-experimental-r1"), extra: make_pack(root, extra, "ge-GE-Proton11-7-r1", flavor="ge")} + wrappers = {depot: tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra: extra / COMPAT["EXTRA_WRAPPER"]} + env = {"PATH": os.defpath, "HOME": str(self.home), "STEAM_COMPAT_DATA_PATH": "/compatdata/42", + "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam), "BL_SYNC_FALLBACK": "1", "BL_SYNC_MIN_NOFILE": "1"} + for tool, wrapper in wrappers.items(): + wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env"))) + dist = self.dist_of(packs[tool]["id"], tool) + result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"], env=env, text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stderr) + argv0, args, seen = json.loads(result.stdout) + self.assertEqual((argv0, args), (str(dist / "proton"), ["waitforexitandrun", "game with spaces.exe"])) + self.assertEqual((seen["BL_SYNC_PACK"], seen["WINESERVER"], seen["WINEESYNC"]), + (packs[tool]["id"], str(dist / WINESERVER), "1")) + self.assertIn("droiddeck-esync: pack %s" % packs[tool]["id"], result.stderr) + for name in ("droiddeck-proton-wrap", "toolmanifest.vdf", "compatibilitytool.vdf", "toolmanifest.vdf.droiddeck-orig", + "bannerlator-proton-wrap", "toolmanifest.vdf.bannerlator-orig"): + self.assertFalse(os.path.lexists(dist / name), name) + result = subprocess.run([str(wrapper), "waitforexitandrun"], env={**env, "BL_SYNC_FALLBACK": "0"}, text=True, capture_output=True) + self.assertEqual(json.loads(result.stdout)[0], str(tool / "proton"), result.stderr) + + def test_session_collects_sync_tables(self): + script = (BIN / "droiddeck-session").read_text() + start = script.index("collect_steam_logs() {") + function = script[start:script.index("\n}\n", start) + 3] + debug = self.tmp / "debug" + env = {"PATH": os.defpath, "HOME": str(self.home), "BL_DEBUG_DIR": str(debug)} + subprocess.run(["bash", "-c", "set -u\n" + function + "collect_steam_logs"], env=env, check=True, capture_output=True) + self.assertFalse((debug / "droiddeck-esync").exists()) + root = self.store() + (root / "launches.log").write_text("launch\n") + (root / "tools.tsv").write_text("tool\n") + (root / "hash-cache.tsv").write_text("cache\n") + subprocess.run(["bash", "-c", "set -u\n" + function + "collect_steam_logs"], env=env, check=True, capture_output=True) + self.assertEqual(sorted(os.listdir(debug / "droiddeck-esync")), ["launches.log", "tools.tsv"]) + + +class BundleTest(unittest.TestCase): + ROOT = Path(__file__).resolve().parents[2] + + def release_env(self): + values = {} + for line in (self.ROOT / "tools/droiddeck-esync/release.env").read_text().splitlines(): + key, _, value = line.partition("=") + values[key] = value + return values + + def test_release_env_pins_a_components_bundle(self): + env = self.release_env() + self.assertEqual(set(env), {"SYNC_BUNDLE_REPO", "SYNC_BUNDLE_TAG", "SYNC_BUNDLE_ASSET", "SYNC_BUNDLE_SHA256"}) + self.assertEqual(env["SYNC_BUNDLE_REPO"], "Droid-Deck/DroidDeck-Components") + self.assertEqual(env["SYNC_BUNDLE_TAG"], "droiddeck-esync-index") + self.assertRegex(env["SYNC_BUNDLE_ASSET"], r"^bundle-[0-9]{8}-[0-9]{6}\.tzst$") + self.assertRegex(env["SYNC_BUNDLE_SHA256"], r"^[0-9a-f]{64}$") + packs = (self.ROOT / "app/src/main/java/com/droiddeck/launcher/session/EsyncPacks.kt").read_text() + self.assertIn(f'const val REPO = "{env["SYNC_BUNDLE_REPO"]}"', packs) + self.assertIn('releases/download/{}/index.json"'.format(env["SYNC_BUNDLE_TAG"]), packs) + + def test_builds_fetch_the_pinned_bundle(self): + try: + import yaml + 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"] + self.assertNotIn("if", bundle) + self.assertIn("tools/droiddeck-esync/release.env", bundle["run"]) + self.assertEqual(bundle["run"].count('-R "$SYNC_BUNDLE_REPO"'), 2) + 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", "")) + 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) + self.assertIn('"${SYNC_BUNDLE_REPO}"', local) + self.assertNotIn("revoked.txt", local) + self.assertFalse((self.ROOT / ".github/workflows/build-droiddeck-esync-packs.yml").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_fsync_preload.py b/tools/tests/test_fsync_preload.py new file mode 100644 index 000000000..43532e5b1 --- /dev/null +++ b/tools/tests/test_fsync_preload.py @@ -0,0 +1,346 @@ +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +PRELOAD = Path(__file__).resolve().parents[1] / "linuxfs/preload" + +PROGRAM = r""" +#define _GNU_SOURCE +#include <errno.h> +#include <fcntl.h> +#include <linux/futex.h> +#include <pthread.h> +#include <signal.h> +#include <stdint.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/mman.h> +#include <sys/stat.h> +#include <sys/syscall.h> +#include <sys/wait.h> +#include <time.h> +#include <unistd.h> + +struct waitv { uint64_t val; uint64_t uaddr; uint32_t flags; uint32_t reserved; }; + +static char name[64]; +static int *page; + +static long waitv(int **addrs, int *vals, int n, const struct timespec *ts, int clk) { + struct waitv v[8]; + for (int i = 0; i < n; i++) { + v[i].val = (uint32_t)vals[i]; + v[i].uaddr = (uintptr_t)addrs[i]; + v[i].flags = 2; + v[i].reserved = 0; + } + return syscall(449, v, n, 0, ts, clk); +} + +static long waitv_retry(int **addrs, int *vals, int n) { + long r; + do r = waitv(addrs, vals, n, NULL, 0); while (r == -1 && errno == EINTR); + return r; +} + +static void wake(int *addr) { + syscall(SYS_futex, addr, FUTEX_WAKE, 0x7fffffff, NULL, NULL, 0); +} + +static void deadline(struct timespec *ts, int clk, long ms) { + clock_gettime(clk, ts); + ts->tv_sec += ms / 1000; + ts->tv_nsec += (ms % 1000) * 1000000; + if (ts->tv_nsec >= 1000000000) { + ts->tv_sec++; + ts->tv_nsec -= 1000000000; + } +} + +static double now(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return ts.tv_sec + ts.tv_nsec / 1e9; +} + +static int *map_page(int fd, int index) { + int *p = mmap(NULL, 0x10000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, (off_t)index * 0x10000); + if (p == MAP_FAILED) { perror("mmap"); exit(2); } + return p; +} + +#define CHECK(c, ...) do { if (!(c)) { printf("FAIL %d: ", __LINE__); printf(__VA_ARGS__); printf("\n"); exit(1); } } while (0) + +static int client(void) { + int fd = shm_open(name, O_RDWR, 0644); + int *p, *addrs[3]; + int vals[3] = {0, 0, 0}; + long r; + CHECK(fd >= 0, "client open %d", errno); + p = map_page(fd, 1); + addrs[0] = &p[0]; addrs[1] = &p[4]; addrs[2] = &p[8]; + __atomic_store_n(&p[12], 1, __ATOMIC_SEQ_CST); + wake(&p[12]); + r = waitv_retry(addrs, vals, 3); + CHECK(r == 2, "client woke with %ld errno %d", r, errno); + __atomic_store_n(&p[16], 1, __ATOMIC_SEQ_CST); + wake(&p[16]); + return 0; +} + +static int sleeper(void) { + int fd = shm_open(name, O_RDWR, 0644); + int *p = map_page(fd, 1), *a = &p[20], v = 0; + __atomic_store_n(&p[24], 1, __ATOMIC_SEQ_CST); + wake(&p[24]); + for (;;) waitv(&a, &v, 1, NULL, 0); + return 0; +} + +struct sem { int *count; int n; }; +static int *sems; +static volatile int taken, given; +#define SEMS 6 +#define ROUNDS 20000 + +static void *consumer(void *arg) { + int got = 0; + while (got < ROUNDS) { + int *addrs[SEMS], vals[SEMS], i; + for (i = 0; i < SEMS; i++) { + int *c = &sems[i * 4], cur = __atomic_load_n(c, __ATOMIC_SEQ_CST); + while (cur > 0) { + if (__atomic_compare_exchange_n(c, &cur, cur - 1, 0, __ATOMIC_SEQ_CST, __ATOMIC_SEQ_CST)) { got++; goto next; } + } + addrs[i] = c; + vals[i] = 0; + } + waitv(addrs, vals, SEMS, NULL, 0); + next:; + } + __atomic_add_fetch(&taken, got, __ATOMIC_SEQ_CST); + return arg; +} + +static int churn_fd; +static volatile int churn_stop; + +static void *churn(void *arg) { + while (!churn_stop) munmap(map_page(churn_fd, 1), 0x10000); + return arg; +} + +static void *one_wait(void *arg) { + int *q = map_page(churn_fd, 1), *a = &q[64], v = 0; + long r; + do r = waitv(&a, &v, 1, NULL, 0); while (r == -1 && errno == EINTR && !__atomic_load_n(a, __ATOMIC_SEQ_CST)); + munmap(q, 0x10000); + return (void *)(intptr_t)(r == -1 && errno == EINTR); +} + +static int used_slots(void) { + char reg[96]; + int fd, n = 0; + uint64_t *head; + snprintf(reg, sizeof(reg), "%s-droiddeck", name); + fd = shm_open(reg, O_RDONLY, 0); + if (fd < 0) return -1; + head = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (head == MAP_FAILED) return -1; + for (int i = 0; i < 32; i++) n += __builtin_popcountll(__atomic_load_n(&head[3 + i], __ATOMIC_SEQ_CST)); + munmap(head, 4096); + return n; +} + +static void *producer(void *arg) { + unsigned seed = (unsigned)(uintptr_t)arg; + for (int i = 0; i < ROUNDS; i++) { + int *c = &sems[(rand_r(&seed) % SEMS) * 4]; + __atomic_add_fetch(c, 1, __ATOMIC_SEQ_CST); + wake(c); + __atomic_add_fetch(&given, 1, __ATOMIC_SEQ_CST); + } + return arg; +} + +int main(int argc, char **argv) { + snprintf(name, sizeof(name), "/wine-%x-fsync", (unsigned)getpid() ^ 0x5a5a00); + if (argc > 2) snprintf(name, sizeof(name), "%s", argv[2]); + if (argc > 1 && !strcmp(argv[1], "client")) return client(); + if (argc > 1 && !strcmp(argv[1], "sleeper")) return sleeper(); + { + long r = syscall(449, NULL, 0, 0, NULL, 0); + CHECK(r == -1 && errno == EINVAL, "probe %ld errno %d", r, errno); + } + int fd = shm_open(name, O_RDWR | O_CREAT | O_EXCL, 0644); + CHECK(fd >= 0, "create %d", errno); + CHECK(ftruncate(fd, 0x20000) == 0, "truncate"); + page = map_page(fd, 1); + { + char reg[96]; + struct stat st; + snprintf(reg, sizeof(reg), "/dev/shm%s-droiddeck", name); + CHECK(stat(reg, &st) == 0 && st.st_size > 0, "registry %s missing", reg); + } + { + int *a = &page[40], v = 1; + long r = waitv(&a, &v, 1, NULL, 0); + CHECK(r == -1 && errno == EAGAIN, "mismatch %ld errno %d", r, errno); + } + for (int clk = 0; clk < 2; clk++) { + int c = clk ? CLOCK_REALTIME : CLOCK_MONOTONIC, *a = &page[44], v = 0; + struct timespec ts; + double t = now(); + long r; + deadline(&ts, c, 150); + do r = waitv(&a, &v, 1, &ts, c); while (r == -1 && errno == EINTR); + t = now() - t; + CHECK(r == -1 && errno == ETIMEDOUT && t > 0.12 && t < 1.5, "timeout clock %d r %ld errno %d after %.3f", c, r, errno, t); + } + { + pid_t pid = fork(); + if (!pid) { execl("/proc/self/exe", argv[0], "client", name, (char *)NULL); _exit(3); } + int *ready = &page[12], *done = &page[16], z = 0, st; + double t = now(); + while (!__atomic_load_n(ready, __ATOMIC_SEQ_CST) && now() - t < 20) waitv(&ready, &z, 1, NULL, 0); + usleep(20000); + __atomic_store_n(&page[8], 1, __ATOMIC_SEQ_CST); + wake(&page[8]); + while (!__atomic_load_n(done, __ATOMIC_SEQ_CST) && now() - t < 20) waitv(&done, &z, 1, NULL, 0); + CHECK(__atomic_load_n(done, __ATOMIC_SEQ_CST), "client never finished"); + waitpid(pid, &st, 0); + CHECK(WIFEXITED(st) && WEXITSTATUS(st) == 0, "client status %d", st); + } + { + int rounds = 2000, z = 0; + double t = now(); + pid_t pid = fork(); + if (!pid) { + int fd2 = shm_open(name, O_RDWR, 0644); + int *p = map_page(fd2, 1); + for (int i = 0; i < rounds; i++) { + int *ping = &p[48], *pong = &p[52]; + while (__atomic_load_n(ping, __ATOMIC_SEQ_CST) == i * 2) { int v = i * 2; waitv(&ping, &v, 1, NULL, 0); } + __atomic_store_n(pong, i * 2 + 2, __ATOMIC_SEQ_CST); + wake(pong); + } + _exit(0); + } + for (int i = 0; i < rounds; i++) { + int *ping = &page[48], *pong = &page[52]; + __atomic_store_n(ping, i * 2 + 2, __ATOMIC_SEQ_CST); + wake(ping); + while (__atomic_load_n(pong, __ATOMIC_SEQ_CST) == i * 2) { int v = i * 2; waitv(&pong, &v, 1, NULL, 0); } + } + waitpid(pid, NULL, 0); + printf("pingpong %.2f us/round\n", (now() - t) * 1e6 / rounds); + (void)z; + } + { + pthread_t c[4], p[4]; + sems = &page[256]; + for (int i = 0; i < 4; i++) pthread_create(&c[i], NULL, consumer, NULL); + for (int i = 0; i < 4; i++) pthread_create(&p[i], NULL, producer, (void *)(uintptr_t)(i + 1)); + for (int i = 0; i < 4; i++) pthread_join(p[i], NULL); + for (int i = 0; i < 4; i++) pthread_join(c[i], NULL); + CHECK(taken == 4 * ROUNDS && given == 4 * ROUNDS, "stress taken %d given %d", taken, given); + for (int i = 0; i < SEMS; i++) CHECK(sems[i * 4] == 0, "sem %d left %d", i, sems[i * 4]); + } + for (int round = 0; round < 3; round++) { + pid_t kids[40]; + int z = 0; + for (int i = 0; i < 40; i++) { + __atomic_store_n(&page[24], 0, __ATOMIC_SEQ_CST); + kids[i] = fork(); + if (!kids[i]) { execl("/proc/self/exe", argv[0], "sleeper", name, (char *)NULL); _exit(3); } + int *r = &page[24]; + while (!__atomic_load_n(r, __ATOMIC_SEQ_CST)) waitv(&r, &z, 1, NULL, 0); + } + for (int i = 0; i < 40; i++) { kill(kids[i], SIGKILL); waitpid(kids[i], NULL, 0); } + double t = now(); + for (int i = 0; i < 1000; i++) wake(&page[20]); + CHECK(now() - t < 2.0, "wakes after killed sleepers took %.3f", now() - t); + } + { + pthread_t churners[2], t; + int rescued = 0, threads = 3000; + double t0 = now(); + churn_fd = fd; + for (int i = 0; i < 2; i++) pthread_create(&churners[i], NULL, churn, NULL); + for (int i = 0; i < threads; i++) { + void *res; + __atomic_store_n(&page[64], 0, __ATOMIC_SEQ_CST); + pthread_create(&t, NULL, one_wait, NULL); + if (i & 1) usleep(50); + __atomic_store_n(&page[64], 1, __ATOMIC_SEQ_CST); + wake(&page[64]); + pthread_join(t, &res); + rescued += (int)(intptr_t)res; + } + churn_stop = 1; + for (int i = 0; i < 2; i++) pthread_join(churners[i], NULL); + printf("thread churn %.2f s, %d of %d waits needed the nap\n", now() - t0, rescued, threads); + CHECK(rescued <= 3 && now() - t0 < 30, "thread churn: %d naps in %.2f s", rescued, now() - t0); + usleep(1100000); + __atomic_store_n(&page[64], 0, __ATOMIC_SEQ_CST); + pthread_create(&t, NULL, one_wait, NULL); + usleep(20000); + __atomic_store_n(&page[64], 1, __ATOMIC_SEQ_CST); + wake(&page[64]); + pthread_join(t, NULL); + CHECK(used_slots() == 1, "registry slots still in use: %d", used_slots()); + } + { + char reg[96]; + struct stat st; + snprintf(reg, sizeof(reg), "/dev/shm%s-droiddeck", name); + shm_unlink(name); + CHECK(stat(reg, &st) != 0, "registry left behind"); + } + printf("fsync emulation ok\n"); + return 0; +} +""" + + +@unittest.skipUnless(shutil.which("cc") and os.path.exists("/dev/shm"), "a C compiler and /dev/shm are needed") +class FsyncPreloadTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory() + base = Path(cls.tmp.name) + cls.lib = base / "libfsync.so" + cls.exe = base / "fsync-test" + source = base / "fsync-test.c" + source.write_text(PROGRAM) + subprocess.run(["cc", "-O2", "-Wall", "-fPIC", "-shared", "-pthread", "-o", str(cls.lib), + str(PRELOAD / "fsync.c"), str(PRELOAD / "robust.c"), "-ldl"], check=True) + subprocess.run(["cc", "-O2", "-Wall", "-pthread", "-o", str(cls.exe), str(source)], check=True) + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def run_program(self, **env): + return subprocess.run([str(self.exe)], env=dict(os.environ, LD_PRELOAD=str(self.lib), **env), + capture_output=True, text=True, timeout=120) + + def test_emulated_waits_wake_time_out_and_survive_dead_waiters(self): + result = self.run_program(BL_FSYNC="1") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("fsync emulation ok", result.stdout) + self.assertIn("droiddeck-fsync: up and running.", result.stderr) + + def test_off_without_the_switch(self): + result = self.run_program(BL_FSYNC="0") + self.assertNotIn("droiddeck-fsync", result.stderr) + self.assertNotIn("fsync emulation ok", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_game_environment.py b/tools/tests/test_game_environment.py index d3d82e654..a7627ee91 100644 --- a/tools/tests/test_game_environment.py +++ b/tools/tests/test_game_environment.py @@ -1,5 +1,6 @@ import json import os +import shutil from pathlib import Path import runpy import subprocess @@ -8,8 +9,20 @@ import tempfile import unittest BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" -MODULE = runpy.run_path(str(BIN / "bannerlator-game-env")) -COMPAT = runpy.run_path(str(BIN / "bannerlator-steam-compat")) +MODULE = runpy.run_path(str(BIN / "droiddeck-game-env")) +COMPAT = runpy.run_path(str(BIN / "steam-compatibility")) +STATE = tempfile.TemporaryDirectory() +COMPAT["main"].__globals__["COMPAT_DIR"] = STATE.name + + +def mapping_of(text): + tokens = COMPAT["tokenize"](text) + return COMPAT["read_mapping"](tokens, COMPAT["mapping_block"](tokens)) + + +def config_text(entries): + mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, name, "75" if app == "0" else "250") for app, name in entries) + return '"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping class GameEnvironmentTest(unittest.TestCase): @@ -94,14 +107,14 @@ class GameEnvironmentTest(unittest.TestCase): env = {**os.environ, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42"} for value in ("first value", "$(touch " + str(home / "injected") + "); 'literal'=value"): config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": value}})) - result = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True) + result = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun", "path with spaces", "a=b"], env=env, text=True) self.assertEqual(json.loads(result), [value, ["waitforexitandrun", "path with spaces", "a=b"]]) self.assertFalse((home / "injected").exists()) for verb, prefix in (("run", "/compatdata/42"), ("waitforexitandrun", "/compatdata/0")): - output = subprocess.check_output([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True) + output = subprocess.check_output([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), verb], env={**env, "STEAM_COMPAT_DATA_PATH": prefix, "CUSTOM": "original"}, text=True) self.assertEqual(json.loads(output)[0], "original") config.write_text("{broken") - result = subprocess.run([sys.executable, str(BIN / "bannerlator-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True) + result = subprocess.run([sys.executable, str(BIN / "droiddeck-game-env"), str(probe), "waitforexitandrun"], env={**env, "CUSTOM": "original"}, text=True, capture_output=True, check=True) self.assertEqual(json.loads(result.stdout)[0], "original") def test_generated_valve_and_third_party_launchers_apply_configuration(self): @@ -118,24 +131,332 @@ class GameEnvironmentTest(unittest.TestCase): proton.write_text("#!/usr/bin/python3\nimport json, os, sys\nprint(json.dumps([os.environ['CUSTOM'], sys.argv[1:]]))\nsys.exit(7)\n") proton.chmod(0o755) (extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }') - COMPAT["build_tool"](str(tools / COMPAT["TOOL"])) + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(depot)) COMPAT["adopt_extras"](str(tools)) config = home / ".config/droiddeck/game-environment.json" config.parent.mkdir(parents=True) config.write_text(json.dumps({"version": 1, "shared": {"CUSTOM": "shared"}, "games": {"42": {"CUSTOM": "specific"}}})) wrappers = (tools / COMPAT["TOOL"] / COMPAT["LAUNCHER"], extra / COMPAT["EXTRA_WRAPPER"]) for wrapper in wrappers: - wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/bannerlator-game-env", str(BIN / "bannerlator-game-env"))) + wrapper.write_text(wrapper.read_text().replace("/usr/local/bin/droiddeck-game-env", str(BIN / "droiddeck-game-env"))) result = subprocess.run([str(wrapper), "waitforexitandrun", "game with spaces.exe"], env={"PATH": os.defpath, "HOME": tmp, "STEAM_COMPAT_DATA_PATH": "/compatdata/42", "STEAM_COMPAT_CLIENT_INSTALL_PATH": str(steam)}, text=True, capture_output=True) self.assertEqual(result.returncode, 7, result.stderr) self.assertEqual(json.loads(result.stdout), ["specific", ["waitforexitandrun", "game with spaces.exe"]]) + def test_default_is_compatible_tool_and_labels_mark_it(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + label = home / "label" + label.write_text("Compatible\n") + COMPAT["display_name"].__globals__["LABEL_FILE"] = str(label) + tools = home / "compatibilitytools.d" + extra = tools / "proton-cachyos-11" + extra.mkdir(parents=True) + (extra / "proton").write_text("") + (extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/proton %verb%" }') + vdf = tools / COMPAT["TOOL"] / "compatibilitytool.vdf" + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), None) + self.assertIn('"display_name" "Proton ARM64 (Compatible)"', vdf.read_text()) + for source in COMPAT["SOURCES"]: + COMPAT["build_tool"](str(tools / COMPAT["TOOL"]), str(home / "steamapps/common" / source)) + self.assertIn('"display_name" "%s ARM64 (Compatible)"' % source.replace(" (ARM64)", ""), vdf.read_text()) + protect = COMPAT["adopt_extras"](str(tools)) + self.assertIn('"display_name" "proton-cachyos-11 (Compatible)"', (extra / "compatibilitytool.vdf").read_text()) + config = home / "config.vdf" + mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % entry for entry in ( + ("0", "GE-Proton10-25", "75"), ("42", "proton_experimental_arm64", "250"), ("43", "proton-cachyos-11", "250"))) + config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping) + COMPAT["register_default"](str(config), ["44"], protect=protect) + tokens = COMPAT["tokenize"](config.read_text()) + names = {tokens[i - 1].strip('"'): tokens[i + 2].strip('"') for i in range(1, len(tokens) - 2) if tokens[i] == "{" and tokens[i + 1] == '"name"'} + self.assertEqual(names, {"0": COMPAT["TOOL"], "42": COMPAT["TOOL"], "43": "proton-cachyos-11", "44": COMPAT["TOOL"]}) + + def test_existing_install_moves_to_new_tool_name(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + (home / "label").write_text("Compatible\n") + COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label") + steam = home / "Steam" + depot = steam / "steamapps/common" / COMPAT["SOURCES"][0] + (depot / "files/bin-arm64").mkdir(parents=True) + (steam / "steamapps/appmanifest_42.acf").write_text("") + legacy = steam / "compatibilitytools.d" / COMPAT["LEGACY_TOOL"] + legacy.mkdir(parents=True) + for name in COMPAT["OWN_FILES"]: + (legacy / name).write_text("old") + (steam / "config").mkdir() + config = steam / "config/config.vdf" + mapping = "".join('"%s" { "name" "%s" "config" "" "priority" "%s" }' % (app, COMPAT["LEGACY_TOOL"], priority) + for app, priority in (("0", "75"), ("42", "250"))) + config.write_text('"InstallConfigStore" { "Software" { "Valve" { "Steam" { "CompatToolMapping" { %s } } } } }' % mapping) + argv = sys.argv + try: + sys.argv = ["steam-compatibility", str(steam)] + COMPAT["main"]() + migrated = config.read_text() + COMPAT["main"]() + finally: + sys.argv = argv + self.assertFalse(legacy.exists()) + self.assertIn('"display_name" "Proton Experimental ARM64 (Compatible)"', + (steam / "compatibilitytools.d" / COMPAT["TOOL"] / "compatibilitytool.vdf").read_text()) + self.assertNotIn(COMPAT["LEGACY_TOOL"], migrated) + self.assertEqual(migrated.count('"%s"' % COMPAT["TOOL"]), 2) + self.assertEqual(config.read_text(), migrated) + + def test_launchers_and_adopted_protons_from_before_the_rename_move_over(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + (home / "label").write_text("Compatible\n") + COMPAT["main"].__globals__["LABEL_FILE"] = str(home / "label") + tools = home / "compatibilitytools.d" + own = tools / COMPAT["TOOL"] + own.mkdir(parents=True) + for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]): + (own / name).write_text("old") + spare = tools / COMPAT["TOOL_11"] + spare.mkdir() + for name in ("toolmanifest.vdf", "compatibilitytool.vdf", COMPAT["LEGACY_LAUNCHER"]): + (spare / name).write_text("old") + extra = tools / "GE-Proton11-7" + extra.mkdir() + (extra / "proton").write_text("") + (extra / "toolmanifest.vdf").write_text('"manifest" { "commandline" "/%s %%verb%%" }' % COMPAT["LEGACY_EXTRA_WRAPPER"]) + (extra / ("toolmanifest.vdf" + COMPAT["LEGACY_ORIGINAL_SUFFIX"])).write_text('"manifest" { "commandline" "/proton %verb%" }') + (extra / COMPAT["LEGACY_EXTRA_WRAPPER"]).write_text("old") + COMPAT["build_tool"](str(own), None) + COMPAT["remove_tool"](str(spare)) + self.assertEqual(COMPAT["adopt_extras"](str(tools)), {"GE-Proton11-7": "GE-Proton11-7"}) + self.assertEqual(sorted(os.listdir(own)), sorted(COMPAT["OWN_FILES"])) + self.assertFalse(spare.exists()) + self.assertEqual(sorted(os.listdir(extra)), sorted(["proton", "toolmanifest.vdf", "compatibilitytool.vdf", + COMPAT["EXTRA_WRAPPER"], "toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"]])) + self.assertIn("/%s %%verb%%" % COMPAT["EXTRA_WRAPPER"], (extra / "toolmanifest.vdf").read_text()) + self.assertIn('"/proton %verb%"', (extra / ("toolmanifest.vdf" + COMPAT["ORIGINAL_SUFFIX"])).read_text()) + self.assertIn('"$here/proton"', (extra / COMPAT["EXTRA_WRAPPER"]).read_text()) + self.assertIn("GE-Proton11-7", [entry["name"] for entry in COMPAT["tool_catalog"](str(home), None)]) + def test_both_proton_wrappers_call_environment_launcher(self): for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): subprocess.run(["bash", "-n"], input=script, text=True, check=True) - self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/bannerlator-game-env', script) + self.assertIn('exec ${BL_TASKSET:-} /usr/local/bin/droiddeck-game-env', script) + + def test_wrappers_preload_the_session_library_before_the_input_shim(self): + overlay = "/root/.local/share/Steam/ubuntu12_64/gameoverlayrenderer.so" + with tempfile.TemporaryDirectory() as tmp: + for name in ("libblsession.so", "libfakeinput.so"): + (Path(tmp) / name).write_bytes(b"") + for script in (COMPAT["LAUNCHER_SH"], COMPAT["EXTRA_WRAPPER_SH"] % "proton"): + start = script.index("for fake in ") + block = script[start:script.index("\ndone\n", start) + 6].replace("/usr/local/lib/", tmp + "/") + for inherited, expected in ( + (None, ["libblsession.so", "libfakeinput.so"]), + (overlay, ["libblsession.so", "libfakeinput.so", overlay]), + (tmp + "/libfakeinput.so:" + overlay, ["libblsession.so", "libfakeinput.so", overlay]), + (tmp + "/libblsession.so:" + tmp + "/libfakeinput.so", ["libblsession.so", "libfakeinput.so"])): + env = {"PATH": os.defpath} + if inherited is not None: + env["LD_PRELOAD"] = inherited + out = subprocess.run(["bash", "-c", block + 'printf %s "$LD_PRELOAD"'], env=env, + capture_output=True, text=True, check=True).stdout + self.assertEqual([entry.replace(tmp + "/", "") for entry in out.split(":")], expected, inherited) + + +class ProtonDefaultTest(unittest.TestCase): + TOOL = COMPAT["TOOL"] + CATALOG = [ + {"name": COMPAT["TOOL"], "display": "Proton Experimental ARM64", "dir": "Proton Experimental (ARM64)", "valve": True}, + {"name": COMPAT["TOOL_11"], "display": "Proton 11.0 ARM64", "dir": "Proton 11.0 (ARM64)", "valve": True}, + {"name": "GE-Proton11-7", "display": "GE-Proton11-7", "dir": "GE-Proton11-7", "valve": False}, + ] + NAMES = [t["name"] for t in CATALOG] + + def plan(self, current, installed, default, auto): + return COMPAT["plan_mapping"](current, installed, self.NAMES, default, auto) + + def test_new_installs_and_valve_picks_follow_the_default(self): + changes, auto = self.plan({"0": self.TOOL, "10": "proton_experimental", "11": "proton_11_arm64", "12": "GE-Proton11-7", "13": "steamlinuxruntime"}, + ["10", "11", "12", "13", "14"], "GE-Proton11-7", {}) + self.assertEqual(changes, {"0": "GE-Proton11-7", "10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"}) + self.assertEqual(auto, {"10": "GE-Proton11-7", "11": "GE-Proton11-7", "14": "GE-Proton11-7"}) + + def test_titles_following_the_default_move_with_it_and_picks_stay(self): + current = {"0": self.TOOL, "10": self.TOOL, "11": "GE-Proton11-7", "12": self.TOOL} + auto = {"10": self.TOOL, "11": self.TOOL, "12": self.TOOL} + changes, auto = self.plan(current, ["10", "11", "12"], COMPAT["TOOL_11"], auto) + self.assertEqual(changes, {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]}) + self.assertEqual(auto, {"10": COMPAT["TOOL_11"], "12": COMPAT["TOOL_11"]}) + + def test_a_removed_tool_falls_back_to_the_default(self): + changes, auto = self.plan({"0": self.TOOL, "10": "GE-Proton10-1", "11": "droiddeck-proton-12-arm64"}, ["10", "11"], self.TOOL, {"10": "GE-Proton10-1"}) + self.assertEqual(changes, {"10": self.TOOL, "11": self.TOOL}) + self.assertEqual(auto, {"10": self.TOOL, "11": self.TOOL}) + + def test_uninstalled_titles_are_forgotten(self): + changes, auto = self.plan({"0": self.TOOL}, [], self.TOOL, {"10": self.TOOL}) + self.assertEqual((changes, auto), ({}, {})) + + def test_the_last_side_to_change_wins(self): + state = {"default": self.TOOL, "applied": 5} + default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 5, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=100) + self.assertEqual((default, state["source"], state["seq"]), ("GE-Proton11-7", "steam", 100)) + default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6, "dir": "Proton 11.0 (ARM64)", "valve": True}, self.CATALOG, now=200) + self.assertEqual((default, state["source"], state["seq"], state["applied"]), (COMPAT["TOOL_11"], "app", 200, 6)) + default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 6}, self.CATALOG, adopt_steam=False, now=300) + self.assertEqual((default, state["seq"]), (COMPAT["TOOL_11"], 200)) + default, state = COMPAT["choose_default"]("proton_experimental", state, {"seq": 6}, self.CATALOG, now=300) + self.assertEqual(default, COMPAT["TOOL_11"]) + + def test_a_request_for_a_missing_tool_is_dropped(self): + default, state = COMPAT["choose_default"](self.TOOL, {"default": self.TOOL}, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=1) + self.assertEqual((default, state["applied"]), (self.TOOL, 9)) + default, state = COMPAT["choose_default"]("GE-Proton11-7", state, {"seq": 9, "dir": "GE-Proton9-1", "valve": False}, self.CATALOG, now=2) + self.assertEqual(default, "GE-Proton11-7") + + def test_settle_before_the_client_starts(self): + with tempfile.TemporaryDirectory() as tmp: + config = Path(tmp) / "config.vdf" + config.write_text(config_text([("0", self.TOOL), ("10", "proton_experimental"), ("11", "GE-Proton11-7")])) + (Path(tmp) / "request.json").write_text(json.dumps({"seq": 3, "dir": "Proton 11.0 (ARM64)", "valve": True})) + default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True) + self.assertEqual(default, COMPAT["TOOL_11"]) + self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "11": "GE-Proton11-7", "12": COMPAT["TOOL_11"]}) + state = json.loads((Path(tmp) / "state.json").read_text()) + self.assertEqual((state["applied"], state["source"], state["tools"]), (3, "app", self.CATALOG)) + config.write_text(config_text([("0", "GE-Proton11-7"), ("10", COMPAT["TOOL_11"]), ("11", "GE-Proton11-7"), ("12", COMPAT["TOOL_11"])])) + default = COMPAT["register_default"](str(config), ["10", "11", "12"], catalog=self.CATALOG, directory=tmp, settle=True) + self.assertEqual(default, "GE-Proton11-7") + self.assertEqual(mapping_of(config.read_text())["10"], "GE-Proton11-7") + self.assertEqual(json.loads((Path(tmp) / "state.json").read_text())["source"], "steam") + config.write_text(config_text([("0", "proton_experimental")])) + default = COMPAT["register_default"](str(config), ["10"], catalog=self.CATALOG, directory=tmp) + self.assertEqual(mapping_of(config.read_text()), {"0": "GE-Proton11-7", "10": "GE-Proton11-7"}) + + def test_the_shortcut_writer_keeps_the_chosen_default(self): + with tempfile.TemporaryDirectory() as tmp: + steam = Path(tmp) / "Steam" + for name in COMPAT["SOURCES"]: + (steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True) + (steam / "config").mkdir() + config = steam / "config/config.vdf" + config.write_text(config_text([("0", COMPAT["TOOL_11"]), ("10", COMPAT["TOOL_11"]), ("3044416433", COMPAT["TOOL_11"])])) + (Path(tmp) / "state.json").write_text(json.dumps({"default": COMPAT["TOOL_11"], "auto": {"10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"]}})) + COMPAT["register_default"](str(config), apps=["3044416433", "3212965118"], protect={}, directory=tmp) + self.assertEqual(mapping_of(config.read_text()), {"0": COMPAT["TOOL_11"], "10": COMPAT["TOOL_11"], "3044416433": COMPAT["TOOL_11"], "3212965118": COMPAT["TOOL_11"]}) + + def test_each_valve_depot_gets_its_own_tool(self): + with tempfile.TemporaryDirectory() as tmp: + steam = Path(tmp) / "Steam" + for name in COMPAT["SOURCES"]: + (steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True) + self.assertEqual(COMPAT["find_source"](str(steam), COMPAT["OWN_TOOLS"][1][1]), str(steam / "steamapps/common/Proton 11.0 (ARM64)")) + self.assertEqual(COMPAT["find_source"](str(steam)), str(steam / "steamapps/common/Proton Experimental (ARM64)")) + self.assertEqual([(t["name"], t["dir"]) for t in COMPAT["tool_catalog"](str(steam), {})], + [(COMPAT["TOOL"], "Proton Experimental (ARM64)"), (COMPAT["TOOL_11"], "Proton 11.0 (ARM64)")]) + for name, sources in COMPAT["OWN_TOOLS"]: + COMPAT["build_tool"](str(steam / "compatibilitytools.d" / name), COMPAT["find_source"](str(steam), sources), sources, name) + self.assertEqual(COMPAT["adopt_extras"](str(steam / "compatibilitytools.d")), {}) + self.assertEqual(sorted(os.listdir(steam / "compatibilitytools.d" / COMPAT["TOOL_11"])), sorted(COMPAT["OWN_FILES"])) + launcher = COMPAT["launcher_sh"](COMPAT["OWN_TOOLS"][1][1]) + self.assertIn('for name in "Proton 11.0 (ARM64)"; do', launcher) + self.assertIn("${major%%.*}", launcher) + + + +class DirectAudioPrefixTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.tool = base / "tool" + (self.tool / "files/bin-arm64").mkdir(parents=True) + wine = self.tool / "files/bin-arm64/wine" + wine.write_text("#!/bin/sh\necho wine-11.0-4c8f2e1 '(Staging)'\n") + wine.chmod(0o755) + self.audio = base / "directaudio" + for arch in ("aarch64-windows", "i386-windows", "aarch64-unix"): + (self.audio / "lib/wine" / arch).mkdir(parents=True) + (self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv").write_bytes(b"arm64x") + (self.audio / "lib/wine/i386-windows/winedirectaudio.drv").write_bytes(b"i386") + self.compat = base / "compat" + self.windows = self.compat / "pfx/drive_c/windows" + for folder in ("system32", "syswow64"): + (self.windows / folder).mkdir(parents=True) + self.reg = self.compat / "pfx/user.reg" + + def run_setup(self): + script = COMPAT["BL_DIRECTAUDIO_SETUP"] + '\nbl_directaudio "$1"\necho "WINEDLLPATH=$WINEDLLPATH"\n' + env = dict(os.environ, BL_DIRECTAUDIO=str(self.audio), STEAM_COMPAT_DATA_PATH=str(self.compat)) + env.pop("WINEDLLPATH", None) + return subprocess.run(["bash", "-c", script, "bash", str(self.tool)], env=env, capture_output=True, text=True, check=True) + + def audio_values(self): + found, current = [], None + for line in self.reg.read_text().splitlines(): + if line.startswith("["): + current = line.rsplit("] ", 1)[0] + "]" + elif line.startswith('"Audio"='): + found.append((current, line)) + return found + + def link(self, folder): + path = self.windows / folder / "winedirectaudio.drv" + return os.readlink(path) if path.is_symlink() else None + + def test_the_driver_is_placed_and_selected_the_way_wine_reads_it(self): + self.reg.write_text("WINE REGISTRY Version 2\n;; All keys relative to \\\\User\\\\S-1-5-21-0-0-0-1000\n\n#arch=win64\n") + result = self.run_setup() + self.assertIn("DirectAudio selected", result.stderr) + self.assertIn("WINEDLLPATH=%s/lib/wine" % self.audio, result.stdout) + self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv")) + self.assertEqual(self.link("syswow64"), str(self.audio / "lib/wine/i386-windows/winedirectaudio.drv")) + self.assertEqual((self.windows / "system32/winedirectaudio.drv").read_bytes(), b"arm64x") + self.assertEqual(self.audio_values(), [("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')]) + before = self.reg.read_text() + self.assertNotIn("DirectAudio selected", self.run_setup().stderr) + self.assertEqual(self.reg.read_text(), before) + self.assertEqual(sorted(os.listdir(self.windows / "system32")), ["winedirectaudio.drv"]) + + def test_older_selections_are_upgraded(self): + self.reg.write_text('WINE REGISTRY Version 2\n\n[SoftwareWineDrivers] 1790995967\n#time=1dd52e24550e980\n"Audio"="directaudio"\n' + '\n[Software\\\\Wine\\\\Drivers] 1790995986\n#time=1dd52e250b31ec4\n"Audio"="directaudio"\n') + self.assertIn("DirectAudio selected", self.run_setup().stderr) + self.assertEqual(self.audio_values(), [("[SoftwareWineDrivers]", '"Audio"="directaudio"'), + ("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio"'), + ("[Software\\\\Wine\\\\Drivers]", '"Audio"="directaudio,pulse"')]) + self.assertNotIn("DirectAudio selected", self.run_setup().stderr) + + def test_a_copied_driver_is_replaced_by_the_staged_one(self): + self.reg.write_text("WINE REGISTRY Version 2\n") + (self.windows / "system32/winedirectaudio.drv").write_bytes(b"stale copy") + self.run_setup() + self.assertEqual(self.link("system32"), str(self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv")) + + def test_nothing_is_selected_without_the_driver_in_the_prefix(self): + self.reg.write_text("WINE REGISTRY Version 2\n") + shutil.rmtree(self.windows) + self.assertIn("could not be placed", self.run_setup().stderr) + self.assertEqual(self.audio_values(), []) + (self.windows / "system32").mkdir(parents=True) + (self.audio / "lib/wine/aarch64-windows/winedirectaudio.drv").unlink() + self.assertIn("could not be placed", self.run_setup().stderr) + self.assertEqual(self.audio_values(), []) + + def test_a_new_prefix_waits_for_the_next_launch(self): + shutil.rmtree(self.compat / "pfx") + self.assertIn("no prefix yet", self.run_setup().stderr) + self.assertFalse(self.reg.exists()) + + def test_another_wine_major_is_left_alone(self): + (self.tool / "files/bin-arm64/wine").write_text("#!/bin/sh\necho wine-10.0\n") + self.reg.write_text("WINE REGISTRY Version 2\n") + result = self.run_setup() + self.assertIn("left off", result.stderr) + self.assertIn("WINEDLLPATH=\n", result.stdout) + self.assertEqual(self.audio_values(), []) + self.assertIsNone(self.link("system32")) if __name__ == "__main__": diff --git a/tools/tests/test_login1.py b/tools/tests/test_login1.py index de6404568..184458f69 100644 --- a/tools/tests/test_login1.py +++ b/tools/tests/test_login1.py @@ -14,7 +14,7 @@ try: except ImportError: Gio = GLib = None -SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-login1" +SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-login1" NAME = "org.freedesktop.login1" ROOT = "/org/freedesktop/login1" IFACE = NAME + ".Manager" diff --git a/tools/tests/test_netmanager.py b/tools/tests/test_netmanager.py index d61890d03..23ce08070 100644 --- a/tools/tests/test_netmanager.py +++ b/tools/tests/test_netmanager.py @@ -20,7 +20,7 @@ try: except ImportError: Gio = GLib = None -SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/bannerlator-netmanager" +SCRIPT = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-netmanager" if Gio: loader = importlib.machinery.SourceFileLoader("netmanager", str(SCRIPT)) spec = importlib.util.spec_from_loader(loader.name, loader) diff --git a/tools/tests/test_request_fex.py b/tools/tests/test_request_fex.py new file mode 100644 index 000000000..43d032a34 --- /dev/null +++ b/tools/tests/test_request_fex.py @@ -0,0 +1,52 @@ +import runpy +import shutil +import tempfile +import unittest +from pathlib import Path + +COMPAT = runpy.run_path(str(Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/steam-compatibility")) + + +class RequestFexTest(unittest.TestCase): + def setUp(self): + self.root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.root, True) + (self.root / "steamapps").mkdir() + client = self.root / "steamrtarm64/steam" + client.parent.mkdir() + self.log = self.root / "client.log" + client.write_text("#!/bin/sh\necho \"start $1\" >> %s\nsleep 0.3\necho \"end $1\" >> %s\n" % (self.log, self.log)) + client.chmod(0o755) + + def install(self, app): + (self.root / ("steamapps/appmanifest_%s.acf" % app)).write_text("") + + def test_one_install_is_asked_for_at_a_time(self): + asked = set() + self.assertEqual(["3127680"], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual(["start steam://install/3127680", "end steam://install/3127680"], self.log.read_text().splitlines()) + self.assertEqual([], COMPAT["request_fex"](str(self.root), asked)) + self.install("3127680") + self.assertEqual(["1628350"], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual(["start steam://install/3127680", "end steam://install/3127680", + "start steam://install/1628350", "end steam://install/1628350"], + self.log.read_text().splitlines()) + self.install("1628350") + self.assertEqual([], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual({"3127680", "1628350"}, asked) + + def test_a_declined_install_is_not_asked_again_in_the_same_session(self): + asked = set() + self.assertEqual(["3127680"], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual([], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual(["3127680"], COMPAT["request_fex"](str(self.root), set())) + + def test_only_what_is_missing_is_asked_for_once(self): + self.install("3127680") + asked = set() + self.assertEqual(["1628350"], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual([], COMPAT["request_fex"](str(self.root), asked)) + self.assertEqual(["start steam://install/1628350", "end steam://install/1628350"], self.log.read_text().splitlines()) + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_session_assets.py b/tools/tests/test_session_assets.py index 5f7a73a55..6d20abc03 100644 --- a/tools/tests/test_session_assets.py +++ b/tools/tests/test_session_assets.py @@ -17,11 +17,11 @@ class SessionAssetsTest(unittest.TestCase): self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.overlay = self.root / 'overlay' - self.helper = self.overlay / 'usr/local/bin/bannerlator-login1' + self.helper = self.overlay / 'usr/local/bin/droiddeck-login1' self.helper.parent.mkdir(parents=True) self.helper.write_bytes(b'current helper') self.apk = self.root / 'app.apk' - self.name = 'assets/linuxfs/usr/local/bin/bannerlator-login1' + self.name = 'assets/linuxfs/usr/local/bin/droiddeck-login1' def test_missing_required_helper_rejects_cached_bundle(self): with ZipFile(self.apk, 'w') as package: diff --git a/tools/tests/test_session_log.py b/tools/tests/test_session_log.py new file mode 100644 index 000000000..baf506484 --- /dev/null +++ b/tools/tests/test_session_log.py @@ -0,0 +1,39 @@ +import re +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SESSION = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin/droiddeck-session" + + +def log_redirect(): + match = re.search(r"^\s*(exec > >\(.*\) 2>&1)$", SESSION.read_text(), re.M) + return match.group(1) + + +class SessionLogTest(unittest.TestCase): + def setUp(self): + self.tmp = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.log = self.tmp / "session.log" + + def run_session(self, body, cap=None): + script = "BL_LOG=%s\n%s%s\n%s\n" % (self.log, "BL_LOG_CAP=%d\n" % cap if cap else "", log_redirect(), body) + subprocess.run(["bash", "-c", script], timeout=30, check=False) + + def test_the_last_lines_survive_the_writer_being_killed(self): + self.run_session('for i in $(seq 1 60); do echo "line $i"; done\nsleep 0.5\n' + 'for p in $(pgrep -P $$); do kill -9 $(pgrep -P $p) $p 2>/dev/null; done') + lines = self.log.read_text().splitlines() + self.assertEqual(60, len(lines)) + self.assertEqual("line 60", lines[-1]) + + def test_the_log_keeps_its_first_bytes_and_drains_the_rest(self): + self.run_session("printf 'abcdefghij\\nklmnop\\n'\nsleep 0.5", cap=5) + self.assertEqual("abcde", self.log.read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py new file mode 100644 index 000000000..7b311ad58 --- /dev/null +++ b/tools/tests/test_steam_compat_live.py @@ -0,0 +1,218 @@ +import json +import os +from pathlib import Path +import runpy +import shutil +import subprocess +import tempfile +import unittest + +BIN = Path(__file__).resolve().parents[1] / "linuxfs/overlay/usr/local/bin" +LIVE = runpy.run_path(str(BIN / "droiddeck-steam-compat")) +COMPAT = LIVE["COMPAT"] +TOOL = COMPAT["TOOL"] +TOOL_11 = COMPAT["TOOL_11"] + +FAKE_STEAM = r""" +const dec = new TextDecoder(), enc = new TextEncoder(); +function readVarint(b, at) { let r = 0n, s = 0n, x; do { x = b[at.i++]; r |= BigInt(x & 0x7f) << s; s += 7n; } while (x & 0x80); return r; } +function fields(b) { + const f = {}, at = { i: 0 }; + while (at.i < b.length) { + const k = Number(readVarint(b, at)), n = k >> 3, w = k & 7; let v; + if (w === 0) v = readVarint(b, at); + else if (w === 1) { v = 0n; for (let j = 0; j < 8; j++) v |= BigInt(b[at.i + j]) << BigInt(8 * j); at.i += 8; } + else if (w === 2) { const len = Number(readVarint(b, at)); v = b.slice(at.i, at.i + len); at.i += len; } + else throw new Error("wire " + w); + (f[n] = f[n] || []).push(v); + } + return f; +} +function vint(n, out) { n = BigInt(n); do { let x = Number(n & 0x7fn); n >>= 7n; if (n) x |= 0x80; out.push(x); } while (n); } +function lenField(n, bytes, out) { vint(n * 8 + 2, out); vint(bytes.length, out); for (const x of bytes) out.push(x); } +function reply(eresult, jobid, body, name) { + const h = []; + if (jobid !== null) { vint(11 * 8 + 1, h); let n = jobid; for (let i = 0; i < 8; i++) { h.push(Number(n & 0xffn)); n >>= 8n; } } + if (name) lenField(12, enc.encode(name), h); + vint(13 * 8, h); vint(eresult, h); + const out = new Uint8Array(8 + h.length + body.length), dv = new DataView(out.buffer); + dv.setUint32(0, (0x80000000 | 147) >>> 0, true); dv.setUint32(4, h.length, true); + out.set(h, 8); out.set(body, 8 + h.length); + return out.buffer; +} +const state = { mapping: JSON.parse(process.argv[2]), known: JSON.parse(process.argv[3]), log: [], authed: false, notify: [] }; +class FakeSocket { + constructor(url) { this.url = url; this.readyState = 0; state.socket = this; setTimeout(() => { this.readyState = 1; this.onopen(); }, 0); } + send(buffer) { + const b = new Uint8Array(buffer), dv = new DataView(buffer.buffer || buffer); + if (dv.getUint32(0, true) !== ((0x80000000 | 146) >>> 0)) throw new Error("bad emsg"); + const hl = dv.getUint32(4, true), h = fields(b.slice(8, 8 + hl)), body = fields(b.slice(8 + hl)); + const method = dec.decode(h[12][0]), jobid = h[10][0]; + let out = [], result = 1; + if (method === "TransportAuth.Authenticate#1") { + state.authed = dec.decode(h[40][0]) === "secret" && dec.decode(body[1][0]) === "secret"; + result = state.authed ? 1 : 5; + } else if (!state.authed) result = 5; + else if (method === "CompatManager.GetCompatTools#1") { + const app = String(body[1] ? body[1][0] : 0n); + for (const name of state.known) { const t = []; lenField(1, enc.encode(name), t); lenField(2, enc.encode(name + " shown"), t); lenField(1, t, out); } + lenField(3, enc.encode(state.mapping[app] || ""), out); + lenField(4, enc.encode("proton_experimental"), out); + } else if (method === "CompatManager.SpecifyCompatTool#1") { + const app = String(body[1] ? body[1][0] : 0n), name = body[2] ? dec.decode(body[2][0]) : ""; + state.mapping[app] = name; state.log.push(app + "=" + name); + setTimeout(() => this.onmessage({ data: reply(1, null, new Uint8Array(0), "CompatManager.NotifyStateChanged#1") }), 0); + } else result = 2; + setTimeout(() => this.onmessage({ data: reply(result, jobid, Uint8Array.from(out)) }), 0); + } + close() {} +} +globalThis.window = globalThis; +globalThis.WebSocket = FakeSocket; +globalThis.SteamClient = { WebUITransport: { GetTransportInfo: async () => ({ portClientdll: 4242, authKeyClientdll: "secret" }) } }; +window.droiddeckCompatChanged = (n) => state.notify.push(n); +const install = require("fs").readFileSync(process.argv[4], "utf8"); +(async () => { + if (eval(install) !== "installed" || eval(install) !== "present") throw new Error("install is not idempotent"); + const c = window.__droiddeckCompat; + const before = await c.get(0); + await c.set(0, "droiddeck-proton-11-arm64"); + await c.set(42, ""); + const after = await c.many(["0", "42", "43"]); + await new Promise((r) => setTimeout(r, 10)); + console.log(JSON.stringify({ before, after, log: state.log, notify: state.notify, url: state.socket.url })); +})().catch((e) => { console.error(e.stack || String(e)); process.exit(1); }); +""" + + +class FakePage: + def __init__(self, mapping, known): + self.mapping = dict(mapping) + self.known = list(known) + self.sets = [] + + def compat(self, call): + name, _, args = call.partition("(") + args = json.loads("[%s]" % args[:-1]) + if name == "get": + return {"selected": self.mapping.get(str(args[0]), ""), "fallback": "proton_experimental", "tools": self.known} + if name == "many": + return {app: self.mapping.get(app, "") for app in args[0]} + if name == "set": + self.mapping[str(args[0])] = args[1] + self.sets.append((str(args[0]), args[1])) + return True + raise AssertionError(call) + + +@unittest.skipUnless(shutil.which("node"), "node is not installed") +class PageScriptTest(unittest.TestCase): + def test_transport_framing_auth_and_notifications(self): + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "install.js" + script.write_text(LIVE["INSTALL_JS"]) + harness = Path(tmp) / "harness.js" + harness.write_text(FAKE_STEAM) + result = subprocess.run(["node", str(harness), json.dumps({"0": TOOL, "42": "proton_experimental"}), + json.dumps([TOOL, TOOL_11, "proton_experimental"]), str(script)], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + out = json.loads(result.stdout) + self.assertEqual(out["url"], "ws://127.0.0.1:4242/transportsocket/") + self.assertEqual(out["before"], {"selected": TOOL, "fallback": "proton_experimental", "tools": [TOOL, TOOL_11, "proton_experimental"]}) + self.assertEqual(out["after"], {"0": TOOL_11, "42": "", "43": ""}) + self.assertEqual(out["log"], ["0=" + TOOL_11, "42="]) + self.assertEqual(out["notify"], ["1", "2"]) + + +class DevtoolsHttpTest(unittest.TestCase): + def test_a_kept_alive_answer_is_read_to_its_length(self): + import socket + import threading + server = socket.socket() + server.bind(("127.0.0.1", 0)) + server.listen(1) + port = server.getsockname()[1] + body = json.dumps([{"title": "other"}, {"title": "SharedJSContext", "webSocketDebuggerUrl": "ws://127.0.0.1:%d/devtools/page/AB" % port}]).encode() + done = threading.Event() + + def answer(): + conn, _ = server.accept() + conn.recv(4096) + conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n" % len(body) + body) + done.wait(10) + conn.close() + server.close() + + thread = threading.Thread(target=answer) + thread.start() + try: + self.assertEqual(LIVE["shared_context"](port), "/devtools/page/AB") + finally: + done.set() + thread.join() + + +class ReconcileTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.steam = base / "Steam" + for name in COMPAT["SOURCES"]: + (self.steam / "steamapps/common" / name / "files/bin-arm64").mkdir(parents=True) + for app in ("10", "11"): + (self.steam / ("steamapps/appmanifest_%s.acf" % app)).write_text("") + self.state = base / "compat" + self.state.mkdir() + COMPAT["main"].__globals__["COMPAT_DIR"] = str(self.state) + COMPAT["main"].__globals__["LABEL_FILE"] = str(base / "no-label") + self.addCleanup(COMPAT["main"].__globals__.__setitem__, "COMPAT_DIR", COMPAT["COMPAT_DIR"]) + self.helper = LIVE["Helper"](str(self.steam), 1) + + def reconcile(self, page): + self.helper.page = page + self.helper.reconcile() + return json.loads((self.state / "state.json").read_text()) + + def test_new_install_gets_the_default_live(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": "proton_experimental"}, [TOOL, TOOL_11]) + state = self.reconcile(page) + self.assertEqual(page.sets, [("11", TOOL)]) + self.assertEqual(state["auto"], {"10": TOOL, "11": TOOL}) + self.assertTrue(state["live"]) + self.assertEqual(self.helper.installed, ["10", "11"]) + + def test_app_request_moves_the_default_and_followers(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": "GE-Proton11-7"}, [TOOL, TOOL_11]) + (self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"10": TOOL}})) + (self.state / "request.json").write_text(json.dumps({"seq": 7, "valve": True, "dir": "Proton 11.0 (ARM64)"})) + state = self.reconcile(page) + self.assertEqual(page.sets, [("0", TOOL_11), ("10", TOOL_11)]) + self.assertEqual((state["default"], state["source"], state["applied"]), (TOOL_11, "app", 7)) + self.assertEqual(page.mapping["11"], "GE-Proton11-7") + + def test_a_steam_menu_change_is_mirrored(self): + page = FakePage({"0": TOOL_11, "10": TOOL}, [TOOL, TOOL_11]) + (self.state / "state.json").write_text(json.dumps({"default": TOOL, "auto": {"10": TOOL}})) + state = self.reconcile(page) + self.assertEqual((state["default"], state["source"]), (TOOL_11, "steam")) + self.assertEqual(page.sets, [("10", TOOL_11), ("11", TOOL_11)]) + + def test_a_tool_the_client_has_not_loaded_waits_for_a_restart(self): + page = FakePage({"0": TOOL, "10": TOOL, "11": TOOL}, [TOOL]) + (self.state / "request.json").write_text(json.dumps({"seq": 3, "valve": True, "dir": "Proton 11.0 (ARM64)"})) + state = self.reconcile(page) + self.assertEqual(page.sets, []) + self.assertEqual((state["pending"], state.get("applied", 0)), (TOOL_11, 0)) + self.assertTrue(self.helper.request_waiting()) + + 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"]()) + LIVE["detach"]() + self.assertFalse(COMPAT["live_helper_running"]()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tests/test_steam_games.py b/tools/tests/test_steam_games.py index 34c96c5d6..1639c6ada 100644 --- a/tools/tests/test_steam_games.py +++ b/tools/tests/test_steam_games.py @@ -18,9 +18,9 @@ def load(name): return module -imports = load('bannerlator-steam-games') -shortcuts = load('bannerlator-steam-shortcuts') -library = load('bannerlator-steam-library') +imports = load('droiddeck-steam-games') +shortcuts = load('droiddeck-steam-shortcuts') +library = load('droiddeck-steam-library') class ImportsTest(unittest.TestCase): @@ -289,7 +289,7 @@ class ImportsTest(unittest.TestCase): path.write_bytes(original) listing = self.root / 'games.json' listing.write_text(json.dumps([self.game])) - run = subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True) + run = subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(listing)], capture_output=True) self.assertEqual(1, run.returncode) self.assertEqual(original, path.read_bytes()) @@ -300,7 +300,7 @@ class ImportsTest(unittest.TestCase): path.write_bytes(shortcuts.write({'shortcuts': {'0': manual, '1': shortcuts.shortcut(self.game)}})) listing = self.root / 'games.json' listing.write_text(json.dumps([self.game])) - subprocess.run(['python3', str(BIN / 'bannerlator-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True) + subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(listing)], check=True, capture_output=True) result = shortcuts.parse(path.read_bytes())['shortcuts'] self.assertEqual([manual], list(result.values())) self.assertEqual({str(self.game['appid']): 42}, json.loads((path.parent / '.droiddeck-routes.json').read_text())) diff --git a/tools/tests/test_steam_install.py b/tools/tests/test_steam_install.py index 76b9f5774..0d2052965 100644 --- a/tools/tests/test_steam_install.py +++ b/tools/tests/test_steam_install.py @@ -8,7 +8,7 @@ import unittest import zipfile -SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/bannerlator-steam-install' +SCRIPT = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steam-install' class SteamInstallTest(unittest.TestCase): @@ -64,7 +64,7 @@ cp "$TEST_CDN/${2##*/}" "$4" result = self.install(channel) self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(channel + '\n', (self.steam / 'package/beta').read_text()) - self.assertEqual('123456\n', (self.steam / 'package/bannerlator-installed').read_text()) + self.assertEqual('123456\n', (self.steam / 'package/droiddeck-installed').read_text()) client = self.steam / 'steamrtarm64/steam' self.assertTrue(os.access(client, os.X_OK)) self.assertEqual(channel, client.read_text()) @@ -99,7 +99,7 @@ cp "$TEST_CDN/${2##*/}" "$4" original = archive.read_bytes() archive.write_bytes(b'corrupt download') self.assertNotEqual(0, self.install('publicbeta').returncode) - self.assertFalse((self.steam / 'package/bannerlator-installed').exists()) + self.assertFalse((self.steam / 'package/droiddeck-installed').exists()) archive.write_bytes(original) self.assertEqual(0, self.install('publicbeta').returncode) self.assertEqual('publicbeta\n', (self.steam / 'package/beta').read_text()) diff --git a/tools/tests/test_steam_ui_scale.py b/tools/tests/test_steam_ui_scale.py index 08a6be773..205320894 100644 --- a/tools/tests/test_steam_ui_scale.py +++ b/tools/tests/test_steam_ui_scale.py @@ -5,7 +5,7 @@ import unittest BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' -MODULE = runpy.run_path(str(BIN / 'bannerlator-steam-ui-scale')) +MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-ui-scale')) initialize = MODULE['initialize'] seed_text = MODULE['seed_text'] parse = MODULE['parse']