mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31ab5fa96 | ||
|
|
8ee1cbe3eb | ||
|
|
6c5d6cda73 | ||
|
|
38c9b733b5 | ||
|
|
02fa7983e9 | ||
|
|
5e55189d03 | ||
|
|
27e5f86793 | ||
|
|
f76a58ea1e | ||
|
|
f53b844add | ||
|
|
c729f4c8e7 | ||
|
|
9f343278c3 | ||
|
|
6beeb29486 | ||
|
|
ade2e982b6 | ||
|
|
8a61a1898b | ||
|
|
dfa3b12f48 | ||
|
|
9ed25d8ae5 | ||
|
|
b4b31d5a08 | ||
|
|
f394906726 | ||
|
|
b50dfa76d1 | ||
|
|
3f81ddff9b | ||
|
|
d1dec8d7a4 | ||
|
|
e60097e5cb | ||
|
|
7c7bf2dcbe | ||
|
|
75593cfd29 | ||
|
|
00d0f8cd2b | ||
|
|
faf4b4e8b5 | ||
|
|
5a61d37c5d | ||
|
|
25079ab819 | ||
|
|
736b7bed5e | ||
|
|
a3f4c911e5 | ||
|
|
caa7682c6f | ||
|
|
e217cbe66d | ||
|
|
4914b2cc69 | ||
|
|
6ecd6fa1d0 | ||
|
|
0f4e83eefd | ||
|
|
d62525f5af | ||
|
|
f4d2c37fbd | ||
|
|
ffd9f92d7b | ||
|
|
213d110766 | ||
|
|
1a8c91b49b | ||
|
|
9644a4cc7f | ||
|
|
9d891d4db1 | ||
|
|
e797bc5f01 | ||
|
|
0cc3fe6bca | ||
|
|
ab8542e4e5 | ||
|
|
c2bcf20172 | ||
|
|
2341659cea | ||
|
|
b2eaf380dc |
No files matched your search
@@ -22,8 +22,10 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Replace older builds only for the same PR. Each push/dispatch gets its own group so one main
|
||||
# build cannot cancel another before it signs and publishes.
|
||||
group: build-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
PRELOAD_CACHE_VERSION: v2
|
||||
@@ -146,6 +148,8 @@ jobs:
|
||||
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
|
||||
fi
|
||||
echo "label=$label" >> "$GITHUB_OUTPUT"
|
||||
echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
|
||||
echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
|
||||
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cache the preloads
|
||||
@@ -383,7 +387,7 @@ jobs:
|
||||
success)
|
||||
echo "### Build passed"
|
||||
echo
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. After approval, a maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
|
||||
echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key. A maintainer can run **PR Release Publish** from \`main\` to create an APK that installs over the release."
|
||||
;;
|
||||
failure)
|
||||
echo "### ERROR: Build failed"
|
||||
@@ -452,6 +456,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
env:
|
||||
CI_REPO: Droid-Deck/DroidDeck-CI
|
||||
KEEP: 10
|
||||
@@ -470,6 +475,34 @@ jobs:
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
|
||||
# What this build is, for the top of the release notes: the merged PR's title and the first
|
||||
# paragraph of its description, or the commit's subject for a push made without a PR.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
|
||||
--jq '[.[] | select(.merged_at != null and .base.ref == "main")] | first // empty')
|
||||
if [ -n "$pr" ]; then
|
||||
number=$(jq -r .number <<<"$pr")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
merged=$(jq -r '.merged_at[:10]' <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Merged to main** - [PR #$number]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$number): $title (merged $merged)"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
else
|
||||
subject=$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA" --jq '.commit.message | split("\n")[0]')
|
||||
echo "**Pushed to main** without a PR - $subject" > notes-head.md
|
||||
fi
|
||||
cat notes-head.md
|
||||
|
||||
- name: Publish to DroidDeck-CI
|
||||
id: publish
|
||||
env:
|
||||
@@ -479,7 +512,9 @@ jobs:
|
||||
tag=main-${GITHUB_SHA::7}
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - unreleased, for testing.
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Signed build of $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/commit/$GITHUB_SHA - in main, not yet in a DroidDeck release; for testing.
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
@@ -489,10 +524,24 @@ jobs:
|
||||
gh release delete "$tag" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null || true
|
||||
gh release create "$tag" -R "$CI_REPO" --target main --prerelease \
|
||||
--title "main ${GITHUB_SHA::7}" --notes "$notes" signed/*
|
||||
# Only the newest KEEP main builds stay; PR releases (pr-N) are left alone.
|
||||
gh release list -R "$CI_REPO" -L 200 --json tagName,createdAt \
|
||||
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.createdAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
||||
while IFS= read -r old; do gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag; done
|
||||
# Keep the newest main builds by publication time. GitHub App releases can share createdAt.
|
||||
gh release list -R "$CI_REPO" -L 200 --json tagName,publishedAt \
|
||||
--jq '[.[] | select(.tagName | startswith("main-"))] | sort_by(.publishedAt) | reverse | .['"$KEEP"':][] | .tagName' |
|
||||
while IFS= read -r old; do
|
||||
# Never remove this run's release, even if the API returns an unexpected ordering.
|
||||
if [ "$old" = "$tag" ]; then
|
||||
continue
|
||||
fi
|
||||
# Another main publish may have removed the same old release concurrently.
|
||||
if ! gh release delete "$old" -R "$CI_REPO" --yes --cleanup-tag 2>/dev/null; then
|
||||
if gh release view "$old" -R "$CI_REPO" >/dev/null 2>&1; then
|
||||
echo "::error::Could not delete old release $old"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
published=$(gh release view "$tag" -R "$CI_REPO" --json tagName,assets)
|
||||
jq -e --arg tag "$tag" '.tagName == $tag and (.assets | length) > 0' <<< "$published" >/dev/null
|
||||
url="$GITHUB_SERVER_URL/$CI_REPO/releases/tag/$tag"
|
||||
apk=$(cd signed && ls DroidDeck-*.apk | awk 'length(a) == 0 || length($0) < length(a) { a = $0 } END { print a }')
|
||||
echo "release=$url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: PR Release Cleanup
|
||||
run-name: remove PR #${{ github.event.pull_request.number }}'s test build
|
||||
|
||||
# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
|
||||
# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
|
||||
# from the PR is checked out or run.
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ closed ]
|
||||
branches: [ main ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
with:
|
||||
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
|
||||
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
|
||||
owner: Droid-Deck
|
||||
repositories: DroidDeck-CI
|
||||
- name: Delete the test build
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="pr-$PR_NUMBER"
|
||||
if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
|
||||
gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
|
||||
echo "Deleted $tag"
|
||||
else
|
||||
echo "No test build for PR #$PR_NUMBER"
|
||||
fi
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: Open, approved pull request
|
||||
description: Open pull request
|
||||
required: true
|
||||
type: number
|
||||
|
||||
@@ -26,8 +26,16 @@ jobs:
|
||||
run_id: ${{ steps.gate.outputs.run_id }}
|
||||
head_sha: ${{ steps.gate.outputs.head_sha }}
|
||||
label: ${{ steps.gate.outputs.label }}
|
||||
variants: ${{ steps.variants.outputs.list }}
|
||||
steps:
|
||||
- name: Check the PR, review, and build
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Every variant a main build ships, so a PR build can be tried under any of them.
|
||||
- name: The packages a signed build ships as
|
||||
id: variants
|
||||
run: echo "list=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT"
|
||||
- name: Check the PR and build
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
@@ -52,24 +60,11 @@ jobs:
|
||||
any(.pull_requests[]?; .number == $number and .head.sha == $head and .base.sha == $base)
|
||||
' <<<"$run" >/dev/null
|
||||
|
||||
approved=$(gh api --paginate --slurp \
|
||||
"repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100" |
|
||||
jq -r --arg head "$head" '[.[][] | select(.commit_id == $head)] |
|
||||
group_by(.user.login) | map(max_by(.id)) | .[] |
|
||||
select(.state == "APPROVED") | .user.login')
|
||||
maintainer_approved=false
|
||||
while IFS= read -r reviewer; do
|
||||
[[ -n "$reviewer" ]] || continue
|
||||
permission=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$reviewer/permission" --jq .permission)
|
||||
[[ "$permission" == write || "$permission" == admin ]] && maintainer_approved=true
|
||||
done <<<"$approved"
|
||||
[[ "$maintainer_approved" == true ]] || { echo '::error::A maintainer must approve this commit'; exit 1; }
|
||||
|
||||
sdk=${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}
|
||||
tools=$(find "$sdk/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)
|
||||
stable_tag=$(gh api repos/The412Banner/DroidDeck/releases/latest --jq .tag_name)
|
||||
stable_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)
|
||||
mkdir -p "$RUNNER_TEMP/stable" "$RUNNER_TEMP/pr"
|
||||
gh release download "$stable_tag" -R The412Banner/DroidDeck \
|
||||
gh release download "$stable_tag" -R "$GITHUB_REPOSITORY" \
|
||||
-p "DroidDeck-$stable_tag.apk" -D "$RUNNER_TEMP/stable"
|
||||
gh run download "$run_id" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D "$RUNNER_TEMP/pr"
|
||||
mapfile -t apks < <(find "$RUNNER_TEMP/pr" -type f -name '*.apk')
|
||||
@@ -89,9 +84,6 @@ jobs:
|
||||
.state == "open" and .draft == false and .base.ref == "main" and
|
||||
.head.sha == $head and .base.sha == $base
|
||||
' <<<"$current" >/dev/null
|
||||
[[ $(gh pr view "$PR_NUMBER" -R "$GITHUB_REPOSITORY" --json reviewDecision --jq .reviewDecision) == APPROVED ]] || {
|
||||
echo '::error::The current PR revision still needs approval'; exit 1;
|
||||
}
|
||||
{
|
||||
echo "run_id=$run_id"
|
||||
echo "head_sha=$head"
|
||||
@@ -103,7 +95,7 @@ jobs:
|
||||
uses: ./.github/workflows/sign.yml
|
||||
with:
|
||||
environment: signing-main
|
||||
variants: '["standard"]'
|
||||
variants: ${{ needs.authorize.outputs.variants }}
|
||||
run_id: ${{ needs.authorize.outputs.run_id }}
|
||||
label: ${{ needs.authorize.outputs.label }}
|
||||
artifact: droiddeck-pr-signed
|
||||
@@ -115,11 +107,33 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: droiddeck-pr-signed-standard
|
||||
pattern: droiddeck-pr-signed-*
|
||||
merge-multiple: true
|
||||
path: signed
|
||||
# What this build is, for the top of the release notes: the PR's title and the first
|
||||
# paragraph of its description.
|
||||
- name: Describe the change
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pr=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
|
||||
title=$(jq -r .title <<<"$pr")
|
||||
summary=$(jq -r '.body // ""' <<<"$pr" | tr -d '\r' | { grep -v '^🤖' || true; } |
|
||||
awk '!p && /^#/ { next } NF { p = 1; print; next } p { exit }' | head -n 6 | cut -c1-400)
|
||||
{
|
||||
echo "**Not merged** - a test build of open [PR #$PR_NUMBER]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR_NUMBER): $title"
|
||||
if [ -n "$summary" ]; then
|
||||
echo
|
||||
echo "$summary"
|
||||
fi
|
||||
} > notes-head.md
|
||||
cat notes-head.md
|
||||
# A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: ci-token
|
||||
@@ -133,23 +147,31 @@ jobs:
|
||||
GH_TOKEN: ${{ steps.ci-token.outputs.token }}
|
||||
PR_NUMBER: ${{ inputs.pr_number }}
|
||||
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
||||
LABEL: ${{ needs.authorize.outputs.label }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=Droid-Deck/DroidDeck-CI
|
||||
tag="pr-$PR_NUMBER"
|
||||
apk="DroidDeck-$LABEL.apk"
|
||||
sha=$(sha256sum "signed/$apk" | cut -d ' ' -f 1)
|
||||
notes="Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
cat signed/SHA256SUMS-*.txt > signed/SHA256SUMS.txt
|
||||
rm signed/SHA256SUMS-*.txt
|
||||
notes="$(cat notes-head.md)
|
||||
|
||||
Source: https://github.com/$GITHUB_REPOSITORY/pull/$PR_NUMBER
|
||||
Commit: $HEAD_SHA
|
||||
APK SHA-256: $sha"
|
||||
Each apk installs over a DroidDeck release and keeps its data. \`standard\` is the normal app; the others are the same app under another package name, installed beside it.
|
||||
|
||||
SHA-256:
|
||||
\`\`\`
|
||||
$(cat signed/SHA256SUMS.txt)
|
||||
\`\`\`"
|
||||
if ! gh release view "$tag" -R "$repo" >/dev/null 2>&1; then
|
||||
gh release create "$tag" -R "$repo" --target main --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
fi
|
||||
gh release upload "$tag" "signed/$apk" -R "$repo" --clobber
|
||||
gh release upload "$tag" signed/* -R "$repo" --clobber
|
||||
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
|
||||
# Drop the previous build's files: anything this build did not just upload.
|
||||
keep=$(cd signed && ls | jq -R . | jq -sc .)
|
||||
gh api "repos/$repo/releases/tags/$tag" |
|
||||
jq -r --arg keep "$apk" '.assets[] | select(.name | endswith(".apk")) | select(.name != $keep) | .id' |
|
||||
jq -r --argjson keep "$keep" '.assets[] | select(.name as $n | $keep | index($n) | not) | .id' |
|
||||
while IFS= read -r id; do
|
||||
gh api --method DELETE "repos/$repo/releases/assets/$id" >/dev/null
|
||||
done
|
||||
@@ -12,6 +12,7 @@ captures/
|
||||
# copies cannot drift.
|
||||
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
|
||||
|
||||
# Kotlin compiler session files (build-tool state, never source).
|
||||
|
||||
@@ -7,14 +7,20 @@
|
||||
|
||||
DroidDeck brings the SteamOS experience to Android: Valve's Steam client in Big Picture on your Adreno handheld, with Windows games through Valve's ARM64 Proton.
|
||||
|
||||
<p align="center"><a href="https://discord.gg/JRGAvawjsm"><img src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white" alt="Join the DroidDeck Discord"></a></p>
|
||||
|
||||
<p align="center"><img src="docs/releases/media/0.2.0/launch-into-steam.gif" width="80%" alt="Tapping DroidDeck on the Android home screen and landing in Steam Big Picture"></p>
|
||||
|
||||
## Requirements and install
|
||||
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators.
|
||||
Use Android 9 or newer on a supported Adreno device (730 or newer, or 8xx). Mali, Xclipse, PowerVR, and Adreno 710 are unsupported. No root is required. Allow about 3 GB for the runtime and 1.1 GB more for the desktop and emulators. Install the APK from [Releases](https://github.com/The412Banner/DroidDeck/releases), install the Linux runtime, then press **Play** and sign in. Steam downloads on first launch. Install **Desktop & apps** to use the desktop and emulators. The **Store** installs Linux apps and games from Flathub (ARM64 builds) with Flatpak; see [docs/development/flatpak.md](docs/development/flatpak.md).
|
||||
|
||||
On Android 12+, if Steam exits without a log, turn off **Restrict child processes** in Developer options.
|
||||
|
||||
## Community
|
||||
|
||||
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, nightly builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
|
||||
|
||||
## Build
|
||||
|
||||
Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd` installed. It builds the ARM64 audio sinks from PulseAudio 13.0 and packages them into the APK at `app/build/outputs/apk/release/app-release.apk`. Set `DROIDDECK_PA13_SOURCE_DIR` to an existing PulseAudio 13.0 source directory to skip downloading it. To install the APK on an attached device, run `tools/deploy_local.sh`.
|
||||
@@ -25,4 +31,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). 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. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
|
||||
@@ -49,6 +49,20 @@ if (!(treeState in ['clean', 'dirty'])) {
|
||||
buildLabel = "${buildOrigin} ${treeState} · ${buildLabel}"
|
||||
def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
// What the in-app updater compares against the published builds: the commit this build is of (a PR
|
||||
// build's head, not the merge CI makes of it), its PR, and whether CI made it at all.
|
||||
def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
|
||||
?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
|
||||
def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
|
||||
def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
|
||||
// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
|
||||
def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
|
||||
// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
|
||||
def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
|
||||
.findAll { it.trim() && !it.startsWith('#') }
|
||||
.collect { def f = it.trim().split(/\s+/); "${f[1]}=${f[2] == '-' ? '' : f[2]}" }
|
||||
.join(';')
|
||||
|
||||
android {
|
||||
namespace 'com.droiddeck.launcher'
|
||||
compileSdk 34
|
||||
@@ -66,6 +80,11 @@ android {
|
||||
versionCode 9
|
||||
versionName "0.2.0"
|
||||
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
|
||||
buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
|
||||
buildConfigField "int", "PR_NUMBER", "${prNumber}"
|
||||
buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
|
||||
buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
|
||||
buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
|
||||
|
||||
ndk {
|
||||
abiFilters 'arm64-v8a'
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
relay helper opens the input stream under this uid, and PulseAudio exposes it to the
|
||||
Steam client as DirectAudioMic. -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_alpha.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_beta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_chain.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_common.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_delta.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// See lsfg_dll.h. PE resource walk + on-device SPIR-V cache for the Lossless
|
||||
// Scaling frame-generation chain.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), LSFG port credited to
|
||||
// Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk
|
||||
// (PancakeTAS). Bannerlator is GPL-3.0.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
|
||||
#include "lsfg_dll.h"
|
||||
#include "lsfg_dxbc.h"
|
||||
@@ -478,20 +478,6 @@ DllStatus validateDll(const std::string& dllPath) {
|
||||
return status;
|
||||
}
|
||||
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16) {
|
||||
if (dllPath.empty()) return Variant::None;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return Variant::None;
|
||||
|
||||
ResourceTable table;
|
||||
if (parseResources(img, table) != DllStatus::Ok) return Variant::None;
|
||||
|
||||
Variant v = selectVariant(table, preferFp16);
|
||||
if (v == Variant::None && hasBaseChain(table)) v = Variant::DxbcTranslated;
|
||||
return v;
|
||||
}
|
||||
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16) {
|
||||
if (dllPath.empty() || cachePath.empty()) return DllStatus::NotInstalled;
|
||||
|
||||
@@ -547,22 +533,6 @@ DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, b
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches) {
|
||||
outMatches = false;
|
||||
if (cachePath.empty() || dllPath.empty()) return DllStatus::CacheUnusable;
|
||||
|
||||
CacheHeader header{};
|
||||
if (!readCacheHeader(cachePath, header)) return DllStatus::CacheUnusable;
|
||||
|
||||
PeImage img;
|
||||
if (!img.open(dllPath)) return DllStatus::NotInstalled;
|
||||
|
||||
outMatches = header.sourceSize == (uint64_t)img.size()
|
||||
&& header.sourceHash == fnv1a64(img.data(), img.size());
|
||||
return DllStatus::Ok;
|
||||
}
|
||||
|
||||
DllStatus cacheVariant(const std::string& cachePath, Variant& outVariant) {
|
||||
outVariant = Variant::None;
|
||||
CacheHeader header{};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// ship as RCDATA resources inside Lossless.dll from the user's own paid copy
|
||||
// of Lossless Scaling (Steam app 993090). Every implementation - upstream
|
||||
// lsfg-vk, LSFG-Android, eden, WinNative - requires the user to supply their
|
||||
// own DLL and extracts the resources on device. Bannerlator does the same, and
|
||||
// own DLL and extracts the resources on device. DroidDeck does the same, and
|
||||
// bundles nothing.
|
||||
//
|
||||
// The DLL is mmap'd READ-ONLY and parsed as data. It is never loaded as a
|
||||
@@ -26,9 +26,9 @@
|
||||
// Both producers fill the same shader-id -> SPIR-V words map, so everything
|
||||
// downstream of this file is source-agnostic.
|
||||
//
|
||||
// Derived from WinNative's lsfg_dll.c (GPL-3.0-or-later), whose LSFG port is
|
||||
// credited to Camille LaVey / the Eden Emulator Project, and which follows
|
||||
// upstream lsfg-vk (PancakeTAS). Bannerlator is GPL-3.0; see THIRD-PARTY-LICENSES.md.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk (PancakeTAS). Ported to WinNative (lsfg_dll.c) and DroidDeck
|
||||
// by @maxjivi05. GPL-3.0-or-later.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -98,18 +98,11 @@ const char* variantName(Variant v);
|
||||
// reported as valid - the translator handles it.
|
||||
DllStatus validateDll(const std::string& dllPath);
|
||||
|
||||
// Which producer would be used for this DLL.
|
||||
Variant dllVariant(const std::string& dllPath, bool preferFp16);
|
||||
|
||||
// Parse the DLL, produce all 25 SPIR-V modules, and write them to cachePath
|
||||
// (via temp file + rename, so a failed build cannot leave a half-written
|
||||
// cache behind). Records which producer ran in the cache header.
|
||||
DllStatus buildCache(const std::string& dllPath, const std::string& cachePath, bool preferFp16);
|
||||
|
||||
// Is the cache current for this DLL? Compares source size + content hash.
|
||||
DllStatus cacheMatchesSource(const std::string& cachePath, const std::string& dllPath,
|
||||
bool& outMatches);
|
||||
|
||||
// Load a previously built cache.
|
||||
DllStatus loadModules(const std::string& cachePath, ModuleSet& outSet);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// See lsfg_dxbc.h. DXBC -> SPIR-V via DXVK's vendored translator.
|
||||
//
|
||||
// Follows upstream lsfg-vk's src/extract/trans.cpp, including its
|
||||
// encounter-order binding renumber. Derived from WinNative's lsfg_dxbc.cpp
|
||||
// (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed, vendored
|
||||
// under cpp/thirdparty/dxbc.
|
||||
// encounter-order binding renumber. Ported to WinNative (lsfg_dxbc.cpp) and
|
||||
// DroidDeck by @maxjivi05 from the work of Camille LaVey / the Eden Emulator
|
||||
// Project (GPL-3.0-or-later); the translator itself is DXVK's, zlib licensed,
|
||||
// vendored under cpp/thirdparty/dxbc.
|
||||
|
||||
#include "lsfg_dxbc.h"
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// See lsfg_engine.h.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
|
||||
#include "lsfg_engine.h"
|
||||
|
||||
@@ -269,7 +270,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// nothing. Copy only when generating, on the one priming frame that
|
||||
// precedes generation, or while the governor is warming up towards a
|
||||
// probe - so the ring is populated by the time it is needed.
|
||||
const bool needHistory = generations > 0 || primeHistory_
|
||||
const bool needHistory = generations > 0 || primeHistory_ || followEveryFrame()
|
||||
|| (governorEnabled_ && governor_.wantsHistory());
|
||||
if (needHistory) {
|
||||
copyPresentedFrame(cmd, source, chain_->Input(count), VkExtent2D{width, height});
|
||||
@@ -284,7 +285,7 @@ void Engine::process(VkCommandBuffer cmd, VkImage source, uint32_t width, uint32
|
||||
// regardless of the game's own settings, and it fed straight back into the
|
||||
// governor: the chain made the source rate collapse, the governor saw the
|
||||
// collapse and refused to generate, and refusing did not stop the chain.
|
||||
if (warm_ && generations > 0) chain_->DispatchShared(cmd, count);
|
||||
if (warm_ && (generations > 0 || followEveryFrame())) chain_->DispatchShared(cmd, count);
|
||||
}
|
||||
|
||||
void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t targetIndex,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// unavoidable cost of interpolating rather than extrapolating; no placement of
|
||||
// this code changes it.
|
||||
//
|
||||
// Ported from WinNative's vkr_lsfg.cpp (GPL-3.0-or-later), LSFG port credited
|
||||
// to Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk.
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative (vkr_lsfg.cpp) and DroidDeck by
|
||||
// @maxjivi05, where it runs on gamescope's output in the compositor.
|
||||
// ============================================================================
|
||||
|
||||
#include <cstdint>
|
||||
@@ -109,6 +110,10 @@ private:
|
||||
// generation last stopped.
|
||||
bool historyFresh() const { return haveCopied_ && lastCopiedCount_ + 1 == frameCount_; }
|
||||
|
||||
// With a target rate the pacer plans 0..3 generations frame by frame, so the ring and the
|
||||
// shared chain have to follow every frame it keeps warm, not just the ones that generate.
|
||||
bool followEveryFrame() const { return warm_ && pacer_.Config().target_rate != 0; }
|
||||
|
||||
Device device_{};
|
||||
std::string cachePath_;
|
||||
std::unique_ptr<LsfgShaders> shaders_;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_gamma.hpp"
|
||||
#include "lsfg_shaders.hpp"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_generate.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -27,12 +27,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeValidateDll(JNIEnv* env, jclass
|
||||
return (jint)lsfg::validateDll(toStdString(env, dllPath));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeDllVariant(JNIEnv* env, jclass, jstring dllPath,
|
||||
jboolean preferFp16) {
|
||||
return (jint)lsfg::dllVariant(toStdString(env, dllPath), preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass, jstring dllPath,
|
||||
jstring cachePath, jboolean preferFp16) {
|
||||
@@ -40,16 +34,6 @@ Java_com_droiddeck_launcher_gpu_LsfgNative_nativeBuildCache(JNIEnv* env, jclass,
|
||||
preferFp16 == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheMatchesSource(JNIEnv* env, jclass,
|
||||
jstring cachePath,
|
||||
jstring dllPath) {
|
||||
bool matches = false;
|
||||
const lsfg::DllStatus status = lsfg::cacheMatchesSource(toStdString(env, cachePath),
|
||||
toStdString(env, dllPath), matches);
|
||||
return (jboolean)(status == lsfg::DllStatus::Ok && matches);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_gpu_LsfgNative_nativeCacheVariant(JNIEnv* env, jclass, jstring cachePath) {
|
||||
lsfg::Variant variant = lsfg::Variant::None;
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_mipmaps.hpp"
|
||||
#include "lsfg_dll.h"
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_pacer.hpp"
|
||||
|
||||
@@ -22,6 +21,8 @@ constexpr float SOURCE_SMOOTHING = 0.15f;
|
||||
constexpr float SOURCE_STALE_SECONDS = 0.5f;
|
||||
constexpr float DISCONTINUITY_SECONDS = 0.25f;
|
||||
constexpr float HEADROOM_EPSILON = 0.02f;
|
||||
// A target is aimed just under, so rounding never puts output past the panel's refresh rate.
|
||||
constexpr float TARGET_MARGIN = 0.1f;
|
||||
constexpr float CREDIT_EPSILON = 1.0e-4f;
|
||||
constexpr float SOURCE_ACCUM_FLOOR = 0.01f;
|
||||
constexpr uint32_t MIN_RATE_SAMPLES = 12;
|
||||
@@ -117,6 +118,7 @@ LsfgPlan LsfgPacer::Plan(size_t capacity, uint64_t source_frames) {
|
||||
if (target_rate > 0.0f && config.refresh_rate > 0.0f) {
|
||||
target_rate = std::min(target_rate, config.refresh_rate);
|
||||
}
|
||||
if (target_rate > 0.0f) target_rate -= TARGET_MARGIN;
|
||||
|
||||
if (target_rate == 0.0f) {
|
||||
// No explicit target rate: the multiplier is the multiplier, full stop.
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#include "lsfg_shaders.hpp"
|
||||
#include "lsfg_common.hpp"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
//
|
||||
// Ported into Bannerlator's compositor from WinNative (GPL-3.0-or-later),
|
||||
// whose LSFG port is credited to Camille LaVey / the Eden Emulator Project and
|
||||
// follows upstream lsfg-vk. Only the Vulkan dispatch differs: Bannerlator
|
||||
// resolves entry points through the renderer's own table (see lsfg_vkd.h).
|
||||
// LSFG from the work of Camille LaVey / the Eden Emulator Project, following
|
||||
// upstream lsfg-vk. Ported to WinNative and DroidDeck by @maxjivi05; only the
|
||||
// Vulkan dispatch differs here (see lsfg_vkd.h).
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// ============================================================================
|
||||
// lsfg_vkd - the Vulkan entry points the LSFG chain calls, as a single global.
|
||||
//
|
||||
// Two dispatch styles meet here. Bannerlator's compositor resolves Vulkan
|
||||
// Two dispatch styles meet here. DroidDeck's compositor resolves Vulkan
|
||||
// through a per-context VkTable (`vk_`), because an adrenotools-loaded driver
|
||||
// lives in an isolated linker namespace and shares no global symbols with the
|
||||
// system loader. The LSFG chain, ported from eden by way of WinNative, calls
|
||||
// system loader. The LSFG chain, ported from Eden by way of WinNative, calls
|
||||
// through a file-scope `vkd`. Rather than rewrite ~2,300 lines of proven chain
|
||||
// code to thread a table through every call, this shim exposes exactly the 34
|
||||
// entry points the chain uses and is filled from the renderer's own table when
|
||||
|
||||
@@ -19,6 +19,7 @@ add_library(bannerwayland SHARED
|
||||
src/wl_toplevel_icon.c
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
generated/color-management-v1-protocol.c
|
||||
generated/xdg-shell-protocol.c
|
||||
generated/linux-dmabuf-v1-protocol.c
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#version 450
|
||||
// Wayland buffers carry premultiplied alpha; the pipeline blends ONE, ONE_MINUS_SRC_ALPHA.
|
||||
layout(binding = 0) uniform sampler2D Surface;
|
||||
layout(location = 0) in vec2 uv;
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
outColor = texture(Surface, uv);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#version 450
|
||||
// One surface as a quad: dst = the target rectangle in NDC, src = the buffer rectangle in UV.
|
||||
layout(push_constant) uniform PC { vec4 dst; vec4 src; } pc;
|
||||
layout(location = 0) out vec2 uv;
|
||||
|
||||
void main() {
|
||||
vec2 t = vec2(gl_VertexIndex & 1, gl_VertexIndex >> 1);
|
||||
gl_Position = vec4(mix(pc.dst.xy, pc.dst.zw, t), 0.0, 1.0);
|
||||
uv = mix(pc.src.xy, pc.src.zw, t);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x00000014,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x00000011,0x00030010,
|
||||
0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,
|
||||
0x00000000,0x00050005,0x00000009,0x4374756f,0x726f6c6f,0x00000000,0x00040005,0x0000000d,
|
||||
0x66727553,0x00656361,0x00030005,0x00000011,0x00007675,0x00040047,0x00000009,0x0000001e,
|
||||
0x00000000,0x00040047,0x0000000d,0x00000021,0x00000000,0x00040047,0x0000000d,0x00000022,
|
||||
0x00000000,0x00040047,0x00000011,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,
|
||||
0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,
|
||||
0x00000004,0x00040020,0x00000008,0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,
|
||||
0x00000003,0x00090019,0x0000000a,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000,
|
||||
0x00000001,0x00000000,0x0003001b,0x0000000b,0x0000000a,0x00040020,0x0000000c,0x00000000,
|
||||
0x0000000b,0x0004003b,0x0000000c,0x0000000d,0x00000000,0x00040017,0x0000000f,0x00000006,
|
||||
0x00000002,0x00040020,0x00000010,0x00000001,0x0000000f,0x0004003b,0x00000010,0x00000011,
|
||||
0x00000001,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
|
||||
0x0004003d,0x0000000b,0x0000000e,0x0000000d,0x0004003d,0x0000000f,0x00000012,0x00000011,
|
||||
0x00050057,0x00000007,0x00000013,0x0000000e,0x00000012,0x0003003e,0x00000009,0x00000013,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
/* Alpha composition: one surface drawn source-over onto the scene - see blend_pass.h. Compositor thread. */
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include "blend_pass.h"
|
||||
#include "vk_loader.h"
|
||||
#include "vk_present.h" /* banner_log, vkp_gpu_name */
|
||||
#include <string.h>
|
||||
|
||||
/* SPIR-V, pre-compiled (glslangValidator -V blend.vert --vn blend_vert_code, the same for blend.frag)
|
||||
* and committed next to the GLSL: the NDK build compiles no shaders. */
|
||||
#include "blend_vert.h"
|
||||
#include "blend_frag.h"
|
||||
|
||||
struct pc_blend { float dst[4]; float src[4]; };
|
||||
|
||||
static VkDevice g_dev;
|
||||
static int g_ready; /* 0 = not built, 1 = ok, -1 = unavailable (said once) */
|
||||
static VkSampler g_sampler;
|
||||
static VkDescriptorSetLayout g_dsl;
|
||||
static VkPipelineLayout g_pl;
|
||||
static VkDescriptorPool g_dpool;
|
||||
static VkShaderModule g_vert, g_frag;
|
||||
static unsigned g_frame = 1;
|
||||
|
||||
/* One render pass + pipeline per target format: the screen swapchain's, the scene image's, the HDR
|
||||
* composition's. */
|
||||
#define NFMT 4
|
||||
static struct { VkFormat fmt; VkRenderPass rp; VkPipeline pipe; } g_out[NFMT];
|
||||
|
||||
#define NSRC 32
|
||||
static struct { VkImage img; VkImageView view; VkDescriptorSet ds; unsigned frame; } g_src[NSRC];
|
||||
#define NDST 12
|
||||
static struct { VkImage img; VkFormat fmt; int w, h; VkImageView view; VkFramebuffer fb; unsigned frame; } g_dst[NDST];
|
||||
|
||||
void blendp_bind_device(VkDevice dev) { g_dev = dev; }
|
||||
|
||||
void blendp_begin_frame(void) { g_frame++; }
|
||||
|
||||
static void src_release(int i) {
|
||||
if (g_src[i].ds) g_vk.FreeDescriptorSets(g_dev, g_dpool, 1, &g_src[i].ds);
|
||||
if (g_src[i].view) g_vk.DestroyImageView(g_dev, g_src[i].view, NULL);
|
||||
memset(&g_src[i], 0, sizeof(g_src[i]));
|
||||
}
|
||||
|
||||
static void dst_release(int i) {
|
||||
if (g_dst[i].fb) g_vk.DestroyFramebuffer(g_dev, g_dst[i].fb, NULL);
|
||||
if (g_dst[i].view) g_vk.DestroyImageView(g_dev, g_dst[i].view, NULL);
|
||||
memset(&g_dst[i], 0, sizeof(g_dst[i]));
|
||||
}
|
||||
|
||||
void blendp_forget_image(VkImage img) {
|
||||
if (!g_dev || !img) return;
|
||||
for (int i = 0; i < NSRC; i++) if (g_src[i].img == img) src_release(i);
|
||||
for (int i = 0; i < NDST; i++) if (g_dst[i].img == img) dst_release(i);
|
||||
}
|
||||
|
||||
static VkShaderModule shader(const uint32_t *code, size_t size) {
|
||||
VkShaderModule m = VK_NULL_HANDLE;
|
||||
VkShaderModuleCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO, .codeSize = size, .pCode = code};
|
||||
if (g_vk.CreateShaderModule(g_dev, &ci, NULL, &m) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return m;
|
||||
}
|
||||
|
||||
/* The target keeps what the blits put there (LOAD) and stays in TRANSFER_DST_OPTIMAL for the blits
|
||||
* that follow. The dependencies order the pass between them. */
|
||||
static VkRenderPass make_rp(VkFormat fmt) {
|
||||
VkAttachmentDescription att = {.format = fmt, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
|
||||
.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE,
|
||||
.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
|
||||
.finalLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL};
|
||||
VkAttachmentReference ref = {0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL};
|
||||
VkSubpassDescription sub = {.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS, .colorAttachmentCount = 1,
|
||||
.pColorAttachments = &ref};
|
||||
VkSubpassDependency deps[2] = {
|
||||
{.srcSubpass = VK_SUBPASS_EXTERNAL, .dstSubpass = 0,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT},
|
||||
{.srcSubpass = 0, .dstSubpass = VK_SUBPASS_EXTERNAL,
|
||||
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.dstStageMask = VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
|
||||
.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT |
|
||||
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT | VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT}};
|
||||
VkRenderPassCreateInfo rci = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO, .attachmentCount = 1,
|
||||
.pAttachments = &att, .subpassCount = 1, .pSubpasses = &sub,
|
||||
.dependencyCount = 2, .pDependencies = deps};
|
||||
VkRenderPass rp = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateRenderPass(g_dev, &rci, NULL, &rp) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return rp;
|
||||
}
|
||||
|
||||
static VkPipeline make_pipe(VkRenderPass rp) {
|
||||
VkPipelineShaderStageCreateInfo st[2] = {
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_VERTEX_BIT,
|
||||
.module = g_vert, .pName = "main"},
|
||||
{.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, .stage = VK_SHADER_STAGE_FRAGMENT_BIT,
|
||||
.module = g_frag, .pName = "main"}};
|
||||
VkPipelineVertexInputStateCreateInfo vi = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
||||
VkPipelineInputAssemblyStateCreateInfo ia = {.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
|
||||
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP};
|
||||
VkDynamicState dyn[2] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||
VkPipelineDynamicStateCreateInfo ds = {.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
|
||||
.dynamicStateCount = 2, .pDynamicStates = dyn};
|
||||
VkPipelineViewportStateCreateInfo vp = {.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO,
|
||||
.viewportCount = 1, .scissorCount = 1};
|
||||
VkPipelineRasterizationStateCreateInfo rs = {.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO,
|
||||
.polygonMode = VK_POLYGON_MODE_FILL, .cullMode = VK_CULL_MODE_NONE,
|
||||
.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE, .lineWidth = 1.0f};
|
||||
VkPipelineMultisampleStateCreateInfo ms = {.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO,
|
||||
.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT};
|
||||
/* The target's alpha is left alone: the screen is opaque whatever is drawn onto it. */
|
||||
VkPipelineColorBlendAttachmentState ba = {
|
||||
.blendEnable = VK_TRUE, .srcColorBlendFactor = VK_BLEND_FACTOR_ONE,
|
||||
.dstColorBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA, .colorBlendOp = VK_BLEND_OP_ADD,
|
||||
.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE, .dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA,
|
||||
.alphaBlendOp = VK_BLEND_OP_ADD,
|
||||
.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT};
|
||||
VkPipelineColorBlendStateCreateInfo cb = {.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO,
|
||||
.attachmentCount = 1, .pAttachments = &ba};
|
||||
VkGraphicsPipelineCreateInfo pi = {.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO, .stageCount = 2,
|
||||
.pStages = st, .pVertexInputState = &vi, .pInputAssemblyState = &ia,
|
||||
.pViewportState = &vp, .pRasterizationState = &rs, .pMultisampleState = &ms,
|
||||
.pColorBlendState = &cb, .pDynamicState = &ds, .layout = g_pl,
|
||||
.renderPass = rp, .subpass = 0};
|
||||
VkPipeline p = VK_NULL_HANDLE;
|
||||
if (g_vk.CreateGraphicsPipelines(g_dev, VK_NULL_HANDLE, 1, &pi, NULL, &p) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void objects_destroy(void) {
|
||||
if (g_vert) g_vk.DestroyShaderModule(g_dev, g_vert, NULL);
|
||||
if (g_frag) g_vk.DestroyShaderModule(g_dev, g_frag, NULL);
|
||||
if (g_dpool) g_vk.DestroyDescriptorPool(g_dev, g_dpool, NULL);
|
||||
if (g_pl) g_vk.DestroyPipelineLayout(g_dev, g_pl, NULL);
|
||||
if (g_dsl) g_vk.DestroyDescriptorSetLayout(g_dev, g_dsl, NULL);
|
||||
if (g_sampler) g_vk.DestroySampler(g_dev, g_sampler, NULL);
|
||||
g_vert = g_frag = VK_NULL_HANDLE; g_dpool = VK_NULL_HANDLE; g_pl = VK_NULL_HANDLE;
|
||||
g_dsl = VK_NULL_HANDLE; g_sampler = VK_NULL_HANDLE;
|
||||
}
|
||||
|
||||
static int objects_ensure(void) {
|
||||
if (g_ready) return g_ready == 1 ? 0 : -1;
|
||||
g_ready = -1;
|
||||
if (!g_dev || !g_vk.CreateGraphicsPipelines) goto fail;
|
||||
VkSamplerCreateInfo sci = {.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, .magFilter = VK_FILTER_LINEAR,
|
||||
.minFilter = VK_FILTER_LINEAR, .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST,
|
||||
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
|
||||
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE};
|
||||
if (g_vk.CreateSampler(g_dev, &sci, NULL, &g_sampler) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorSetLayoutBinding b = {.binding = 0, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.descriptorCount = 1, .stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT};
|
||||
VkDescriptorSetLayoutCreateInfo dli = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
|
||||
.bindingCount = 1, .pBindings = &b};
|
||||
if (g_vk.CreateDescriptorSetLayout(g_dev, &dli, NULL, &g_dsl) != VK_SUCCESS) goto fail;
|
||||
VkPushConstantRange pcr = {.stageFlags = VK_SHADER_STAGE_VERTEX_BIT, .offset = 0, .size = sizeof(struct pc_blend)};
|
||||
VkPipelineLayoutCreateInfo pli = {.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO, .setLayoutCount = 1,
|
||||
.pSetLayouts = &g_dsl, .pushConstantRangeCount = 1, .pPushConstantRanges = &pcr};
|
||||
if (g_vk.CreatePipelineLayout(g_dev, &pli, NULL, &g_pl) != VK_SUCCESS) goto fail;
|
||||
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, NSRC};
|
||||
VkDescriptorPoolCreateInfo dpi = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
|
||||
.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT, .maxSets = NSRC,
|
||||
.poolSizeCount = 1, .pPoolSizes = &ps};
|
||||
if (g_vk.CreateDescriptorPool(g_dev, &dpi, NULL, &g_dpool) != VK_SUCCESS) goto fail;
|
||||
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",
|
||||
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 "
|
||||
"are drawn opaque this session");
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int out_slot(VkFormat fmt) {
|
||||
int i;
|
||||
for (i = 0; i < NFMT && g_out[i].pipe; i++) if (g_out[i].fmt == fmt) return i;
|
||||
if (i == NFMT) return -1;
|
||||
VkRenderPass rp = make_rp(fmt);
|
||||
VkPipeline pipe = rp ? make_pipe(rp) : VK_NULL_HANDLE;
|
||||
if (!pipe) {
|
||||
if (rp) g_vk.DestroyRenderPass(g_dev, rp, NULL);
|
||||
return -1;
|
||||
}
|
||||
g_out[i].fmt = fmt; g_out[i].rp = rp; g_out[i].pipe = pipe;
|
||||
return i;
|
||||
}
|
||||
|
||||
static VkImageView make_view(VkImage img, VkFormat fmt) {
|
||||
VkImageView v = VK_NULL_HANDLE;
|
||||
VkImageViewCreateInfo ci = {.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, .image = img,
|
||||
.viewType = VK_IMAGE_VIEW_TYPE_2D, .format = fmt,
|
||||
.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}};
|
||||
if (g_vk.CreateImageView(g_dev, &ci, NULL, &v) != VK_SUCCESS) return VK_NULL_HANDLE;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* The slot to recycle: a free one, else the one unused for longest - never one this command buffer
|
||||
* already refers to. -1 = every slot is in this frame. */
|
||||
#define OLDEST(arr, n, out) do { \
|
||||
unsigned age_ = 0; \
|
||||
(out) = -1; \
|
||||
for (int k_ = 0; k_ < (n); k_++) { \
|
||||
if (!(arr)[k_].img) { (out) = k_; break; } \
|
||||
if ((arr)[k_].frame != g_frame && g_frame - (arr)[k_].frame > age_) { age_ = g_frame - (arr)[k_].frame; (out) = k_; } \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static VkDescriptorSet src_ensure(VkImage img, VkFormat fmt, VkImageLayout layout) {
|
||||
int i;
|
||||
for (i = 0; i < NSRC; i++)
|
||||
if (g_src[i].img == img) { g_src[i].frame = g_frame; return g_src[i].ds; }
|
||||
OLDEST(g_src, NSRC, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
if (!(g_src[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkDescriptorSetAllocateInfo ai = {.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
|
||||
.descriptorPool = g_dpool, .descriptorSetCount = 1, .pSetLayouts = &g_dsl};
|
||||
if (g_vk.AllocateDescriptorSets(g_dev, &ai, &g_src[i].ds) != VK_SUCCESS) {
|
||||
g_src[i].ds = VK_NULL_HANDLE;
|
||||
src_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
VkDescriptorImageInfo ii = {.sampler = g_sampler, .imageView = g_src[i].view, .imageLayout = layout};
|
||||
VkWriteDescriptorSet wr = {.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, .dstSet = g_src[i].ds, .dstBinding = 0,
|
||||
.descriptorCount = 1, .descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER,
|
||||
.pImageInfo = &ii};
|
||||
g_vk.UpdateDescriptorSets(g_dev, 1, &wr, 0, NULL);
|
||||
g_src[i].img = img; g_src[i].frame = g_frame;
|
||||
return g_src[i].ds;
|
||||
}
|
||||
|
||||
static VkFramebuffer dst_ensure(VkImage img, VkFormat fmt, int w, int h, VkRenderPass rp) {
|
||||
int i;
|
||||
for (i = 0; i < NDST; i++) {
|
||||
if (g_dst[i].img != img) continue;
|
||||
if (g_dst[i].fmt == fmt && g_dst[i].w == w && g_dst[i].h == h) { g_dst[i].frame = g_frame; return g_dst[i].fb; }
|
||||
if (g_dst[i].frame == g_frame) return VK_NULL_HANDLE;
|
||||
break;
|
||||
}
|
||||
if (i == NDST) OLDEST(g_dst, NDST, i);
|
||||
if (i < 0) return VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
if (!(g_dst[i].view = make_view(img, fmt))) return VK_NULL_HANDLE;
|
||||
VkFramebufferCreateInfo fi = {.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO, .renderPass = rp,
|
||||
.attachmentCount = 1, .pAttachments = &g_dst[i].view, .width = (uint32_t)w,
|
||||
.height = (uint32_t)h, .layers = 1};
|
||||
if (g_vk.CreateFramebuffer(g_dev, &fi, NULL, &g_dst[i].fb) != VK_SUCCESS) {
|
||||
g_dst[i].fb = VK_NULL_HANDLE;
|
||||
dst_release(i);
|
||||
return VK_NULL_HANDLE;
|
||||
}
|
||||
g_dst[i].img = img; g_dst[i].fmt = fmt; g_dst[i].w = w; g_dst[i].h = h; g_dst[i].frame = g_frame;
|
||||
return g_dst[i].fb;
|
||||
}
|
||||
|
||||
static void src_layout(VkCommandBuffer cmd, VkImage img, VkImageLayout from, VkImageLayout to) {
|
||||
int sampling = to == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkImageMemoryBarrier b = {.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .oldLayout = from, .newLayout = to,
|
||||
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
|
||||
.image = img, .subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1},
|
||||
.srcAccessMask = sampling ? VK_ACCESS_TRANSFER_READ_BIT : VK_ACCESS_SHADER_READ_BIT,
|
||||
.dstAccessMask = sampling ? VK_ACCESS_SHADER_READ_BIT : VK_ACCESS_TRANSFER_READ_BIT};
|
||||
g_vk.CmdPipelineBarrier(cmd, sampling ? VK_PIPELINE_STAGE_TRANSFER_BIT : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||||
sampling ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
0, 0, NULL, 0, NULL, 1, &b);
|
||||
}
|
||||
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit) {
|
||||
if (!src || !dst || src_w <= 0 || src_h <= 0 || dst_w <= 0 || dst_h <= 0 || objects_ensure() != 0) return -1;
|
||||
int slot = out_slot(dst_fmt);
|
||||
if (slot < 0) return -1;
|
||||
VkImageLayout sampled = src_general ? VK_IMAGE_LAYOUT_GENERAL : VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||||
VkDescriptorSet ds = src_ensure(src, src_fmt, sampled);
|
||||
if (!ds) return -1;
|
||||
VkFramebuffer fb = dst_ensure(dst, dst_fmt, dst_w, dst_h, g_out[slot].rp);
|
||||
if (!fb) return -1;
|
||||
|
||||
const VkOffset3D *d = blit->dstOffsets, *s = blit->srcOffsets;
|
||||
int x0 = d[0].x < d[1].x ? d[0].x : d[1].x, x1 = d[0].x < d[1].x ? d[1].x : d[0].x;
|
||||
int y0 = d[0].y < d[1].y ? d[0].y : d[1].y, y1 = d[0].y < d[1].y ? d[1].y : d[0].y;
|
||||
if (x0 < 0) x0 = 0;
|
||||
if (y0 < 0) y0 = 0;
|
||||
if (x1 > dst_w) x1 = dst_w;
|
||||
if (y1 > dst_h) y1 = dst_h;
|
||||
if (x1 <= x0 || y1 <= y0) return 0;
|
||||
|
||||
struct pc_blend pc = {
|
||||
.dst = {2.0f * d[0].x / dst_w - 1.0f, 2.0f * d[0].y / dst_h - 1.0f,
|
||||
2.0f * d[1].x / dst_w - 1.0f, 2.0f * d[1].y / dst_h - 1.0f},
|
||||
.src = {(float)s[0].x / src_w, (float)s[0].y / src_h, (float)s[1].x / src_w, (float)s[1].y / src_h}};
|
||||
|
||||
if (!src_general) src_layout(cmd, src, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, sampled);
|
||||
VkRenderPassBeginInfo rpi = {.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO, .renderPass = g_out[slot].rp,
|
||||
.framebuffer = fb,
|
||||
.renderArea = {{x0, y0}, {(uint32_t)(x1 - x0), (uint32_t)(y1 - y0)}}};
|
||||
VkViewport vp = {0, 0, (float)dst_w, (float)dst_h, 0, 1};
|
||||
g_vk.CmdBeginRenderPass(cmd, &rpi, VK_SUBPASS_CONTENTS_INLINE);
|
||||
g_vk.CmdSetViewport(cmd, 0, 1, &vp);
|
||||
g_vk.CmdSetScissor(cmd, 0, 1, &rpi.renderArea);
|
||||
g_vk.CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_out[slot].pipe);
|
||||
g_vk.CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, g_pl, 0, 1, &ds, 0, NULL);
|
||||
g_vk.CmdPushConstants(cmd, g_pl, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
|
||||
g_vk.CmdDraw(cmd, 4, 1, 0, 0);
|
||||
g_vk.CmdEndRenderPass(cmd);
|
||||
if (!src_general) src_layout(cmd, src, sampled, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#ifndef BLEND_PASS_H
|
||||
#define BLEND_PASS_H
|
||||
/*
|
||||
* Alpha composition. The scene is composed with blits, which replace what is under them; a surface
|
||||
* with translucent pixels (gamescope's overlay and notification planes over a game, a toolkit's
|
||||
* shadowed popup) is drawn through this pass instead, source-over with premultiplied alpha.
|
||||
*
|
||||
* Compositor thread. Objects are built on the first use; if the driver refuses them the pass is
|
||||
* unavailable for the session (logged once) and the caller keeps blitting.
|
||||
*/
|
||||
#define VK_USE_PLATFORM_ANDROID_KHR
|
||||
#include <vulkan/vulkan.h>
|
||||
|
||||
/* Called from dev_init (device up). */
|
||||
void blendp_bind_device(VkDevice dev);
|
||||
/* A source or target image is about to be destroyed: drop what is cached for it. Frames are fenced,
|
||||
* so nothing submitted still uses it. */
|
||||
void blendp_forget_image(VkImage img);
|
||||
/* A new command buffer is being recorded: what the previous one used may be recycled again. */
|
||||
void blendp_begin_frame(void);
|
||||
|
||||
/* Record one surface over `dst`. `dst` is in TRANSFER_DST_OPTIMAL before and after. `src` was created
|
||||
* with SAMPLED usage and is in GENERAL (src_general) or TRANSFER_SRC_OPTIMAL, and is left there.
|
||||
* `blit` is the draw as the blit path maps it. 0 = recorded, -1 = unavailable (nothing recorded). */
|
||||
int blendp_draw(VkCommandBuffer cmd, VkImage src, VkFormat src_fmt, int src_w, int src_h, int src_general,
|
||||
VkImage dst, VkFormat dst_fmt, int dst_w, int dst_h, const VkImageBlit *blit);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,55 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t blend_vert_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x0000003a,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
|
||||
0x0008000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x0000001b,0x00000031,
|
||||
0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d,0x00000000,0x00030005,
|
||||
0x00000009,0x00000074,0x00060005,0x0000000c,0x565f6c67,0x65747265,0x646e4978,0x00007865,
|
||||
0x00060005,0x00000019,0x505f6c67,0x65567265,0x78657472,0x00000000,0x00060006,0x00000019,
|
||||
0x00000000,0x505f6c67,0x7469736f,0x006e6f69,0x00070006,0x00000019,0x00000001,0x505f6c67,
|
||||
0x746e696f,0x657a6953,0x00000000,0x00070006,0x00000019,0x00000002,0x435f6c67,0x4470696c,
|
||||
0x61747369,0x0065636e,0x00070006,0x00000019,0x00000003,0x435f6c67,0x446c6c75,0x61747369,
|
||||
0x0065636e,0x00030005,0x0000001b,0x00000000,0x00030005,0x0000001d,0x00004350,0x00040006,
|
||||
0x0000001d,0x00000000,0x00747364,0x00040006,0x0000001d,0x00000001,0x00637273,0x00030005,
|
||||
0x0000001f,0x00006370,0x00030005,0x00000031,0x00007675,0x00040047,0x0000000c,0x0000000b,
|
||||
0x0000002a,0x00030047,0x00000019,0x00000002,0x00050048,0x00000019,0x00000000,0x0000000b,
|
||||
0x00000000,0x00050048,0x00000019,0x00000001,0x0000000b,0x00000001,0x00050048,0x00000019,
|
||||
0x00000002,0x0000000b,0x00000003,0x00050048,0x00000019,0x00000003,0x0000000b,0x00000004,
|
||||
0x00030047,0x0000001d,0x00000002,0x00050048,0x0000001d,0x00000000,0x00000023,0x00000000,
|
||||
0x00050048,0x0000001d,0x00000001,0x00000023,0x00000010,0x00040047,0x00000031,0x0000001e,
|
||||
0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,
|
||||
0x00000020,0x00040017,0x00000007,0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,
|
||||
0x00000007,0x00040015,0x0000000a,0x00000020,0x00000001,0x00040020,0x0000000b,0x00000001,
|
||||
0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x0004002b,0x0000000a,0x0000000e,
|
||||
0x00000001,0x00040017,0x00000015,0x00000006,0x00000004,0x00040015,0x00000016,0x00000020,
|
||||
0x00000000,0x0004002b,0x00000016,0x00000017,0x00000001,0x0004001c,0x00000018,0x00000006,
|
||||
0x00000017,0x0006001e,0x00000019,0x00000015,0x00000006,0x00000018,0x00000018,0x00040020,
|
||||
0x0000001a,0x00000003,0x00000019,0x0004003b,0x0000001a,0x0000001b,0x00000003,0x0004002b,
|
||||
0x0000000a,0x0000001c,0x00000000,0x0004001e,0x0000001d,0x00000015,0x00000015,0x00040020,
|
||||
0x0000001e,0x00000009,0x0000001d,0x0004003b,0x0000001e,0x0000001f,0x00000009,0x00040020,
|
||||
0x00000020,0x00000009,0x00000015,0x0004002b,0x00000006,0x00000029,0x00000000,0x0004002b,
|
||||
0x00000006,0x0000002a,0x3f800000,0x00040020,0x0000002e,0x00000003,0x00000015,0x00040020,
|
||||
0x00000030,0x00000003,0x00000007,0x0004003b,0x00000030,0x00000031,0x00000003,0x00050036,
|
||||
0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000008,
|
||||
0x00000009,0x00000007,0x0004003d,0x0000000a,0x0000000d,0x0000000c,0x000500c7,0x0000000a,
|
||||
0x0000000f,0x0000000d,0x0000000e,0x0004006f,0x00000006,0x00000010,0x0000000f,0x0004003d,
|
||||
0x0000000a,0x00000011,0x0000000c,0x000500c3,0x0000000a,0x00000012,0x00000011,0x0000000e,
|
||||
0x0004006f,0x00000006,0x00000013,0x00000012,0x00050050,0x00000007,0x00000014,0x00000010,
|
||||
0x00000013,0x0003003e,0x00000009,0x00000014,0x00050041,0x00000020,0x00000021,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000022,0x00000021,0x0007004f,0x00000007,0x00000023,
|
||||
0x00000022,0x00000022,0x00000000,0x00000001,0x00050041,0x00000020,0x00000024,0x0000001f,
|
||||
0x0000001c,0x0004003d,0x00000015,0x00000025,0x00000024,0x0007004f,0x00000007,0x00000026,
|
||||
0x00000025,0x00000025,0x00000002,0x00000003,0x0004003d,0x00000007,0x00000027,0x00000009,
|
||||
0x0008000c,0x00000007,0x00000028,0x00000001,0x0000002e,0x00000023,0x00000026,0x00000027,
|
||||
0x00050051,0x00000006,0x0000002b,0x00000028,0x00000000,0x00050051,0x00000006,0x0000002c,
|
||||
0x00000028,0x00000001,0x00070050,0x00000015,0x0000002d,0x0000002b,0x0000002c,0x00000029,
|
||||
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000001b,0x0000001c,0x0003003e,0x0000002f,
|
||||
0x0000002d,0x00050041,0x00000020,0x00000032,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000033,0x00000032,0x0007004f,0x00000007,0x00000034,0x00000033,0x00000033,0x00000000,
|
||||
0x00000001,0x00050041,0x00000020,0x00000035,0x0000001f,0x0000000e,0x0004003d,0x00000015,
|
||||
0x00000036,0x00000035,0x0007004f,0x00000007,0x00000037,0x00000036,0x00000036,0x00000002,
|
||||
0x00000003,0x0004003d,0x00000007,0x00000038,0x00000009,0x0008000c,0x00000007,0x00000039,
|
||||
0x00000001,0x0000002e,0x00000034,0x00000037,0x00000038,0x0003003e,0x00000031,0x00000039,
|
||||
0x000100fd,0x00010038
|
||||
};
|
||||
@@ -238,6 +238,8 @@ struct surface {
|
||||
struct dmabuf_buffer *dmabuf_buf; /* its imported image, kept until replaced or the surface goes */
|
||||
struct wl_listener dmabuf_destroy;
|
||||
int buf_w, buf_h, has_content;
|
||||
int buf_alpha;
|
||||
int opaque[4], pending_opaque[4], pending_opaque_set; /* x, y, w, h; w = 0: none */
|
||||
int src_set, dst_set;
|
||||
float src[4];
|
||||
int dst[2];
|
||||
@@ -433,9 +435,16 @@ static int on_release_timer(int fd, uint32_t mask, void *data) {
|
||||
* delivered (a swapchain rebuild destroys buffers with queued releases) or that a coarser earlier
|
||||
* limit had spaced out, and the game's first frames after that waited on empty slots. */
|
||||
static void release_buffer(struct surface *s, struct wl_resource *buffer, int64_t since_ns) {
|
||||
int limit = g_fps_limit;
|
||||
if (limit <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t interval = 1000000000LL / limit, now = now_ns();
|
||||
const int limit = g_fps_limit;
|
||||
/* Without a limit of its own a game is paced to the screen. A swapchain that does not wait for
|
||||
* frame callbacks (MAILBOX, IMMEDIATE) is held back by nothing else here, so handing a replaced
|
||||
* buffer straight back let it draw as fast as the GPU allowed and everything above the refresh
|
||||
* rate was thrown away - 178 frames a second for 91 on screen, measured (WinNative, maxjivi05).
|
||||
* One release per refresh is the back-pressure X11 gives, and the screen shows the same frames
|
||||
* either way. */
|
||||
const int64_t interval = limit > 0 ? 1000000000LL / limit : g_refresh_ns;
|
||||
if (interval <= 0 || g_release_timer_fd < 0) { wl_buffer_send_release(buffer); perf_note_release(since_ns); return; }
|
||||
int64_t now = now_ns();
|
||||
int64_t at = s->next_release_ns + interval;
|
||||
int64_t latest = now + interval * (int64_t)(s->releases_pending + 1);
|
||||
if (at < now) at = now;
|
||||
@@ -532,6 +541,13 @@ static void cursor_publish_shm(struct wl_shm_buffer *shm, int hx, int hy) {
|
||||
wl_shm_buffer_end_access(shm);
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
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) {
|
||||
@@ -722,6 +738,7 @@ static void take_shm(struct surface *s, struct wl_shm_buffer *shm, struct wl_res
|
||||
wl_buffer_send_release(buffer);
|
||||
s->buf_w = w;
|
||||
s->buf_h = h;
|
||||
s->buf_alpha = wl_shm_buffer_get_format(shm) == WL_SHM_FORMAT_ARGB8888;
|
||||
s->has_content = s->shm_img != NULL;
|
||||
g_stat_shm++;
|
||||
struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
@@ -773,6 +790,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;
|
||||
s->buf_alpha = (b->format & 0xff) == 'A';
|
||||
s->has_content = b->img != NULL;
|
||||
g_stat_dmabuf++;
|
||||
const int announced_now = !s->announced_vulkan;
|
||||
@@ -944,8 +962,27 @@ static void surface_frame(struct wl_client *c, struct wl_resource *r, uint32_t c
|
||||
wl_resource_set_implementation(callback, NULL, NULL, frame_callback_destroy);
|
||||
wl_list_insert(s->pending_frames.prev, wl_resource_get_link(callback));
|
||||
}
|
||||
/* The bounding box serves pointer confinement. The exact flag also tells opaque-region handling
|
||||
* whether the bounding box itself is the whole region. */
|
||||
struct region { int set, exact; int x, y, w, h; };
|
||||
|
||||
/* A wl_surface's opaque region is pending until its next commit. An exact region that covers
|
||||
* the surface lets the blit path keep handling an otherwise alpha-capable buffer. */
|
||||
static void surface_set_opaque(struct wl_client *c, struct wl_resource *r,
|
||||
struct wl_resource *region) {}
|
||||
struct wl_resource *region) {
|
||||
struct surface *s = wl_resource_get_user_data(r);
|
||||
struct region *rg = region ? wl_resource_get_user_data(region) : NULL;
|
||||
memset(s->pending_opaque, 0, sizeof(s->pending_opaque));
|
||||
s->pending_opaque_set = 1;
|
||||
if (rg && rg->set && rg->exact) {
|
||||
s->pending_opaque[0] = rg->x;
|
||||
s->pending_opaque[1] = rg->y;
|
||||
s->pending_opaque[2] = rg->w;
|
||||
s->pending_opaque[3] = rg->h;
|
||||
}
|
||||
struct client_info *ci = client_info_of(c);
|
||||
if (ci) ci->declares_opaque = 1;
|
||||
}
|
||||
static void surface_set_input(struct wl_client *c, struct wl_resource *r,
|
||||
struct wl_resource *region) {}
|
||||
|
||||
@@ -1036,6 +1073,10 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) {
|
||||
memcpy(s->dst, s->pending_dst, sizeof(s->dst));
|
||||
s->pending_dst_set = 0;
|
||||
}
|
||||
if (s->pending_opaque_set) {
|
||||
memcpy(s->opaque, s->pending_opaque, sizeof(s->opaque));
|
||||
s->pending_opaque_set = 0;
|
||||
}
|
||||
|
||||
if (s->pending_attach) {
|
||||
struct wl_resource *buffer = s->pending_buffer;
|
||||
@@ -1174,24 +1215,29 @@ static void surface_resource_destroy(struct wl_resource *r) {
|
||||
|
||||
/* ------------------------------------------------------------------ wl_region */
|
||||
|
||||
/* A region is kept as the bounding box of its rectangles: enough for pointer confinement,
|
||||
* where winewayland sends one rectangle (the ClipCursor area). Subtractions are ignored. */
|
||||
struct region { int set; int x, y, w, h; };
|
||||
|
||||
static void region_destroy(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
|
||||
static void region_add(struct wl_client *c, struct wl_resource *r,
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {
|
||||
struct region *rg = wl_resource_get_user_data(r);
|
||||
if (!rg || w <= 0 || h <= 0) return;
|
||||
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = 1; return; }
|
||||
int x2 = rg->x + rg->w > x + w ? rg->x + rg->w : x + w;
|
||||
int y2 = rg->y + rg->h > y + h ? rg->y + rg->h : y + h;
|
||||
if (!rg->set) { rg->x = x; rg->y = y; rg->w = w; rg->h = h; rg->set = rg->exact = 1; return; }
|
||||
long long x2 = (long long)rg->x + rg->w, y2 = (long long)rg->y + rg->h;
|
||||
long long nx2 = (long long)x + w, ny2 = (long long)y + h;
|
||||
int inside = x >= rg->x && y >= rg->y && nx2 <= x2 && ny2 <= y2;
|
||||
int around = x <= rg->x && y <= rg->y && nx2 >= x2 && ny2 >= y2;
|
||||
if (!inside && !around) rg->exact = 0;
|
||||
if (nx2 > x2) x2 = nx2;
|
||||
if (ny2 > y2) y2 = ny2;
|
||||
if (x < rg->x) rg->x = x;
|
||||
if (y < rg->y) rg->y = y;
|
||||
rg->w = x2 - rg->x; rg->h = y2 - rg->y;
|
||||
rg->w = (int)(x2 - rg->x > INT32_MAX ? INT32_MAX : x2 - rg->x);
|
||||
rg->h = (int)(y2 - rg->y > INT32_MAX ? INT32_MAX : y2 - rg->y);
|
||||
}
|
||||
static void region_subtract(struct wl_client *c, struct wl_resource *r,
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {}
|
||||
int32_t x, int32_t y, int32_t w, int32_t h) {
|
||||
struct region *rg = wl_resource_get_user_data(r);
|
||||
if (rg && w > 0 && h > 0) rg->exact = 0;
|
||||
}
|
||||
static void region_resource_destroy(struct wl_resource *r) { free(wl_resource_get_user_data(r)); }
|
||||
static const struct wl_region_interface region_impl = {
|
||||
.destroy = region_destroy,
|
||||
@@ -1665,6 +1711,17 @@ static void note_hdr_unimported(const struct draw_list *dl, struct surface *s, i
|
||||
g_hdr_unimported_below = dl->n;
|
||||
}
|
||||
|
||||
/* A client that describes opaque regions can intentionally leave part of an alpha buffer see-through.
|
||||
* Gamescope uses this for its full-size Steam notification and overlay planes. */
|
||||
static int surface_translucent(const struct surface *s, int w, int h) {
|
||||
if (!s->buf_alpha) return 0;
|
||||
const struct client_info *ci = client_info_of(wl_resource_get_client(s->resource));
|
||||
if (!ci || !ci->declares_opaque) return 0;
|
||||
const int *o = s->opaque;
|
||||
return !(o[2] > 0 && o[0] <= 0 && o[1] <= 0 &&
|
||||
(long long)o[0] + o[2] >= w && (long long)o[1] + o[3] >= h);
|
||||
}
|
||||
|
||||
static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy) {
|
||||
struct vkp_image *img = surface_image(s);
|
||||
float sx = 0, sy = 0, sw = (float)s->buf_w, sh = (float)s->buf_h;
|
||||
@@ -1684,7 +1741,8 @@ static void add_surface(struct draw_list *dl, struct surface *s, int ox, int oy)
|
||||
dl->d = d;
|
||||
dl->cap = cap;
|
||||
}
|
||||
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh};
|
||||
dl->d[dl->n++] = (struct vkp_draw){img, sx, sy, sw, sh, ox, oy, dw, dh,
|
||||
surface_translucent(s, dw, dh)};
|
||||
s->drawn = 1;
|
||||
}
|
||||
|
||||
@@ -1755,6 +1813,7 @@ static int on_frame_timer(void *data) {
|
||||
static int layer_candidate(const struct draw_list *dl, int scene_w, int scene_h) {
|
||||
for (int i = dl->n - 1; i >= 0 && i >= dl->n - 2; i--) {
|
||||
const struct vkp_draw *d = &dl->d[i];
|
||||
if (d->blend) continue; /* the translucent plane must not replace the opaque game layer */
|
||||
if (!vkp_image_is_dmabuf(d->img)) continue;
|
||||
if (d->dx != 0 || d->dy != 0 || d->dw != scene_w || d->dh != scene_h) continue;
|
||||
if (d->sx != 0 || d->sy != 0 || (int)d->sw != vkp_image_width(d->img) ||
|
||||
@@ -2093,7 +2152,7 @@ static void render_scene(void) {
|
||||
* its own layer, cropped and placed by the display. */
|
||||
int go[8], ov = 0;
|
||||
if (over == 1 && li >= 0 && vkp_map_draw(&dl.d[li + 1], go))
|
||||
ov = sc_layer_present_overlay(dl.d[li + 1].img, go) == 0 ? 1 : -1;
|
||||
ov = sc_layer_present_overlay(dl.d[li + 1].img, go, dl.d[li + 1].blend) == 0 ? 1 : -1;
|
||||
if (ov <= 0) sc_layer_hide_overlay();
|
||||
if (ov < 0) { /* the overlay layer refused it: draw the whole scene the old way */
|
||||
sc_layer_hide();
|
||||
|
||||
@@ -60,6 +60,9 @@ struct client_info {
|
||||
/* An OpenGL program whose EGL gave up on the GPU: it asked for dma-buf feedback (EGL's
|
||||
* Wayland GPU path always does), never made a dma-buf buffer, and draws wl_shm frames. */
|
||||
unsigned asked_feedback : 1, shm_gl_said : 1;
|
||||
/* Clients that describe opaque regions can use alpha outside those regions. Wine windows
|
||||
* never describe them, and their unused alpha channel must not make them translucent. */
|
||||
unsigned declares_opaque : 1;
|
||||
unsigned dmabuf_buffers, shm_frames;
|
||||
struct client_info *next;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ void banner_log(const char *tag, const char *fmt, ...) __attribute__((format(pri
|
||||
static _Atomic int g_kind = VKP_FG_ENGINE_LSFG;
|
||||
static _Atomic int g_armed;
|
||||
static _Atomic int g_mult = 2;
|
||||
static _Atomic int g_target; /* LSFG adaptive fps, 0 = fixed multiplier */
|
||||
static _Atomic int g_model = 3;
|
||||
static _Atomic int g_preset = 2;
|
||||
static _Atomic int g_cfg_dirty = 1;
|
||||
@@ -51,6 +52,7 @@ static struct {
|
||||
|
||||
/* ---- engine + generation ring (compositor thread) ------------------------------------ */
|
||||
static int g_engine_kind = -1; /* kind of the running engine, -1 = none */
|
||||
static int g_engine_cache_gen = -1; /* the LSFG cache it loaded */
|
||||
static int g_engine_ok;
|
||||
/* The engine of this kind (with this cache) could not start or build its chain; not retried
|
||||
* every frame - a new engine selection or a new cache clears it. */
|
||||
@@ -92,15 +94,20 @@ static int64_t now_ns(void) {
|
||||
void vkp_framegen_set_engine(int kind) {
|
||||
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_WINFG) kind = VKP_FG_ENGINE_LSFG;
|
||||
atomic_store(&g_kind, kind);
|
||||
atomic_store(&g_failed_kind, -1);
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_armed(int armed, int multiplier) {
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps) {
|
||||
if (target_fps < 0) target_fps = 0;
|
||||
/* Adaptive sizes the ring and the swapchain for the most it may ever generate. */
|
||||
if (target_fps) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (multiplier > VKP_FG_MAX_GENERATIONS + 1) multiplier = VKP_FG_MAX_GENERATIONS + 1;
|
||||
if (atomic_exchange(&g_mult, multiplier) != multiplier) atomic_store(&g_cfg_dirty, 1);
|
||||
if (atomic_exchange(&g_target, target_fps) != target_fps) atomic_store(&g_cfg_dirty, 1);
|
||||
atomic_store(&g_armed, armed ? 1 : 0);
|
||||
LOGD("framegen: set_armed(%d, x%d)", armed, multiplier);
|
||||
LOGD("framegen: set_armed(%d, x%d, target %d fps)", armed, multiplier, target_fps);
|
||||
}
|
||||
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path) {
|
||||
@@ -285,7 +292,9 @@ int vkp_framegen_extra_images(void) {
|
||||
}
|
||||
|
||||
static int ensure_engine(int kind) {
|
||||
if (g_engine_ok && g_engine_kind == kind) return 1;
|
||||
const int cache_gen = atomic_load(&g_cache_gen);
|
||||
if (g_engine_ok && g_engine_kind == kind && (kind != VKP_FG_ENGINE_LSFG || g_engine_cache_gen == cache_gen))
|
||||
return 1;
|
||||
if (start_failed_for(kind)) return 0;
|
||||
if (g_engine_ok) { destroy_ring(); fge_stop(); g_engine_ok = 0; g_engine_kind = -1; }
|
||||
char *path = NULL;
|
||||
@@ -293,7 +302,7 @@ static int ensure_engine(int kind) {
|
||||
if (g_cache_path) path = strdup(g_cache_path);
|
||||
pthread_mutex_unlock(&g_lock);
|
||||
if (kind == VKP_FG_ENGINE_LSFG && !path) {
|
||||
FGLOG("LSFG Native can't start: no shader cache (import Lossless.dll in Settings)");
|
||||
FGLOG("LSFG Native can't start: no shader cache from Lossless.dll");
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
@@ -305,29 +314,39 @@ static int ensure_engine(int kind) {
|
||||
mark_failed(kind);
|
||||
return 0;
|
||||
}
|
||||
g_engine_ok = 1; g_engine_kind = kind;
|
||||
g_engine_ok = 1; g_engine_kind = kind; g_engine_cache_gen = cache_gen;
|
||||
atomic_store(&g_cfg_dirty, 1);
|
||||
g_built_w = g_built_h = 0;
|
||||
FGLOG("%s engine ready (%s)", fge_engine_name(kind), fge_build_info());
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int g_logged_mult;
|
||||
/* "x3 (2 interpolated frames per game frame)" or "adaptive to 90 fps (up to 3 ...)". */
|
||||
static const char *describe_mode(char *buf, size_t n, int mult, int target) {
|
||||
if (target) snprintf(buf, n, "adaptive to %d fps (up to %d interpolated frames per game frame)", target, mult - 1);
|
||||
else snprintf(buf, n, "x%d (%d interpolated frame%s per game frame)", mult, mult - 1, mult == 2 ? "" : "s");
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int g_logged_mult, g_logged_target;
|
||||
static void log_arm_transition(int armed, int kind, int mult) {
|
||||
const int target = atomic_load(&g_target);
|
||||
char mode[96];
|
||||
if (armed == g_was_armed) {
|
||||
if (armed && mult != g_logged_mult) {
|
||||
g_logged_mult = mult;
|
||||
FGLOG("%s multiplier changed to x%d (%d interpolated frames per game frame)", fge_engine_name(kind), mult, mult - 1);
|
||||
if (armed && (mult != g_logged_mult || target != g_logged_target)) {
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
FGLOG("%s now %s", fge_engine_name(kind), describe_mode(mode, sizeof(mode), mult, target));
|
||||
}
|
||||
return;
|
||||
}
|
||||
g_was_armed = armed;
|
||||
if (armed) {
|
||||
g_logged_mult = mult;
|
||||
g_logged_mult = mult; g_logged_target = target;
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
FGLOG("%s x%d armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind), mult, (double)flow, (double)hz);
|
||||
FGLOG("%s %s armed (flow scale %.2f, panel %.0f Hz): real frames are presented one slot late "
|
||||
"with the interpolated frames ahead of them", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, target), (double)flow, (double)hz);
|
||||
} else {
|
||||
FGLOG("frame generation off (%llu frames generated this session)", (unsigned long long)g_total_generated);
|
||||
g_generating_logged = 0;
|
||||
@@ -348,7 +367,8 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
if (atomic_exchange(&g_cfg_dirty, 0)) {
|
||||
float flow, hz;
|
||||
pthread_mutex_lock(&g_lock); flow = g_flow; hz = g_refresh_hz; pthread_mutex_unlock(&g_lock);
|
||||
fge_configure((uint32_t)mult, flow, hz, atomic_load(&g_model), atomic_load(&g_preset));
|
||||
fge_configure((uint32_t)mult, (uint32_t)atomic_load(&g_target), flow, hz,
|
||||
atomic_load(&g_model), atomic_load(&g_preset));
|
||||
}
|
||||
if (!fge_prepare((uint32_t)w, (uint32_t)h, fmt)) {
|
||||
if (fmt != VK_FORMAT_R8G8B8A8_UNORM && fge_unavailable()) {
|
||||
@@ -426,8 +446,9 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
|
||||
0, 0, NULL, 0, NULL, nb, bars);
|
||||
if (!g_generating_logged) {
|
||||
g_generating_logged = 1;
|
||||
FGLOG("generating: %s x%d at %dx%d (%u interpolated frame%s per game frame%s)",
|
||||
fge_engine_name(kind), mult, w, h, n_gen, n_gen == 1 ? "" : "s",
|
||||
char mode[96];
|
||||
FGLOG("generating: %s %s at %dx%d%s", fge_engine_name(kind),
|
||||
describe_mode(mode, sizeof(mode), mult, atomic_load(&g_target)), w, h,
|
||||
fmt == VK_FORMAT_R16G16B16A16_SFLOAT ? ", FP16: the HDR picture" : "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,10 @@ enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
|
||||
|
||||
/* ---- app controls (any thread) ------------------------------------------------------- */
|
||||
void vkp_framegen_set_engine(int kind);
|
||||
/* multiplier 2..4; armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (LsfgNative.ensureCache). */
|
||||
/* multiplier 2..4, or target_fps > 0 for LSFG's adaptive mode (up to 4x, as the target needs);
|
||||
* armed = 0 stops generating (the ring and the engine stay for a re-arm). */
|
||||
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
|
||||
/* LSFG Native: the SPIR-V cache built from the user's Lossless.dll (Lossless.sync); a new path reloads the engine. */
|
||||
void vkp_framegen_set_lsfg_cache_path(const char *path);
|
||||
/* Flow scale 0.25..1.0 and the panel's real refresh rate (the pacer never generates above it). */
|
||||
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
|
||||
|
||||
@@ -197,11 +197,11 @@ int fge_unavailable(void) {
|
||||
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
|
||||
}
|
||||
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset) {
|
||||
if (multiplier < 2) multiplier = 2;
|
||||
if (g_kind == 1 && g_winfg) g_winfg->configure(multiplier, model, perf_preset, flow_scale);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, 0, flow_scale, refresh_hz);
|
||||
else if (g_lsfg) g_lsfg->configure(multiplier, target_fps, flow_scale, refresh_hz);
|
||||
}
|
||||
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) {
|
||||
|
||||
@@ -45,8 +45,9 @@ int fge_start(int kind, const char *cache_path);
|
||||
void fge_stop(void);
|
||||
/* The engine came up but later found it cannot generate (chain build failed). */
|
||||
int fge_unavailable(void);
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset);
|
||||
/* target_fps > 0: LSFG picks 0..3 generations per frame to reach it (Win-FG ignores it). */
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz,
|
||||
int model, int perf_preset);
|
||||
/* Build/rebuild the chain for this extent; the guest extent for the flow pyramid is the same
|
||||
* (the Wayland scene is the game's own size in fullscreen). 1 = chain valid. */
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt);
|
||||
|
||||
@@ -17,8 +17,9 @@ int fge_format_ok(VkFormat fmt) { (void)fmt; return 0; }
|
||||
int fge_start(int kind, const char *cache_path) { (void)kind; (void)cache_path; return -1; }
|
||||
void fge_stop(void) {}
|
||||
int fge_unavailable(void) { return 1; }
|
||||
void fge_configure(uint32_t multiplier, float flow_scale, float refresh_hz, int model, int perf_preset) {
|
||||
(void)multiplier; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
void fge_configure(uint32_t multiplier, uint32_t target_fps, float flow_scale, float refresh_hz, int model,
|
||||
int perf_preset) {
|
||||
(void)multiplier; (void)target_fps; (void)flow_scale; (void)refresh_hz; (void)model; (void)perf_preset;
|
||||
}
|
||||
int fge_prepare(uint32_t w, uint32_t h, VkFormat fmt) { (void)w; (void)h; (void)fmt; return 0; }
|
||||
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
|
||||
|
||||
@@ -14,9 +14,9 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenEngine(JN
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenArmed(JNIEnv *env, jclass clazz,
|
||||
jboolean armed, jint multiplier) {
|
||||
jboolean armed, jint multiplier, jint targetFps) {
|
||||
(void)env; (void)clazz;
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier);
|
||||
vkp_framegen_set_armed(armed ? 1 : 0, (int)multiplier, (int)targetFps);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
|
||||
@@ -85,6 +85,7 @@ static struct {
|
||||
|
||||
#define ASC_VISIBILITY_HIDE 0
|
||||
#define ASC_VISIBILITY_SHOW 1
|
||||
#define ASC_TRANSPARENCY_TRANSLUCENT 1
|
||||
#define ASC_TRANSPARENCY_OPAQUE 2
|
||||
|
||||
static int load_api(void) {
|
||||
@@ -766,7 +767,8 @@ static ASurfaceControl *swap_sc_begin(struct layer *l) {
|
||||
|
||||
/* 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 banner_color *color,
|
||||
int translucent) {
|
||||
struct slot *s = &l->slots[idx];
|
||||
ASurfaceTransaction *tx = api.txCreate();
|
||||
if (!tx) return -1;
|
||||
@@ -774,9 +776,8 @@ static int present_slot(struct layer *l, int idx, const int r[8], const struct b
|
||||
if (old) api.setZOrder(tx, l->sc, l->z);
|
||||
/* The blit was waited for on the CPU, so no acquire fence is needed (-1). */
|
||||
api.setBuffer(tx, l->sc, s->ahb, -1);
|
||||
/* The compositor composes with blits, which overwrite: nothing is ever alpha-blended on the
|
||||
* copy path either, so every layer is opaque and the picture matches it pixel for pixel. */
|
||||
api.setBufferTransparency(tx, l->sc, ASC_TRANSPARENCY_OPAQUE);
|
||||
api.setBufferTransparency(tx, l->sc,
|
||||
translucent ? ASC_TRANSPARENCY_TRANSLUCENT : ASC_TRANSPARENCY_OPAQUE);
|
||||
apply_geometry(tx, l, r);
|
||||
apply_frame_rate(tx, l);
|
||||
apply_colour(tx, l, color);
|
||||
@@ -883,7 +884,7 @@ int sc_layer_present(struct vkp_image *src, int scene_w, int scene_h, const stru
|
||||
int idx = take_free_slot(l, sw, sh, AHB_RGBA8, &wait_fd);
|
||||
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) 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 (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
@@ -908,7 +909,7 @@ int sc_layer_present_pass(const struct vkp_draw *draws, int n, int scene_w, int
|
||||
int idx = take_free_slot(l, rw, rh, AHB_RGBA8, &wait_fd);
|
||||
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
|
||||
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, NULL) != 0) return -1; /* the effects chain's result is 8-bit sRGB */
|
||||
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); "
|
||||
@@ -950,7 +951,7 @@ int sc_layer_present_hdr_scene(const struct vkp_draw *draws, int n, const struct
|
||||
}
|
||||
if (idx < 0) { vkp_pass_abort(); log_drop(l); return 0; }
|
||||
if (vkp_pass_copy_to(l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, r, tm ? NULL : color) != 0) return -1; /* tone-mapped = plain sRGB: no tag */
|
||||
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);
|
||||
static int said = -1;
|
||||
@@ -1031,7 +1032,7 @@ int sc_layer_overlay_affordable(void) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent) {
|
||||
struct layer *l = layer_of(SC_LAYER_OVERLAY);
|
||||
if (!src || !geo) return -1;
|
||||
/* The caller decides with sc_layer_overlay_affordable() BEFORE it commits the game to a layer;
|
||||
@@ -1046,7 +1047,7 @@ int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]) {
|
||||
/* The window is copied into the layer buffer 1:1; `geo` crops it and places it, so the layer
|
||||
* is exactly the window's rectangle on screen and nothing else is blended anywhere. */
|
||||
if (vkp_blit_image(src, l->slots[idx].img, wait_fd) != 0) return -1;
|
||||
if (present_slot(l, idx, geo, NULL) != 0) return -1;
|
||||
if (present_slot(l, idx, geo, NULL, translucent) != 0) return -1;
|
||||
if (!l->first_logged) {
|
||||
l->first_logged = 1;
|
||||
log_layer_count();
|
||||
|
||||
@@ -150,8 +150,9 @@ int sc_layer_overlay_affordable(void);
|
||||
|
||||
/* OVERLAY layer: show `src` (one window's imported frame) above the game layer, at the placement
|
||||
* `geo` = {src x0,y0,x1,y1 in image pixels, dst x0,y0,x1,y1 in output pixels} from vkp_map_draw.
|
||||
* `translucent` preserves a client's alpha over the game for Steam notifications and overlays.
|
||||
* 0 = shown or dropped, -1 = unavailable (the caller must fall back to the copy path). */
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8]);
|
||||
int sc_layer_present_overlay(struct vkp_image *src, const int geo[8], int translucent);
|
||||
|
||||
/* The scene is not a single fullscreen window this frame: hide every layer that is up. */
|
||||
void sc_layer_hide(void);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "effects_chain.h"
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "banner_color.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -32,6 +33,8 @@ struct vkp_image {
|
||||
void *map; /* shm images: persistently mapped linear memory */
|
||||
VkDeviceSize offset, row_pitch;
|
||||
int in_general; /* shm images: moved from PREINITIALIZED to GENERAL */
|
||||
VkFormat fmt;
|
||||
int sampled; /* created with SAMPLED usage: the alpha pass can read it */
|
||||
};
|
||||
|
||||
static ANativeWindow *g_window; /* the window frames go to; compositor thread only */
|
||||
@@ -230,6 +233,7 @@ static void destroy_swapchain(void) {
|
||||
if (g_dev_state != 1) return;
|
||||
g_vk.DeviceWaitIdle(g_dev);
|
||||
reset_sync();
|
||||
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);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
g_swap_md_identity = 0; /* a new swapchain gets the metadata again */
|
||||
@@ -525,6 +529,7 @@ static int dev_init(void) {
|
||||
|
||||
vkp_effects_bind_device(g_dev, g_pd, &g_memprops);
|
||||
hdrc_bind_device(g_dev, &g_memprops);
|
||||
blendp_bind_device(g_dev);
|
||||
g_dev_state = 1;
|
||||
reset_sync();
|
||||
init_black_image();
|
||||
@@ -873,9 +878,8 @@ const char *vkp_modifier_name(uint64_t modifier) {
|
||||
return other;
|
||||
}
|
||||
|
||||
/* Can the driver create the image vkp_image_import_dmabuf() creates for this format+modifier
|
||||
* (2D, DRM_FORMAT_MODIFIER tiling, TRANSFER_SRC, dma-buf memory) - and import it? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
/* Can the driver create and import this format/modifier with the requested image usage? */
|
||||
static int modifier_importable(VkFormat fmt, uint64_t modifier, VkImageUsageFlags usage) {
|
||||
if (!g_vk.GetPhysicalDeviceImageFormatProperties2) return 1; /* can't ask; the create will tell */
|
||||
VkPhysicalDeviceExternalImageFormatInfo ext = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_EXTERNAL_IMAGE_FORMAT_INFO,
|
||||
@@ -886,7 +890,7 @@ static int modifier_importable(VkFormat fmt, uint64_t modifier) {
|
||||
VkPhysicalDeviceImageFormatInfo2 info = {
|
||||
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_IMAGE_FORMAT_INFO_2, .pNext = &mod,
|
||||
.format = fmt, .type = VK_IMAGE_TYPE_2D, .tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT};
|
||||
.usage = usage};
|
||||
VkExternalImageFormatProperties extp = {.sType = VK_STRUCTURE_TYPE_EXTERNAL_IMAGE_FORMAT_PROPERTIES};
|
||||
VkImageFormatProperties2 props = {.sType = VK_STRUCTURE_TYPE_IMAGE_FORMAT_PROPERTIES_2, .pNext = &extp};
|
||||
if (g_vk.GetPhysicalDeviceImageFormatProperties2(g_pd, &info, &props) != VK_SUCCESS) return 0;
|
||||
@@ -914,7 +918,7 @@ int vkp_dmabuf_modifiers(uint32_t drm_format, uint64_t *out, int max) {
|
||||
if (m != VKP_MOD_LINEAR && m != VKP_MOD_QCOM_COMPRESSED) why = "unknown layout";
|
||||
else if (props[i].drmFormatModifierPlaneCount != 1) why = "not single-plane";
|
||||
else if (!(props[i].drmFormatModifierTilingFeatures & VK_FORMAT_FEATURE_BLIT_SRC_BIT)) why = "no blit source";
|
||||
else if (!modifier_importable(fmt, m)) why = "not importable as a dma-buf";
|
||||
else if (!modifier_importable(fmt, m, VK_IMAGE_USAGE_TRANSFER_SRC_BIT)) why = "not importable as a dma-buf";
|
||||
if (why) {
|
||||
LOGI("dmabuf: %c%c%c%c %s reported by the driver but not advertised: %s",
|
||||
drm_format & 0xff, (drm_format >> 8) & 0xff, (drm_format >> 16) & 0xff,
|
||||
@@ -942,6 +946,10 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h; img->dmabuf = 1; img->blit_dst = as_blit_dst ? 1 : 0;
|
||||
img->fmt = drm_to_vk(drm_format);
|
||||
const VkImageUsageFlags sampled_usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
|
||||
img->sampled = !as_blit_dst && g_vk.GetPhysicalDeviceImageFormatProperties2 &&
|
||||
modifier_importable(img->fmt, modifier, sampled_usage);
|
||||
|
||||
VkSubresourceLayout plane = {.offset = offset, .rowPitch = stride};
|
||||
VkImageDrmFormatModifierExplicitCreateInfoEXT modInfo = {
|
||||
@@ -952,10 +960,11 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
.handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT};
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &extImg,
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = drm_to_vk(drm_format), .extent = {w, h, 1},
|
||||
.imageType = VK_IMAGE_TYPE_2D, .format = img->fmt, .extent = {w, h, 1},
|
||||
.mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT,
|
||||
.tiling = VK_IMAGE_TILING_DRM_FORMAT_MODIFIER_EXT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.usage = as_blit_dst ? VK_IMAGE_USAGE_TRANSFER_DST_BIT
|
||||
: img->sampled ? sampled_usage : VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
|
||||
if (cr != VK_SUCCESS) {
|
||||
@@ -1008,18 +1017,35 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
|
||||
return img;
|
||||
}
|
||||
|
||||
/* Linear wl_shm images can use the blend pass only if the driver samples them with a linear filter. */
|
||||
static int shm_sampled(void) {
|
||||
static int known;
|
||||
if (!known) {
|
||||
const VkFormatFeatureFlags need = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT |
|
||||
VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT;
|
||||
VkFormatProperties2 fp = {.sType = VK_STRUCTURE_TYPE_FORMAT_PROPERTIES_2};
|
||||
if (g_vk.GetPhysicalDeviceFormatProperties2)
|
||||
g_vk.GetPhysicalDeviceFormatProperties2(g_pd, VK_FORMAT_B8G8R8A8_UNORM, &fp);
|
||||
known = (fp.formatProperties.linearTilingFeatures & need) == need ? 1 : -1;
|
||||
}
|
||||
return known == 1;
|
||||
}
|
||||
|
||||
struct vkp_image *vkp_image_create_shm(int w, int h) {
|
||||
if (w <= 0 || h <= 0 || dev_init() != 0) return NULL;
|
||||
|
||||
struct vkp_image *img = calloc(1, sizeof(*img));
|
||||
if (!img) return NULL;
|
||||
img->w = w; img->h = h;
|
||||
img->fmt = VK_FORMAT_B8G8R8A8_UNORM;
|
||||
img->sampled = shm_sampled();
|
||||
|
||||
VkImageCreateInfo ici = {
|
||||
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .imageType = VK_IMAGE_TYPE_2D,
|
||||
.format = VK_FORMAT_B8G8R8A8_UNORM, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.format = img->fmt, .extent = {w, h, 1}, .mipLevels = 1, .arrayLayers = 1,
|
||||
.samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_LINEAR,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT, .sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | (img->sampled ? VK_IMAGE_USAGE_SAMPLED_BIT : 0),
|
||||
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
|
||||
.initialLayout = VK_IMAGE_LAYOUT_PREINITIALIZED};
|
||||
if (g_vk.CreateImage(g_dev, &ici, NULL, &img->image) != VK_SUCCESS) { free(img); return NULL; }
|
||||
|
||||
@@ -1062,6 +1088,7 @@ int vkp_image_height(const struct vkp_image *img) { return img ? img->h : 0; }
|
||||
void vkp_image_destroy(struct vkp_image *img) {
|
||||
if (!img) return;
|
||||
if (g_dev) {
|
||||
blendp_forget_image(img->image);
|
||||
if (img->map) g_vk.UnmapMemory(g_dev, img->mem);
|
||||
if (img->image) g_vk.DestroyImage(g_dev, img->image, NULL);
|
||||
if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
|
||||
@@ -1111,8 +1138,21 @@ static int draw_to_scene_blit(const struct vkp_draw *d, int scene_w, int scene_h
|
||||
return map_draw(d, 1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, (float)scene_w, (float)scene_h, blit);
|
||||
}
|
||||
|
||||
/* A translucent draw needs source-over blending; every other draw keeps the existing blit path. */
|
||||
static void record_draw(VkCommandBuffer cmd, const struct vkp_draw *d, const VkImageBlit *blit, VkImage target,
|
||||
VkFormat target_fmt, int target_w, int target_h, VkFilter filter) {
|
||||
const struct vkp_image *im = d->img;
|
||||
if (d->blend && im->sampled &&
|
||||
blendp_draw(cmd, im->image, im->fmt, im->w, im->h, !im->dmabuf, target, target_fmt, target_w, target_h,
|
||||
blit) == 0)
|
||||
return;
|
||||
g_vk.CmdBlitImage(cmd, im->image, im->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, blit, filter);
|
||||
}
|
||||
|
||||
static void destroy_scene_image(void) {
|
||||
if (g_scene.img) hdrc_forget_image(g_scene.img); /* the HDR pass may have drawn into it */
|
||||
if (g_scene.img) blendp_forget_image(g_scene.img);
|
||||
if (g_scene.img) g_vk.DestroyImage(g_dev, g_scene.img, NULL);
|
||||
if (g_scene.mem) g_vk.FreeMemory(g_dev, g_scene.mem, NULL);
|
||||
memset(&g_scene, 0, sizeof(g_scene));
|
||||
@@ -1150,6 +1190,7 @@ static int ensure_scene_image(int w, int h) {
|
||||
static int ensure_img(struct vkp_img_slot *s, int w, int h, VkFormat fmt, VkImageUsageFlags usage, const char *what) {
|
||||
if (s->img && s->w == w && s->h == h && s->fmt == fmt) return 0;
|
||||
if (s->img) hdrc_forget_image(s->img);
|
||||
if (s->img) blendp_forget_image(s->img);
|
||||
if (s->img) g_vk.DestroyImage(g_dev, s->img, NULL);
|
||||
if (s->mem) g_vk.FreeMemory(g_dev, s->mem, NULL);
|
||||
memset(s, 0, sizeof(*s));
|
||||
@@ -1190,7 +1231,8 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
int scene_w, int scene_h, VkImage out, VkFormat out_fmt, enum hdrc_mode mode) {
|
||||
if (!hf || !hf->is_hdr || n <= 0) return -1;
|
||||
if (ensure_img(&g_mixed, scene_w, scene_h, HDR_FMT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT,
|
||||
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
|
||||
VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT,
|
||||
"HDR composition (mixed)") != 0)
|
||||
return -1;
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
@@ -1214,9 +1256,7 @@ static int compose_hdr(VkCommandBuffer cmd, const struct vkp_draw *draws, int n,
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
/* A blit converts UNORM to UNORM by value: 8-bit sRGB and 10-bit PQ both land unchanged. */
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_mixed.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_mixed.img, HDR_FMT, scene_w, scene_h, VK_FILTER_LINEAR);
|
||||
if (hf->is_hdr[i] && lowest_hdr < 0) lowest_hdr = i;
|
||||
}
|
||||
static int truncated_said;
|
||||
@@ -1249,6 +1289,7 @@ static void device_lost(const char *where) {
|
||||
vkp_framegen_device_lost();
|
||||
banner_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);
|
||||
g_swapchain = VK_NULL_HANDLE;
|
||||
if (g_surface) g_vk.DestroySurfaceKHR(g_inst, g_surface, NULL);
|
||||
@@ -1547,6 +1588,7 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Source images: take dmabufs from the client's queue family, move shm images to
|
||||
@@ -1589,8 +1631,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
free(bars);
|
||||
|
||||
/* Clear the composition target to black (the letterbox on the direct path, the desktop's
|
||||
* background on the pass) before the draws land on it. A draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle (blits never blend), so then only what lies outside it
|
||||
* background on the pass) before the draws land on it. An opaque draw that covers the whole scene
|
||||
* overwrites every pixel of its rectangle, so then only what lies outside it
|
||||
* needs black: the letterbox bars on the direct path, nothing at all on the pass (the scene image
|
||||
* is exactly the scene). Anything else - no such draw, the HDR composition - clears the whole
|
||||
* target as before. */
|
||||
@@ -1600,7 +1642,8 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
if (!(hf && pass)) {
|
||||
for (int i = n - 1; i >= 0 && !covered; i--) {
|
||||
const struct vkp_draw *d = &draws[i];
|
||||
if (!d->img || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w || d->dy + d->dh < scene_h) continue;
|
||||
if (!d->img || d->blend || d->dx > 0 || d->dy > 0 || d->dx + d->dw < scene_w ||
|
||||
d->dy + d->dh < scene_h) continue;
|
||||
covered = pass ? draw_to_scene_blit(d, scene_w, scene_h, &cover_blit) : draw_to_blit(d, &cover_blit);
|
||||
}
|
||||
}
|
||||
@@ -1633,10 +1676,9 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img) continue;
|
||||
if (pass ? !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit) : !draw_to_blit(&draws[i], &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
target, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
record_draw(cmd, &draws[i], &blit, target, pass ? scene_fmt : g_swap_fmt,
|
||||
pass ? scene_w : (int)g_extent.width, pass ? scene_h : (int)g_extent.height,
|
||||
pass ? VK_FILTER_LINEAR : blit_filter);
|
||||
drawn++;
|
||||
}
|
||||
|
||||
@@ -2073,6 +2115,7 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
VkCommandBufferBeginInfo bi = {.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
||||
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT};
|
||||
g_vk.BeginCommandBuffer(cmd, &bi);
|
||||
blendp_begin_frame();
|
||||
VkImageSubresourceRange range = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
|
||||
/* Same acquisition as vkp_render's compositor pass: the sources come from the client's queue
|
||||
@@ -2132,9 +2175,8 @@ static int pass_begin_impl(int scene_w, int scene_h, const struct vkp_draw *draw
|
||||
for (int i = 0; i < n; i++) {
|
||||
VkImageBlit blit;
|
||||
if (!draws[i].img || !draw_to_scene_blit(&draws[i], scene_w, scene_h, &blit)) continue;
|
||||
g_vk.CmdBlitImage(cmd, draws[i].img->image,
|
||||
draws[i].img->dmabuf ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_GENERAL,
|
||||
g_scene.img, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &blit, VK_FILTER_LINEAR);
|
||||
record_draw(cmd, &draws[i], &blit, g_scene.img, SCENE_FMT, scene_w, scene_h,
|
||||
VK_FILTER_LINEAR);
|
||||
}
|
||||
if (vkp_effects_active()) vkp_effects_set_formats(VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ struct vkp_draw {
|
||||
struct vkp_image *img;
|
||||
float sx, sy, sw, sh;
|
||||
int dx, dy, dw, dh;
|
||||
int blend; /* translucent: composed over what is under it (blend_pass.h) */
|
||||
};
|
||||
|
||||
// The GPU the renderer runs on ("Adreno (TM) 750"), empty before the device is up.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
#include <jni.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <pthread.h>
|
||||
#include <stdlib.h>
|
||||
@@ -25,6 +26,7 @@ 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);
|
||||
@@ -294,6 +296,13 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSnapshot(
|
||||
return n;
|
||||
}
|
||||
|
||||
/* The serial nativeCursorSnapshot would return, without copying anything. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeCursorSerial(JNIEnv *env, jclass clazz) {
|
||||
(void)env; (void)clazz;
|
||||
return banner_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(
|
||||
@@ -670,3 +679,29 @@ JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeTextInputDelete(JNIEnv *env, jclass clazz, jint before, jint after) {
|
||||
banner_host_text_delete(before, after);
|
||||
}
|
||||
|
||||
/* KGSL's power control, off (the GPU held at its top clock) or back on. The property is the
|
||||
* device's, not this process's: it outlives the fd and the app, so the caller clears it at stop
|
||||
* and again at every app start. No root; the kernel's thermal limits still apply. */
|
||||
extern void adrenotools_set_turbo(bool turbo);
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
(void)env; (void)clazz;
|
||||
adrenotools_set_turbo(on == JNI_TRUE);
|
||||
}
|
||||
|
||||
/* Raises one of our own processes or threads to `nice` with a bare setpriority(), never lowering
|
||||
* it. android.os.Process.setThreadPriority is not used for this: it also moves the thread between
|
||||
* scheduling groups by priority, and a guest process must stay where Android put the app. Returns
|
||||
* the nice value it is left at, or 100 when it could not be read. */
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeRaisePriority(JNIEnv *env, jclass clazz, jint tid, jint nice) {
|
||||
(void)env; (void)clazz;
|
||||
errno = 0;
|
||||
int before = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
if (before == -1 && errno != 0) return 100;
|
||||
if (before > nice) setpriority(PRIO_PROCESS, (id_t)tid, nice);
|
||||
errno = 0;
|
||||
int after = getpriority(PRIO_PROCESS, (id_t)tid);
|
||||
return after == -1 && errno != 0 ? 100 : after;
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Application
|
||||
import com.droiddeck.launcher.runtime.BwrapSpawner
|
||||
import com.droiddeck.launcher.session.CrashHandler
|
||||
import com.droiddeck.launcher.session.GpuClockPin
|
||||
|
||||
/** Process-wide setup: the crash handler, so a session's folder is finished even when we die. */
|
||||
/**
|
||||
* Process-wide setup: the crash handler, so a session's folder is finished even when we die, and
|
||||
* the GPU clock pin cleared in case a killed process left it set, and the socket Flatpak's
|
||||
* sandboxes are started through (BwrapSpawner).
|
||||
*/
|
||||
class App : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
CrashHandler.install(this)
|
||||
// Before anything else touches the session prefs: a new install's first read decides it.
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleDeckModeDefault(this)
|
||||
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
|
||||
GpuClockPin.clearLeftover(this)
|
||||
BwrapSpawner.start(this)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ internal class ComponentsMenu(private val activity: android.app.Activity, privat
|
||||
var compCatalog by mutableStateOf<List<ComponentsManager.CatalogItem>>(emptyList())
|
||||
var compCatalogAt by mutableStateOf(0L)
|
||||
var compProton by mutableStateOf<String?>(null)
|
||||
var compComp by mutableStateOf("dxvk")
|
||||
var compComp by mutableStateOf(com.droiddeck.launcher.ui.GPU_TAB)
|
||||
var compChecking by mutableStateOf(false)
|
||||
var compBusy by mutableStateOf<String?>(null)
|
||||
var compDownloads by mutableStateOf<Map<String, Int>>(emptyMap())
|
||||
|
||||
@@ -10,6 +10,10 @@ import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import com.droiddeck.launcher.gpu.DriverBundle
|
||||
import com.droiddeck.launcher.gpu.DriverPairs
|
||||
import com.droiddeck.launcher.gpu.GpuInfo
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriver
|
||||
import com.droiddeck.launcher.gpu.LinuxVulkanDriverManager
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
@@ -17,11 +21,16 @@ import com.droiddeck.launcher.gpu.TurnipReleases
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.DriverRow
|
||||
import com.droiddeck.launcher.ui.GpuDriversState
|
||||
import com.droiddeck.launcher.ui.PairRow
|
||||
import com.droiddeck.launcher.wayland.CompositorHost
|
||||
|
||||
/**
|
||||
* The Linux and Android driver menus: the drivers each can pick from, the release downloads the
|
||||
* last check found, and what each mode is set to. The launcher screen keeps one and hands its
|
||||
* rows to the mode settings dialog.
|
||||
* The GPU drivers: what this GPU is, the matched driver pairs the release repos offer for it, and
|
||||
* - in Auto, the default - keeping the recommended pair installed and set. Under Advanced, the
|
||||
* Linux (runtime) and Android (display) lists as before, each picked on its own - except a
|
||||
* bundle's two halves (DriverBundle), which are always picked, and deleted, together. The launcher
|
||||
* screen keeps one and hands it to the Components page's GPU drivers tab.
|
||||
*/
|
||||
internal class DriverMenus(private val activity: Activity, private val ui: Handler) {
|
||||
var linuxRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
@@ -33,17 +42,42 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
var canRestoreBundled by mutableStateOf(false)
|
||||
/** Asset name -> download percent, while it downloads. */
|
||||
private val releaseProgress = HashMap<String, Int>()
|
||||
var linuxSteam by mutableStateOf("")
|
||||
var linuxDesktop by mutableStateOf("")
|
||||
var linuxSelected by mutableStateOf("")
|
||||
val gpu: GpuInfo = GpuInfo.detect()
|
||||
// Read in refreshDrivers/ensureAuto: the activity has no context yet while its fields are made.
|
||||
var mode by mutableStateOf(SessionPrefs.GPU_DRIVERS_AUTO)
|
||||
var pairRows by mutableStateOf<List<PairRow>>(emptyList())
|
||||
/** The pair being installed, and how far along. */
|
||||
var pairBusy by mutableStateOf<String?>(null)
|
||||
var pairPercent by mutableIntStateOf(-1)
|
||||
/** What Auto last did or found, for the line under the pair in use. */
|
||||
var autoStatus by mutableStateOf("")
|
||||
/** The bundle both drivers are set to, as "DD-Turnip 0.1.0", or null when they are not one. */
|
||||
var activeBundle by mutableStateOf<String?>(null)
|
||||
private var autoCheckedThisProcess = false
|
||||
|
||||
fun state() = GpuDriversState(
|
||||
gpuName = gpu.name, gpuFamily = gpu.family.label, soc = gpu.soc, supportText = gpu.supportText,
|
||||
supported = gpu.support == GpuInfo.Support.TESTED, unsupported = gpu.support == GpuInfo.Support.UNSUPPORTED,
|
||||
auto = mode == SessionPrefs.GPU_DRIVERS_AUTO, pairs = pairRows, busy = pairBusy, percent = pairPercent,
|
||||
autoStatus = autoStatus, releaseStatus = releaseStatus, checking = releaseChecking,
|
||||
linuxRows = linuxRows, linuxSelected = linuxSelected, androidRows = androidRows, androidSelected = androidSelected,
|
||||
linuxDownloads = linuxDownloads, androidDownloads = androidDownloads, canRestoreBundled = canRestoreBundled,
|
||||
activeBundle = activeBundle,
|
||||
)
|
||||
var androidRows by mutableStateOf<List<DriverRow>>(emptyList())
|
||||
var androidSelected by mutableStateOf("")
|
||||
|
||||
fun refreshDrivers() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
fun origin(id: String) = if (TurnipReleases.isDownloaded(activity, id)) DriverRow.DOWNLOADED else DriverRow.IMPORTED
|
||||
val bundles = DriverBundle.all(activity)
|
||||
fun bundleRow(id: String, linux: Boolean) = bundles.firstOrNull { (if (linux) it.linuxId else it.androidId) == id }?.let {
|
||||
DriverRow(id, it.label, "Android + Linux: sets the ${if (linux) "display" else "runtime"} driver with it", true, DriverRow.BUNDLE)
|
||||
}
|
||||
linuxRows = LinuxVulkanDriver.optionValues(activity).map { id ->
|
||||
if (id.isEmpty()) DriverRow("", "Runtime default", "the Turnip built into the runtime", false)
|
||||
else DriverRow(
|
||||
else bundleRow(id, linux = true) ?: DriverRow(
|
||||
id, lm.getDriverName(id),
|
||||
listOfNotNull(
|
||||
lm.getDriverVersion(id).takeIf { it.isNotEmpty() },
|
||||
@@ -52,8 +86,8 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
true, origin(id),
|
||||
)
|
||||
}
|
||||
linuxSteam = SessionPrefs.linuxDriver(activity, SessionService.MODE_STEAM)
|
||||
linuxDesktop = SessionPrefs.linuxDriver(activity, SessionService.MODE_DESKTOP)
|
||||
linuxSelected = SessionPrefs.linuxDriver(activity)
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val auto = td.autoId()
|
||||
androidRows = buildList {
|
||||
@@ -63,23 +97,211 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
false,
|
||||
))
|
||||
for (id in td.visibleBundled()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, DriverRow.BUNDLED))
|
||||
for (id in td.enumerateImported()) add(DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
for (id in td.enumerateImported()) add(bundleRow(id, linux = false) ?: DriverRow(id, td.displayName(id), td.driverVersion(id), true, origin(id)))
|
||||
}
|
||||
canRestoreBundled = td.hiddenBundled().isNotEmpty()
|
||||
androidSelected = SessionPrefs.androidDriver(activity)
|
||||
activeBundle = DriverBundle.active(activity)?.label
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
}
|
||||
|
||||
/** One line for the settings row that opens this: "Auto · WinNative · Balanced". */
|
||||
fun summary(): String {
|
||||
val active = pairRows.firstOrNull { it.active }?.name ?: activeBundle
|
||||
return (if (mode == SessionPrefs.GPU_DRIVERS_AUTO) "Auto" else "Manual") + (active?.let { " · $it" } ?: "")
|
||||
}
|
||||
|
||||
/** The pairs the last check found, with what is installed and in use; this GPU's first. */
|
||||
fun refreshPairs() {
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val recommended = DriverPairs.recommendedKey(gpu, pairs)
|
||||
pairRows = pairs.map { p ->
|
||||
val bundle = p.bundle?.let { installedBundle(it) }
|
||||
val displayId = if (p.bundle != null) bundle?.androidId else p.display?.let { TurnipReleases.installedId(activity, it, td::isInstalled) }
|
||||
val linuxId = if (p.bundle != null) bundle?.linuxId else p.linux?.let { TurnipReleases.installedId(activity, it, lm::isInstalled) }
|
||||
val mb = p.assets.sumOf { it.size } / 1_048_576.0
|
||||
PairRow(
|
||||
key = p.key, name = p.name, version = p.version,
|
||||
detail = if (!p.complete) "Only one half is published right now" else "%.0f MB for both".format(mb),
|
||||
recommended = p.key == recommended, suits = p.suits(gpu), complete = p.complete,
|
||||
installed = displayId != null && linuxId != null,
|
||||
active = displayId != null && linuxId != null &&
|
||||
SessionPrefs.androidDriver(activity) == displayId && SessionPrefs.linuxDriver(activity) == linuxId,
|
||||
)
|
||||
}.sortedByDescending { it.recommended }
|
||||
}
|
||||
|
||||
fun setMode(auto: Boolean) {
|
||||
mode = if (auto) SessionPrefs.GPU_DRIVERS_AUTO else SessionPrefs.GPU_DRIVERS_MANUAL
|
||||
SessionPrefs.setGpuDriverMode(activity, mode)
|
||||
autoStatus = ""
|
||||
if (auto) ensureAuto(force = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto: make the recommended pair for this GPU the one installed and set. Looks online when
|
||||
* [force]d (refresh), or when the last check is a day old - Banners-Turnip rebuilds hourly, and
|
||||
* a new pair a day is plenty - otherwise works from the last check. Once per app start on its
|
||||
* own; the pair it replaces is removed, anything the user imported or picked is left alone.
|
||||
*/
|
||||
fun ensureAuto(force: Boolean) {
|
||||
mode = SessionPrefs.gpuDriverMode(activity)
|
||||
if (mode != SessionPrefs.GPU_DRIVERS_AUTO || pairBusy != null || releaseChecking) return
|
||||
if (!force && autoCheckedThisProcess) return
|
||||
autoCheckedThisProcess = true
|
||||
if (DriverPairs.recommendedKey(gpu, emptyList()) == null) {
|
||||
autoStatus = "No drivers to set: ${gpu.supportText.lowercase()}"
|
||||
return
|
||||
}
|
||||
releaseChecking = true
|
||||
autoStatus = "Checking for the latest drivers…"
|
||||
Thread({
|
||||
val cached = TurnipReleases.cached(activity)
|
||||
val stale = cached == null || System.currentTimeMillis() - cached.checkedAt > 24 * 3_600_000L ||
|
||||
cached.assets.none { it.pair.isNotEmpty() }
|
||||
val problem = if (force || stale) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
|
||||
val key = DriverPairs.recommendedKey(gpu, pairs)
|
||||
val pair = pairs.firstOrNull { it.key == key }
|
||||
val row = pairRows.firstOrNull { it.key == key }
|
||||
when {
|
||||
row?.active == true -> autoStatus = "Up to date" + (problem?.let { " (couldn't check: $it)" } ?: "")
|
||||
pair == null || !pair.complete ->
|
||||
autoStatus = problem?.let { "Couldn't check: $it" } ?: "The recommended drivers aren't published right now"
|
||||
else -> installPair(pair, auto = true)
|
||||
}
|
||||
}
|
||||
}, "gpu-driver-auto").start()
|
||||
}
|
||||
|
||||
/** Manual: install (if need be) and set both halves of a pair. */
|
||||
fun selectPair(key: String) {
|
||||
if (pairBusy != null) return
|
||||
val pair = DriverPairs.from(TurnipReleases.cached(activity)).firstOrNull { it.key == key && it.complete } ?: return
|
||||
installPair(pair, auto = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download what is missing of [pair], then set both halves - only once both are in, so a
|
||||
* failed download never leaves a half-changed pair. Auto's own downloads are recorded, and the
|
||||
* ones the new pair replaces are removed.
|
||||
*/
|
||||
private fun installPair(pair: DriverPairs.DriverPair, auto: Boolean) {
|
||||
if (!pair.complete) return
|
||||
pairBusy = pair.key
|
||||
pairPercent = 0
|
||||
if (auto) autoStatus = "Downloading ${pair.name} ${pair.version}…"
|
||||
Thread({
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
var downloaded = false
|
||||
val result = runCatching {
|
||||
val zips = pair.assets
|
||||
val ids = zips.mapIndexed { i, asset ->
|
||||
installedIdOf(asset)
|
||||
?: installAsset(asset) { pct -> ui.post { pairPercent = (i * 100 + pct) / zips.size } }.also { downloaded = true }
|
||||
}
|
||||
if (pair.bundle != null) DriverBundle.get(activity, ids[0])!!.let { listOf(it.androidId, it.linuxId) } else ids
|
||||
}
|
||||
ui.post {
|
||||
pairBusy = null
|
||||
pairPercent = -1
|
||||
result.onSuccess { (displayId, linuxId) ->
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != displayId
|
||||
SessionPrefs.setAndroidDriver(activity, displayId)
|
||||
SessionPrefs.setLinuxDriver(activity, linuxId)
|
||||
val restart = if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
if (auto) {
|
||||
val previous = SessionPrefs.gpuAutoInstalled(activity)
|
||||
for (id in previous - setOf(displayId, linuxId)) {
|
||||
val bundle = DriverBundle.containing(activity, id, lm.isInstalled(id))
|
||||
when {
|
||||
bundle != null -> { DriverBundle.remove(activity, bundle); TurnipReleases.forget(activity, bundle.id) }
|
||||
lm.isInstalled(id) -> lm.removeDriver(id)
|
||||
else -> td.remove(id)
|
||||
}
|
||||
TurnipReleases.forget(activity, id)
|
||||
}
|
||||
SessionPrefs.setGpuAutoInstalled(activity, setOf(displayId, linuxId))
|
||||
autoStatus = (if (downloaded) "Updated to" else "Switched to") + " ${pair.name} ${pair.version}.$restart"
|
||||
} else {
|
||||
android.widget.Toast.makeText(activity, "Using ${pair.name} ${pair.version}.$restart", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
Log.w(TAG, "driver pair ${pair.key}", e)
|
||||
val why = if (e is IllegalArgumentException) e.message else "Download failed: ${e.message}"
|
||||
if (auto) autoStatus = why ?: "Download failed"
|
||||
else android.widget.Toast.makeText(activity, why, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "gpu-driver-pair").start()
|
||||
}
|
||||
|
||||
/** The bundle a release bundle was downloaded as, while both its halves are installed. */
|
||||
private fun installedBundle(asset: TurnipReleases.Asset): DriverBundle.Bundle? =
|
||||
TurnipReleases.installedId(activity, asset) { DriverBundle.get(activity, it) != null }?.let { DriverBundle.get(activity, it) }
|
||||
|
||||
/** The id a release asset was installed as (a bundle's id for a bundle), or null when it is not installed. */
|
||||
private fun installedIdOf(asset: TurnipReleases.Asset): String? = when {
|
||||
asset.bundle -> installedBundle(asset)?.id
|
||||
asset.linux -> TurnipReleases.installedId(activity, asset, LinuxVulkanDriverManager(activity)::isInstalled)
|
||||
else -> TurnipReleases.installedId(activity, asset, TurnipDriver(activity)::isInstalled)
|
||||
}
|
||||
|
||||
/**
|
||||
* One release asset, downloaded, checked and installed through the importer; returns its id,
|
||||
* a bundle's id for a bundle.
|
||||
*/
|
||||
private fun installAsset(asset: TurnipReleases.Asset, progress: (Int) -> Unit): String {
|
||||
var file: File? = null
|
||||
try {
|
||||
file = TurnipReleases.download(activity, asset, progress)
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = when {
|
||||
asset.bundle -> DriverBundle.install(activity, uri).id
|
||||
asset.linux -> LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else -> TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
}
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
return id
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import off the main thread - a driver zip is a few MB and the glibc check reads the whole
|
||||
* library - then say what happened. A refusal's message is the user-facing reason.
|
||||
* library - then say what happened. A refusal's message is the user-facing reason. A bundle,
|
||||
* from any import, installs both halves and is set as both drivers at once. [linux] null (the
|
||||
* tab's own Import .zip) takes a single driver to whichever list its libc belongs in.
|
||||
*/
|
||||
fun importDriver(uri: Uri, linux: Boolean) {
|
||||
fun importDriver(uri: Uri, linux: Boolean?) {
|
||||
val name = activity.displayNameOf(uri)
|
||||
Thread({
|
||||
var bundle: DriverBundle.Bundle? = null
|
||||
val problem = try {
|
||||
if (linux) LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
else TurnipDriver(activity).installFromZip(uri, name)
|
||||
when {
|
||||
DriverBundle.isBundle(activity, uri) -> bundle = DriverBundle.install(activity, uri)
|
||||
linux == true -> LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
linux == false -> TurnipDriver(activity).installFromZip(uri, name)
|
||||
else -> try {
|
||||
TurnipDriver(activity).installFromZip(uri, name)
|
||||
} catch (display: IllegalArgumentException) {
|
||||
try {
|
||||
LinuxVulkanDriverManager(activity).installDriver(uri, name)
|
||||
} catch (runtime: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Not a driver zip: neither an AdrenoTools driver, a -Linux Turnip nor an Android + Linux bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
@@ -88,26 +310,57 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
"Import failed: ${e.message}"
|
||||
}
|
||||
ui.post {
|
||||
val done = bundle?.let { useBundle(it) } ?: "Imported ${name ?: "driver"}"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Imported ${name ?: "driver"}",
|
||||
if (problem != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
activity, problem ?: done,
|
||||
if (problem != null || bundle != null) android.widget.Toast.LENGTH_LONG else android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
}
|
||||
}, "import-driver").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one driver from a list, which is Manual (Auto would put its pair back). A bundle's half
|
||||
* sets the bundle's other half in the other list as well.
|
||||
*/
|
||||
fun selectDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
when {
|
||||
bundle != null -> useBundle(bundle)
|
||||
linux -> SessionPrefs.setLinuxDriver(activity, id)
|
||||
else -> SessionPrefs.setAndroidDriver(activity, id)
|
||||
}
|
||||
setMode(false)
|
||||
refreshDrivers()
|
||||
}
|
||||
|
||||
/** Set both halves of [bundle] as the drivers in use; returns what to tell the user. */
|
||||
private fun useBundle(bundle: DriverBundle.Bundle): String {
|
||||
val displayChanged = SessionPrefs.androidDriver(activity) != bundle.androidId
|
||||
DriverBundle.select(activity, bundle)
|
||||
if (mode == SessionPrefs.GPU_DRIVERS_AUTO) setMode(false)
|
||||
return "Using ${bundle.label} as both the runtime and display driver." +
|
||||
if (displayChanged && CompositorHost.isStarted) " The display driver applies after DroidDeck restarts." else ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an imported or downloaded driver. A mode still set to it goes back to its default, so a
|
||||
* session never starts on a driver that is gone; a release download is forgotten, so the menu
|
||||
* offers it again.
|
||||
*/
|
||||
fun deleteDriver(id: String, linux: Boolean) {
|
||||
val bundle = DriverBundle.containing(activity, id, linux)
|
||||
if (bundle != null) {
|
||||
DriverBundle.remove(activity, bundle)
|
||||
TurnipReleases.forget(activity, bundle.id)
|
||||
android.widget.Toast.makeText(activity, "Deleted ${bundle.label} (both drivers)", android.widget.Toast.LENGTH_SHORT).show()
|
||||
refreshDrivers()
|
||||
return
|
||||
}
|
||||
if (linux) {
|
||||
LinuxVulkanDriverManager(activity).removeDriver(id)
|
||||
for (mode in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
if (SessionPrefs.linuxDriver(activity, mode) == id) SessionPrefs.setLinuxDriver(activity, mode, "")
|
||||
}
|
||||
if (SessionPrefs.linuxDriver(activity) == id) SessionPrefs.setLinuxDriver(activity, "")
|
||||
} else {
|
||||
val td = TurnipDriver(activity)
|
||||
if (id in TurnipDriver.BUNDLED) td.hideBundled(id) else td.remove(id)
|
||||
@@ -121,11 +374,9 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
/** The download entries and the refresh line, from what the last check found. */
|
||||
fun refreshReleaseRows() {
|
||||
val check = TurnipReleases.cached(activity)
|
||||
val lm = LinuxVulkanDriverManager(activity)
|
||||
val td = TurnipDriver(activity)
|
||||
fun rows(linux: Boolean) = check?.assets.orEmpty()
|
||||
.filter { it.linux == linux }
|
||||
.filter { a -> TurnipReleases.installedId(activity, a) { id -> if (linux) lm.isInstalled(id) else td.isInstalled(id) } == null }
|
||||
.filter { it.bundle || it.linux == linux }
|
||||
.filter { a -> installedIdOf(a) == null }
|
||||
.map { a ->
|
||||
val mb = "%.1f MB".format(a.size / 1_048_576.0)
|
||||
com.droiddeck.launcher.ui.DownloadRow(a.name, "${a.source} ${a.tag}", "${a.label} · $mb", releaseProgress[a.name])
|
||||
@@ -154,7 +405,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
fun checkLatestTurnip() {
|
||||
if (releaseChecking) return
|
||||
releaseChecking = true
|
||||
releaseStatus = "Checking Banners-Turnip and WinNative…"
|
||||
releaseStatus = "Checking Banners-Turnip, WinNative and DroidDeck…"
|
||||
Thread({
|
||||
val problem = try { TurnipReleases.refresh(activity); null } catch (e: Exception) {
|
||||
Log.w(TAG, "latest Turnip check", e); e.message ?: "check failed"
|
||||
@@ -162,6 +413,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
ui.post {
|
||||
releaseChecking = false
|
||||
refreshReleaseRows()
|
||||
refreshPairs()
|
||||
if (problem != null) releaseStatus = "Couldn't check: $problem"
|
||||
}
|
||||
}, "turnip-release-check").start()
|
||||
@@ -174,28 +426,20 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
|
||||
releaseProgress[assetName] = 0
|
||||
refreshReleaseRows()
|
||||
Thread({
|
||||
var file: java.io.File? = null
|
||||
val problem = try {
|
||||
file = TurnipReleases.download(activity, asset) { pct ->
|
||||
ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() }
|
||||
}
|
||||
val uri = Uri.fromFile(file)
|
||||
val id = if (asset.linux) LinuxVulkanDriverManager(activity).installDriver(uri, asset.name)
|
||||
else TurnipDriver(activity).installFromZip(uri, asset.name)
|
||||
TurnipReleases.recordDownload(activity, asset, id)
|
||||
installAsset(asset) { pct -> ui.post { releaseProgress[assetName] = pct; refreshReleaseRows() } }
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.message
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "release driver download", e)
|
||||
"Download failed: ${e.message}"
|
||||
} finally {
|
||||
file?.let { com.droiddeck.launcher.core.FileUtils.delete(it) }
|
||||
}
|
||||
ui.post {
|
||||
releaseProgress.remove(assetName)
|
||||
val pick = if (asset.bundle) "pick it in either list to set both drivers" else "pick it in the menu"
|
||||
android.widget.Toast.makeText(
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - pick it in the menu",
|
||||
activity, problem ?: "Installed ${asset.name.removeSuffix(".zip")} - $pick",
|
||||
android.widget.Toast.LENGTH_LONG,
|
||||
).show()
|
||||
refreshDrivers()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.Manifest
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
@@ -27,7 +28,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.runtime.DesktopCatalog
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntimeInstaller
|
||||
@@ -38,6 +39,7 @@ import com.droiddeck.launcher.session.OfflineMode
|
||||
import com.droiddeck.launcher.session.ProtonExtras
|
||||
import com.droiddeck.launcher.session.ComponentsManager
|
||||
import com.droiddeck.launcher.ui.ComponentsPage
|
||||
import com.droiddeck.launcher.session.GameSaves
|
||||
import com.droiddeck.launcher.session.SessionLogShare
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.ui.ProtonPage
|
||||
@@ -56,7 +58,6 @@ import com.droiddeck.launcher.ui.ControllerActions
|
||||
import com.droiddeck.launcher.ui.ControllerMappingPage
|
||||
import com.droiddeck.launcher.input.ControllerPrefs
|
||||
import com.droiddeck.launcher.input.ControllerEditorActivity
|
||||
import com.droiddeck.launcher.ui.CreditsDialog
|
||||
import com.droiddeck.launcher.ui.FrontEndScreen
|
||||
import com.droiddeck.launcher.ui.FrontEndState
|
||||
import com.droiddeck.launcher.ui.FrontEndActions
|
||||
@@ -81,6 +82,7 @@ class MainActivity : ComponentActivity() {
|
||||
private val drivers = DriverMenus(this, ui)
|
||||
private val components = ComponentsMenu(this, ui)
|
||||
private val decky = DeckyMenu(this, ui)
|
||||
private val updates = UpdatesMenu(this, ui)
|
||||
private val protons = ProtonMenu(this, ui)
|
||||
|
||||
// The screen's state. Compose redraws whatever reads these when they change.
|
||||
@@ -99,17 +101,22 @@ class MainActivity : ComponentActivity() {
|
||||
private var percent by mutableIntStateOf(-1)
|
||||
private var failed by mutableStateOf(false)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private var showRemove by mutableStateOf(false)
|
||||
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
|
||||
private var glThread by mutableStateOf(true)
|
||||
private var noGlError by mutableStateOf(true)
|
||||
private var steamDeckMode by mutableStateOf(false)
|
||||
private var showCredits by mutableStateOf(false)
|
||||
private var mangoapp by mutableStateOf(true)
|
||||
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
|
||||
private var showProtons by mutableStateOf(false)
|
||||
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
|
||||
private var showComponents by mutableStateOf(false)
|
||||
private var focusComponentsContent by mutableStateOf(true)
|
||||
private var showMapping by mutableStateOf(false)
|
||||
private var saveBusy: String? = null
|
||||
/** What to do with the zip or folder the file picker hands back after a game page's Manage saves. */
|
||||
private var onSavePicked: ((File) -> Unit)? = null
|
||||
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
|
||||
private var catalog by mutableStateOf<List<DesktopCatalog.Entry>?>(null)
|
||||
private var catalogLoading by mutableStateOf(false)
|
||||
@@ -126,7 +133,9 @@ class MainActivity : ComponentActivity() {
|
||||
private var gameCores by mutableStateOf<Set<Int>>(emptySet())
|
||||
private var tuSysmem by mutableStateOf(false)
|
||||
private var zinkLazy by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(false)
|
||||
private var noXalia by mutableStateOf(true)
|
||||
private var gamescopeRealtime by mutableStateOf(false)
|
||||
private var gpuClockPin by mutableStateOf(false)
|
||||
private var prootNoSeccomp by mutableStateOf(false)
|
||||
private var guestHostname by mutableStateOf(SessionPrefs.DEFAULT_GUEST_HOSTNAME)
|
||||
private var phantomWarning by mutableStateOf<String?>(null)
|
||||
@@ -136,6 +145,8 @@ class MainActivity : ComponentActivity() {
|
||||
private var clientDirectAudio by mutableStateOf(false)
|
||||
private var forceFullscreen by mutableStateOf(true)
|
||||
private var launcherFullscreen by mutableStateOf(true)
|
||||
private var storeEnabled by mutableStateOf(false)
|
||||
private var appImagesEnabled by mutableStateOf(false)
|
||||
private var mic by mutableStateOf(false)
|
||||
|
||||
// The app's own picker (files/), once per kind of pick: the two driver lists validate
|
||||
@@ -149,6 +160,68 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickAnyDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { drivers.importDriver(it, linux = null) }
|
||||
}
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private val pickSaveZip = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
}
|
||||
private val pickSaveDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val then = onSavePicked.also { onSavePicked = null } ?: return@registerForActivityResult
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let(then)
|
||||
}
|
||||
|
||||
/** Takes a new or updated Lossless Scaling from Steam and shows what LSFG can use. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
ui.post { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
ui.post {
|
||||
lossless = state
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
/** Import a save zip into [game]: pick it in the app's file picker, then back up and unzip off the main thread. */
|
||||
private fun importSaves(name: String, game: () -> GameSaves.Game) {
|
||||
if (SessionState.running) {
|
||||
android.widget.Toast.makeText(this, "Close the Steam session first, so the game can't save over the import", android.widget.Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
onSavePicked = { zip ->
|
||||
saveAction("Importing into $name") {
|
||||
val (written, backup) = GameSaves.import(game(), zip)
|
||||
val kind = GameSaves.layoutOf(zip)?.label ?: "zip"
|
||||
"Imported $written files from the $kind into $name" + (backup?.let { ". Old saves backed up to Download/DroidDeck/Saves/backups" } ?: "")
|
||||
}
|
||||
}
|
||||
pickSaveZip.launch(InAppFilePicker.buildIntent(this, listOf("zip"), "Choose a save zip for $name", GameSaves.savesDir().parentFile?.parentFile?.path))
|
||||
}
|
||||
|
||||
/** Export [game]'s saves in [layout] to a folder picked in the app's file picker. */
|
||||
private fun exportSaves(name: String, layout: GameSaves.Layout, game: () -> GameSaves.Game) {
|
||||
onSavePicked = { dir ->
|
||||
saveAction("Exporting $name") {
|
||||
val (zip, count) = GameSaves.export(game(), layout, dir)
|
||||
"Exported $count files as a ${layout.label}: ${zip.path.removePrefix("/storage/emulated/0/")}"
|
||||
}
|
||||
}
|
||||
GameSaves.savesDir().mkdirs()
|
||||
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, "Choose where to save $name (${layout.label})", GameSaves.savesDir().path))
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGamesDirs(this, addedGamesDirs + path)
|
||||
@@ -172,6 +245,9 @@ class MainActivity : ComponentActivity() {
|
||||
private var addedGamesArt by mutableStateOf(true)
|
||||
@Volatile private var artFetchRunning = false
|
||||
private var addedGames by mutableStateOf<List<com.droiddeck.launcher.ui.AddedGameRow>>(emptyList())
|
||||
private val pickAppImage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { com.droiddeck.launcher.store.AppImageState.import(this, it) }
|
||||
}
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setRomsDir(this, path)
|
||||
@@ -188,6 +264,7 @@ class MainActivity : ComponentActivity() {
|
||||
private var theme by mutableStateOf("graphite")
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var hdrOn by mutableStateOf(false)
|
||||
private var fpsLimit by mutableStateOf(0)
|
||||
private var hdrReason by mutableStateOf<String?>(null)
|
||||
private var touchMode by mutableStateOf(SessionPrefs.TOUCH_AUTO)
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
@@ -264,9 +341,13 @@ class MainActivity : ComponentActivity() {
|
||||
theme = SessionPrefs.theme(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
launcherFullscreen = SessionPrefs.launcherFullscreen(this)
|
||||
storeEnabled = SessionPrefs.storeEnabled(this)
|
||||
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
|
||||
applyLauncherFullscreen()
|
||||
updates.start()
|
||||
setContent {
|
||||
DroidDeckTheme(theme) {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
val sm = settingsMode
|
||||
val page: (@Composable () -> Unit)? = when {
|
||||
sm != null -> { { ModeSettingsHost(sm) } }
|
||||
@@ -285,8 +366,8 @@ class MainActivity : ComponentActivity() {
|
||||
offlineAccount = offlineAccount, offline = offline,
|
||||
frameGenLabel = frameGenLabel, romsDir = romsDir, logsEnabled = logsEnabled,
|
||||
steamGames = steamGames, emulators = emulatorList, running = runningLabel,
|
||||
frameGenEngine = FrameGen.engine(this), frameGenMultiplier = FrameGen.multiplier(this),
|
||||
lsfgReady = LsfgNative.isInstalled(this),
|
||||
frameGen = FrameGen.mode(this),
|
||||
lossless = lossless,
|
||||
pageKey = sm?.let { "settings:$it" } ?: if (showPerformance) "performance" else if (showProtons) "protons" else if (showComponents) "components" else if (showMapping) "controller-mapping" else null,
|
||||
theme = theme,
|
||||
isHomeApp = homeAppSelected,
|
||||
@@ -304,9 +385,12 @@ class MainActivity : ComponentActivity() {
|
||||
buildLabel = BuildConfig.BUILD_LABEL,
|
||||
oscMode = oscMode,
|
||||
controller = controllerSettings,
|
||||
updates = updates.state(),
|
||||
phantomProcessStatus = phantomProcessStatus,
|
||||
showPhantomGate = showPhantomGate,
|
||||
launcherFullscreen = launcherFullscreen,
|
||||
storeEnabled = storeEnabled,
|
||||
appImagesEnabled = appImagesEnabled,
|
||||
),
|
||||
FrontEndActions(
|
||||
onPlay = { startSteamSession() },
|
||||
@@ -326,6 +410,25 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_DESKTOP))
|
||||
},
|
||||
onEmulator = { e -> launchProgram(e.program) },
|
||||
// A Flatpak app from the store, full screen under gamescope like an emulator.
|
||||
onImportAppImage = {
|
||||
pickAppImage.launch(InAppFilePicker.buildIntent(this, listOf("appimage"), "Choose an AppImage"))
|
||||
},
|
||||
// An imported AppImage, full screen under gamescope like an emulator.
|
||||
onAppImage = { dir, name ->
|
||||
Library.flatpakNames[dir] = name
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(dir)))
|
||||
},
|
||||
onFlatpakApp = { id, name ->
|
||||
Library.flatpakNames[id] = name
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_MODE, SessionService.MODE_RUN)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM, com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER)
|
||||
.putExtra(SessionService.EXTRA_PROGRAM_ARGS, arrayOf(id)))
|
||||
},
|
||||
onRom = { g ->
|
||||
val e = emulatorList.first { it.id == g.emulatorId }
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
@@ -340,12 +443,18 @@ class MainActivity : ComponentActivity() {
|
||||
onInstallPackage = { id -> installPackage(id) },
|
||||
onRemovePackage = { id -> removePackage(id) },
|
||||
onRuntime = { onRuntimeButton() },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this, engine, multiplier)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this, mode)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(this, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onProtons = { openProtons() },
|
||||
onComponents = { focusContent -> openComponents(focusContent) },
|
||||
// A game page's Manage saves: the game's Proton and saves are read when the work runs, off the main thread.
|
||||
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
|
||||
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
|
||||
onPerformance = { refreshCores(); showProtons = false; showComponents = false; showMapping = false; showPerformance = true },
|
||||
onRoms = { showRoms = true },
|
||||
onFiles = { startActivity(Intent(this, com.droiddeck.launcher.files.FileManagerActivity::class.java)) },
|
||||
@@ -370,7 +479,6 @@ class MainActivity : ComponentActivity() {
|
||||
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
|
||||
offline = OfflineMode.enabled(this)
|
||||
},
|
||||
onCredits = { showCredits = true },
|
||||
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
|
||||
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
|
||||
onLauncherFullscreen = { on ->
|
||||
@@ -378,6 +486,8 @@ class MainActivity : ComponentActivity() {
|
||||
launcherFullscreen = on
|
||||
applyLauncherFullscreen()
|
||||
},
|
||||
onStoreEnabled = { on -> SessionPrefs.setStoreEnabled(this, on); storeEnabled = on },
|
||||
onAppImagesEnabled = { on -> SessionPrefs.setAppImagesEnabled(this, on); appImagesEnabled = on },
|
||||
onHomeApp = { manageHomeApp() },
|
||||
onHomeScreen = { on ->
|
||||
HomeApp.setHomeScreenEnabled(this, on)
|
||||
@@ -388,9 +498,6 @@ class MainActivity : ComponentActivity() {
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
backActionsInverted = inverted
|
||||
},
|
||||
onCheckLatestBuild = {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/The412Banner/DroidDeck/actions/workflows/build.yml")))
|
||||
},
|
||||
onRefreshPhantomStatus = { refreshPhantomStatus() },
|
||||
onOpenDeveloperOptions = { displayId -> openDeveloperOptions(displayId) },
|
||||
onWirelessAdbPair = { host, port, code, complete ->
|
||||
@@ -441,6 +548,7 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
},
|
||||
updates = updates.actions(),
|
||||
controller = ControllerActions(
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
|
||||
@@ -482,7 +590,6 @@ class MainActivity : ComponentActivity() {
|
||||
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
|
||||
onDismiss = { showRemove = false },
|
||||
)
|
||||
if (showCredits) CreditsDialog { showCredits = false }
|
||||
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
|
||||
returning?.let { r ->
|
||||
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
|
||||
@@ -493,6 +600,7 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedInstanceState == null) ui.post { startSteamAtStartupIfEnabled() }
|
||||
|
||||
@@ -503,8 +611,9 @@ class MainActivity : ComponentActivity() {
|
||||
if (checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
// The microphone is on by default; ask once, with the storage prompt, so voice chat works
|
||||
// without a trip to the settings. A refusal is not asked again - the toggle asks when used.
|
||||
// The microphone is off by default. Someone who turned it on before the permission was
|
||||
// granted is asked once here, with the storage prompt; a refusal is not asked again - the
|
||||
// toggle asks when used.
|
||||
if (SessionPrefs.micEnabled(this) && !SessionPrefs.micAsked(this)
|
||||
&& checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
wanted.add(Manifest.permission.RECORD_AUDIO)
|
||||
@@ -551,11 +660,11 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** On the Components page the pad's LB / RB step through FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
/** On the Components page the pad's LB / RB step through GPU drivers, FEX, DXVK and VKD3D-Proton, wrapping around. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (showComponents && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
|
||||
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||
val all = ComponentsManager.COMPONENTS
|
||||
val all = listOf(com.droiddeck.launcher.ui.GPU_TAB) + ComponentsManager.COMPONENTS
|
||||
val step = if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else all.size - 1
|
||||
components.compComp = all[(all.indexOf(components.compComp).coerceAtLeast(0) + step) % all.size]
|
||||
}
|
||||
@@ -572,6 +681,11 @@ class MainActivity : ComponentActivity() {
|
||||
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
|
||||
}
|
||||
refreshPhantomStatus()
|
||||
syncLossless()
|
||||
// Opening the app and coming back from a session both land here.
|
||||
updates.onResume()
|
||||
// Auto keeps this GPU's recommended driver pair installed and set: once per app start.
|
||||
drivers.ensureAuto(force = false)
|
||||
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
|
||||
// session has just ended). Cheap when nothing is queued.
|
||||
if (!SessionState.running) Thread({
|
||||
@@ -602,6 +716,11 @@ class MainActivity : ComponentActivity() {
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
updates.unregister()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
|
||||
// The session covers the page by now; coming back finds it as it was.
|
||||
@@ -656,6 +775,19 @@ class MainActivity : ComponentActivity() {
|
||||
}, "catalog-desktop").start()
|
||||
}
|
||||
|
||||
/** Runs a save import or export off the main thread, one at a time, and says how it went. */
|
||||
private fun saveAction(label: String, work: () -> String) {
|
||||
if (saveBusy != null) return
|
||||
saveBusy = label
|
||||
Thread({
|
||||
val message = runCatching(work).getOrElse { e -> "$label failed: ${e.message ?: e.javaClass.simpleName}" }
|
||||
ui.post {
|
||||
saveBusy = null
|
||||
android.widget.Toast.makeText(this, message, android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "game-saves-action").start()
|
||||
}
|
||||
|
||||
private fun openProtons() {
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
@@ -680,14 +812,16 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun openComponents(focusContent: Boolean = true) {
|
||||
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
|
||||
focusComponentsContent = focusContent
|
||||
if (tab != null) components.compComp = tab
|
||||
settingsMode = null
|
||||
showPerformance = false
|
||||
showProtons = false
|
||||
showMapping = false
|
||||
showComponents = true
|
||||
components.refreshComponents(snapshotFirst = true)
|
||||
drivers.refreshDrivers()
|
||||
}
|
||||
|
||||
private fun importComponent(uri: Uri) {
|
||||
@@ -722,6 +856,22 @@ class MainActivity : ComponentActivity() {
|
||||
onRefresh = { components.refreshComponentCatalog() },
|
||||
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, "Choose a component package (-linux .wcp)")) },
|
||||
onBack = { showComponents = false },
|
||||
gpu = drivers.state(),
|
||||
gpuActions = com.droiddeck.launcher.ui.GpuDriversActions(
|
||||
onAuto = { on -> drivers.setMode(on) },
|
||||
onPair = { key -> drivers.selectPair(key) },
|
||||
onRefresh = { if (drivers.mode == SessionPrefs.GPU_DRIVERS_AUTO) drivers.ensureAuto(force = true) else drivers.checkLatestTurnip() },
|
||||
// Picking one driver on its own is Manual: Auto would put its pair back.
|
||||
onSelectLinux = { id -> drivers.selectDriver(id, linux = true) },
|
||||
onSelectAndroid = { id -> drivers.selectDriver(id, linux = false) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip or Android + Linux bundle)")) },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip or Android + Linux bundle)")) },
|
||||
onImportZip = { pickAnyDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a driver zip or an Android + Linux bundle")) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -788,10 +938,8 @@ class MainActivity : ComponentActivity() {
|
||||
ModeSettingsPage(
|
||||
ModeSettings(
|
||||
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
|
||||
hdr = hdrOn, hdrReason = hdrReason,
|
||||
linuxRows = drivers.linuxRows,
|
||||
linuxSelected = if (mode == SessionService.MODE_STEAM) drivers.linuxSteam else drivers.linuxDesktop,
|
||||
androidRows = drivers.androidRows, androidSelected = drivers.androidSelected,
|
||||
hdr = hdrOn, hdrReason = hdrReason, fpsLimit = fpsLimit,
|
||||
gpuDrivers = drivers.summary(),
|
||||
touchMode = touchMode,
|
||||
suspendPolicy = suspendPolicy,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
@@ -805,12 +953,13 @@ class MainActivity : ComponentActivity() {
|
||||
storageOptions = storageOptions,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
steamDeckMode = mode == SessionService.MODE_STEAM && steamDeckMode,
|
||||
mangoapp = mangoapp,
|
||||
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
|
||||
runSteamAtStartup = mode == SessionService.MODE_STEAM && runSteamAtStartup,
|
||||
addedGamesDirs = if (mode == SessionService.MODE_STEAM) addedGamesDirs else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
addedGamesArt = addedGamesArt,
|
||||
linuxDownloads = drivers.linuxDownloads, androidDownloads = drivers.androidDownloads, releaseStatus = drivers.releaseStatus,
|
||||
releaseChecking = drivers.releaseChecking, canRestoreBundled = drivers.canRestoreBundled,
|
||||
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
|
||||
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
|
||||
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
|
||||
@@ -821,15 +970,8 @@ class MainActivity : ComponentActivity() {
|
||||
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
|
||||
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
|
||||
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
|
||||
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); drivers.refreshDrivers() },
|
||||
onImportLinux = { pickLinuxDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a Linux runtime driver (-Linux zip)")) },
|
||||
onRemoveLinux = { id -> drivers.deleteDriver(id, linux = true) },
|
||||
onRefreshReleases = { drivers.checkLatestTurnip() },
|
||||
onDownloadDriver = { name -> drivers.downloadReleaseDriver(name) },
|
||||
onRestoreBundled = { TurnipDriver(this).restoreBundled(); drivers.refreshDrivers() },
|
||||
onSelectAndroid = { id -> SessionPrefs.setAndroidDriver(this, id); drivers.refreshDrivers() },
|
||||
onImportAndroid = { pickAndroidDriver.launch(InAppFilePicker.buildIntent(this, ZIP_EXT, "Choose a display driver (AdrenoTools zip)")) },
|
||||
onRemoveAndroid = { id -> drivers.deleteDriver(id, linux = false) },
|
||||
onGpuDrivers = { openComponents(focusContent = true, tab = com.droiddeck.launcher.ui.GPU_TAB) },
|
||||
onFpsLimit = { fps -> SessionPrefs.setFpsLimit(this, mode, fps); fpsLimit = fps },
|
||||
onTouch = { t -> SessionPrefs.setTouchMode(this, t); touchMode = t },
|
||||
onSuspendPolicy = { policy -> SessionPrefs.setSuspendPolicy(this, mode, policy); suspendPolicy = policy },
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
@@ -856,6 +998,13 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onSteamDeckMode = { on ->
|
||||
SessionPrefs.setSteamDeckMode(this, on)
|
||||
steamDeckMode = on
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
},
|
||||
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
|
||||
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
|
||||
onRunSteamAtStartup = { on ->
|
||||
SessionPrefs.setRunSteamAtStartup(this, on)
|
||||
runSteamAtStartup = on
|
||||
@@ -886,15 +1035,18 @@ class MainActivity : ComponentActivity() {
|
||||
PerformancePage(
|
||||
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, steamDeckMode = steamDeckMode, noXalia = noXalia,
|
||||
tuSysmem = tuSysmem, zinkLazy = zinkLazy, glThread = glThread, noGlError = noGlError, noXalia = noXalia,
|
||||
gamescopeRealtime = gamescopeRealtime,
|
||||
gpuClockPin = gpuClockPin,
|
||||
prootNoSeccomp = prootNoSeccomp, guestHostname = guestHostname, phantomWarning = phantomWarning,
|
||||
onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on },
|
||||
onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on },
|
||||
onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on },
|
||||
onGlThread = { on -> SessionPrefs.setGlThread(this, on); glThread = on },
|
||||
onNoGlError = { on -> SessionPrefs.setNoGlError(this, on); noGlError = on },
|
||||
onSteamDeckMode = { on -> SessionPrefs.setSteamDeckMode(this, on); steamDeckMode = on },
|
||||
onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = 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 },
|
||||
onGuestHostname = { name -> SessionPrefs.setGuestHostname(this, name) },
|
||||
onClientCore = { core, on ->
|
||||
@@ -939,11 +1091,15 @@ class MainActivity : ComponentActivity() {
|
||||
customResolution = SessionPrefs.customResolution(this, mode)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
steamDeckMode = SessionPrefs.steamDeckMode(this)
|
||||
mangoapp = SessionPrefs.mangoapp(this)
|
||||
steamController = SessionPrefs.steamController(this)
|
||||
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
|
||||
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
fpsLimit = SessionPrefs.fpsLimit(this, mode)
|
||||
hdrReason = com.droiddeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
@@ -988,6 +1144,8 @@ class MainActivity : ComponentActivity() {
|
||||
tuSysmem = SessionPrefs.tuSysmem(this)
|
||||
zinkLazy = SessionPrefs.zinkLazy(this)
|
||||
noXalia = SessionPrefs.noXalia(this)
|
||||
gamescopeRealtime = SessionPrefs.gamescopeRealtime(this)
|
||||
gpuClockPin = SessionPrefs.gpuClockPin(this)
|
||||
prootNoSeccomp = SessionPrefs.prootNoSeccomp(this)
|
||||
guestHostname = SessionPrefs.guestHostname(this)
|
||||
refreshPhantomStatus()
|
||||
@@ -1005,7 +1163,8 @@ class MainActivity : ComponentActivity() {
|
||||
logsEnabled = SessionPrefs.logsEnabled(this)
|
||||
runningLabel = if (SessionState.running) when (SessionState.mode) {
|
||||
SessionService.MODE_DESKTOP -> "Desktop"
|
||||
SessionService.MODE_RUN -> SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
|
||||
SessionService.MODE_RUN -> Library.nameForProgram(SessionState.program)
|
||||
?: SessionState.program?.substringAfterLast('/')?.substringBefore('.') ?: "Program"
|
||||
else -> "Steam"
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
@@ -1013,7 +1172,7 @@ class MainActivity : ComponentActivity() {
|
||||
val games = if (ready) Library.steamGames(this) + com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.frontend.AddedGameArt.resolve(this, g).let { art ->
|
||||
Library.SteamGame(
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, "added", g.gameId,
|
||||
g.steamAppId ?: g.appId.toInt(), g.name, art.portrait ?: art.header, Library.ADDED, g.gameId,
|
||||
hero = art.hero ?: art.header, gameFiles = g.folder,
|
||||
protonPrefix = Library.protonPrefix(this, g.steamAppId?.toLong() ?: g.appId),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import android.hardware.input.InputManager
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.content.Intent
|
||||
@@ -23,6 +24,7 @@ import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -32,9 +34,10 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.LsfgNative
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
@@ -47,7 +50,6 @@ import com.droiddeck.launcher.input.SecondScreenMode
|
||||
import com.droiddeck.launcher.input.TouchpadGestures
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.LoadingState
|
||||
import com.droiddeck.launcher.session.PerfHints
|
||||
import com.droiddeck.launcher.session.PerfHud
|
||||
import com.droiddeck.launcher.session.PerfMode
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
@@ -92,6 +94,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private lateinit var loading: LoadingState
|
||||
private lateinit var hud: PerfHud
|
||||
private var padBridge: PadBridge? = null
|
||||
/** The handheld's gyro and accelerometer, fed to the Deck controller while the session shows. */
|
||||
private var padMotion: com.droiddeck.launcher.input.PadMotion? = null
|
||||
/** Between onResume and onPause. */
|
||||
private var resumed = false
|
||||
private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion() } }
|
||||
|
||||
/** Motion is read while the session shows and only if the pad is a Deck controller - which
|
||||
* the service can decide after this activity has resumed (SessionState.deckPadListener). */
|
||||
private fun updatePadMotion() {
|
||||
if (resumed && SessionState.deckPad) padMotion?.start() else padMotion?.stop()
|
||||
}
|
||||
private var onScreenControls: OnScreenControls? = null
|
||||
private var keyboard: KeyboardHost? = null
|
||||
private var watching = true
|
||||
@@ -159,22 +172,43 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private var pcKeyboardOpen by mutableStateOf(false)
|
||||
private var hudOn by mutableStateOf(true)
|
||||
private var fillScreen by mutableStateOf(true)
|
||||
private var frameGenLabel by mutableStateOf("Off")
|
||||
private var frameGenEngine by mutableStateOf(FrameGen.ENGINE_OFF)
|
||||
private var frameGenMultiplier by mutableStateOf(2)
|
||||
private var lossless by mutableStateOf(Lossless.State.NONE)
|
||||
private val pickLossless = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedFile(r.data)?.let { importLossless(it) }
|
||||
}
|
||||
private var frameGen by mutableStateOf(FrameGen.Mode.OFF)
|
||||
private var fexPreset by mutableStateOf("")
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var shapeMode by mutableStateOf(SessionPrefs.SHAPE_AUTO)
|
||||
private var secondScreenMode by mutableStateOf(SecondScreenMode.NONE)
|
||||
private var secondScreenMode by mutableStateOf(SessionState.secondScreenMode)
|
||||
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(-1)
|
||||
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
|
||||
/** Between onStart and onStop: while stopped the second screen stays closed, its choice kept. */
|
||||
private var started = false
|
||||
private lateinit var displayManager: DisplayManager
|
||||
private var secondScreenPresentation: SecondScreenPresentation? = null
|
||||
private val displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayRemoved(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) = refreshSecondScreenDisplays()
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
followPanelRefresh(displayId)
|
||||
refreshSecondScreenDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's rate, followed for the rest of the session. It is read once at start, and a
|
||||
* panel that drops from 120 to 60 Hz later (battery saver, heat, a vendor overriding the mode
|
||||
* asked for) left the compositor telling gamescope every frame took 8.3 ms, so the client and
|
||||
* its games paced against a vblank that no longer came. (WinNative 53836ca9.)
|
||||
*/
|
||||
private fun followPanelRefresh(displayId: Int) {
|
||||
if (!CompositorHost.isStarted) return
|
||||
val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return
|
||||
if (display.displayId != displayId) return
|
||||
val hz = display.refreshRate
|
||||
if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz)
|
||||
}
|
||||
private var isHomeApp by mutableStateOf(false)
|
||||
private var androidApps by mutableStateOf<List<HomeApp.LaunchableApp>>(emptyList())
|
||||
@@ -203,9 +237,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode))
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
// Game-tier power policy for the whole session: a sustained clock floor, the panel's
|
||||
// fastest mode, and the OS told it is in gameplay. Logged so a slow device says why.
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this))
|
||||
// Game-tier power policy for the whole session: the panel's fastest mode (one the frame cap
|
||||
// divides, when there is a cap), and the OS told it is in gameplay. Logged so a slow device
|
||||
// says why. The cap is the session's: an activity re-created mid-session keeps it.
|
||||
if (!SessionState.running) {
|
||||
SessionState.fpsLimit = SessionPrefs.fpsLimit(
|
||||
this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM),
|
||||
)
|
||||
}
|
||||
Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit))
|
||||
goFullscreen()
|
||||
// The device's volume keys change the stream the session plays on (the relay and
|
||||
// PulseAudio are media playback); they are never forwarded to the guest.
|
||||
@@ -224,6 +264,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
|
||||
padBridge = bridge
|
||||
padMotion = com.droiddeck.launcher.input.PadMotion(this) {
|
||||
@Suppress("DEPRECATION")
|
||||
(if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0
|
||||
}
|
||||
SessionState.deckPadListener = deckPadListener
|
||||
// A player on the pad or the on-screen controls has no use for the mouse arrow; the next
|
||||
// touchpad or mouse move brings it back (showCursor), once the pad has been quiet a moment.
|
||||
bridge.setOnPlayerInput {
|
||||
@@ -298,6 +343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
isFocusableInTouchMode = true
|
||||
setContent {
|
||||
DroidDeckTheme {
|
||||
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
|
||||
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
|
||||
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
|
||||
if (hud.text.isNotEmpty()) HudText(hud.text)
|
||||
@@ -319,6 +365,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
// Opening the drawer takes the controller away from the game: release its pad.
|
||||
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
|
||||
if (drawerOpen) {
|
||||
syncLossless()
|
||||
padBridge?.releaseAll()
|
||||
androidx.compose.runtime.withFrameNanos { }
|
||||
val requested = sessionOverlay.requestFocus()
|
||||
@@ -342,8 +389,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
androidApps = androidApps,
|
||||
hudOn = hudOn,
|
||||
fillScreen = if (SessionState.mode == SessionService.MODE_STEAM) fillScreen else null,
|
||||
frameGenEngine = frameGenEngine, frameGenMultiplier = frameGenMultiplier,
|
||||
lsfgReady = LsfgNative.isInstalled(this@SessionActivity),
|
||||
frameGen = frameGen,
|
||||
lossless = lossless,
|
||||
oscMode = oscMode, suspendPolicy = suspendPolicy, touchMode = touchMode,
|
||||
touchAuto = if (usingTouchpad()) "touchpad" else "direct",
|
||||
shapeMode = shapeMode, fexPreset = fexPreset,
|
||||
@@ -352,11 +399,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
|
||||
onFillScreen = { on -> SessionPrefs.setForceFullscreen(this@SessionActivity, on); fillScreen = on },
|
||||
onFrameGenPick = { engine, multiplier ->
|
||||
FrameGen.set(this@SessionActivity, engine, multiplier)
|
||||
onFrameGenPick = { mode ->
|
||||
FrameGen.set(this@SessionActivity, mode)
|
||||
readPrefs()
|
||||
applyFrameGen()
|
||||
},
|
||||
onImportLossless = {
|
||||
pickLossless.launch(InAppFilePicker.buildIntent(
|
||||
this@SessionActivity, listOf("dll"), getString(R.string.lsfg_pick_title)))
|
||||
},
|
||||
onKeyboard = { drawerOpen = false; pcKeyboardOpen = false; keyboard?.toggle() },
|
||||
onHardwareKeyboard = { drawerOpen = false; pcKeyboardOpen = !pcKeyboardOpen },
|
||||
onSteamMenu = if (SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
|
||||
@@ -428,6 +479,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshHomeApp() {
|
||||
@@ -460,7 +512,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
performBackAction(double = false)
|
||||
}
|
||||
pendingBackAction = pending
|
||||
uiHandler.postDelayed(pending, BACK_DOUBLE_PRESS_TIMEOUT_MS)
|
||||
// Android's own double-tap window (300 ms): the single action waits it out, so it is felt.
|
||||
uiHandler.postDelayed(pending, android.view.ViewConfiguration.getDoubleTapTimeout().toLong())
|
||||
}
|
||||
|
||||
private fun performBackAction(double: Boolean) {
|
||||
@@ -560,9 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
hudOn = SessionPrefs.hudEnabled(this)
|
||||
fillScreen = SessionPrefs.forceFullscreen(this)
|
||||
touchMode = SessionPrefs.touchMode(this)
|
||||
frameGenLabel = FrameGen.label(this)
|
||||
frameGenEngine = FrameGen.engine(this)
|
||||
frameGenMultiplier = FrameGen.multiplier(this)
|
||||
frameGen = FrameGen.mode(this)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
@@ -757,12 +808,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
CompositorHost.startOrAttach(
|
||||
holder.surface, runtimeDir.path,
|
||||
driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) },
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(),
|
||||
applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit,
|
||||
)
|
||||
if (!SessionState.running) SessionEvents.record("compositor.started")
|
||||
// ADPF: the compositor thread's frame intervals go to the power HAL against the panel's
|
||||
// period, so a long frame raises CPU clocks now rather than after the load averages up.
|
||||
PerfHints.arm(this, refreshHz())
|
||||
// The service owns everything below the compositor. It is started whenever no session is
|
||||
// running - NOT only when the compositor was just started: the compositor lives for the
|
||||
// whole process, so the second Play after a session ended used to re-attach the Surface,
|
||||
@@ -848,9 +896,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return Pair(width and 1.inv(), height and 1.inv())
|
||||
}
|
||||
|
||||
/**
|
||||
* The rate of the mode PerfMode asked for, not the one the panel is still in: the switch lands
|
||||
* some frames after onCreate, and read too early it gave gamescope `-r 60` on a 120 Hz panel -
|
||||
* the client and every game then paced themselves to 60. (WinNative's requestedPanelRefreshRate.)
|
||||
*/
|
||||
private fun refreshHz(): Float {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
|
||||
val hz = display?.refreshRate ?: 60f
|
||||
val wanted = window.attributes.preferredDisplayModeId
|
||||
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
|
||||
?: display?.refreshRate ?: 60f
|
||||
return if (hz > 1f) hz else 60f
|
||||
}
|
||||
|
||||
@@ -862,10 +917,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
val hz = refreshHz()
|
||||
Thread({
|
||||
val problem = FrameGen.apply(this, hz)
|
||||
if (problem != null) runOnUiThread { Toast.makeText(this, problem, Toast.LENGTH_LONG).show() }
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
if (problem != null) Toast.makeText(this, problem, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "frame-gen").start()
|
||||
}
|
||||
|
||||
/** A Lossless Scaling installed or updated in Steam during the session is taken when the drawer opens. */
|
||||
private fun syncLossless() {
|
||||
Thread({
|
||||
Lossless.sync(this)
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread { lossless = state }
|
||||
}, "lossless-sync").start()
|
||||
}
|
||||
|
||||
private fun importLossless(dll: File) {
|
||||
Thread({
|
||||
val message = Lossless.message(this, Lossless.import(this, dll))
|
||||
val state = Lossless.state(this)
|
||||
runOnUiThread {
|
||||
lossless = state
|
||||
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
if (FrameGen.engine(this) == FrameGen.ENGINE_LSFG) applyFrameGen()
|
||||
}
|
||||
}, "lossless-import").start()
|
||||
}
|
||||
|
||||
// ── Session state ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Keeps the loading overlay current from the session log, and its clock moving. */
|
||||
@@ -927,8 +1007,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
return
|
||||
}
|
||||
// It stays until the user leaves: a failure read against a timer is a failure not read.
|
||||
val what = when (SessionState.mode) {
|
||||
SessionService.MODE_RUN -> com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "The program"
|
||||
SessionService.MODE_DESKTOP -> "The desktop"
|
||||
else -> "Steam"
|
||||
}
|
||||
loading.showEnded(
|
||||
hint ?: "Steam stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
hint ?: "$what stopped unexpectedly. Share the logs with a bug report, or try again.",
|
||||
"Exit status $status · ${SessionState.logFile?.path ?: "no log"}",
|
||||
)
|
||||
focusEndedScreen()
|
||||
@@ -1229,6 +1314,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
*/
|
||||
private fun syncClientCursor(): Boolean {
|
||||
if (!CompositorHost.isStarted) return false
|
||||
// Asked on every pointer move; the image is copied only when the client has changed it.
|
||||
if (WaylandCompositor.nativeCursorSerial() == cursorSerial) return false
|
||||
val n = WaylandCompositor.nativeCursorSnapshot(cursorBuf)
|
||||
if (n < 6) return false
|
||||
val serial = cursorBuf[0]
|
||||
@@ -1270,11 +1357,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (selectedSecondScreenDisplay !in newIds) selectedSecondScreenDisplay = candidates.firstOrNull()?.id ?: -1
|
||||
if (secondScreenMode != SecondScreenMode.NONE && candidates.isEmpty()) closeSecondScreen(reset = true)
|
||||
// A choice kept while its display was away (asleep, waking): back as soon as it is.
|
||||
else if (started && secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
private fun selectSecondScreenDisplay(displayId: Int) {
|
||||
if (secondScreenDisplays.none { it.id == displayId }) return
|
||||
selectedSecondScreenDisplay = displayId
|
||||
SessionState.secondScreenDisplay = displayId
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
}
|
||||
|
||||
@@ -1296,8 +1386,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun showSecondScreen(mode: SecondScreenMode) {
|
||||
val target = displayManager.getDisplay(selectedSecondScreenDisplay)
|
||||
if (target == null || !target.isValid || (target.flags and Display.FLAG_PRESENTATION) == 0) {
|
||||
closeSecondScreen(reset = true)
|
||||
refreshSecondScreenDisplays()
|
||||
// Not there right now - a panel still waking, say. The choice is kept; the display
|
||||
// listener shows it again when the display comes back (refreshSecondScreenDisplays).
|
||||
closeSecondScreen(reset = false)
|
||||
return
|
||||
}
|
||||
var presentation = secondScreenPresentation
|
||||
@@ -1329,6 +1420,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!presentation.isShowing) presentation.show()
|
||||
presentation.showMode(mode)
|
||||
secondScreenMode = mode
|
||||
SessionState.secondScreenMode = mode
|
||||
SessionState.secondScreenDisplay = selectedSecondScreenDisplay
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "could not show second-screen controls on ${target.name}", e)
|
||||
closeSecondScreen(reset = true)
|
||||
@@ -1348,7 +1441,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun closeSecondScreen(reset: Boolean) {
|
||||
secondScreenPresentation?.closeControls()
|
||||
secondScreenPresentation = null
|
||||
if (reset) secondScreenMode = SecondScreenMode.NONE
|
||||
if (reset) {
|
||||
secondScreenMode = SecondScreenMode.NONE
|
||||
SessionState.secondScreenMode = SecondScreenMode.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/** Touchpad on the desktop, direct in Steam, unless the drawer says otherwise. */
|
||||
@@ -1461,15 +1557,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
started = true
|
||||
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
|
||||
refreshSecondScreenDisplays()
|
||||
if (secondScreenMode != SecondScreenMode.NONE) showSecondScreen(secondScreenMode)
|
||||
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
|
||||
SessionService.setActivityVisible(this, true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
started = false
|
||||
displayManager.unregisterDisplayListener(displayListener)
|
||||
closeSecondScreen(reset = true)
|
||||
// Closed while the session is out of sight (sleep, a closed lid, another app), and kept:
|
||||
// onStart puts it back.
|
||||
closeSecondScreen(reset = false)
|
||||
SessionService.setActivityVisible(this, false)
|
||||
super.onStop()
|
||||
}
|
||||
@@ -1506,6 +1606,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper()))
|
||||
onScreenControls?.reload()
|
||||
updateOnScreenControls()
|
||||
resumed = true
|
||||
updatePadMotion()
|
||||
readPrefs()
|
||||
if (CompositorHost.isStarted) applyFrameGen()
|
||||
}
|
||||
@@ -1519,6 +1621,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
releaseDrawerDirection()
|
||||
// A button held when the app goes away would stay held in the ring for the whole session.
|
||||
onScreenControls?.releaseAll()
|
||||
resumed = false
|
||||
updatePadMotion()
|
||||
keyboard?.takeIf { it.shown }?.hide()
|
||||
super.onPause()
|
||||
}
|
||||
@@ -1572,6 +1676,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
closeSecondScreen(reset = true)
|
||||
if (::hud.isInitialized) hud.stop()
|
||||
padBridge?.stop()
|
||||
padMotion?.stop()
|
||||
if (SessionState.deckPadListener === deckPadListener) SessionState.deckPadListener = null
|
||||
if (SessionState.endListener === endListener) SessionState.endListener = null
|
||||
WaylandCompositor.clearFirstFrameListener(firstFrameListener)
|
||||
super.onDestroy()
|
||||
@@ -1598,7 +1704,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
companion object {
|
||||
private const val TAG = "SessionActivity"
|
||||
private const val UNBUFFERED_SOURCES = InputDevice.SOURCE_CLASS_JOYSTICK or InputDevice.SOURCE_CLASS_TRACKBALL or InputDevice.SOURCE_CLASS_POSITION
|
||||
private const val BACK_DOUBLE_PRESS_TIMEOUT_MS = 500L
|
||||
private const val CURSOR_PAD_HOLD_MS = 1200L
|
||||
private const val DRAWER_HAT_THRESHOLD = 0.5f
|
||||
private const val DRAWER_STICK_ENTER_THRESHOLD = 0.55f
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.droiddeck.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.ui.UpdatesActions
|
||||
import com.droiddeck.launcher.ui.UpdatesState
|
||||
import com.droiddeck.launcher.update.AppUpdates
|
||||
import com.droiddeck.launcher.update.SelfInstaller
|
||||
|
||||
/**
|
||||
* The Updates page's state: the channels' latest builds, the one followed, and an update being
|
||||
* downloaded or installed. Checked when the launcher comes to the front - opening the app, and
|
||||
* coming back from a session - at most once a minute.
|
||||
*/
|
||||
internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
|
||||
// Read in start(): the activity has no context yet while its fields are made.
|
||||
var catalog by mutableStateOf<AppUpdates.Catalog?>(null)
|
||||
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
|
||||
var checking by mutableStateOf(false)
|
||||
var error by mutableStateOf<String?>(null)
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
var percent by mutableIntStateOf(-1)
|
||||
var askPermission by mutableStateOf(false)
|
||||
// An install waiting for the user to allow "Install unknown apps"; it goes on when they come back.
|
||||
private var pending: AppUpdates.Release? = null
|
||||
private var lastCheck = 0L
|
||||
private var request = 0
|
||||
|
||||
fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
|
||||
|
||||
fun actions() = UpdatesActions(
|
||||
onCheck = { check(force = true) },
|
||||
onFollow = ::choose,
|
||||
onInstall = ::install,
|
||||
onAllowInstalls = {
|
||||
askPermission = false
|
||||
runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
|
||||
.onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
|
||||
},
|
||||
onDismissPermission = { askPermission = false; pending = null },
|
||||
)
|
||||
|
||||
val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
|
||||
|
||||
/** On every return to the launcher: go on with an install the user just allowed, and check if due. */
|
||||
fun onResume() {
|
||||
pending?.let { r ->
|
||||
if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
|
||||
}
|
||||
if (!SessionState.running) check(force = false)
|
||||
}
|
||||
|
||||
fun check(force: Boolean) {
|
||||
if (checking || stage != null) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastCheck < 60_000) return
|
||||
lastCheck = now
|
||||
val id = ++request
|
||||
checking = true
|
||||
Thread({
|
||||
val result = runCatching { AppUpdates.refresh(activity) }
|
||||
ui.post {
|
||||
if (id != request) return@post
|
||||
checking = false
|
||||
result.onSuccess {
|
||||
catalog = it
|
||||
follow = AppUpdates.follow(activity, it)
|
||||
error = null
|
||||
}.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
|
||||
}
|
||||
}, "app-updates").start()
|
||||
}
|
||||
|
||||
private fun choose(f: AppUpdates.Follow) {
|
||||
AppUpdates.setFollow(activity, f)
|
||||
follow = f
|
||||
error = null
|
||||
}
|
||||
|
||||
private fun install(r: AppUpdates.Release) {
|
||||
if (stage != null) return
|
||||
val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
|
||||
if (SessionState.running) { error = "Stop the running session first, then update"; return }
|
||||
if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
|
||||
error = null
|
||||
stage = "Downloading"
|
||||
percent = 0
|
||||
Thread({
|
||||
val result = runCatching {
|
||||
val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
|
||||
ui.post { stage = "Installing"; percent = -1 }
|
||||
SelfInstaller.install(activity, file)
|
||||
}
|
||||
result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
|
||||
}, "app-update-install").start()
|
||||
}
|
||||
|
||||
/** PackageInstaller's answer: Android's confirm prompt to show, or why it failed. Success restarts the app. */
|
||||
private val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
||||
if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
|
||||
stage = "Waiting for you to confirm"
|
||||
activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> stage = null
|
||||
else -> {
|
||||
stage = null
|
||||
error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** From onCreate: the last check, the channel followed, and PackageInstaller's answers. */
|
||||
fun start() {
|
||||
catalog = AppUpdates.cached(activity)
|
||||
follow = AppUpdates.follow(activity, catalog)
|
||||
val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
|
||||
if (Build.VERSION.SDK_INT >= 33) activity.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
else activity.registerReceiver(receiver, filter)
|
||||
}
|
||||
|
||||
fun unregister() {
|
||||
runCatching { activity.unregisterReceiver(receiver) }
|
||||
}
|
||||
}
|
||||
@@ -113,19 +113,24 @@ object DeviceReport {
|
||||
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
|
||||
|
||||
h("Drivers")
|
||||
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
|
||||
k("GPU", "${gpu.name} · ${gpu.family.label} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
|
||||
k("Driver mode", SessionPrefs.gpuDriverMode(context) +
|
||||
(com.droiddeck.launcher.gpu.DriverPairs.recommendedKey(gpu, com.droiddeck.launcher.gpu.DriverPairs.from(
|
||||
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)))?.let { " (recommended pair: $it)" } ?: ""))
|
||||
val turnip = TurnipDriver(context)
|
||||
val androidChoice = SessionPrefs.androidDriver(context)
|
||||
k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice)
|
||||
k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim())
|
||||
k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", "))
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) {
|
||||
val id = SessionPrefs.linuxDriver(context, m)
|
||||
run {
|
||||
val id = SessionPrefs.linuxDriver(context)
|
||||
val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)"
|
||||
else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() +
|
||||
lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } +
|
||||
if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]"
|
||||
k("Linux driver ($m)", label)
|
||||
k("Linux driver", label)
|
||||
}
|
||||
k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path)
|
||||
|
||||
@@ -144,8 +149,10 @@ object DeviceReport {
|
||||
k("Threaded GL (glthread)", SessionPrefs.glThread(context))
|
||||
k("No GL error checks", SessionPrefs.noGlError(context))
|
||||
k("Steam Deck mode", SessionPrefs.steamDeckMode(context))
|
||||
k("Steam controller", SessionPrefs.steamController(context))
|
||||
k("FEX preset", SessionPrefs.fexPreset(context).ifEmpty { "FEX defaults" })
|
||||
k("Skip xalia", SessionPrefs.noXalia(context))
|
||||
k("gamescope realtime", SessionPrefs.gamescopeRealtime(context))
|
||||
k("proot without seccomp", SessionPrefs.prootNoSeccomp(context))
|
||||
k("Guest host name", SessionPrefs.guestHostname(context))
|
||||
k("DirectAudio for games", SessionPrefs.directAudio(context))
|
||||
|
||||
@@ -104,6 +104,7 @@ public final class FileUtils {
|
||||
public static String sizeToString(long bytes) {
|
||||
if (bytes >= 1L << 30) return String.format(java.util.Locale.US, "%.1f GB", bytes / (float) (1L << 30));
|
||||
if (bytes >= 1L << 20) return String.format(java.util.Locale.US, "%.0f MB", bytes / (float) (1L << 20));
|
||||
if (bytes >= 1L << 10) return String.format(java.util.Locale.US, "%.0f KB", bytes / (float) (1L << 10));
|
||||
return bytes + " B";
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,8 @@ object HostProcess {
|
||||
}
|
||||
}
|
||||
|
||||
private fun pidOf(process: Process): Int = try {
|
||||
@JvmStatic
|
||||
fun pidOf(process: Process): Int = try {
|
||||
val field = process.javaClass.getDeclaredField("pid")
|
||||
field.isAccessible = true
|
||||
val pid = field.getInt(process)
|
||||
|
||||
@@ -23,6 +23,9 @@ object Library {
|
||||
val hero: File? = null, val lastPlayed: Long = 0L,
|
||||
val gameFiles: File? = null, val protonPrefix: File? = null,
|
||||
)
|
||||
/** The [SteamGame.library] of a game added to the library rather than installed by Steam. */
|
||||
const val ADDED = "added"
|
||||
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
@@ -163,7 +166,13 @@ object Library {
|
||||
)
|
||||
|
||||
/** The emulator's name for a program path from the rail ("ARMSX2"), or null. */
|
||||
fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name
|
||||
fun nameForProgram(program: String?): String? =
|
||||
if (program == com.droiddeck.launcher.runtime.FlatpakManager.LAUNCHER || program == com.droiddeck.launcher.runtime.AppImageManager.LAUNCHER) {
|
||||
com.droiddeck.launcher.session.SessionState.programArgs.firstOrNull()?.let { flatpakNames[it] ?: it.substringAfterLast('.') }
|
||||
} else specs.firstOrNull { it.program == program }?.name
|
||||
|
||||
/** Flatpak apps' and AppImages' names by id or directory, as launched: the session only knows that. */
|
||||
val flatpakNames = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Whether a program from the rail runs at the panel's own resolution rather than the session's
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* An all-in-one driver package: one zip carrying both halves of a matched pair, the display
|
||||
* (Android, bionic) driver in one directory and the runtime (Linux, glibc) driver in another,
|
||||
* indexed by a root `manifest.json` - DroidDeck's own DD-Turnip releases (Droid-Deck/Drivers):
|
||||
*
|
||||
* ```
|
||||
* manifest.json {"package", "version", "drivers": {"android": {"path"}, "linux": {"path"}}}
|
||||
* android/ meta.json (AdrenoTools) + libvulkan_freedreno.so
|
||||
* linux/ meta.json + libvulkan_freedreno.so
|
||||
* ```
|
||||
*
|
||||
* Installing one puts each half through its own importer, with that importer's checks, and
|
||||
* remembers the two as one bundle: picking either half sets both, and deleting either deletes both,
|
||||
* so a bundle is always used whole. Zips of the older kind, one driver each, never reach this.
|
||||
*/
|
||||
object DriverBundle {
|
||||
private const val TAG = "DriverBundle"
|
||||
private const val PREFS = "driver_bundles"
|
||||
private const val MANIFEST = "manifest.json"
|
||||
/** Refuses a zip that unpacks to more than this: both halves together are about 30 MB. */
|
||||
private const val MAX_BYTES = 512L shl 20
|
||||
|
||||
class Manifest(val name: String, val version: String, val tag: String, val androidPath: String, val linuxPath: String)
|
||||
|
||||
/** An installed bundle: [androidId] in TurnipDriver's list, [linuxId] in LinuxVulkanDriverManager's. */
|
||||
class Bundle(val id: String, val name: String, val version: String, val androidId: String, val linuxId: String) {
|
||||
/** "DD-Turnip 0.1.0". */
|
||||
val label: String get() = if (version.isEmpty()) name else "$name $version"
|
||||
}
|
||||
|
||||
/** The manifest of a bundle, or null when [json] is not one (both halves, in two directories). */
|
||||
fun parseManifest(json: JSONObject): Manifest? {
|
||||
val drivers = json.optJSONObject("drivers") ?: return null
|
||||
val android = cleanPath(drivers.optJSONObject("android")?.optString("path").orEmpty()) ?: return null
|
||||
val linux = cleanPath(drivers.optJSONObject("linux")?.optString("path").orEmpty()) ?: return null
|
||||
if (android == linux) return null
|
||||
return Manifest(
|
||||
json.optString("package").ifEmpty { "Driver bundle" }, json.optString("version"),
|
||||
json.optString("tag"), android, linux,
|
||||
)
|
||||
}
|
||||
|
||||
private fun cleanPath(path: String): String? {
|
||||
val p = path.trim().trim('/')
|
||||
return p.takeIf { it.isNotEmpty() && it.split('/').none { part -> part.isEmpty() || part == "." || part == ".." } }
|
||||
}
|
||||
|
||||
/** True when the zip at [uri] is a bundle rather than a single driver. */
|
||||
fun isBundle(context: Context, uri: Uri): Boolean = try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
generateSequence { zip.nextEntry }.firstOrNull { !it.isDirectory && it.name == MANIFEST }
|
||||
?.let { parseManifest(JSONObject(zip.readBytes().toString(Charsets.UTF_8))) } != null
|
||||
}
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "reading $uri as a bundle", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Install both halves of the bundle at [uri]. Both or neither: a half that is refused removes
|
||||
* the one already in. Throws [IllegalArgumentException] with a user-facing reason when the zip
|
||||
* is not a usable bundle, [IOException] on read failures.
|
||||
*/
|
||||
fun install(context: Context, uri: Uri): Bundle {
|
||||
val work = File(context.cacheDir, "bundle-${System.nanoTime()}")
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
var androidId: String? = null
|
||||
try {
|
||||
unzip(context, uri, work)
|
||||
val manifest = File(work, MANIFEST).takeIf { it.isFile }
|
||||
?.let { runCatching { parseManifest(JSONObject(FileUtils.readString(it))) }.getOrNull() }
|
||||
?: throw IllegalArgumentException("Not a driver bundle: no manifest.json naming an android and a linux driver")
|
||||
val androidDir = File(work, manifest.androidPath)
|
||||
val linuxDir = File(work, manifest.linuxPath)
|
||||
|
||||
val androidStage = td.newStagingDir()
|
||||
androidId = try {
|
||||
androidDir.listFiles()?.filter { it.isFile }?.forEach { it.copyTo(File(androidStage, it.name)) }
|
||||
td.adopt(androidStage, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(androidStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val linuxStage = lm.newStagingDir()
|
||||
val linuxId = try {
|
||||
val library = linuxDir.listFiles()?.firstOrNull { it.isFile && it.name.startsWith("libvulkan_freedreno") && it.name.endsWith(".so") }
|
||||
library?.copyTo(File(linuxStage, LinuxVulkanDriverManager.LIB_NAME))
|
||||
val meta = File(linuxDir, LinuxVulkanDriverManager.META_NAME).takeIf { it.isFile }
|
||||
?.let { runCatching { JSONObject(FileUtils.readString(it)) }.getOrNull() }
|
||||
lm.adopt(linuxStage, library?.name, meta, manifest.name)
|
||||
} catch (e: Exception) {
|
||||
FileUtils.delete(linuxStage)
|
||||
throw e
|
||||
}
|
||||
|
||||
val base = LinuxVulkanDriverManager.sanitizeId(manifest.tag.ifEmpty { "${manifest.name}-${manifest.version}" })
|
||||
val saved = registry(context)
|
||||
var id = base
|
||||
var n = 2
|
||||
while (saved.contains(id)) id = "$base-${n++}"
|
||||
val bundle = Bundle(id, manifest.name, manifest.version, androidId, linuxId)
|
||||
saved.edit().putString(id, JSONObject().put("name", bundle.name).put("version", bundle.version)
|
||||
.put("android", androidId).put("linux", linuxId).toString()).apply()
|
||||
Log.i(TAG, "installed bundle $id: display $androidId, runtime $linuxId")
|
||||
return bundle
|
||||
} catch (e: Exception) {
|
||||
androidId?.let { td.remove(it) }
|
||||
throw e
|
||||
} finally {
|
||||
FileUtils.delete(work)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unzip(context: Context, uri: Uri, dest: File) {
|
||||
val root = dest.canonicalFile
|
||||
var total = 0L
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
for (entry in generateSequence { zip.nextEntry }) {
|
||||
if (entry.isDirectory) continue
|
||||
val out = File(root, entry.name).canonicalFile
|
||||
if (!out.path.startsWith(root.path + File.separator)) throw IllegalArgumentException("Not a driver bundle: ${entry.name} points outside it")
|
||||
out.parentFile?.mkdirs()
|
||||
out.outputStream().use { sink ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val r = zip.read(buf)
|
||||
if (r <= 0) break
|
||||
total += r
|
||||
if (total > MAX_BYTES) throw IllegalArgumentException("Not a driver bundle: it unpacks to more than ${MAX_BYTES shr 20} MB")
|
||||
sink.write(buf, 0, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: throw IOException("cannot open $uri")
|
||||
}
|
||||
|
||||
/** Every installed bundle whose two halves are both still there. */
|
||||
fun all(context: Context): List<Bundle> {
|
||||
val td = TurnipDriver(context)
|
||||
val lm = LinuxVulkanDriverManager(context)
|
||||
return registry(context).all.mapNotNull { (id, raw) ->
|
||||
runCatching {
|
||||
val j = JSONObject(raw as String)
|
||||
Bundle(id, j.getString("name"), j.optString("version"), j.getString("android"), j.getString("linux"))
|
||||
}.getOrNull()
|
||||
}.filter { td.isInstalled(it.androidId) && lm.isInstalled(it.linuxId) }.sortedBy { it.id }
|
||||
}
|
||||
|
||||
fun get(context: Context, id: String): Bundle? = all(context).firstOrNull { it.id == id }
|
||||
|
||||
/** The bundle [driverId] is a half of, in the runtime list when [linux], else the display list. */
|
||||
fun containing(context: Context, driverId: String, linux: Boolean): Bundle? =
|
||||
all(context).firstOrNull { (if (linux) it.linuxId else it.androidId) == driverId }
|
||||
|
||||
/** The bundle both drivers are set to right now, if they are one. */
|
||||
fun active(context: Context): Bundle? {
|
||||
val android = SessionPrefs.androidDriver(context)
|
||||
val linux = SessionPrefs.linuxDriver(context)
|
||||
return all(context).firstOrNull { it.androidId == android && it.linuxId == linux }
|
||||
}
|
||||
|
||||
/** Set both halves as the drivers in use. */
|
||||
fun select(context: Context, bundle: Bundle) {
|
||||
SessionPrefs.setAndroidDriver(context, bundle.androidId)
|
||||
SessionPrefs.setLinuxDriver(context, bundle.linuxId)
|
||||
}
|
||||
|
||||
/** Delete both halves; a driver still set to either goes back to its default. */
|
||||
fun remove(context: Context, bundle: Bundle) {
|
||||
TurnipDriver(context).remove(bundle.androidId)
|
||||
LinuxVulkanDriverManager(context).removeDriver(bundle.linuxId)
|
||||
if (SessionPrefs.linuxDriver(context) == bundle.linuxId) SessionPrefs.setLinuxDriver(context, "")
|
||||
registry(context).edit().remove(bundle.id).apply()
|
||||
Log.i(TAG, "removed bundle ${bundle.id}")
|
||||
}
|
||||
|
||||
private fun registry(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import com.droiddeck.launcher.gpu.GpuInfo.Family
|
||||
|
||||
/**
|
||||
* The release drivers as matched pairs: a display build (the app's compositor, bionic) and a Linux
|
||||
* build (the runtime, glibc) that were made to run together. Mixing a runtime driver carrying a
|
||||
* patch with a display driver that lacks it is where the hard-to-read bugs come from, so the app
|
||||
* installs and selects both halves of one pair at once.
|
||||
*
|
||||
* - Banners-Turnip publishes each variant as a set in every release (`Turnip-<tag><variant>.zip`
|
||||
* and `...-Linux.zip`), rebuilt from upstream Mesa as it moves: the pair is one variant of the
|
||||
* newest release that carries both halves.
|
||||
* - WinNative ships its display (`v1.x`) and Linux (`linux-v0.x`) builds as two release lines with
|
||||
* their own numbers: the pair is the newest of each, of one flavour (Balanced or Performance).
|
||||
* - DroidDeck's DD-Turnip ships both halves in one zip per release (DriverBundle): the pair is the
|
||||
* newest bundle, and it can never be mixed.
|
||||
*/
|
||||
object DriverPairs {
|
||||
const val BANNER = "banner"
|
||||
const val BANNER_A8XX = "banner-a8xx"
|
||||
const val BANNER_710 = "banner-710-720-test"
|
||||
const val BANNER_ONEUI = "banner-8g2-oneui"
|
||||
const val WN_BALANCED = "wn-b"
|
||||
const val WN_PERFORMANCE = "wn-p"
|
||||
const val DD_TURNIP = "dd-turnip"
|
||||
/** Each DD-Turnip release is its own pair: "dd-turnip:DD-Turnip-v0.1.0". */
|
||||
fun ddTurnip(tag: String) = "$DD_TURNIP:$tag"
|
||||
|
||||
class DriverPair(
|
||||
val key: String,
|
||||
/** "Banners-Turnip · Adreno 6xx/7xx", "WinNative · Balanced". */
|
||||
val name: String,
|
||||
/** The families the pair is built for; the list only offers it on those unless asked. */
|
||||
val families: Set<Family>,
|
||||
val display: TurnipReleases.Asset?,
|
||||
val linux: TurnipReleases.Asset?,
|
||||
/** Both halves in one zip, instead of [display] and [linux]. */
|
||||
val bundle: TurnipReleases.Asset? = null,
|
||||
) {
|
||||
val complete: Boolean get() = bundle != null || (display != null && linux != null)
|
||||
/** The zips to download for the pair: the bundle, or the two halves. */
|
||||
val assets: List<TurnipReleases.Asset> get() = listOfNotNull(bundle ?: display, if (bundle == null) linux else null)
|
||||
/** "r4 · 1.19 + 0.1.2": the build of each half. */
|
||||
val version: String
|
||||
get() = listOfNotNull(bundle?.tag, display?.tag, linux?.tag).distinct().joinToString(" + ") { it.removePrefix("linux-").removePrefix("DD-Turnip-") }
|
||||
fun suits(gpu: GpuInfo) = gpu.family in families ||
|
||||
(gpu.family == Family.ADRENO_UNKNOWN && (Family.A7XX in families || Family.A8XX in families))
|
||||
}
|
||||
|
||||
private val ALL_ADRENO = setOf(Family.A6XX, Family.A7XX, Family.A7XX_LOW, Family.A8XX, Family.ADRENO_UNKNOWN)
|
||||
|
||||
private fun describe(key: String): Pair<String, Set<Family>> = when {
|
||||
key.startsWith(DD_TURNIP) -> "DroidDeck · DD-Turnip" to ALL_ADRENO
|
||||
else -> describeFixed(key)
|
||||
}
|
||||
|
||||
private fun describeFixed(key: String): Pair<String, Set<Family>> = when (key) {
|
||||
BANNER -> "Banners-Turnip · Adreno 6xx/7xx" to setOf(Family.A6XX, Family.A7XX)
|
||||
BANNER_A8XX -> "Banners-Turnip · Adreno 8xx" to setOf(Family.A8XX)
|
||||
BANNER_710 -> "Banners-Turnip · Adreno 710/720/722 (test)" to setOf(Family.A7XX_LOW)
|
||||
BANNER_ONEUI -> "Banners-Turnip · 8 Gen 2 on One UI" to setOf(Family.A7XX)
|
||||
WN_BALANCED -> "WinNative · Balanced" to ALL_ADRENO
|
||||
WN_PERFORMANCE -> "WinNative · Performance" to ALL_ADRENO
|
||||
else -> key to ALL_ADRENO
|
||||
}
|
||||
|
||||
/** Every pair the last release check found, in the order the list shows them. */
|
||||
fun from(check: TurnipReleases.Check?): List<DriverPair> {
|
||||
val assets = check?.assets.orEmpty().filter { it.pair.isNotEmpty() }
|
||||
val order = listOf(DD_TURNIP, BANNER, BANNER_A8XX, BANNER_ONEUI, BANNER_710, WN_BALANCED, WN_PERFORMANCE)
|
||||
return assets.map { it.pair }.distinct()
|
||||
.sortedBy { key -> order.indexOf(key.substringBefore(':')).let { i -> if (i < 0) order.size else i } }
|
||||
.map { key ->
|
||||
val (name, families) = describe(key)
|
||||
val halves = assets.filter { it.pair == key }
|
||||
halves.firstOrNull { it.bundle }?.let { return@map DriverPair(key, name, families, null, null, it) }
|
||||
// The list keeps the newest release of each half; for Banners-Turnip the two halves
|
||||
// must come from the same release, so a set missing one half in its newest release
|
||||
// is offered incomplete rather than mixed with an older one.
|
||||
val display = halves.firstOrNull { !it.linux }
|
||||
val linux = halves.firstOrNull { it.linux }
|
||||
val sameRelease = !key.startsWith(BANNER) || display?.tag == linux?.tag
|
||||
DriverPair(key, name, families, display, if (sameRelease) linux else null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair Auto sets for [gpu], among the [pairs] the last release check found: the newest
|
||||
* official DD-Turnip release on the Adreno 840 (840 and 840v2), WinNative Balanced on the other
|
||||
* 8xx (Banners' A8xx builds still break on some first boots) and on an 840 while no DD-Turnip
|
||||
* release is listed, the OneUI build for an 8 Gen 2 under One UI, the patched test builds for
|
||||
* the 710/720/722, and upstream Banners-Turnip for the rest. Null when nothing fits (not Adreno).
|
||||
*/
|
||||
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>): String? = when (gpu.family) {
|
||||
Family.NOT_ADRENO -> null
|
||||
Family.A8XX -> if (gpu.model == 840) newestDdTurnip(pairs) ?: WN_BALANCED else WN_BALANCED
|
||||
Family.A7XX_LOW -> BANNER_710
|
||||
Family.A7XX -> if (gpu.oneUi8Gen2) BANNER_ONEUI else BANNER
|
||||
Family.A6XX -> BANNER
|
||||
// KGSL gave no model: Android 16 arrived with the 8xx, so a device that new is taken as one.
|
||||
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
|
||||
}
|
||||
|
||||
/** The complete DD-Turnip pair with the highest version, or null when none is listed. */
|
||||
fun newestDdTurnip(pairs: List<DriverPair>): String? = pairs
|
||||
.filter { it.key.startsWith("$DD_TURNIP:") && it.complete }
|
||||
.maxByOrNull { p ->
|
||||
Regex("""(\d+)\.(\d+)\.(\d+)$""").find(p.key)?.destructured
|
||||
?.let { (major, minor, patch) -> major.toLong() * 1_000_000_000_000 + minor.toLong() * 1_000_000 + patch.toLong() } ?: -1L
|
||||
}?.key
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
|
||||
/**
|
||||
@@ -9,7 +10,7 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
*
|
||||
* Both engines generate on gamescope's final output inside our compositor, so they work for any
|
||||
* game with nothing changed in the runtime. Win-FG is our own and needs nothing; LSFG needs the
|
||||
* shader chain from the user's copy of Lossless Scaling (see [LsfgNative]). Every compositor
|
||||
* shader chain from the user's copy of Lossless Scaling (see [Lossless]). Every compositor
|
||||
* setter is a value store on the native side, so this can be applied before the compositor is
|
||||
* up and changed live from the main screen while a session runs.
|
||||
*/
|
||||
@@ -26,62 +27,77 @@ object FrameGen {
|
||||
private const val FLOW_SCALE_WINFG = 0.60f
|
||||
private const val FLOW_SCALE_LSFG = 0.80f
|
||||
|
||||
/** LSFG's adaptive targets: it generates as many frames as it takes to reach one. */
|
||||
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
|
||||
|
||||
private const val PREFS = "frame_gen"
|
||||
|
||||
fun engine(context: Context): String =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
|
||||
fun multiplier(context: Context): Int =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt("multiplier", 2).coerceIn(2, 4)
|
||||
|
||||
fun set(context: Context, engine: String, multiplier: Int) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", engine).putInt("multiplier", multiplier).apply()
|
||||
/** A fixed [multiplier] (2..4), or for LSFG a [target] frame rate (0 = fixed). */
|
||||
data class Mode(val engine: String, val multiplier: Int = 2, val target: Int = 0) {
|
||||
companion object {
|
||||
val OFF = Mode(ENGINE_OFF)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×" - for the main screen. */
|
||||
fun label(context: Context): String = label(engine(context), multiplier(context))
|
||||
fun engine(context: Context): String = mode(context).engine
|
||||
|
||||
fun label(engine: String, multiplier: Int): String = when (engine) {
|
||||
ENGINE_WINFG -> "Win-FG $multiplier×"
|
||||
ENGINE_LSFG -> "LSFG $multiplier×"
|
||||
else -> "Off"
|
||||
fun mode(context: Context): Mode {
|
||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
|
||||
val target = prefs.getInt("target", 0)
|
||||
if (engine == ENGINE_LSFG && target in ADAPTIVE_TARGETS) return Mode(engine, target = target)
|
||||
return Mode(engine, prefs.getInt("multiplier", 2).coerceIn(2, 4))
|
||||
}
|
||||
|
||||
fun set(context: Context, mode: Mode) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putString("engine", mode.engine).putInt("multiplier", mode.multiplier).putInt("target", mode.target).apply()
|
||||
}
|
||||
|
||||
/** "Off", "Win-FG 2×", "LSFG 3×", "LSFG Adaptive 90". */
|
||||
fun label(context: Context): String = label(context, mode(context))
|
||||
|
||||
fun label(context: Context, mode: Mode): String = when {
|
||||
mode.engine == ENGINE_WINFG -> context.getString(R.string.frame_gen_winfg, mode.multiplier)
|
||||
mode.engine == ENGINE_LSFG && mode.target > 0 -> context.getString(R.string.frame_gen_lsfg_adaptive, mode.target)
|
||||
mode.engine == ENGINE_LSFG -> context.getString(R.string.frame_gen_lsfg, mode.multiplier)
|
||||
else -> context.getString(R.string.frame_gen_off)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the saved setting into the compositor. Returns a message for the user when the
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null.
|
||||
* engine cannot run (LSFG without Lossless Scaling), or null. Blocks while a new or updated
|
||||
* Lossless.dll is prepared.
|
||||
*/
|
||||
fun apply(context: Context, refreshHz: Float): String? {
|
||||
val engine = engine(context)
|
||||
val multiplier = multiplier(context)
|
||||
var problem: String? = null
|
||||
when (engine) {
|
||||
val mode = mode(context)
|
||||
when (mode.engine) {
|
||||
ENGINE_LSFG -> {
|
||||
val status = LsfgNative.ensureCache(context)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
problem = if (status == LsfgNative.STATUS_NOT_INSTALLED)
|
||||
"LSFG needs Lossless Scaling installed from the Steam client"
|
||||
else "LSFG: " + LsfgNative.statusName(status)
|
||||
Log.w(TAG, problem)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
val status = Lossless.sync(context)
|
||||
val cache = Lossless.cacheFile(context)
|
||||
if (status != LsfgNative.STATUS_OK || cache == null) {
|
||||
WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
val problem = context.getString(
|
||||
if (status == LsfgNative.STATUS_NOT_INSTALLED) R.string.lsfg_problem_missing else R.string.lsfg_problem_failed,
|
||||
)
|
||||
Log.w(TAG, "$problem (status $status)")
|
||||
return problem
|
||||
}
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_LSFG)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(LsfgNative.cacheFile(context).absolutePath)
|
||||
WaylandCompositor.nativeSetLsfgCachePath(cache.absolutePath)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, mode.target)
|
||||
}
|
||||
ENGINE_WINFG -> {
|
||||
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_WINFG)
|
||||
WaylandCompositor.nativeSetWinFgTuning(WINFG_MODEL, WINFG_PERF_PRESET)
|
||||
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_WINFG, refreshHz)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, multiplier)
|
||||
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
|
||||
}
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0)
|
||||
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
|
||||
}
|
||||
Log.i(TAG, "frame generation: $engine x$multiplier at ${refreshHz}Hz")
|
||||
return problem
|
||||
Log.i(TAG, "frame generation: $mode at ${refreshHz}Hz")
|
||||
return null
|
||||
}
|
||||
|
||||
private const val TAG = "FrameGen"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.os.Build
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What GPU this is, in the terms the driver lists are sorted by. KGSL names the model
|
||||
* ("Adreno740v2", "Adreno825"); the family decides which Turnip builds run on it at all, and the
|
||||
* support level is what the app has actually been tested on (Adreno 725 and up) - an Adreno 610
|
||||
* may start, but it is not "supported" just because it is a Qualcomm chip.
|
||||
*/
|
||||
data class GpuInfo(
|
||||
/** "Adreno 740", or the vendor's own name when this is not an Adreno. */
|
||||
val name: String,
|
||||
/** The three-digit Adreno model (740, 825), or 0 when KGSL does not say. */
|
||||
val model: Int,
|
||||
val family: Family,
|
||||
/** The SoC as the device reports it ("SM8550", "QCS8550"), or "" when it does not. */
|
||||
val soc: String,
|
||||
/** Samsung's One UI on an 8 Gen 2: its Turnip needs the OneUI build, or frames tear and flicker. */
|
||||
val oneUi8Gen2: Boolean,
|
||||
) {
|
||||
enum class Family(val label: String) {
|
||||
A8XX("Adreno 8xx"),
|
||||
/** Adreno 710/720/722: gen 7 cores cut down enough to need their own patches. */
|
||||
A7XX_LOW("Adreno 710/720/722"),
|
||||
A7XX("Adreno 7xx"),
|
||||
A6XX("Adreno 6xx"),
|
||||
/** An Adreno whose model KGSL does not give: treated as the newest family it could be. */
|
||||
ADRENO_UNKNOWN("Adreno"),
|
||||
NOT_ADRENO("Not an Adreno GPU"),
|
||||
}
|
||||
|
||||
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
|
||||
|
||||
val support: Support
|
||||
get() = when {
|
||||
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
|
||||
family == Family.A8XX -> Support.TESTED
|
||||
family == Family.A7XX && model >= 725 -> Support.TESTED
|
||||
else -> Support.UNTESTED
|
||||
}
|
||||
|
||||
/** One line for the device card and the system check. */
|
||||
val supportText: String
|
||||
get() = when (support) {
|
||||
Support.TESTED -> "Supported"
|
||||
Support.UNTESTED -> if (family == Family.A7XX_LOW) "Experimental: its drivers are test builds"
|
||||
else "Below tested hardware (Adreno 725 and newer): it may not run"
|
||||
Support.UNSUPPORTED -> "Not supported: DroidDeck needs an Adreno (Snapdragon) GPU"
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun detect(): GpuInfo {
|
||||
val adreno = File("/sys/class/kgsl/kgsl-3d0").exists() || File("/vendor/lib64/hw/vulkan.adreno.so").exists()
|
||||
val raw = listOf("/sys/class/kgsl/kgsl-3d0/gpu_model", "/sys/class/kgsl/kgsl-3d0/gpu_chipid")
|
||||
.firstNotNullOfOrNull { FileUtils.readString(File(it))?.trim()?.takeIf(String::isNotEmpty) }
|
||||
val soc = if (Build.VERSION.SDK_INT >= 31) Build.SOC_MODEL.takeIf { it.isNotBlank() && it != Build.UNKNOWN }.orEmpty() else ""
|
||||
val model = raw?.let { Regex("""(\d{3})""").find(it)?.groupValues?.get(1)?.toIntOrNull() } ?: 0
|
||||
val family = familyOf(adreno, model)
|
||||
val samsung = Build.MANUFACTURER.equals("samsung", ignoreCase = true)
|
||||
return GpuInfo(
|
||||
name = if (!adreno) Build.HARDWARE.ifBlank { "this GPU" } else if (model > 0) "Adreno $model" else "Adreno",
|
||||
model = model, family = family, soc = soc,
|
||||
oneUi8Gen2 = samsung && model == 740,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun familyOf(adreno: Boolean, model: Int): Family = when {
|
||||
!adreno -> Family.NOT_ADRENO
|
||||
model == 0 -> Family.ADRENO_UNKNOWN
|
||||
model >= 800 -> Family.A8XX
|
||||
model in listOf(710, 720, 722) -> Family.A7XX_LOW
|
||||
model >= 700 -> Family.A7XX
|
||||
model >= 600 -> Family.A6XX
|
||||
else -> Family.ADRENO_UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,9 +126,7 @@ public class LinuxVulkanDriverManager {
|
||||
* Linux driver; {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installDriver(Uri zipUri, String displayName) throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
String soName = null;
|
||||
@@ -156,9 +154,32 @@ public class LinuxVulkanDriverManager {
|
||||
}
|
||||
}
|
||||
// The zip's own freedreno_icd.aarch64.json is dropped on purpose: its
|
||||
// library_path is relative to itself, and we write an absolute one below.
|
||||
// library_path is relative to itself, and adopt() writes an absolute one.
|
||||
}
|
||||
}
|
||||
String id = adopt(tmpDir, soName, zipMeta, displayName);
|
||||
keep = true;
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
File tmpDir = new File(rootDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver ({@link #LIB_NAME} in {@code tmpDir}, copied from {@code soName} in
|
||||
* the zip), write its manifest and meta.json, and move it in. Returns the id; the caller
|
||||
* deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String soName, JSONObject zipMeta, String displayName) throws IOException {
|
||||
try {
|
||||
if (soName == null) {
|
||||
throw new IllegalArgumentException("No libvulkan_freedreno*.so in this zip. An Android "
|
||||
+ "(AdrenoTools) or -Wayland Turnip zip is not a Linux runtime driver.");
|
||||
@@ -212,13 +233,10 @@ public class LinuxVulkanDriverManager {
|
||||
if (!FileUtils.writeString(new File(tmpDir, META_NAME), meta.toString(2))) throw new IOException("cannot write meta.json");
|
||||
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported Linux Vulkan driver " + id + " (" + soName + ", minGlibc=" + minGlibc + ") -> " + dir);
|
||||
return id;
|
||||
} catch (org.json.JSONException e) {
|
||||
throw new IOException("manifest write failed: " + e.getMessage());
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.droiddeck.launcher.gpu
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.security.DigestOutputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* LSFG's shader chain, from the user's own Lossless Scaling (Steam app 993090).
|
||||
*
|
||||
* The copy the Steam client installs, in any library it installs to, is taken by
|
||||
* itself and taken again whenever the client updates it. Once the session has seen the account
|
||||
* own the app, a Lossless.dll can also be imported by hand; it stays until the Steam copy changes
|
||||
* or another is imported. The DLL is copied in and its shaders are built into a cache named after
|
||||
* its contents, so a different DLL hands the compositor a different path and its engine reloads.
|
||||
* Everything here blocks: call it off the main thread.
|
||||
*/
|
||||
object Lossless {
|
||||
enum class Source { NONE, STEAM, IMPORT }
|
||||
|
||||
enum class ImportResult { UPDATED, UNCHANGED, NOT_OWNED, INVALID, FAILED }
|
||||
|
||||
data class State(val owned: Boolean, val ready: Boolean, val source: Source) {
|
||||
companion object {
|
||||
val NONE = State(owned = false, ready = false, source = Source.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "Lossless"
|
||||
private const val PREFS = "lossless"
|
||||
private const val KEY_OWNED = "owned"
|
||||
private const val KEY_ID = "id"
|
||||
private const val KEY_SOURCE = "source"
|
||||
private const val KEY_STEAM_STAMP = "steamStamp"
|
||||
private const val STEAM_DLL = "steamapps/common/Lossless Scaling/Lossless.dll"
|
||||
private const val UNCHANGED = -1
|
||||
|
||||
fun state(context: Context): State {
|
||||
val source = prefs(context).getString(KEY_SOURCE, null)
|
||||
?.let { name -> Source.entries.firstOrNull { it.name == name } } ?: Source.NONE
|
||||
return State(owned(context), cacheFile(context)?.isFile == true, source)
|
||||
}
|
||||
|
||||
/** Recorded once: by the session's ownership check (a flag in the guest's home) or a Steam copy. */
|
||||
fun owned(context: Context): Boolean {
|
||||
if (prefs(context).getBoolean(KEY_OWNED, false)) return true
|
||||
if (!ownedFlag(context).exists()) return false
|
||||
markOwned(context)
|
||||
return true
|
||||
}
|
||||
|
||||
fun cacheFile(context: Context): File? = prefs(context).getString(KEY_ID, null)?.let { cacheFor(context, it) }
|
||||
|
||||
/** Takes a new or updated Steam copy and rebuilds a missing or outdated cache. A [LsfgNative] status. */
|
||||
@Synchronized
|
||||
fun sync(context: Context): Int {
|
||||
val steam = steamDll(context)
|
||||
if (steam != null) {
|
||||
markOwned(context)
|
||||
val stamp = "${steam.length()}:${steam.lastModified()}"
|
||||
if (stamp != prefs(context).getString(KEY_STEAM_STAMP, null)) {
|
||||
val status = adopt(context, steam, Source.STEAM)
|
||||
if (status != LsfgNative.STATUS_OK && status != UNCHANGED) {
|
||||
Log.w(TAG, "Steam copy $steam not taken: ${statusName(status)}")
|
||||
}
|
||||
prefs(context).edit().putString(KEY_STEAM_STAMP, stamp).apply()
|
||||
}
|
||||
}
|
||||
return ensureBuilt(context)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun import(context: Context, dll: File): ImportResult {
|
||||
if (!owned(context)) return ImportResult.NOT_OWNED
|
||||
return when (adopt(context, dll, Source.IMPORT)) {
|
||||
UNCHANGED -> ImportResult.UNCHANGED
|
||||
LsfgNative.STATUS_OK -> ImportResult.UPDATED
|
||||
LsfgNative.STATUS_UNREADABLE_FILE, LsfgNative.STATUS_NOT_PORTABLE_EXECUTABLE,
|
||||
LsfgNative.STATUS_MISSING_SHADERS -> ImportResult.INVALID
|
||||
else -> ImportResult.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
fun message(context: Context, result: ImportResult): String = context.getString(
|
||||
when (result) {
|
||||
ImportResult.UPDATED -> R.string.lsfg_imported
|
||||
ImportResult.UNCHANGED -> R.string.lsfg_import_unchanged
|
||||
ImportResult.NOT_OWNED -> R.string.lsfg_import_not_owned
|
||||
ImportResult.INVALID -> R.string.lsfg_import_invalid
|
||||
ImportResult.FAILED -> R.string.lsfg_import_failed
|
||||
},
|
||||
)
|
||||
|
||||
private fun ensureBuilt(context: Context): Int {
|
||||
val id = prefs(context).getString(KEY_ID, null) ?: return LsfgNative.STATUS_NOT_INSTALLED
|
||||
val cache = cacheFor(context, id)
|
||||
if (usable(cache)) return LsfgNative.STATUS_OK
|
||||
val dll = storedDll(context)
|
||||
if (!dll.isFile) return LsfgNative.STATUS_NOT_INSTALLED
|
||||
return build(dll, cache)
|
||||
}
|
||||
|
||||
private fun adopt(context: Context, source: File, from: Source): Int {
|
||||
val staged = File(dir(context).apply { mkdirs() }, "Lossless.dll.staged")
|
||||
try {
|
||||
val id = copyHashing(source, staged)
|
||||
if (id == prefs(context).getString(KEY_ID, null) && usable(cacheFor(context, id))) return UNCHANGED
|
||||
val cache = cacheFor(context, id)
|
||||
var status = LsfgNative.nativeValidateDll(staged.path)
|
||||
if (status == LsfgNative.STATUS_OK) status = build(staged, cache)
|
||||
if (status != LsfgNative.STATUS_OK) {
|
||||
cache.delete()
|
||||
return status
|
||||
}
|
||||
if (!staged.renameTo(storedDll(context))) {
|
||||
cache.delete()
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
prefs(context).edit().putString(KEY_ID, id).putString(KEY_SOURCE, from.name).apply()
|
||||
removeStale(context, id)
|
||||
Log.i(TAG, "Lossless.dll from $source ($from) ready as $id")
|
||||
return LsfgNative.STATUS_OK
|
||||
} catch (e: java.io.IOException) {
|
||||
Log.w(TAG, "could not copy $source", e)
|
||||
return LsfgNative.STATUS_UNREADABLE_FILE
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "could not take $source", t)
|
||||
return LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
} finally {
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(dll: File, cache: File): Int = try {
|
||||
val status = LsfgNative.nativeBuildCache(dll.path, cache.path, true)
|
||||
Log.i(TAG, "shader cache ${cache.name}: ${statusName(status)}" +
|
||||
if (status == LsfgNative.STATUS_OK) " (${LsfgNative.nativeVariantName(LsfgNative.nativeCacheVariant(cache.path))})" else "")
|
||||
status
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "shader cache ${cache.name}", t)
|
||||
LsfgNative.STATUS_CACHE_UNUSABLE
|
||||
}
|
||||
|
||||
private fun usable(cache: File): Boolean = cache.isFile &&
|
||||
runCatching { LsfgNative.nativeCacheVariant(cache.path) != LsfgNative.VARIANT_NONE }.getOrDefault(false)
|
||||
|
||||
private fun copyHashing(source: File, target: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
source.inputStream().use { input ->
|
||||
DigestOutputStream(target.outputStream(), digest).use { output -> input.copyTo(output) }
|
||||
}
|
||||
return digest.digest().take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun removeStale(context: Context, keep: String) {
|
||||
File(context.filesDir, "lsfg-native").deleteRecursively()
|
||||
dir(context).listFiles { f -> f.name.startsWith("shaders-") && f.name != cacheFor(context, keep).name }
|
||||
?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
/** The newest copy in any library the client installs to: its own, the card's, the Games folders. */
|
||||
private fun steamDll(context: Context): File? {
|
||||
val libraries = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"),
|
||||
GameStorage.effective(context)?.path?.let(::File),
|
||||
) + SessionPrefs.addedGamesDirs(context).map(::File)
|
||||
return libraries.map { File(it, STEAM_DLL) }.filter { it.isFile }.maxByOrNull { it.lastModified() }
|
||||
}
|
||||
|
||||
private fun markOwned(context: Context) {
|
||||
if (!prefs(context).getBoolean(KEY_OWNED, false)) prefs(context).edit().putBoolean(KEY_OWNED, true).apply()
|
||||
}
|
||||
|
||||
private fun statusName(status: Int): String =
|
||||
runCatching { LsfgNative.nativeStatusName(status) }.getOrDefault("status $status")
|
||||
|
||||
private fun ownedFlag(context: Context) = File(LinuxRuntime.rootDir(context), "root/.config/droiddeck/lossless-owned")
|
||||
private fun dir(context: Context) = File(context.filesDir, "lsfg")
|
||||
private fun storedDll(context: Context) = File(dir(context), "Lossless.dll")
|
||||
private fun cacheFor(context: Context, id: String) = File(dir(context), "shaders-$id.cache")
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -1,99 +1,30 @@
|
||||
package com.droiddeck.launcher.gpu;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
* LSFG frame generation's shader chain: extracted from the user's own copy of Lossless Scaling
|
||||
* and cached on device.
|
||||
*
|
||||
* <p>The 25 compute shaders are not redistributable. They live as resources inside the user's
|
||||
* paid {@code Lossless.dll} (Steam app 993090), which in this app the user installs from the
|
||||
* Steam client itself - so the DLL sits in the runtime's own Steam library. It is mapped
|
||||
* read-only and parsed as data, never loaded or executed. Extraction is slow enough to be worth
|
||||
* doing once (the DXBC chain is translated to SPIR-V), so the result is cached, keyed on the
|
||||
* DLL's size and hash. Call {@link #ensureCache} off the main thread.
|
||||
*
|
||||
* <p>Ported from Bannerlator's LsfgNative; the native side is the same lsfg_jni.cpp compiled into
|
||||
* the compositor library.
|
||||
* The native half of LSFG's shader cache (lsfg_jni.cpp, compiled into the compositor library):
|
||||
* {@code Lossless.dll} is mapped read-only and parsed as data, never loaded or executed, and its
|
||||
* DXBC chain translated to SPIR-V. {@link Lossless} decides when; these calls block.
|
||||
*/
|
||||
public final class LsfgNative {
|
||||
private static final String TAG = "LsfgNative";
|
||||
|
||||
final class LsfgNative {
|
||||
static { System.loadLibrary("bannerwayland"); }
|
||||
|
||||
private LsfgNative() {}
|
||||
|
||||
// Mirrors lsfg::DllStatus.
|
||||
public static final int STATUS_OK = 0;
|
||||
public static final int STATUS_NOT_INSTALLED = 1;
|
||||
public static final int STATUS_UNREADABLE_FILE = 2;
|
||||
public static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
public static final int STATUS_MISSING_SHADERS = 4;
|
||||
public static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
public static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
static final int STATUS_OK = 0;
|
||||
static final int STATUS_NOT_INSTALLED = 1;
|
||||
static final int STATUS_UNREADABLE_FILE = 2;
|
||||
static final int STATUS_NOT_PORTABLE_EXECUTABLE = 3;
|
||||
static final int STATUS_MISSING_SHADERS = 4;
|
||||
static final int STATUS_TRANSLATION_FAILED = 5;
|
||||
static final int STATUS_CACHE_UNUSABLE = 6;
|
||||
|
||||
private static native int nativeValidateDll(String dllPath);
|
||||
private static native int nativeDllVariant(String dllPath, boolean preferFp16);
|
||||
private static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
private static native boolean nativeCacheMatchesSource(String cachePath, String dllPath);
|
||||
private static native int nativeCacheVariant(String cachePath);
|
||||
private static native String nativeStatusName(int status);
|
||||
private static native String nativeVariantName(int variant);
|
||||
// Mirrors lsfg::Variant.
|
||||
static final int VARIANT_NONE = 0;
|
||||
|
||||
/** Where Lossless Scaling lands when installed from the client inside the runtime. */
|
||||
public static File losslessDll(Context context) {
|
||||
return new File(LinuxRuntime.rootDir(context),
|
||||
"root/.local/share/Steam/steamapps/common/Lossless Scaling/Lossless.dll");
|
||||
}
|
||||
|
||||
/** Where the translated SPIR-V chain is cached. */
|
||||
public static File cacheFile(Context context) {
|
||||
return new File(context.getFilesDir(), "lsfg-native/shaders.cache");
|
||||
}
|
||||
|
||||
public static boolean isInstalled(Context context) {
|
||||
return losslessDll(context).isFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure a usable cache exists for the installed DLL, building it if the DLL is new or
|
||||
* changed. Returns a {@code STATUS_*} code; {@link #STATUS_OK} means the engine can start.
|
||||
*/
|
||||
public static int ensureCache(Context context) {
|
||||
File dll = losslessDll(context);
|
||||
if (!dll.isFile()) return STATUS_NOT_INSTALLED;
|
||||
File cache = cacheFile(context);
|
||||
try {
|
||||
if (cache.isFile() && nativeCacheMatchesSource(cache.getPath(), dll.getPath())) {
|
||||
return STATUS_OK;
|
||||
}
|
||||
int status = nativeValidateDll(dll.getPath());
|
||||
if (status != STATUS_OK) {
|
||||
Log.w(TAG, "Lossless.dll rejected: " + statusName(status));
|
||||
return status;
|
||||
}
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
cache.getParentFile().mkdirs();
|
||||
status = nativeBuildCache(dll.getPath(), cache.getPath(), true);
|
||||
Log.i(TAG, "cache build: " + statusName(status)
|
||||
+ (status == STATUS_OK ? " (" + nativeVariantName(nativeCacheVariant(cache.getPath())) + ")" : ""));
|
||||
return status;
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "ensureCache", t);
|
||||
return STATUS_CACHE_UNUSABLE;
|
||||
}
|
||||
}
|
||||
|
||||
public static String statusName(int status) {
|
||||
try {
|
||||
return nativeStatusName(status);
|
||||
} catch (Throwable t) {
|
||||
return "status " + status;
|
||||
}
|
||||
}
|
||||
static native int nativeValidateDll(String dllPath);
|
||||
static native int nativeBuildCache(String dllPath, String cachePath, boolean preferFp16);
|
||||
static native int nativeCacheVariant(String cachePath);
|
||||
static native String nativeStatusName(int status);
|
||||
static native String nativeVariantName(int variant);
|
||||
}
|
||||
@@ -179,10 +179,7 @@ public final class TurnipDriver {
|
||||
* {@link IOException} on read/extract failures.
|
||||
*/
|
||||
public String installFromZip(Uri zipUri, String displayName) throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.currentTimeMillis());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
File tmpDir = newStagingDir();
|
||||
boolean keep = false;
|
||||
try {
|
||||
try (InputStream is = context.getContentResolver().openInputStream(zipUri);
|
||||
@@ -198,26 +195,44 @@ public final class TurnipDriver {
|
||||
Files.copy(zis, new File(tmpDir, base).toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
String id = adopt(tmpDir, displayName);
|
||||
keep = true;
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
} finally {
|
||||
if (!keep) FileUtils.delete(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
/** An empty directory beside the installed drivers, for a driver's files before {@link #adopt}. */
|
||||
File newStagingDir() throws IOException {
|
||||
if (!contentDir.exists()) contentDir.mkdirs();
|
||||
File tmpDir = new File(contentDir, ".tmp-" + System.nanoTime());
|
||||
FileUtils.delete(tmpDir);
|
||||
if (!tmpDir.mkdirs()) throw new IOException("cannot create " + tmpDir);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a staged driver (meta.json and its library, flat) and move it in as a new driver.
|
||||
* Returns the id; the caller deletes {@code tmpDir} when this throws.
|
||||
*/
|
||||
String adopt(File tmpDir, String displayName) throws IOException {
|
||||
String rejected = rejectionReason(tmpDir);
|
||||
if (rejected != null) throw new IllegalArgumentException("Not an AdrenoTools driver: " + rejected);
|
||||
|
||||
JSONObject meta = readMetaFile(new File(tmpDir, "meta.json"));
|
||||
String name = meta != null ? meta.optString("name", "") : "";
|
||||
if (name.isEmpty()) {
|
||||
name = displayName != null ? displayName : "adrenotools-driver";
|
||||
if (name.toLowerCase().endsWith(".zip")) name = name.substring(0, name.length() - 4);
|
||||
}
|
||||
String id = uniqueId(LinuxVulkanDriverManager.sanitizeId(name));
|
||||
File dir = new File(contentDir, id);
|
||||
if (!tmpDir.renameTo(dir)) throw new IOException("cannot move into " + dir);
|
||||
Log.i(TAG, "imported AdrenoTools driver " + id + " (" + (meta != null ? meta.optString("libraryName", "") : "?") + ") -> " + dir);
|
||||
return id;
|
||||
}
|
||||
|
||||
private static JSONObject readMetaFile(File metaFile) {
|
||||
try {
|
||||
return new JSONObject(FileUtils.readString(metaFile));
|
||||
|
||||
@@ -29,12 +29,20 @@ object TurnipReleases {
|
||||
|
||||
/** [label] names the variant for the menu: the GPUs it is for, and a build flavour. */
|
||||
/** [sha256] is the release asset's digest from GitHub; empty for a list cached before it was kept. */
|
||||
/**
|
||||
* [pair] names the matched set an asset belongs to (DriverPairs): a display build and a Linux
|
||||
* build of the same pair are made to run together. "" for a list cached before pairs were kept.
|
||||
*/
|
||||
/** [bundle]: one zip holding both halves of the pair (DriverBundle); [linux] is false for it. */
|
||||
class Asset(val source: String, val tag: String, val name: String, val url: String, val size: Long, val linux: Boolean, val label: String,
|
||||
val sha256: String = "")
|
||||
val sha256: String = "", val pair: String = "", val bundle: Boolean = false)
|
||||
/** [latest] = each source's newest release tag, for the refresh line. */
|
||||
class Check(val assets: List<Asset>, val latest: List<Pair<String, String>>, val failed: List<String>, val checkedAt: Long)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Pair<Boolean, String>?)
|
||||
/** What an asset is: a Linux build or not, the menu label, and the pair it belongs to. */
|
||||
private class Kind(val linux: Boolean, val label: String, val pair: String, val bundle: Boolean = false)
|
||||
|
||||
private class Source(val label: String, val repo: String, val classify: (name: String, tag: String) -> Kind?)
|
||||
|
||||
private val SOURCES = listOf(
|
||||
// Turnip-<tag>[-variant][-Linux|-Wayland].zip; -Wayland is for a path this app does not have.
|
||||
@@ -45,12 +53,14 @@ object TurnipReleases {
|
||||
if (variant.endsWith("-Wayland")) return@Source null
|
||||
val linux = variant.endsWith("-Linux")
|
||||
variant = variant.removeSuffix("-Linux")
|
||||
linux to when {
|
||||
val label = when {
|
||||
variant.isEmpty() -> "Adreno 6xx/7xx"
|
||||
variant == "-A8xx" -> "Adreno 8xx"
|
||||
variant.startsWith("-710-720") -> "Adreno 710/720" + if (variant.contains("Test")) " (test)" else ""
|
||||
variant.contains("OneUI", ignoreCase = true) -> "8 Gen 2 on One UI"
|
||||
else -> variant.trimStart('-')
|
||||
}
|
||||
Kind(linux, label, "banner" + variant.lowercase())
|
||||
},
|
||||
// WN-Turnip-<ver>-<b|p>_Axxx.zip (Android) and WN-Linux-Turnip-<ver>-<b|p>_Axxx.zip (Linux):
|
||||
// b = Balanced, p = Performance, Axxx = every Adreno.
|
||||
@@ -58,7 +68,13 @@ object TurnipReleases {
|
||||
val m = Regex("""^WN-(Linux-)?Turnip-[^-]+-([a-z]+)_(\w+)\.zip$""").find(name) ?: return@Source null
|
||||
val flavour = when (m.groupValues[2]) { "b" -> "Balanced"; "p" -> "Performance"; else -> m.groupValues[2] }
|
||||
val gpus = if (m.groupValues[3] == "Axxx") "all Adreno" else m.groupValues[3]
|
||||
(m.groupValues[1].isNotEmpty()) to "$gpus · $flavour"
|
||||
Kind(m.groupValues[1].isNotEmpty(), "$gpus · $flavour", "wn-" + m.groupValues[2])
|
||||
},
|
||||
// DD-Turnip-v<ver>.zip: DroidDeck's own all-in-one build, both halves in one zip, published
|
||||
// to Drivers-CI (releases only). Every release is offered, not just the newest, each its own pair.
|
||||
Source("DroidDeck", "Droid-Deck/Drivers-CI") { name, tag ->
|
||||
if (!Regex("""^DD-Turnip-v\d+\.\d+\.\d+\.zip$""").matches(name)) return@Source null
|
||||
Kind(false, "Android + Linux · all Adreno", DriverPairs.ddTurnip(tag), bundle = true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -91,14 +107,19 @@ object TurnipReleases {
|
||||
for (j in 0 until list.length()) {
|
||||
val a = list.getJSONObject(j)
|
||||
val name = a.getString("name")
|
||||
val (linux, label) = src.classify(name, tag) ?: continue
|
||||
val kind = src.classify(name, tag) ?: continue
|
||||
// A bundle is only offered from an official release, never a pre-release.
|
||||
if (kind.bundle && r.optBoolean("prerelease")) continue
|
||||
val linux = kind.linux
|
||||
val label = kind.label
|
||||
// Only assets GitHub has a sha256 for are offered: the download is checked against it.
|
||||
val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
|
||||
// Newest first: the first release carrying a variant is the one offered.
|
||||
if (!seen.add("$linux|$label")) continue
|
||||
if (!seen.add(if (kind.bundle) kind.pair else "$linux|$label")) continue
|
||||
assets.put(JSONObject().put("source", src.label).put("tag", tag).put("name", name)
|
||||
.put("url", a.getString("browser_download_url")).put("size", a.optLong("size"))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha))
|
||||
.put("linux", linux).put("label", label).put("sha256", sha).put("pair", kind.pair)
|
||||
.put("bundle", kind.bundle))
|
||||
}
|
||||
}
|
||||
if (newest != null) latest.put(JSONObject().put("source", src.label).put("tag", newest))
|
||||
@@ -136,7 +157,8 @@ object TurnipReleases {
|
||||
for (i in 0 until list.length()) {
|
||||
val a = list.getJSONObject(i)
|
||||
out.add(Asset(a.getString("source"), a.getString("tag"), a.getString("name"), a.getString("url"),
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", "")))
|
||||
a.optLong("size"), a.getBoolean("linux"), a.getString("label"), a.optString("sha256", ""), a.optString("pair", ""),
|
||||
a.optBoolean("bundle", false)))
|
||||
}
|
||||
val latest = ArrayList<Pair<String, String>>()
|
||||
val l = json.optJSONArray("latest") ?: JSONArray()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
/**
|
||||
* The Steam Deck controller's back grips and trackpads, which a handheld's own pad does not have:
|
||||
* the second screen's Deck controls (DeckControlsPanel) drive them, and each change goes into the
|
||||
* pad's ring ([FakeInputWriter.writeDeckControls]) for libfakeinput's Deck report.
|
||||
*
|
||||
* Trackpad positions are -1..1 each way with y up, as on a Deck; a pad touched reports its
|
||||
* position, a pad clicked also reports full pressure.
|
||||
*/
|
||||
object DeckControls {
|
||||
/** Bits of the ring's controls word - MUST match DECK_EXTRA_* in fakeinput_steam.cpp. */
|
||||
const val L4 = 1
|
||||
const val R4 = 2
|
||||
const val L5 = 4
|
||||
const val R5 = 8
|
||||
private const val LEFT_TOUCH = 16
|
||||
private const val RIGHT_TOUCH = 32
|
||||
private const val LEFT_CLICK = 64
|
||||
private const val RIGHT_CLICK = 128
|
||||
|
||||
/** The pad the Deck controller is made from (PadBridge's slot). */
|
||||
private const val SLOT = 0
|
||||
|
||||
private var controls = 0
|
||||
private val pads = ShortArray(4)
|
||||
private val pressure = ShortArray(2)
|
||||
|
||||
@Synchronized
|
||||
fun setGrip(bit: Int, down: Boolean) {
|
||||
set(bit, down)
|
||||
publish()
|
||||
}
|
||||
|
||||
/** A finger on (or off) one trackpad at x, y in -1..1, y up. */
|
||||
@Synchronized
|
||||
fun setPad(right: Boolean, touching: Boolean, x: Float = 0f, y: Float = 0f) {
|
||||
val at = if (right) 2 else 0
|
||||
set(if (right) RIGHT_TOUCH else LEFT_TOUCH, touching)
|
||||
pads[at] = if (touching) axis(x) else 0
|
||||
pads[at + 1] = if (touching) axis(y) else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun setClick(right: Boolean, down: Boolean) {
|
||||
set(if (right) RIGHT_CLICK else LEFT_CLICK, down)
|
||||
pressure[if (right) 1 else 0] = if (down) Short.MAX_VALUE else 0
|
||||
publish()
|
||||
}
|
||||
|
||||
/** Everything let go - for when the panel goes away with a finger still on it. */
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
if (controls == 0 && pads.all { it.toInt() == 0 } && pressure.all { it.toInt() == 0 }) return
|
||||
controls = 0
|
||||
pads.fill(0)
|
||||
pressure.fill(0)
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun set(bit: Int, on: Boolean) {
|
||||
controls = if (on) controls or bit else controls and bit.inv()
|
||||
}
|
||||
|
||||
private fun axis(value: Float): Short = (value.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
|
||||
|
||||
private fun publish() = FakeInputWriter.writeDeckControls(SLOT, controls, pads, pressure)
|
||||
}
|
||||
@@ -67,6 +67,9 @@ public class FakeInputWriter {
|
||||
/** Bit 10 of the snapshot word. The first ten are PadState's own bit order; the Steam
|
||||
* button is not, because PadState keeps 10 and 11 for the triggers. */
|
||||
private static final int SNAPSHOT_IDX_MODE = 10;
|
||||
/** Bit 11: the Quick Access button. Snapshot only - an Xbox pad's evdev node has no such key;
|
||||
* libfakeinput's Deck controller reads it from here. */
|
||||
private static final int SNAPSHOT_IDX_QAM = 11;
|
||||
|
||||
private static final int EVENT_SIZE = 24;
|
||||
static final int MAX_EVENTS_PER_UPDATE = 32;
|
||||
@@ -92,6 +95,18 @@ public class FakeInputWriter {
|
||||
private static final int RING_SNAPSHOT_BUTTONS_OFFSET = 40;
|
||||
private static final int RING_SNAPSHOT_AXES_OFFSET = 44; // short[8]
|
||||
private static final String RING_DIR_NAME = "fakeinput-rings";
|
||||
// After the events - MUST match the block in fakeinput_steam.cpp (DeckImu): what a Deck has and
|
||||
// an Xbox pad does not - motion, back grips, trackpads - in the Steam Deck controller's axes and
|
||||
// units, under a seqlock like the snapshot's. Only libfakeinput's Deck controller reads it.
|
||||
private static final int IMU_OFFSET = RING_SIZE;
|
||||
private static final int IMU_MAGIC = 0x31554D49; // IMU1
|
||||
private static final int IMU_SEQ_OFFSET = IMU_OFFSET + 8;
|
||||
private static final int IMU_ACCEL_OFFSET = IMU_OFFSET + 16; // short[3]
|
||||
private static final int IMU_GYRO_OFFSET = IMU_OFFSET + 22; // short[3]
|
||||
private static final int DECK_PADS_OFFSET = IMU_OFFSET + 28; // short[4]: left X, Y, right X, Y
|
||||
private static final int DECK_PRESSURE_OFFSET = IMU_OFFSET + 36; // short[2]: left, right
|
||||
private static final int DECK_CONTROLS_OFFSET = IMU_OFFSET + 40; // int: DeckControls bits
|
||||
private static final int RING_FILE_SIZE = RING_SIZE + 64;
|
||||
|
||||
private static final Object RING_LOCK = new Object();
|
||||
private static final RingSlot[] RING_SLOTS = new RingSlot[MAX_FAKE_INPUT_SLOTS];
|
||||
@@ -110,6 +125,7 @@ public class FakeInputWriter {
|
||||
private volatile boolean destroyed = false;
|
||||
|
||||
private final boolean[] prevButtonStates = new boolean[12];
|
||||
private boolean prevQam;
|
||||
private int prevThumbLX, prevThumbLY, prevThumbRX, prevThumbRY;
|
||||
private int prevTriggerL, prevTriggerR;
|
||||
private int prevHatX, prevHatY;
|
||||
@@ -217,30 +233,40 @@ public class FakeInputWriter {
|
||||
for (int i = 0; i < 8; i++) {
|
||||
data.putShort(RING_SNAPSHOT_AXES_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(IMU_OFFSET, 0);
|
||||
data.putLong(IMU_SEQ_OFFSET, 0L);
|
||||
for (int i = 0; i < 12; i++) {
|
||||
data.putShort(IMU_ACCEL_OFFSET + (i * 2), (short) 0);
|
||||
}
|
||||
data.putInt(DECK_CONTROLS_OFFSET, 0);
|
||||
}
|
||||
|
||||
// Lock order: RING_LOCK, then the slot. Every writer holds the slot while it touches data, and
|
||||
// re-checks it there: a writer that looked the slot up just before this ran finds it gone.
|
||||
private static void releaseRingSlotLocked(int slot) {
|
||||
RingSlot ringSlot = RING_SLOTS[slot];
|
||||
if (ringSlot == null) {
|
||||
return;
|
||||
}
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
synchronized (ringSlot) {
|
||||
ringSlot.data = null;
|
||||
if (ringSlot.ringChannel != null) {
|
||||
try {
|
||||
ringSlot.ringChannel.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
ringSlot.ringChannel = null;
|
||||
}
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
if (ringSlot.ringRaf != null) {
|
||||
try {
|
||||
ringSlot.ringRaf.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
ringSlot.ringRaf = null;
|
||||
}
|
||||
if (ringSlot.ringFile != null && ringSlot.ringFile.exists()) {
|
||||
ringSlot.ringFile.delete();
|
||||
}
|
||||
RING_SLOTS[slot] = null;
|
||||
}
|
||||
@@ -289,9 +315,9 @@ public class FakeInputWriter {
|
||||
FileChannel channel = null;
|
||||
try {
|
||||
raf = new RandomAccessFile(ringFile, "rw");
|
||||
raf.setLength(RING_SIZE);
|
||||
raf.setLength(RING_FILE_SIZE);
|
||||
channel = raf.getChannel();
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_SIZE);
|
||||
ByteBuffer data = channel.map(FileChannel.MapMode.READ_WRITE, 0, RING_FILE_SIZE);
|
||||
initializeRingHeader(data);
|
||||
|
||||
RingSlot ringSlot = new RingSlot();
|
||||
@@ -332,6 +358,9 @@ public class FakeInputWriter {
|
||||
return false;
|
||||
}
|
||||
synchronized (ringSlot) {
|
||||
if (ringSlot.data == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ringSlot.active) {
|
||||
if (ringSlot.everActivated) {
|
||||
ringSlot.generation++;
|
||||
@@ -381,6 +410,9 @@ public class FakeInputWriter {
|
||||
ByteBuffer source = this.buffer.duplicate();
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) {
|
||||
return false;
|
||||
}
|
||||
long writeSeq = ring.getLong(RING_WRITE_SEQ_OFFSET);
|
||||
int sourceLimit = source.limit();
|
||||
while (source.remaining() >= EVENT_SIZE) {
|
||||
@@ -416,6 +448,9 @@ public class FakeInputWriter {
|
||||
buttons |= (1 << i);
|
||||
}
|
||||
}
|
||||
if (this.prevQam) {
|
||||
buttons |= (1 << SNAPSHOT_IDX_QAM);
|
||||
}
|
||||
long seq = ring.getLong(RING_SNAPSHOT_SEQ_OFFSET);
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
@@ -434,6 +469,58 @@ public class FakeInputWriter {
|
||||
ring.putLong(RING_SNAPSHOT_SEQ_OFFSET, seq + 2); // even: write complete
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the pad's motion for slot {@code slot}: accelerometer and gyro, already in the Deck
|
||||
* controller's axes and units (see PadMotion). Safe from any thread.
|
||||
*/
|
||||
public static void writeMotion(int slot, short[] accel, short[] gyro) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1); // odd: write in progress
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ring.putShort(IMU_ACCEL_OFFSET + i * 2, accel[i]);
|
||||
ring.putShort(IMU_GYRO_OFFSET + i * 2, gyro[i]);
|
||||
}
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the Deck's back grips and trackpads for slot {@code slot} (see DeckControls):
|
||||
* {@code controls} is the DeckControls bit set, {@code pads} left X, Y, right X, Y and
|
||||
* {@code pressure} left, right, already in the Deck's units. Safe from any thread.
|
||||
*/
|
||||
public static void writeDeckControls(int slot, int controls, short[] pads, short[] pressure) {
|
||||
RingSlot ringSlot;
|
||||
synchronized (RING_LOCK) {
|
||||
ringSlot = slot >= 0 && slot < RING_SLOTS.length ? RING_SLOTS[slot] : null;
|
||||
}
|
||||
if (ringSlot == null) return;
|
||||
synchronized (ringSlot) {
|
||||
ByteBuffer ring = ringSlot.data;
|
||||
if (ring == null) return;
|
||||
long seq = ring.getLong(IMU_SEQ_OFFSET);
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 1);
|
||||
nativeStoreFence();
|
||||
for (int i = 0; i < 4; i++) ring.putShort(DECK_PADS_OFFSET + i * 2, pads[i]);
|
||||
for (int i = 0; i < 2; i++) ring.putShort(DECK_PRESSURE_OFFSET + i * 2, pressure[i]);
|
||||
ring.putInt(DECK_CONTROLS_OFFSET, controls);
|
||||
ring.putInt(IMU_OFFSET, IMU_MAGIC);
|
||||
nativeStoreFence();
|
||||
ring.putLong(IMU_SEQ_OFFSET, seq + 2);
|
||||
}
|
||||
}
|
||||
|
||||
private static short clampShort(int value) {
|
||||
if (value > Short.MAX_VALUE) {
|
||||
return Short.MAX_VALUE;
|
||||
@@ -512,6 +599,11 @@ public class FakeInputWriter {
|
||||
}
|
||||
}
|
||||
|
||||
if (prevQam) {
|
||||
prevQam = false;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Zero all axes
|
||||
if (prevThumbLX != 0) {
|
||||
prevThumbLX = 0;
|
||||
@@ -620,6 +712,11 @@ public class FakeInputWriter {
|
||||
// reports as button 8 - exactly the "guide:b8" in the mapping Steam writes for this pad.
|
||||
writeButton(SNAPSHOT_IDX_MODE,
|
||||
state.isDown(PadState.GUIDE));
|
||||
boolean qam = state.isDown(PadState.QAM);
|
||||
if (forceResend || qam != prevQam) {
|
||||
prevQam = qam;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Sticks
|
||||
int lx = (int) (state.leftX * 32767);
|
||||
|
||||
@@ -7,6 +7,8 @@ import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import com.droiddeck.launcher.session.SessionState;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
/**
|
||||
@@ -19,7 +21,10 @@ import java.io.File;
|
||||
* SDL → Big Picture.
|
||||
*
|
||||
* <p>The identity the interposer reports is an Xbox 360 pad, which is what makes SDL apply a known
|
||||
* mapping without the user configuring anything.
|
||||
* mapping without the user configuring anything. In a Steam session it is a Steam Deck controller
|
||||
* to the client instead ({@link com.droiddeck.launcher.session.SteamDeckPad}), which has a Quick
|
||||
* Access button of its own; an Xbox pad has none, so there the client's menu is reached with the
|
||||
* Guide-then-A chord the client itself answers.
|
||||
*/
|
||||
public final class PadBridge {
|
||||
private static final String TAG = "PadBridge";
|
||||
@@ -54,6 +59,8 @@ public final class PadBridge {
|
||||
private boolean systemQamPressed;
|
||||
private boolean qamChordActive;
|
||||
private boolean qamSyntheticAPressed;
|
||||
/** The Deck controller's Quick Access button, held for one tap. */
|
||||
private boolean qamTapPressed;
|
||||
private int qamChordGeneration;
|
||||
/** Told on the main thread when a player uses the pad or the on-screen controls; see [setOnPlayerInput]. */
|
||||
private volatile Runnable onPlayerInput;
|
||||
@@ -77,6 +84,7 @@ public final class PadBridge {
|
||||
systemQamPressed = false;
|
||||
qamChordActive = false;
|
||||
qamSyntheticAPressed = false;
|
||||
qamTapPressed = false;
|
||||
qamChordGeneration++;
|
||||
if (open) {
|
||||
state.clear();
|
||||
@@ -204,7 +212,8 @@ public final class PadBridge {
|
||||
boolean qamStarted = qamPressed && !systemQamPressed;
|
||||
systemGuidePressed = guidePressed;
|
||||
systemQamPressed = qamPressed;
|
||||
if (qamStarted) startQamChord();
|
||||
// A Deck controller's QAM button is held for as long as the touch one is.
|
||||
if (qamStarted && !SessionState.getDeckPad()) startQamChord();
|
||||
else publish();
|
||||
}
|
||||
|
||||
@@ -217,9 +226,23 @@ public final class PadBridge {
|
||||
publish();
|
||||
}
|
||||
|
||||
/** Starts the same Guide-then-A sequence used by the touch QAM button. */
|
||||
/** Opens the client's Quick Access menu: a tap of the Deck's button, or the Guide-then-A chord. */
|
||||
public synchronized void triggerQam() {
|
||||
startQamChord();
|
||||
if (!SessionState.getDeckPad()) {
|
||||
startQamChord();
|
||||
return;
|
||||
}
|
||||
if (qamTapPressed) return;
|
||||
qamTapPressed = true;
|
||||
int generation = qamChordGeneration;
|
||||
publish();
|
||||
chordHandler.postDelayed(() -> releaseQamTap(generation), QAM_A_HOLD_MS);
|
||||
}
|
||||
|
||||
private synchronized void releaseQamTap(int generation) {
|
||||
if (generation != qamChordGeneration || !qamTapPressed) return;
|
||||
qamTapPressed = false;
|
||||
publish();
|
||||
}
|
||||
|
||||
private void startQamChord() {
|
||||
@@ -258,16 +281,17 @@ public final class PadBridge {
|
||||
|
||||
private void publish() {
|
||||
if (!open && !start()) return;
|
||||
if (systemGuidePressed || qamChordActive) {
|
||||
effectiveState.copyFrom(state);
|
||||
effectiveState.press(PadState.GUIDE, true);
|
||||
if (qamSyntheticAPressed) {
|
||||
effectiveState.press(PadState.A, state.isDown(PadState.A) || qamSyntheticAPressed);
|
||||
}
|
||||
writer.writePad(effectiveState);
|
||||
} else {
|
||||
boolean guide = systemGuidePressed || qamChordActive;
|
||||
boolean qam = SessionState.getDeckPad() && (systemQamPressed || qamTapPressed);
|
||||
if (!guide && !qam) {
|
||||
writer.writePad(state);
|
||||
return;
|
||||
}
|
||||
effectiveState.copyFrom(state);
|
||||
if (guide) effectiveState.press(PadState.GUIDE, true);
|
||||
if (qam) effectiveState.press(PadState.QAM, true);
|
||||
if (qamSyntheticAPressed) effectiveState.press(PadState.A, true);
|
||||
writer.writePad(effectiveState);
|
||||
}
|
||||
|
||||
private static float axis(MotionEvent event, int axis) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.droiddeck.launcher.input
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The handheld's own gyro and accelerometer, as the Steam Deck controller's (SteamDeckPad).
|
||||
*
|
||||
* The phone is the controller's body, so its motion sensors are the ones a Deck's would be. Android
|
||||
* reports them in the device's natural orientation; they are turned to the screen as it is shown
|
||||
* (x right, y up, z towards the player - SDL's frame for a gamepad), then to the Deck's own axes
|
||||
* and units as SDL's Deck driver undoes them (SDL_hidapi_steamdeck.c): X right, Y away from the
|
||||
* player, Z up; 1 g = 16384, 2000 °/s = 32768. The Steam client calibrates the gyro itself.
|
||||
*
|
||||
* Sensors run only while the session is on screen with the pad a Deck controller, sampled as often
|
||||
* as the Deck reports (4 ms); each reading goes straight into the pad's ring
|
||||
* ([FakeInputWriter.writeMotion]), where libfakeinput's 4 ms report picks up the latest.
|
||||
*/
|
||||
class PadMotion(private val context: Context, private val rotation: () -> Int) : SensorEventListener {
|
||||
private val sensors = context.getSystemService(SensorManager::class.java)
|
||||
private var thread: HandlerThread? = null
|
||||
/** Readings are taken only while this is set; a callback already queued when [stop] ran drops. */
|
||||
@Volatile private var active = false
|
||||
// Touched only on the worker thread.
|
||||
private val accel = ShortArray(3)
|
||||
private val gyro = ShortArray(3)
|
||||
|
||||
fun start() {
|
||||
if (thread != null || sensors == null) return
|
||||
val gyroscope = sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
|
||||
val accelerometer = sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
if (gyroscope == null && accelerometer == null) {
|
||||
Log.i(TAG, "pad motion: this device has no gyro or accelerometer")
|
||||
return
|
||||
}
|
||||
val worker = HandlerThread("pad-motion", android.os.Process.THREAD_PRIORITY_DISPLAY).apply { start() }
|
||||
thread = worker
|
||||
val handler = Handler(worker.looper)
|
||||
active = true
|
||||
gyroscope?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
accelerometer?.let { sensors.registerListener(this, it, SAMPLING_US, handler) }
|
||||
Log.i(TAG, "pad motion: gyro ${gyroscope?.name ?: "none"}, accelerometer ${accelerometer?.name ?: "none"}")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val worker = thread ?: return
|
||||
thread = null
|
||||
active = false
|
||||
sensors?.unregisterListener(this)
|
||||
// A still controller, not one frozen mid-turn - published on the worker, after any reading
|
||||
// already queued there, so nothing can overwrite it.
|
||||
Handler(worker.looper).post {
|
||||
gyro.fill(0)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
}
|
||||
worker.quitSafely()
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (!active) return
|
||||
val values = event.values
|
||||
// Natural orientation to the screen's: Android's display-rotation transform.
|
||||
val (x, y) = when (rotation()) {
|
||||
Surface.ROTATION_90 -> -values[1] to values[0]
|
||||
Surface.ROTATION_180 -> -values[0] to -values[1]
|
||||
Surface.ROTATION_270 -> values[1] to -values[0]
|
||||
else -> values[0] to values[1]
|
||||
}
|
||||
val z = values[2]
|
||||
val (target, scale) = when (event.sensor.type) {
|
||||
Sensor.TYPE_GYROSCOPE -> gyro to GYRO_COUNTS_PER_RAD_S
|
||||
Sensor.TYPE_ACCELEROMETER -> accel to ACCEL_COUNTS_PER_M_S2
|
||||
else -> return
|
||||
}
|
||||
// Screen frame (right, up, towards the player) to the Deck's (right, away, up).
|
||||
target[0] = counts(x * scale)
|
||||
target[1] = counts(-z * scale)
|
||||
target[2] = counts(y * scale)
|
||||
FakeInputWriter.writeMotion(SLOT, accel, gyro)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
private fun counts(value: Float): Short = value.roundToInt().coerceIn(-32768, 32767).toShort()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PadMotion"
|
||||
/** The pad the Deck controller is made from (PadBridge's slot). */
|
||||
private const val SLOT = 0
|
||||
/** The Deck controller's report interval (libfakeinput: DECK_REPORT_INTERVAL_US). */
|
||||
private const val SAMPLING_US = 4000
|
||||
private const val ACCEL_COUNTS_PER_M_S2 = 16384f / SensorManager.GRAVITY_EARTH
|
||||
private const val GYRO_COUNTS_PER_RAD_S = (32768f / 2000f) * (180f / Math.PI.toFloat())
|
||||
}
|
||||
}
|
||||
@@ -57,5 +57,7 @@ class PadState {
|
||||
const val R3 = 9
|
||||
/** The Steam/Guide button, which the fake evdev node reports as BTN_MODE. */
|
||||
const val GUIDE = 12
|
||||
/** The Quick Access button: a Deck controller's (SteamDeckPad) only, since an Xbox pad has none. */
|
||||
const val QAM = 13
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ enum class SecondScreenMode(val id: String, val label: String) {
|
||||
NONE("none", "None"),
|
||||
KEYBOARD_TRACKPAD("keyboard-trackpad", "Keyboard + trackpad"),
|
||||
TERMINAL("terminal", "Terminal"),
|
||||
/** The Steam Deck controller's back grips and trackpads; offered while the pad is one. */
|
||||
DECK_CONTROLS("deck-controls", "Deck grips + trackpads"),
|
||||
}
|
||||
|
||||
data class SecondScreenDisplay(val id: Int, val label: String)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import java.io.File
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
|
||||
class Item(val id: String, val name: String, val icon: File?, val comment: String?) {
|
||||
val guestDir: String get() = "$GUEST_DIR/$id"
|
||||
}
|
||||
|
||||
private fun root(context: Context) = File(LinuxRuntime.rootDir(context), GUEST_DIR.substring(1))
|
||||
|
||||
fun list(context: Context): List<Item> =
|
||||
root(context).listFiles()?.filter { File(it, "app/AppRun").exists() || Files.isLink(File(it, "app/AppRun")) }
|
||||
?.map { dir ->
|
||||
Item(
|
||||
dir.name,
|
||||
FileUtils.readString(File(dir, "name"))?.trim()?.takeIf { it.isNotEmpty() } ?: dir.name,
|
||||
File(dir, "icon.png").takeIf { it.isFile },
|
||||
FileUtils.readString(File(dir, "comment"))?.trim()?.takeIf { it.isNotEmpty() },
|
||||
)
|
||||
}?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
|
||||
/**
|
||||
* What is wrong with [file] as an AppImage for this device, or null when it can be imported.
|
||||
* An AppImage is an ELF program (its runtime) with "AI" and the format version at byte 8.
|
||||
*/
|
||||
internal fun problem(file: File): String? {
|
||||
if (!file.isFile) return "The file is gone"
|
||||
val head = ByteArray(20)
|
||||
try {
|
||||
RandomAccessFile(file, "r").use { if (it.read(head) < head.size) return "The file is too small to be an AppImage" }
|
||||
} catch (e: Exception) {
|
||||
return "The file cannot be read: ${e.message}"
|
||||
}
|
||||
if (head[0] != 0x7f.toByte() || head[1] != 'E'.code.toByte() || head[2] != 'L'.code.toByte() || head[3] != 'F'.code.toByte()) {
|
||||
return "This is not an AppImage (it is not a Linux program)"
|
||||
}
|
||||
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"
|
||||
head[10].toInt() != 2 -> "Only type 2 AppImages can be imported (this one is type ${head[10].toInt()})"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** A directory name from the image's file name: "Some_App-1.2-aarch64.AppImage" -> "some-app-1-2-aarch64". */
|
||||
internal fun idFor(fileName: String, taken: Set<String>): String {
|
||||
val base = fileName.removeSuffix(".AppImage").removeSuffix(".appimage").lowercase()
|
||||
.replace(Regex("[^a-z0-9]+"), "-").trim('-').take(48).ifEmpty { "appimage" }
|
||||
var id = base
|
||||
var n = 2
|
||||
while (id in taken) id = "$base-${n++}"
|
||||
return id
|
||||
}
|
||||
|
||||
/** Imports [file]; null on success, else what went wrong. [onProgress] gets a stage line. */
|
||||
fun import(context: Context, file: File, onProgress: (String) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
problem(file)?.let { return it }
|
||||
val base = root(context).apply { mkdirs() }
|
||||
// Room for the image while it is extracted, and the extracted tree (about the same again).
|
||||
if (StatFs(base.path).availableBytes < file.length() * 3) {
|
||||
return "Not enough free space: importing needs about ${FileUtils.sizeToString(file.length() * 3)}"
|
||||
}
|
||||
val id = idFor(file.name, base.list()?.toSet() ?: emptySet())
|
||||
val dir = File(base, id)
|
||||
val image = File(dir, "image.AppImage")
|
||||
try {
|
||||
dir.mkdirs()
|
||||
// Copied in, not run where it lies: shared storage is mounted noexec, and proot's loader
|
||||
// maps a program executable.
|
||||
onProgress("Copying ${file.name}")
|
||||
file.inputStream().use { input -> image.outputStream().use { FileUtils.copy(input, it) } }
|
||||
image.setExecutable(true, false)
|
||||
onProgress("Extracting ${file.name}")
|
||||
val guestDir = "$GUEST_DIR/$id"
|
||||
val out = StringBuilder()
|
||||
val status = GuestCommand.run(context, listOf(
|
||||
"/bin/bash", "-c",
|
||||
"cd \"$1\" && ./image.AppImage --appimage-extract >/dev/null && mv squashfs-root app",
|
||||
"extract", guestDir,
|
||||
), logName = "appimage-import") { line -> if (out.length < 2000) out.appendLine(line) }
|
||||
if (status != 0 || !File(dir, "app").isDirectory) {
|
||||
FileUtils.delete(dir)
|
||||
return "The AppImage could not be extracted" + (out.lines().lastOrNull { it.isNotBlank() }?.let { ": $it" } ?: "")
|
||||
}
|
||||
image.delete()
|
||||
describe(dir, file)
|
||||
writeMenuEntry(context, dir)
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "import ${file.name}", e)
|
||||
FileUtils.delete(dir)
|
||||
return e.message ?: "Import failed"
|
||||
} finally {
|
||||
image.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/** Name, comment and icon from the image's own desktop entry; the file name when it has none. */
|
||||
private fun describe(dir: File, source: File) {
|
||||
val app = File(dir, "app")
|
||||
val entry = app.listFiles { f -> f.isFile && f.name.endsWith(".desktop") }?.firstOrNull()
|
||||
val text = entry?.let { FileUtils.readString(it) }
|
||||
fun key(k: String) = text?.let { desktopKey(it, k) }
|
||||
val name = key("Name") ?: source.name.substringBeforeLast('.')
|
||||
FileUtils.writeString(File(dir, "name"), name)
|
||||
key("Comment")?.let { FileUtils.writeString(File(dir, "comment"), it) }
|
||||
key("Categories")?.let { FileUtils.writeString(File(dir, "categories"), it) }
|
||||
// The icon the entry names, beside it or anywhere in the image's icon theme, else .DirIcon.
|
||||
val iconName = key("Icon")
|
||||
val candidates = listOfNotNull(
|
||||
iconName?.let { File(app, "$it.png") },
|
||||
iconName?.let { n -> app.walkTopDown().maxDepth(8).filter { it.isFile && it.name == "$n.png" }.maxByOrNull { it.length() } },
|
||||
File(app, ".DirIcon"),
|
||||
)
|
||||
candidates.firstOrNull { it.isFile && isPng(it) }?.copyTo(File(dir, "icon.png"), overwrite = true)
|
||||
}
|
||||
|
||||
private fun isPng(f: File): Boolean = try {
|
||||
f.inputStream().use { val b = ByteArray(4); it.read(b) == 4 && b[1] == 'P'.code.toByte() && b[2] == 'N'.code.toByte() && b[3] == 'G'.code.toByte() }
|
||||
} catch (e: Exception) { false }
|
||||
|
||||
/** A key of a desktop entry's main group, unlocalised. */
|
||||
internal fun desktopKey(desktop: String, key: String): String? {
|
||||
var inMain = false
|
||||
for (line in desktop.lineSequence()) {
|
||||
val t = line.trim()
|
||||
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
|
||||
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun menuEntry(context: Context, id: String) =
|
||||
// A system directory, not ~/.local/share/applications: the desktop copies game entries from
|
||||
// here into the user's with a GPU launcher (droiddeck-desktop-gpu) while it runs on pixman.
|
||||
File(LinuxRuntime.rootDir(context), "usr/local/share/applications/droiddeck-appimage-$id.desktop")
|
||||
|
||||
/** The Linux desktop's menu entry: the desktop's own Exec rules (and droiddeck-gpu) apply to it. */
|
||||
private fun writeMenuEntry(context: Context, dir: File) {
|
||||
val id = dir.name
|
||||
val name = FileUtils.readString(File(dir, "name"))?.trim() ?: id
|
||||
val categories = FileUtils.readString(File(dir, "categories"))?.trim()?.takeIf { it.isNotEmpty() } ?: "Utility;"
|
||||
val icon = if (File(dir, "icon.png").isFile) "$GUEST_DIR/$id/icon.png" else "application-x-executable"
|
||||
FileUtils.writeString(menuEntry(context, id),
|
||||
"[Desktop Entry]\nType=Application\nName=$name\nExec=$LAUNCHER $GUEST_DIR/$id %U\nIcon=$icon\n" +
|
||||
"Terminal=false\nCategories=$categories\nX-DroidDeck-AppImage=$id\n")
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
if (id.isEmpty() || '/' in id || id.startsWith(".")) return
|
||||
FileUtils.delete(File(root(context), id))
|
||||
menuEntry(context, id).delete()
|
||||
}
|
||||
|
||||
private object Files {
|
||||
fun isLink(f: File) = java.nio.file.Files.isSymbolicLink(f.toPath())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.net.LocalServerSocket
|
||||
import android.net.LocalSocket
|
||||
import android.os.Process
|
||||
import android.system.Os
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* Flatpak's sandboxes, started for the runtime's bwrap stand-in (bannerlator-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
|
||||
* session's proot rather than inside it - it traces the app once instead of twice. The stand-in
|
||||
* sends the sandbox Flatpak asked for (its binds, directories, symlinks and files, in guest paths),
|
||||
* this builds it under the cache and starts it, then streams the output, the pid and the exit
|
||||
* status back until one side goes away: the stand-in ending takes the sandbox down with it.
|
||||
*
|
||||
* The socket is abstract, so anything on the device could connect: only this app's own uid - the
|
||||
* uid every program in the runtime already runs as - is served.
|
||||
*/
|
||||
object BwrapSpawner {
|
||||
private const val TAG = "BwrapSpawner"
|
||||
const val SOCKET = "com.droiddeck.launcher.bwrap"
|
||||
private val seq = AtomicInteger()
|
||||
@Volatile private var server: LocalServerSocket? = null
|
||||
|
||||
/** Listens for sandboxes for the rest of the process's life; later calls do nothing. */
|
||||
@Synchronized
|
||||
fun start(context: Context) {
|
||||
if (server != null) return
|
||||
val app = context.applicationContext
|
||||
val socket = try {
|
||||
LocalServerSocket(SOCKET)
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "cannot listen on $SOCKET", e)
|
||||
return
|
||||
}
|
||||
server = socket
|
||||
FileUtils.delete(File(app.cacheDir, "bwrap"))
|
||||
Thread({
|
||||
while (true) {
|
||||
val client = try { socket.accept() } catch (e: IOException) { Log.e(TAG, "accept", e); break }
|
||||
Thread({ serve(app, client) }, "bwrap-${seq.get() + 1}").apply { isDaemon = true; start() }
|
||||
}
|
||||
}, "bwrap-server").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun serve(context: Context, client: LocalSocket) {
|
||||
val id = seq.incrementAndGet()
|
||||
val dir = File(context.cacheDir, "bwrap/$id")
|
||||
client.use {
|
||||
val out = DataOutputStream(client.outputStream)
|
||||
try {
|
||||
val peer = client.peerCredentials
|
||||
if (peer.uid != Process.myUid()) {
|
||||
Log.w(TAG, "refused a sandbox for uid ${peer.uid}")
|
||||
return
|
||||
}
|
||||
val input = DataInputStream(client.inputStream)
|
||||
val kind = input.readUnsignedByte()
|
||||
val body = ByteArray(input.readInt()).also { input.readFully(it) }
|
||||
if (kind != 'R'.code) return
|
||||
val request = JSONObject(String(body, Charsets.UTF_8))
|
||||
val plan = plan(context, request, dir)
|
||||
run(context, plan, client, out, id)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "sandbox $id", e)
|
||||
runCatching {
|
||||
frame(out, 'E', (e.message ?: e.toString()).toByteArray())
|
||||
frame(out, 'X', "1".toByteArray())
|
||||
}
|
||||
} finally {
|
||||
FileUtils.delete(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What proot is started with: the sandbox root, its binds (host to guest), and the program. */
|
||||
class Plan(val root: File, val binds: List<Bind>, val cwd: String, val argv: List<String>, val env: Map<String, String>)
|
||||
|
||||
data class Bind(val host: String, val guest: String) {
|
||||
val spec: String get() = if (host == guest) host else "$host:$guest"
|
||||
|
||||
companion object {
|
||||
fun parse(spec: String): Bind {
|
||||
val colon = spec.indexOf(':')
|
||||
return if (colon < 0) Bind(spec, spec) else Bind(spec.substring(0, colon), spec.substring(colon + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True when [path] is [dir] or inside it. */
|
||||
internal fun under(path: String, dir: String): Boolean =
|
||||
dir == "/" || path == dir || path.startsWith(if (dir.endsWith("/")) dir else "$dir/")
|
||||
|
||||
/**
|
||||
* Where a path the session's programs see lives on the host: through the session's own binds
|
||||
* (the most specific one), or else inside the rootfs.
|
||||
*/
|
||||
internal fun hostPath(sessionBinds: List<Bind>, rootfs: String, guest: String): String {
|
||||
val bind = sessionBinds.filter { under(guest, it.guest) }.maxByOrNull { it.guest.length }
|
||||
?: return rootfs.trimEnd('/') + guest
|
||||
val rest = guest.substring(bind.guest.length).trimStart('/')
|
||||
return if (rest.isEmpty()) bind.host else bind.host.trimEnd('/') + "/" + rest
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest [src] bound at [dest]: the bind itself, then every session bind below [src] moved
|
||||
* under [dest] - /dev brings the session's /dev/shm and GPU node, /proc its stand-ins for what
|
||||
* Android keeps apps from reading.
|
||||
*/
|
||||
internal fun bindsFor(sessionBinds: List<Bind>, rootfs: String, src: String, dest: String): List<Bind> {
|
||||
val out = ArrayList<Bind>()
|
||||
out.add(Bind(hostPath(sessionBinds, rootfs, src), dest))
|
||||
for (b in sessionBinds.sortedBy { it.guest.length }) {
|
||||
if (b.guest != src && under(b.guest, src)) {
|
||||
out.add(Bind(b.host, (dest.trimEnd('/') + "/" + b.guest.substring(src.length).trimStart('/'))))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
internal fun plan(context: Context, request: JSONObject, dir: File): Plan {
|
||||
val rootfs = LinuxRuntime.rootDir(context).path
|
||||
val session = LinuxRuntime.lastBinds(context).map { Bind.parse(it) }
|
||||
val root = File(dir, "root").apply { mkdirs() }
|
||||
val binds = ArrayList<Bind>()
|
||||
var scratch = 0
|
||||
fun addBind(b: Bind) {
|
||||
binds.removeAll { it.guest == b.guest }
|
||||
binds.add(b)
|
||||
}
|
||||
fun coveringBind(dest: String) = binds.filter { under(dest, it.guest) }.maxByOrNull { it.guest.length }
|
||||
fun newScratch(): File = File(dir, "x${scratch++}")
|
||||
/**
|
||||
* Where [dest] can be created on the host: inside the sandbox root, or inside a directory
|
||||
* of ours bound into it (a --tmpfs under the runtime's /usr, which Flatpak fills with the
|
||||
* GL extension's links). Null inside anything else bound in - the runtime itself, a host
|
||||
* directory - which must not be written to.
|
||||
*/
|
||||
fun writable(dest: String): File? {
|
||||
val cover = coveringBind(dest) ?: return inRoot(root, dest)
|
||||
if (!under(cover.host, dir.path)) return null
|
||||
return inRoot(File(cover.host), dest.substring(cover.guest.length))
|
||||
}
|
||||
val ops = request.getJSONArray("ops")
|
||||
for (i in 0 until ops.length()) {
|
||||
val op = ops.getJSONObject(i)
|
||||
val dest = op.optString("dest")
|
||||
when (op.getString("t")) {
|
||||
"bind" -> {
|
||||
val src = op.getString("src")
|
||||
val all = bindsFor(session, rootfs, src, dest)
|
||||
if (!File(all.first().host).exists()) {
|
||||
if (!op.optBoolean("try")) Log.w(TAG, "sandbox source $src is missing; left out")
|
||||
continue
|
||||
}
|
||||
all.forEach { addBind(it) }
|
||||
}
|
||||
"proc" -> bindsFor(session, rootfs, "/proc", dest).forEach { addBind(it) }
|
||||
"dev" -> bindsFor(session, rootfs, "/dev", dest).forEach { addBind(it) }
|
||||
"dir" -> {
|
||||
val perms = op.optInt("perms", 0x1ed)
|
||||
val here = writable(dest)
|
||||
when {
|
||||
op.optBoolean("empty") && coveringBind(dest) != null ->
|
||||
addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
|
||||
here != null && (here.isDirectory || here.mkdirs()) -> chmod(here, perms)
|
||||
File(hostPath(binds, root.path, dest)).isDirectory -> {}
|
||||
else -> addBind(Bind(newScratch().apply { mkdirs(); chmod(this, perms) }.path, dest))
|
||||
}
|
||||
}
|
||||
"file" -> {
|
||||
val data = Base64.decode(op.getString("data"), Base64.DEFAULT)
|
||||
val here = writable(dest)
|
||||
val f = if (here != null && here.parentFile?.let { it.isDirectory || it.mkdirs() } == true) here
|
||||
else newScratch().also { addBind(Bind(it.path, dest)) }
|
||||
f.writeBytes(data)
|
||||
chmod(f, op.optInt("perms", 0x1b6))
|
||||
}
|
||||
"symlink" -> {
|
||||
val target = op.getString("target")
|
||||
val link = writable(dest)
|
||||
if (link != null) {
|
||||
link.parentFile?.mkdirs()
|
||||
if (!Files.isSymbolicLink(link.toPath()) && !link.exists()) Files.createSymbolicLink(link.toPath(), java.nio.file.Paths.get(target))
|
||||
} else {
|
||||
// A link inside the runtime's own tree cannot be made; what it points at is
|
||||
// bound in its place.
|
||||
val abs = if (target.startsWith("/")) target else normalize(dest.substringBeforeLast('/') + "/" + target)
|
||||
val host = hostPath(binds, root.path, abs)
|
||||
if (File(host).exists()) addBind(Bind(host, dest)) else Log.w(TAG, "symlink $dest -> $target: nothing there; left out")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (binds.any { it.guest == "/proc" }) genericCpuinfo(context)?.let { addBind(Bind(it.path, "/proc/cpuinfo")) }
|
||||
val argv = request.getJSONArray("argv").let { a -> (0 until a.length()).map { a.getString(it) } }
|
||||
val env = LinkedHashMap<String, String>()
|
||||
request.optJSONObject("env")?.let { e -> e.keys().forEach { k -> env[k] = e.getString(k) } }
|
||||
if (binds.any { it.guest == "/dev" }) gpu(rootfs, root, env)?.forEach { addBind(it) }
|
||||
preload(session, rootfs, env).forEach { addBind(it) }
|
||||
browserSandboxes(env)
|
||||
return Plan(root, binds, request.optString("cwd", "/").ifEmpty { "/" }, argv, env)
|
||||
}
|
||||
|
||||
/** Where a sandbox finds the runtime's own GPU driver. */
|
||||
private const val GPU_DIR = "/run/droiddeck-gpu"
|
||||
|
||||
/**
|
||||
* The GPU, for an app that is given /dev. Flathub's Mesa has Turnip, but built for the DRM
|
||||
* kernel driver: Adreno on Android is KGSL, so apps fell back to drawing on the CPU. The
|
||||
* runtime's own Turnip has the KGSL backend and needs nothing the Freedesktop runtime lacks
|
||||
* but three libraries, which come with it; Mesa's GL then runs on it through Zink. An app's
|
||||
* own choice of driver (VK_ICD_FILENAMES, MESA_LOADER_DRIVER_OVERRIDE) is left alone.
|
||||
*/
|
||||
private fun gpu(rootfs: String, root: File, env: MutableMap<String, String>): List<Bind>? {
|
||||
val lib = File(rootfs, "usr/lib")
|
||||
val driver = File(lib, "libvulkan_freedreno.so")
|
||||
if (!driver.isFile || !File("/dev/kgsl-3d0").exists()) return null
|
||||
if (env.containsKey("VK_ICD_FILENAMES") || env.containsKey("VK_DRIVER_FILES")) return null
|
||||
// A window on the desktop while labwc composites with pixman gets shared-memory buffers
|
||||
// only; Zink cannot present there, so such an app keeps Flathub's own Mesa (llvmpipe).
|
||||
// From the desktop, games go through droiddeck-gpu's gamescope and do get the GPU.
|
||||
if (env["WLR_RENDERER"] == "pixman" && env["GAMESCOPE_WAYLAND_DISPLAY"] == null) return null
|
||||
val binds = ArrayList<Bind>()
|
||||
for (name in listOf("libvulkan_freedreno.so", "libdisplay-info.so.3", "libSPIRV-Tools.so", "libSPIRV-Tools-opt.so")) {
|
||||
val f = File(lib, name)
|
||||
if (!f.exists()) { Log.w(TAG, "gpu: the runtime has no $name; apps draw on the CPU"); return null }
|
||||
binds.add(Bind(f.canonicalPath, "$GPU_DIR/$name"))
|
||||
}
|
||||
// Beside the sandbox root, in the sandbox's own directory: removed with it.
|
||||
val icd = File(root.parentFile, "freedreno_icd.json")
|
||||
icd.writeText("{\"file_format_version\": \"1.0.0\", \"ICD\": {\"library_path\": \"$GPU_DIR/libvulkan_freedreno.so\", \"api_version\": \"1.4.0\"}}\n")
|
||||
binds.add(Bind(icd.path, "$GPU_DIR/freedreno_icd.json"))
|
||||
env["VK_DRIVER_FILES"] = "$GPU_DIR/freedreno_icd.json"
|
||||
env["VK_ICD_FILENAMES"] = "$GPU_DIR/freedreno_icd.json"
|
||||
if (!env.containsKey("MESA_LOADER_DRIVER_OVERRIDE") && !env.containsKey("GALLIUM_DRIVER")) {
|
||||
env["MESA_LOADER_DRIVER_OVERRIDE"] = "zink"
|
||||
env["GALLIUM_DRIVER"] = "zink"
|
||||
}
|
||||
// The driver's own libraries, found beside it; nothing else is in that directory.
|
||||
env["LD_LIBRARY_PATH"] = listOf(env["LD_LIBRARY_PATH"], GPU_DIR).filter { !it.isNullOrEmpty() }.joinToString(":")
|
||||
return binds
|
||||
}
|
||||
|
||||
/**
|
||||
* What every program in the session preloads (SessionFiles writes /etc/ld.so.preload): the
|
||||
* session shim - SysV shared memory for X11, the network calls Android refuses - and the
|
||||
* controller reader. A sandbox's /etc is the runtime's, so the same libraries go in through
|
||||
* LD_PRELOAD instead, with the directory the controllers' rings live in; without them an app
|
||||
* saw no gamepad. Both are built against an older glibc than any Flathub runtime has.
|
||||
*/
|
||||
private fun preload(session: List<Bind>, rootfs: String, env: MutableMap<String, String>): List<Bind> {
|
||||
val libs = FileUtils.readString(File(rootfs, "etc/ld.so.preload"))?.lines()
|
||||
?.map { it.trim() }?.filter { it.startsWith("/") && File(rootfs + it).isFile } ?: return emptyList()
|
||||
if (libs.isEmpty()) return emptyList()
|
||||
val binds = ArrayList<Bind>()
|
||||
val paths = libs.map { lib -> "$PRELOAD_DIR/${lib.substringAfterLast('/')}".also { binds.add(Bind(rootfs + lib, it)) } }
|
||||
env["LD_PRELOAD"] = (paths + listOfNotNull(env["LD_PRELOAD"]?.takeIf { it.isNotBlank() })).joinToString(":")
|
||||
// Not through ZYPAK_LD_PRELOAD to Chromium's children: the session shim's stat hooks then
|
||||
// hide the setuid bit zypak fakes on chrome-sandbox, and Chromium refuses to start.
|
||||
// The controllers: FAKE_EVDEV_DIR is <session>/dev/input, the rings beside it in <session>/dev.
|
||||
env["FAKE_EVDEV_DIR"]?.let { File(it).parent }?.let { dev ->
|
||||
binds.addAll(bindsFor(session, rootfs, dev, dev))
|
||||
}
|
||||
return binds
|
||||
}
|
||||
|
||||
private const val PRELOAD_DIR = "/run/droiddeck-preload"
|
||||
|
||||
/**
|
||||
* Browsers sandbox their own children, and neither way works in here. Firefox wants
|
||||
* namespaces and seccomp from the kernel; its content processes died without them (the
|
||||
* desktop sets the same for the runtime's own Firefox). Chromium and Electron apps start
|
||||
* their zygote through Flatpak's portal (zypak's spawn strategy), and the portal refuses:
|
||||
* it knows a Flatpak by /proc/<pid>/root/.flatpak-info, and a proot sandbox's root is the
|
||||
* host's. zypak's older strategy mimics the zygote inside the app's own sandbox instead.
|
||||
*/
|
||||
private fun browserSandboxes(env: MutableMap<String, String>) {
|
||||
env.putIfAbsent("ZYPAK_ZYGOTE_STRATEGY_SPAWN", "0")
|
||||
// Qt WebEngine is Chromium without zypak: it sets up its own namespace sandbox.
|
||||
env.putIfAbsent("QTWEBENGINE_DISABLE_SANDBOX", "1")
|
||||
// Chromium on Wayland asks the render node the compositor names for its DRM version;
|
||||
// KGSL has none, and the GPU process aborts until Chromium gives up ("GPU process isn't
|
||||
// usable"). Electron picks Wayland from XDG_SESSION_TYPE; with an X server there (every
|
||||
// session has Xwayland) it stays on X11, which works. GTK and Qt go by their own settings.
|
||||
if (env.containsKey("DISPLAY")) env["XDG_SESSION_TYPE"] = "x11"
|
||||
for (name in listOf("MOZ_DISABLE_CONTENT_SANDBOX", "MOZ_DISABLE_GMP_SANDBOX", "MOZ_DISABLE_RDD_SANDBOX",
|
||||
"MOZ_DISABLE_SOCKET_PROCESS_SANDBOX", "MOZ_DISABLE_UTILITY_SANDBOX")) {
|
||||
env.putIfAbsent(name, "1")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* /proc/cpuinfo without the cores' names. Snapdragon's ARMv9 cores (Cortex-A510/A715/X3) have
|
||||
* SVE2 by architecture, and LLVM - llvmpipe's JIT in Flathub's Mesa, which is what apps draw
|
||||
* with here - takes the CPU part to mean it may use it. Qualcomm leaves SVE off, so the first
|
||||
* shader compiled killed the app with SIGILL. Without a part number LLVM targets generic
|
||||
* ARMv8 plus the "Features" line, which lists what the kernel really enabled.
|
||||
*/
|
||||
private fun genericCpuinfo(context: Context): File? = try {
|
||||
val out = File(context.cacheDir, "cpuinfo-generic")
|
||||
val text = File("/proc/cpuinfo").readLines()
|
||||
.filterNot { it.startsWith("CPU implementer") || it.startsWith("CPU part") || it.startsWith("CPU variant") || it.startsWith("CPU revision") }
|
||||
.joinToString("\n", postfix = "\n")
|
||||
// Sandboxes start side by side; one must never read another's half-written copy.
|
||||
if (!out.isFile || out.readText() != text) {
|
||||
val staged = File.createTempFile("cpuinfo", null, context.cacheDir)
|
||||
staged.writeText(text)
|
||||
if (!staged.renameTo(out)) staged.delete()
|
||||
}
|
||||
out
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "cpuinfo", e); null
|
||||
}
|
||||
|
||||
/** [path] with "." and ".." resolved lexically. */
|
||||
internal fun normalize(path: String): String {
|
||||
val out = ArrayList<String>()
|
||||
for (part in path.split('/')) when (part) {
|
||||
"", "." -> {}
|
||||
".." -> if (out.isNotEmpty()) out.removeAt(out.size - 1)
|
||||
else -> out.add(part)
|
||||
}
|
||||
return "/" + out.joinToString("/")
|
||||
}
|
||||
|
||||
/**
|
||||
* [guest] inside the sandbox root, or null when the way there crosses a symlink: one of
|
||||
* Flatpak's (var/run -> ../run) may point anywhere once followed on the host.
|
||||
*/
|
||||
private fun inRoot(root: File, guest: String): File? {
|
||||
var f = root
|
||||
for (part in guest.split('/').filter { it.isNotEmpty() }) {
|
||||
if (part == "..") return null
|
||||
f = File(f, part)
|
||||
if (Files.isSymbolicLink(f.toPath())) return null
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
private fun chmod(f: File, mode: Int) {
|
||||
try { Os.chmod(f.path, mode) } catch (e: Exception) { /* the default mode serves */ }
|
||||
}
|
||||
|
||||
private fun run(context: Context, plan: Plan, client: LocalSocket, out: DataOutputStream, id: Int) {
|
||||
val cmd = LinuxRuntime.prootPrefix(context, plan.root, plan.cwd)
|
||||
// DROIDDECK_PROOT_VERBOSE=N in the app's environment (flatpak run --env=...) has proot
|
||||
// say what it does, to logcat in full: for a sandbox that dies without a word.
|
||||
val verbose = plan.env["DROIDDECK_PROOT_VERBOSE"]?.toIntOrNull()
|
||||
if (verbose != null) { cmd.add(1, "-v"); cmd.add(2, verbose.toString()) }
|
||||
plan.binds.forEach { cmd.add("-b"); cmd.add(it.spec) }
|
||||
// proot hands its own environment on; the sandbox gets exactly what Flatpak asked for.
|
||||
cmd.add("/usr/bin/env")
|
||||
cmd.add("-i")
|
||||
plan.env.forEach { (k, v) -> cmd.add("$k=$v") }
|
||||
cmd.addAll(plan.argv)
|
||||
val builder = ProcessBuilder(cmd).redirectErrorStream(true).directory(plan.root)
|
||||
builder.environment().apply {
|
||||
clear()
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
val pid = HostProcess.pidOf(process)
|
||||
// A sandbox lives exactly as long as its bwrap stand-in, which ends it; a session starting
|
||||
// meanwhile (OrphanReaper) must not take it - an install's extra-data step, say.
|
||||
com.droiddeck.launcher.session.OrphanReaper.keep(pid)
|
||||
Log.i(TAG, "sandbox $id pid $pid: ${plan.argv.joinToString(" ")} (${plan.binds.size} binds)")
|
||||
synchronized(out) { frame(out, 'P', pid.toString().toByteArray()) }
|
||||
// The stand-in going away (Flatpak killed, the session over) ends the sandbox.
|
||||
Thread({
|
||||
try { while (client.inputStream.read() >= 0) { /* nothing is sent after the request */ } }
|
||||
catch (e: IOException) { /* closed */ }
|
||||
if (process.isAlive) {
|
||||
Log.i(TAG, "sandbox $id: its bwrap is gone; stopping it")
|
||||
process.destroy()
|
||||
}
|
||||
}, "bwrap-$id-watch").apply { isDaemon = true; start() }
|
||||
val buffer = ByteArray(16384)
|
||||
// The start of what the sandbox says goes to logcat too: a sandbox that fails at once
|
||||
// (proot refusing a bind, a missing library) says why there even when nothing reads it.
|
||||
var logged = 0
|
||||
process.inputStream.use { stream ->
|
||||
while (true) {
|
||||
val n = try { stream.read(buffer) } catch (e: IOException) { -1 }
|
||||
if (n < 0) break
|
||||
if (logged < 4096 || verbose != null) {
|
||||
String(buffer, 0, if (verbose != null) n else minOf(n, 4096 - logged)).lines().filter { it.isNotBlank() }
|
||||
.forEach { Log.i(TAG, "sandbox $id: $it") }
|
||||
logged += n
|
||||
}
|
||||
try { synchronized(out) { frame(out, 'O', buffer.copyOf(n)) } } catch (e: IOException) { break }
|
||||
}
|
||||
}
|
||||
val status = process.waitFor()
|
||||
com.droiddeck.launcher.session.OrphanReaper.release(pid)
|
||||
Log.i(TAG, "sandbox $id exited $status")
|
||||
runCatching { synchronized(out) { frame(out, 'X', status.toString().toByteArray()) } }
|
||||
}
|
||||
|
||||
private fun frame(out: DataOutputStream, kind: Char, payload: ByteArray) {
|
||||
out.writeByte(kind.code)
|
||||
out.writeInt(payload.size)
|
||||
out.write(payload)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
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
|
||||
* 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
|
||||
* Steam or the desktop is up, and a session starting meanwhile leaves it running (OrphanReaper).
|
||||
*/
|
||||
object FlatpakManager {
|
||||
private const val TAG = "FlatpakManager"
|
||||
const val FLATPAK = "/usr/bin/flatpak"
|
||||
const val BWRAP = "/usr/local/bin/bannerlator-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"
|
||||
private const val USER_DIR = "root/.local/share/flatpak"
|
||||
|
||||
/** One store operation at a time: Flatpak locks its installation per transaction anyway. */
|
||||
@Volatile var busy: String? = null
|
||||
private set
|
||||
|
||||
class App(val id: String, val name: String, val icon: File?, val branch: String, val summary: String?)
|
||||
|
||||
/** Flatpak is in the runtime, with Flathub to install from. */
|
||||
fun ready(context: Context): Boolean {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
return File(root, FLATPAK.substring(1)).isFile &&
|
||||
File(root, "var/cache/droiddeck-flatpak/installed").isFile &&
|
||||
(FileUtils.readString(File(root, "$USER_DIR/repo/config"))?.contains("[remote \"flathub\"]") == true)
|
||||
}
|
||||
|
||||
private fun arch(): String = when (android.os.Build.SUPPORTED_ABIS.firstOrNull()) {
|
||||
"x86_64" -> "x86_64"
|
||||
else -> "aarch64"
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed apps, read from the installation itself: each app's deployed files carry the
|
||||
* desktop entry and icons it exports, so listing them needs no process in the runtime.
|
||||
*/
|
||||
fun installedApps(context: Context): List<App> {
|
||||
val apps = File(LinuxRuntime.rootDir(context), "$USER_DIR/app")
|
||||
val arch = arch()
|
||||
return apps.listFiles()?.mapNotNull { dir ->
|
||||
val id = dir.name
|
||||
val archDir = File(dir, arch)
|
||||
val branch = archDir.listFiles()?.firstOrNull { File(it, "active").exists() } ?: return@mapNotNull null
|
||||
val active = File(branch, "active")
|
||||
val export = File(active, "export/share")
|
||||
val desktop = FileUtils.readString(File(export, "applications/$id.desktop"))
|
||||
val name = desktop?.let { entry(it, "Name") } ?: id.substringAfterLast('.')
|
||||
val summary = desktop?.let { entry(it, "Comment") }
|
||||
App(id, name, icon(export, id), branch.name, summary)
|
||||
}?.sortedBy { it.name.lowercase() } ?: emptyList()
|
||||
}
|
||||
|
||||
fun isInstalled(context: Context, id: String): Boolean =
|
||||
File(LinuxRuntime.rootDir(context), "$USER_DIR/app/$id/${arch()}").listFiles()?.any { File(it, "active").exists() } == true
|
||||
|
||||
/** A key of the desktop entry's main group ([Desktop Entry]), unlocalised. */
|
||||
private fun entry(desktop: String, key: String): String? {
|
||||
var inMain = false
|
||||
for (line in desktop.lineSequence()) {
|
||||
val t = line.trim()
|
||||
if (t.startsWith("[")) { inMain = t == "[Desktop Entry]"; continue }
|
||||
if (inMain && t.startsWith("$key=")) return t.substringAfter('=').trim().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The largest PNG the app exports (the front end draws no SVG). */
|
||||
private fun icon(export: File, id: String): File? {
|
||||
val hicolor = File(export, "icons/hicolor")
|
||||
return hicolor.listFiles()
|
||||
?.mapNotNull { size -> size.name.substringBefore('x').toIntOrNull()?.let { it to File(size, "apps/$id.png") } }
|
||||
?.filter { it.second.isFile }
|
||||
?.maxByOrNull { it.first }?.second
|
||||
}
|
||||
|
||||
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
|
||||
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
|
||||
return GuestCommand.run(context, argv, fakeRoot, name, onLine)
|
||||
}
|
||||
|
||||
private inline fun <T> exclusive(what: String, block: () -> T): T? {
|
||||
synchronized(this) {
|
||||
if (busy != null) return null
|
||||
busy = what
|
||||
}
|
||||
try { return block() } finally { busy = null }
|
||||
}
|
||||
|
||||
/** Puts Flatpak into the runtime and adds Flathub. Null on success, else what went wrong. */
|
||||
fun setup(context: Context, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!LinuxRuntime.isInstalled(context)) return "Install the Linux runtime first"
|
||||
return exclusive("setup") {
|
||||
var failure: String? = null
|
||||
val status = runGuest(context, listOf("/bin/bash", SETUP), fakeRoot = true) { line ->
|
||||
Log.i(TAG, "setup: $line")
|
||||
when {
|
||||
line.startsWith("== STEP ") -> onProgress(line.removePrefix("== STEP "), -1)
|
||||
line.startsWith("== FAIL ") -> failure = line.removePrefix("== FAIL ")
|
||||
}
|
||||
}
|
||||
when {
|
||||
failure != null -> failure
|
||||
status != 0 -> "Flatpak setup failed (exit $status)"
|
||||
!ready(context) -> "Flatpak setup did not finish"
|
||||
else -> null
|
||||
}
|
||||
} ?: "Another store task is running"
|
||||
}
|
||||
|
||||
/**
|
||||
* One store operation - install, uninstall or update - with its progress as a stage line and
|
||||
* an overall percentage across the steps Flatpak plans (a runtime, its extensions, the app).
|
||||
*/
|
||||
private fun transaction(context: Context, verb: String, id: String?, onProgress: (String, Int) -> Unit): String? {
|
||||
if (!ready(context)) return "Set up Flatpak first"
|
||||
return exclusive("$verb:${id ?: "all"}") {
|
||||
var error: String? = null
|
||||
// Progress lines carry no ref; they belong to the step the last "op" line started.
|
||||
var step = "Starting"
|
||||
val argv = listOfNotNull("/usr/bin/python3", HELPER, verb, id)
|
||||
val status = runGuest(context, argv, fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull()
|
||||
if (o == null) { Log.i(TAG, "$verb: $line"); return@runGuest }
|
||||
when (o.optString("e")) {
|
||||
"op" -> { step = stage(o); onProgress(step, overall(o, 0)) }
|
||||
"progress" -> onProgress(step, overall(o, o.optInt("percent")))
|
||||
"error" -> { error = o.optString("message"); Log.w(TAG, "$verb $id: $error") }
|
||||
"warning" -> Log.w(TAG, "$verb $id: ${o.optString("message")}")
|
||||
}
|
||||
}
|
||||
error ?: if (status != 0) "Flatpak exited with status $status" else null
|
||||
} ?: "Another store task is running"
|
||||
}
|
||||
|
||||
private fun stage(o: JSONObject): String {
|
||||
val ref = o.optString("ref")
|
||||
val name = refLabel(ref)
|
||||
val step = if (o.optInt("n") > 1) " (${o.optInt("i")}/${o.optInt("n")})" else ""
|
||||
return when (o.optString("kind")) {
|
||||
"uninstall" -> "Removing $name$step"
|
||||
"update" -> "Updating $name$step"
|
||||
else -> "Installing $name$step"
|
||||
}.let { if (ref.isEmpty()) o.optString("status").ifEmpty { "Working" } else it }
|
||||
}
|
||||
|
||||
/** A ref in words: "app/org.supertuxproject.SuperTux/aarch64/stable" is "SuperTux". */
|
||||
internal fun refLabel(ref: String): String {
|
||||
val parts = ref.split('/')
|
||||
val id = parts.getOrNull(1) ?: return ref
|
||||
if (parts.firstOrNull() == "app") return id.substringAfterLast('.')
|
||||
val branch = parts.getOrNull(3)?.let { " $it" } ?: ""
|
||||
return when {
|
||||
id.endsWith(".Locale") -> "translations"
|
||||
".GL." in id || ".GL32." in id -> "graphics drivers"
|
||||
"codecs" in id.lowercase() || id.endsWith(".ffmpeg-full") -> "media codecs"
|
||||
id.endsWith(".Platform") -> when (val vendor = id.removeSuffix(".Platform").substringAfterLast('.')) {
|
||||
"gnome", "kde" -> vendor.uppercase()
|
||||
else -> vendor.replaceFirstChar { it.uppercase() }
|
||||
} + " runtime$branch"
|
||||
else -> id.substringAfterLast('.')
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole transaction's percentage: steps done, plus this one's share. */
|
||||
private fun overall(o: JSONObject, percent: Int): Int {
|
||||
val n = o.optInt("n").coerceAtLeast(1)
|
||||
val i = o.optInt("i").coerceIn(1, n)
|
||||
return (((i - 1) * 100 + percent.coerceIn(0, 100)) / n)
|
||||
}
|
||||
|
||||
fun install(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "install", id, onProgress)
|
||||
fun uninstall(context: Context, id: String, onProgress: (String, Int) -> Unit) = transaction(context, "uninstall", id, onProgress)
|
||||
fun update(context: Context, id: String?, onProgress: (String, Int) -> Unit) = transaction(context, "update", id, onProgress)
|
||||
|
||||
/** Installed apps with an update on Flathub, or null when that could not be checked. */
|
||||
fun updates(context: Context): Set<String>? {
|
||||
if (!ready(context)) return emptySet()
|
||||
var apps: Set<String>? = null
|
||||
exclusive("updates") {
|
||||
runGuest(context, listOf("/usr/bin/python3", HELPER, "updates"), fakeRoot = false) { line ->
|
||||
val o = runCatching { JSONObject(line) }.getOrNull() ?: return@runGuest
|
||||
if (o.optString("e") == "updates") {
|
||||
val a = o.optJSONArray("apps")
|
||||
apps = (0 until (a?.length() ?: 0)).map { a!!.getString(it) }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
return apps
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.droiddeck.launcher.runtime
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.session.OrphanReaper
|
||||
import com.droiddeck.launcher.session.SessionFiles
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* One command in the Linux runtime outside any session - the store's Flatpak work, an AppImage
|
||||
* import - in a proot of its own. A session starting meanwhile leaves it running (OrphanReaper).
|
||||
*/
|
||||
object GuestCommand {
|
||||
/**
|
||||
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
|
||||
* for the package tools, which refuse any uid but 0. With [logName], everything the command
|
||||
* says also goes to Download/DroidDeck/<logName>.log beside the session logs, so a problem can
|
||||
* be handed over like a session's.
|
||||
*/
|
||||
fun run(context: Context, argv: List<String>, fakeRoot: Boolean = false, logName: String? = null,
|
||||
onLine: (String) -> Unit): Int {
|
||||
val log = logName?.let {
|
||||
try { File(LinuxRuntime.debugLogDir().apply { mkdirs() }, "$it.log").printWriter() } catch (e: Exception) { null }
|
||||
}
|
||||
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
|
||||
try {
|
||||
return runLogged(context, argv, fakeRoot) { line -> log?.println(line); log?.flush(); onLine(line) }
|
||||
} finally {
|
||||
log?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runLogged(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
|
||||
val root = LinuxRuntime.rootDir(context)
|
||||
LinuxRuntime.writeAccounts(context)
|
||||
SessionFiles.stage(context, root)
|
||||
// The resolver otherwise names the DNS of whatever network the last session was on.
|
||||
LinuxNetworkLinkComponent(context, root).publish()
|
||||
val runtimeDir = File(context.filesDir, ".flatpak-rt").apply { mkdirs() }
|
||||
val cmd = LinuxRuntime.prootPrefix(context, root, "/root", fakeRoot)
|
||||
LinuxRuntime.binds(context, null, runtimeDir, Environment.getExternalStorageDirectory(), null)
|
||||
.forEach { cmd.add("-b"); cmd.add(it) }
|
||||
cmd += listOf(
|
||||
"/usr/bin/env", "-i", "HOME=/root", "USER=root", "LANG=C.UTF-8",
|
||||
"PATH=/usr/local/bin:/usr/bin:/bin", "XDG_RUNTIME_DIR=${runtimeDir.path}",
|
||||
"XDG_DATA_HOME=/root/.local/share", "FLATPAK_BWRAP=${FlatpakManager.BWRAP}",
|
||||
"XDG_DATA_DIRS=/root/.local/share/flatpak/exports/share:/usr/local/share:/usr/share",
|
||||
)
|
||||
cmd += argv
|
||||
val builder = ProcessBuilder(cmd).directory(root).redirectErrorStream(true)
|
||||
builder.environment().apply {
|
||||
put("PROOT_LOADER", LinuxRuntime.prootLoader(context).path)
|
||||
put("PROOT_TMP_DIR", context.cacheDir.path)
|
||||
if (SessionPrefs.prootNoSeccomp(context)) put("PROOT_NO_SECCOMP", "1")
|
||||
LinuxRuntime.prootLibraryPath(context).takeIf { it.isNotEmpty() }?.let { put("LD_LIBRARY_PATH", it) }
|
||||
}
|
||||
val process = builder.start()
|
||||
val pid = HostProcess.pidOf(process)
|
||||
OrphanReaper.keep(pid)
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines -> lines.forEach(onLine) }
|
||||
return process.waitFor()
|
||||
} finally {
|
||||
OrphanReaper.release(pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,37 @@ public final class LinuxRuntime {
|
||||
File externalStorage, List<String> extraBinds,
|
||||
List<String> guestCommand) {
|
||||
File root = rootDir(context);
|
||||
List<String> binds = binds(context, sessionRoot, runtimeDir, externalStorage, extraBinds);
|
||||
// A session's binds are the view its programs have; a one-off command's are not recorded.
|
||||
if (sessionRoot != null) lastBinds = binds;
|
||||
List<String> cmd = prootPrefix(context, root, "/root");
|
||||
for (String spec : binds) bind(cmd, spec);
|
||||
cmd.addAll(guestCommand);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* The binds of the last command built: the guest's view of the host, which the Flatpak
|
||||
* sandboxes started beside a session (BwrapSpawner) translate their paths through.
|
||||
*/
|
||||
private static volatile List<String> lastBinds;
|
||||
|
||||
public static List<String> lastBinds(Context context) {
|
||||
List<String> binds = lastBinds;
|
||||
if (binds != null) return binds;
|
||||
return binds(context, null, null, android.os.Environment.getExternalStorageDirectory(), null);
|
||||
}
|
||||
|
||||
/** proot and its options up to the binds: the rootfs at {@code root}, starting in {@code cwd}. */
|
||||
public static List<String> prootPrefix(Context context, File root, String cwd) {
|
||||
return prootPrefix(context, root, cwd, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* As above; {@code fakeRoot} has the guest see uid 0 (proot -0), for the package tools that
|
||||
* refuse any other uid. Files it writes still belong to the app.
|
||||
*/
|
||||
public static List<String> prootPrefix(Context context, File root, String cwd, boolean fakeRoot) {
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(prootBinary(context).getPath());
|
||||
cmd.add("--kill-on-exit");
|
||||
@@ -125,12 +156,24 @@ public final class LinuxRuntime {
|
||||
// this the keymap never compiles and Xwayland dies. -i makes proot answer those calls
|
||||
// itself while still reporting our real ids, so nothing inside sees a different user.
|
||||
int uid = Process.myUid();
|
||||
cmd.add("-i");
|
||||
cmd.add(uid + ":" + uid);
|
||||
if (fakeRoot) {
|
||||
cmd.add("-0");
|
||||
} else {
|
||||
cmd.add("-i");
|
||||
cmd.add(uid + ":" + uid);
|
||||
}
|
||||
cmd.add("-r");
|
||||
cmd.add(root.getPath());
|
||||
cmd.add("-w");
|
||||
cmd.add("/root");
|
||||
cmd.add(cwd);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/** The {@code host:guest} (or same-path) bind specs of a session; see {@link #command}. */
|
||||
public static List<String> binds(Context context, File sessionRoot, File runtimeDir,
|
||||
File externalStorage, List<String> extraBinds) {
|
||||
File root = rootDir(context);
|
||||
List<String> cmd = new ArrayList<>();
|
||||
bind(cmd, "/dev");
|
||||
bind(cmd, "/proc");
|
||||
bind(cmd, "/sys");
|
||||
@@ -142,7 +185,7 @@ public final class LinuxRuntime {
|
||||
bind(cmd, new File(root, "etc/bannerlator/empty").getPath() + ":/sys/fs/selinux");
|
||||
bind(cmd, context.getFilesDir().getPath());
|
||||
bind(cmd, context.getCacheDir().getPath());
|
||||
bind(cmd, runtimeDir.getPath());
|
||||
if (runtimeDir != null) bind(cmd, runtimeDir.getPath());
|
||||
if (sessionRoot != null) bind(cmd, sessionRoot.getPath());
|
||||
if (externalStorage != null && externalStorage.isDirectory()) {
|
||||
bind(cmd, externalStorage.getPath());
|
||||
@@ -193,11 +236,14 @@ public final class LinuxRuntime {
|
||||
}
|
||||
}
|
||||
bindGpuNode(context, cmd);
|
||||
bindAdrenoStats(cmd);
|
||||
bindCpuTemps(cmd, root);
|
||||
if (extraBinds != null) {
|
||||
for (String spec : extraBinds) bind(cmd, spec);
|
||||
}
|
||||
cmd.addAll(guestCommand);
|
||||
return cmd;
|
||||
List<String> specs = new ArrayList<>();
|
||||
for (int i = 0; i + 1 < cmd.size(); i += 2) specs.add(cmd.get(i + 1));
|
||||
return specs;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,13 +290,89 @@ public final class LinuxRuntime {
|
||||
bind(cmd, KGSL_DEVICE + ":/dev/dri/" + node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valve's mangoapp (Deck mode's performance overlay) reads an Adreno GPU's load, clock and
|
||||
* temperatures from where they are on Valve's own hardware; every Adreno under Android keeps
|
||||
* them in KGSL's sysfs, readable by the app, though which files a kernel has differs between
|
||||
* Snapdragon generations - so each value takes the first source that exists, the order other
|
||||
* Android PC emulators (GameNative, Winlator forks) read them in. A value found nowhere is left
|
||||
* alone.
|
||||
*/
|
||||
private static void bindAdrenoStats(List<String> cmd) {
|
||||
String kgsl = "/sys/class/kgsl/kgsl-3d0/";
|
||||
String gpuTemp = firstReadable(kgsl + "temp", kgsl + "devfreq/temp", thermalZone("gpu"));
|
||||
String[][] stats = {
|
||||
{firstReadable(kgsl + "gpu_busy_percentage", kgsl + "devfreq/gpu_load"), "/sys/kernel/debug/dri/0/perf_now"},
|
||||
{firstReadable(kgsl + "devfreq/cur_freq", kgsl + "gpuclk"),
|
||||
"/sys/devices/platform/soc@0/3d00000.gpu/devfreq/3d00000.gpu/cur_freq"},
|
||||
{gpuTemp, "/sys/class/thermal/thermal_zone28/temp"},
|
||||
{gpuTemp, "/sys/class/thermal/thermal_zone26/temp"},
|
||||
{thermalZone("ddr"), "/sys/class/thermal/thermal_zone22/temp"},
|
||||
};
|
||||
for (String[] stat : stats) {
|
||||
if (stat[0] != null) bind(cmd, stat[0] + ":" + stat[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* mangoapp's CPU temperature is the mean of the thermal zones named cpuN-thermal or
|
||||
* cpuN-top-thermal, as mainline kernels name them; Android kernels name the same sensors
|
||||
* cpuss-0, cpu-1-0, cpu0-silver-usr, apc1-cpu0-usr and the like, so those zones are shown
|
||||
* under the mainline name.
|
||||
*/
|
||||
private static void bindCpuTemps(List<String> cmd, File root) {
|
||||
File name = new File(root, "etc/bannerlator/cpu-thermal-type");
|
||||
try {
|
||||
if (!name.isFile()) Files.write(name.toPath(), "cpu0-thermal\n".getBytes(StandardCharsets.US_ASCII));
|
||||
} catch (IOException e) {
|
||||
return;
|
||||
}
|
||||
for (java.util.Map.Entry<String, File> zone : thermalZones().entrySet()) {
|
||||
String type = zone.getKey();
|
||||
if (type.contains("cpu") && !type.contains("gpu") && !type.matches("cpu\\d-(top-)?thermal")) {
|
||||
bind(cmd, name.getPath() + ":" + new File(zone.getValue(), "type").getPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstReadable(String... paths) {
|
||||
for (String path : paths) {
|
||||
if (path != null && new File(path).canRead()) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The temp file of the thermal zone for a sensor: the one named so, else the first whose name has it. */
|
||||
private static String thermalZone(String sensor) {
|
||||
java.util.Map<String, File> zones = thermalZones();
|
||||
File zone = zones.get(sensor);
|
||||
for (java.util.Map.Entry<String, File> e : new java.util.TreeMap<>(zones).entrySet()) {
|
||||
if (zone == null && e.getKey().contains(sensor)) zone = e.getValue();
|
||||
}
|
||||
return zone != null ? new File(zone, "temp").getPath() : null;
|
||||
}
|
||||
|
||||
/** The device's thermal zones by their sensor's name, in lower case. */
|
||||
private static java.util.Map<String, File> thermalZones() {
|
||||
java.util.Map<String, File> byType = new java.util.HashMap<>();
|
||||
File[] zones = new File("/sys/class/thermal").listFiles((dir, n) -> n.startsWith("thermal_zone"));
|
||||
if (zones == null) return byType;
|
||||
for (File zone : zones) {
|
||||
try (java.io.BufferedReader r = new java.io.BufferedReader(new java.io.FileReader(new File(zone, "type")))) {
|
||||
byType.putIfAbsent(String.valueOf(r.readLine()).trim().toLowerCase(java.util.Locale.ROOT), zone);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
return byType;
|
||||
}
|
||||
|
||||
private static void bind(List<String> cmd, String spec) {
|
||||
cmd.add("-b");
|
||||
cmd.add(spec);
|
||||
}
|
||||
|
||||
/** PRoot's complex -k format: sysname, nodename, release, version, machine, domain, HWCAP. */
|
||||
private static String guestUtsname(String hostname) {
|
||||
static String guestUtsname(String hostname) {
|
||||
StructUtsname host = Os.uname();
|
||||
return "\\" + host.sysname + "\\" + hostname + "\\" + host.release
|
||||
+ "\\" + host.version + "\\" + host.machine + "\\localdomain\\-1\\";
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.os.Environment
|
||||
import com.droiddeck.launcher.frontend.Library
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Game saves in and out of a game's Proton prefix, as zips other apps read: GameHub keeps a prefix's
|
||||
* drive_c rooted at "/drive_c/users/steamuser/…", Winlator / WinNative / Bannerlator at
|
||||
* "drive_c/users/xuser/…". Proton here runs as steamuser too, so on the way in any user folder but
|
||||
* Public becomes steamuser, and on the way out the chosen layout decides the user segment.
|
||||
*
|
||||
* Saves belong to the game's prefix (compatdata/<appid>/pfx), whichever Proton it runs with.
|
||||
* Ported from Bannerlator's GameSaveBackup and SaveLocator.
|
||||
*/
|
||||
object GameSaves {
|
||||
enum class Layout(val label: String, val rootPrefix: String, val user: String) {
|
||||
GAMEHUB("GameHub zip", "/drive_c/", "steamuser"),
|
||||
WINLATOR("Winlator zip", "drive_c/", "xuser"),
|
||||
}
|
||||
|
||||
/** A game, its prefix, and the save folders found in it. */
|
||||
data class Game(val game: Library.SteamGame, val prefix: File?, val saves: List<SaveDir>) {
|
||||
val launched get() = prefix?.let { File(it, "drive_c").isDirectory } == true
|
||||
}
|
||||
|
||||
/** A folder of saves, relative to the steamuser profile. */
|
||||
data class SaveDir(val relPath: String, val files: Int, val bytes: Long)
|
||||
|
||||
private const val USER = "steamuser"
|
||||
|
||||
fun savesDir(): File = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck/Saves")
|
||||
private fun backupsDir(): File = File(savesDir(), "backups")
|
||||
|
||||
/** [game] with its prefix and the saves found there; reads files, so not on the main thread. */
|
||||
fun game(game: Library.SteamGame): Game = Game(game, game.protonPrefix, game.protonPrefix?.let { locate(it, game) } ?: emptyList())
|
||||
|
||||
// ------------------------------------------------------------------ finding saves
|
||||
|
||||
/** Roots of the steamuser profile that hold most PC game saves, in the order they are trusted. */
|
||||
private val SAVE_ROOTS = listOf("Documents/My Games", "Saved Games", "AppData/Roaming", "Documents", "AppData/Local", "AppData/LocalLow")
|
||||
private const val KEEP_THRESHOLD = 50
|
||||
|
||||
private fun profile(prefix: File) = File(prefix, "drive_c/users/$USER")
|
||||
|
||||
/**
|
||||
* Folders under the save roots, one and two deep, whose names match the game's name or its folder's.
|
||||
* Nested matches keep the outer folder only.
|
||||
*/
|
||||
fun locate(prefix: File, game: Library.SteamGame): List<SaveDir> {
|
||||
val profile = profile(prefix)
|
||||
if (!profile.isDirectory) return emptyList()
|
||||
val ids = listOfNotNull(game.name, game.gameFiles?.name).map(::ident).filter { it.norm.isNotEmpty() }
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
val hits = LinkedHashMap<String, Int>()
|
||||
fun consider(dir: File) {
|
||||
val d = ident(dir.name)
|
||||
if (d.norm.isEmpty()) return
|
||||
val s = ids.maxOf { score(it, d) }
|
||||
if (s < KEEP_THRESHOLD) return
|
||||
val rel = dir.relativeTo(profile).path.replace(File.separatorChar, '/')
|
||||
if ((hits[rel] ?: -1) < s) hits[rel] = s
|
||||
}
|
||||
for (root in SAVE_ROOTS) {
|
||||
File(profile, root).listFiles()?.filter { it.isDirectory }?.forEach { d1 ->
|
||||
consider(d1)
|
||||
d1.listFiles()?.filter { it.isDirectory }?.forEach(::consider)
|
||||
}
|
||||
}
|
||||
val kept = ArrayList<String>()
|
||||
for (rel in hits.entries.sortedByDescending { it.value }.map { it.key }) {
|
||||
if (kept.none { rel == it || rel.startsWith("$it/") }) kept.add(rel)
|
||||
}
|
||||
return kept.map { rel ->
|
||||
var files = 0
|
||||
var bytes = 0L
|
||||
File(profile, rel).walkTopDown().filter { it.isFile }.forEach { files++; bytes += it.length() }
|
||||
SaveDir(rel, files, bytes)
|
||||
}.filter { it.files > 0 }
|
||||
}
|
||||
|
||||
private data class Ident(val norm: String, val tokens: Set<String>)
|
||||
|
||||
private fun ident(raw: String): Ident {
|
||||
val cleaned = raw.lowercase().replace("®", "").replace("™", "").replace("©", "")
|
||||
return Ident(cleaned.filter { it.isLetterOrDigit() }, cleaned.split(Regex("[^a-z0-9]+")).filter { it.isNotEmpty() }.toSet())
|
||||
}
|
||||
|
||||
/** exact 100, one contains the other 70, half the words shared 50, close spelling 40. */
|
||||
private fun score(a: Ident, b: Ident): Int {
|
||||
if (a.norm.isEmpty() || b.norm.isEmpty()) return 0
|
||||
if (a.norm == b.norm) return 100
|
||||
if (a.norm.contains(b.norm) || b.norm.contains(a.norm)) return 70
|
||||
if (a.tokens.isNotEmpty() && b.tokens.isNotEmpty() &&
|
||||
a.tokens.intersect(b.tokens).size.toDouble() / minOf(a.tokens.size, b.tokens.size) >= 0.5) return 50
|
||||
return if (1.0 - levenshtein(a.norm, b.norm).toDouble() / maxOf(a.norm.length, b.norm.length) >= 0.85) 40 else 0
|
||||
}
|
||||
|
||||
private fun levenshtein(a: String, b: String): Int {
|
||||
val prev = IntArray(b.length + 1) { it }
|
||||
val cur = IntArray(b.length + 1)
|
||||
for (i in 1..a.length) {
|
||||
cur[0] = i
|
||||
for (j in 1..b.length) cur[j] = minOf(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + if (a[i - 1] == b[j - 1]) 0 else 1)
|
||||
System.arraycopy(cur, 0, prev, 0, cur.size)
|
||||
}
|
||||
return prev[b.length]
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ export
|
||||
|
||||
private fun stamp() = SimpleDateFormat("yyyy-MM-dd_HHmm", Locale.US).format(Date())
|
||||
private fun safe(name: String) = name.replace(Regex("[/\\\\:*?\"<>|]"), "_").trim().ifEmpty { "game" }
|
||||
|
||||
/**
|
||||
* Zips the game's saves into [dir] as "<Game>_<date>.zip" in [layout]. The found save folders when
|
||||
* there are any, else the whole steamuser profile minus Temp and crash dumps. Returns the zip.
|
||||
*/
|
||||
fun export(game: Game, layout: Layout, dir: File): Pair<File, Int> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
dir.mkdirs()
|
||||
val out = File(dir, "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val count = zipProfile(profile(prefix), game.saves.map { it.relPath }.ifEmpty { null }, layout, out)
|
||||
if (count == 0) { out.delete(); error("No save files found for ${game.game.name}") }
|
||||
return out to count
|
||||
}
|
||||
|
||||
private fun zipProfile(profile: File, roots: List<String>?, layout: Layout, out: File): Int {
|
||||
if (!profile.isDirectory) return 0
|
||||
val scope = roots?.map { File(profile, it) }?.filter { it.exists() } ?: listOf(profile)
|
||||
var count = 0
|
||||
ZipOutputStream(BufferedOutputStream(FileOutputStream(out))).use { zos ->
|
||||
for (top in scope) top.walkTopDown()
|
||||
.onEnter { d -> d.relativeTo(profile).path.replace(File.separatorChar, '/').let { !it.equals("AppData/Local/Temp", true) && !it.equals("AppData/Local/CrashDumps", true) } }
|
||||
.filter { it.isFile }
|
||||
.forEach { f ->
|
||||
val rel = f.relativeTo(profile).path.replace(File.separatorChar, '/')
|
||||
zos.putNextEntry(ZipEntry("${layout.rootPrefix}users/${layout.user}/$rel"))
|
||||
f.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ import
|
||||
|
||||
/**
|
||||
* Unzips a GameHub or Winlator save zip into the game's drive_c, after zipping what its steamuser
|
||||
* profile holds now into Download/DroidDeck/Saves/backups. Entries outside drive_c, launcher
|
||||
* shortcuts and anything escaping drive_c are skipped. Returns (files written, backup or null).
|
||||
*/
|
||||
fun import(game: Game, zip: File): Pair<Int, File?> {
|
||||
val prefix = game.prefix ?: error("${game.game.name} has not been launched yet")
|
||||
val driveC = File(prefix, "drive_c")
|
||||
if (!driveC.isDirectory) error("${game.game.name} has not been launched yet")
|
||||
if (!looksLikeSaveZip(zip)) error("${zip.name} has no drive_c folder, so it isn't a GameHub or Winlator save zip")
|
||||
|
||||
backupsDir().mkdirs()
|
||||
val backup = File(backupsDir(), "${safe(game.game.name)}_${stamp()}.zip")
|
||||
val backedUp = zipProfile(profile(prefix), null, Layout.GAMEHUB, backup)
|
||||
if (backedUp == 0) backup.delete()
|
||||
|
||||
val base = driveC.canonicalFile
|
||||
var written = 0
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) {
|
||||
remap(e.name)?.let { rel ->
|
||||
val out = File(driveC, rel).canonicalFile
|
||||
if (out.path.startsWith(base.path + File.separator)) {
|
||||
if (e.isDirectory) out.mkdirs()
|
||||
else {
|
||||
out.parentFile?.mkdirs()
|
||||
Files.copy(zis, out.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
written++
|
||||
}
|
||||
}
|
||||
}
|
||||
zis.closeEntry()
|
||||
e = zis.nextEntry
|
||||
}
|
||||
}
|
||||
return written to backup.takeIf { it.exists() }
|
||||
}
|
||||
|
||||
/** Which layout a zip was written in, from its first user folder; null when it has none. */
|
||||
fun layoutOf(zip: File): Layout? = runCatching {
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) {
|
||||
val segs = driveCSegments(e.name)
|
||||
if (segs != null && segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true))
|
||||
return@use if (segs[1].equals("xuser", true)) Layout.WINLATOR else Layout.GAMEHUB
|
||||
e = zis.nextEntry
|
||||
}
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun looksLikeSaveZip(zip: File): Boolean = runCatching {
|
||||
ZipInputStream(BufferedInputStream(zip.inputStream())).use { zis ->
|
||||
var e = zis.nextEntry
|
||||
while (e != null) { if (driveCSegments(e.name) != null) return@use true; e = zis.nextEntry }
|
||||
false
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** An entry's path below drive_c, with any user folder but Public renamed to steamuser. */
|
||||
private fun remap(raw: String): String? {
|
||||
val segs = driveCSegments(raw)?.takeIf { it.isNotEmpty() } ?: return null
|
||||
if (segs.any { it == ".." }) return null
|
||||
// GameHub also packs its own launcher shortcuts; they are never saves.
|
||||
if (segs[0].equals("proton_shortcuts", true)) return null
|
||||
val last = segs.last().lowercase()
|
||||
if ((last.endsWith(".lnk") || last.endsWith(".desktop") || last.endsWith(".url")) && segs.any { it.equals("Desktop", true) }) return null
|
||||
if (segs.size >= 2 && segs[0].equals("users", true) && !segs[1].equals("Public", true)) segs[1] = USER
|
||||
return segs.joinToString("/")
|
||||
}
|
||||
|
||||
private fun driveCSegments(raw: String): MutableList<String>? {
|
||||
val segs = raw.replace('\\', '/').trimStart('/').split("/").filter { it.isNotEmpty() && it != "." }
|
||||
if (segs.isEmpty() || !segs[0].equals("drive_c", true)) return null
|
||||
return segs.drop(1).toMutableList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The GPU held at its top clock for a session, without root: KGSL's power-control property turned
|
||||
* off on /dev/kgsl-3d0, the node the driver already opens (adrenotools' "turbo"). Left to itself the
|
||||
* governor lets the GPU sit low between bursts, and a frame that lands on a low clock is a hitch;
|
||||
* Bannerlator measured the clock going from 231 to 1000 MHz with this set. It costs power and heat,
|
||||
* so it is opt-in, and the kernel's thermal limits still apply on top of it.
|
||||
*
|
||||
* The property belongs to the device, not to this process, and a killed app cannot unwind it: it
|
||||
* is cleared when a session stops, and at the next app start when a marker file says this app set
|
||||
* it and never cleared it - only then, so another app's own setting is not undone.
|
||||
* (Bannerlator, PerfGpuTurbo.)
|
||||
*/
|
||||
object GpuClockPin {
|
||||
private const val TAG = "GpuClockPin"
|
||||
private const val KGSL_NODE = "/dev/kgsl-3d0"
|
||||
|
||||
private const val MARKER = "gpu-clock-pinned"
|
||||
|
||||
@Volatile
|
||||
private var pinned = false
|
||||
|
||||
private val supported: Boolean by lazy { File(KGSL_NODE).exists() }
|
||||
|
||||
/** At a session's start: pins the clock when the user asked for it. The line is for the log. */
|
||||
@Synchronized
|
||||
fun start(context: Context): String {
|
||||
if (!SessionPrefs.gpuClockPin(context)) return "gpu clock: governed"
|
||||
if (!supported) return "gpu clock: no $KGSL_NODE, left governed"
|
||||
return try {
|
||||
File(context.filesDir, MARKER).createNewFile()
|
||||
WaylandCompositor.nativeSetGpuTurbo(true)
|
||||
pinned = true
|
||||
"gpu clock: held at its top"
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "pin", t)
|
||||
"gpu clock: pin failed"
|
||||
}
|
||||
}
|
||||
|
||||
/** At a session's end. */
|
||||
@Synchronized
|
||||
fun stop(context: Context) {
|
||||
if (pinned) clear(context)
|
||||
}
|
||||
|
||||
/** At the app's start: a process killed with the clock pinned left only the marker behind. */
|
||||
@Synchronized
|
||||
fun clearLeftover(context: Context) {
|
||||
if (File(context.filesDir, MARKER).exists()) clear(context)
|
||||
}
|
||||
|
||||
private fun clear(context: Context) {
|
||||
if (supported) {
|
||||
try {
|
||||
WaylandCompositor.nativeSetGpuTurbo(false)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "clear", t)
|
||||
}
|
||||
}
|
||||
pinned = false
|
||||
File(context.filesDir, MARKER).delete()
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,28 @@ import java.io.File
|
||||
* force-stop the app before every launch. Everything of ours that should be alive between
|
||||
* sessions is inside the app process, so at session start anything else under our uid is a
|
||||
* leftover, and this sweeps it. Each pid is checked by uid at kill time, never by name.
|
||||
*
|
||||
* The one exception is work the app started outside any session and is still waiting on - a
|
||||
* Flatpak install from the store - registered with [keep]: that process and everything under it
|
||||
* are left to finish.
|
||||
*/
|
||||
object OrphanReaper {
|
||||
private const val TAG = "OrphanReaper"
|
||||
private val kept = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
|
||||
|
||||
/** Spares [pid] and its descendants until [release]. */
|
||||
fun keep(pid: Int) { if (pid > 0) kept.add(pid) }
|
||||
|
||||
fun release(pid: Int) { kept.remove(pid) }
|
||||
|
||||
fun reap(reason: String): Int {
|
||||
val me = android.os.Process.myPid()
|
||||
val uid = android.os.Process.myUid()
|
||||
val spared = descendants(kept)
|
||||
var killed = 0
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
if (pid == me || pid <= 1) return@forEach
|
||||
if (pid == me || pid <= 1 || pid in spared) return@forEach
|
||||
if (uidOf(pid) != uid) return@forEach
|
||||
val name = try { File(entry, "cmdline").readText().substringBefore('\u0000') } catch (e: Exception) { "?" }
|
||||
try {
|
||||
@@ -38,6 +49,25 @@ object OrphanReaper {
|
||||
return killed
|
||||
}
|
||||
|
||||
/** [roots] and every process below them, by each process's parent in /proc/<pid>/stat. */
|
||||
private fun descendants(roots: Set<Int>): Set<Int> {
|
||||
if (roots.isEmpty()) return emptySet()
|
||||
val parent = HashMap<Int, Int>()
|
||||
File("/proc").listFiles()?.forEach { entry ->
|
||||
val pid = entry.name.toIntOrNull() ?: return@forEach
|
||||
val stat = try { File(entry, "stat").readText() } catch (e: Exception) { return@forEach }
|
||||
// "pid (comm) state ppid ...": comm may hold spaces and parentheses; the last ')' ends it.
|
||||
stat.substringAfterLast(") ").split(' ').getOrNull(1)?.toIntOrNull()?.let { parent[pid] = it }
|
||||
}
|
||||
val out = HashSet(roots)
|
||||
var grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for ((pid, ppid) in parent) if (ppid in out && out.add(pid)) grew = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun uidOf(pid: Int): Int {
|
||||
return try {
|
||||
File("/proc/$pid/status").useLines { lines ->
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PerformanceHintManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* ADPF (Android 12+): a PerformanceHintManager session over the compositor thread, told the
|
||||
* panel's frame period as its target and each presented frame's interval as the actual. When a
|
||||
* frame runs long the power HAL raises CPU clocks for the next one at once, instead of the
|
||||
* governor waiting for the load average to climb - the difference between a menu that stutters
|
||||
* on the first scroll and one that does not.
|
||||
*
|
||||
* The session may only carry this process's threads, so the guest (gamescope, the Steam client
|
||||
* under FEX) cannot join it; they are covered by the app-wide game category and sustained mode.
|
||||
* Below API 31 nothing here runs; on a power HAL without hint sessions createHintSession returns
|
||||
* null and the log says so.
|
||||
*/
|
||||
object PerfHints {
|
||||
private const val TAG = "PerfHints"
|
||||
|
||||
@Volatile private var session: PerformanceHintManager.Session? = null
|
||||
@Volatile private var target = 0L
|
||||
/** Compositor thread only. */
|
||||
private var last = 0L
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
/** Reports leave the compositor thread: each one is a binder call to the power HAL. */
|
||||
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "adpf-report").apply { isDaemon = true } }
|
||||
|
||||
/** Opens the session once the compositor thread exists (it is started just before this). */
|
||||
fun arm(context: Context, refreshHz: Float) {
|
||||
if (Build.VERSION.SDK_INT < 31) { Log.i(TAG, "adpf: needs Android 12; this is API ${Build.VERSION.SDK_INT}"); return }
|
||||
val wanted = (1_000_000_000.0 / refreshHz.coerceAtLeast(30f)).toLong()
|
||||
val s = session
|
||||
if (s != null) {
|
||||
if (wanted != target) { target = wanted; try { s.updateTargetWorkDuration(wanted) } catch (t: Throwable) { Log.w(TAG, "adpf: retarget", t) } }
|
||||
return
|
||||
}
|
||||
target = wanted
|
||||
val app = context.applicationContext
|
||||
var tries = 0
|
||||
main.post(object : Runnable {
|
||||
override fun run() {
|
||||
if (session != null) return
|
||||
val tid = try { WaylandCompositor.nativeCompositorTid() } catch (t: Throwable) { 0 }
|
||||
if (tid == 0) {
|
||||
if (++tries < 30) main.postDelayed(this, 100) else Log.w(TAG, "adpf: the compositor thread never reported its tid")
|
||||
return
|
||||
}
|
||||
open(app, tid)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private fun open(app: Context, tid: Int) {
|
||||
val pm = app.getSystemService(PerformanceHintManager::class.java)
|
||||
if (pm == null) { Log.i(TAG, "adpf: no PerformanceHintManager on this device"); return }
|
||||
val s = try { pm.createHintSession(intArrayOf(tid), target) } catch (t: Throwable) { Log.w(TAG, "adpf: createHintSession", t); null }
|
||||
if (s == null) { Log.i(TAG, "adpf: unsupported here (the power HAL has no hint sessions)"); return }
|
||||
last = 0L
|
||||
session = s
|
||||
Log.i(TAG, "adpf: session on compositor thread $tid · target ${target / 1000} µs · HAL wants updates every ${pm.preferredUpdateRateNanos / 1000} µs")
|
||||
}
|
||||
|
||||
/** Compositor thread, once per presented game frame; the interval since the last is the actual. */
|
||||
@JvmStatic
|
||||
fun onFrame() {
|
||||
val s = session ?: return
|
||||
val now = System.nanoTime()
|
||||
val prev = last
|
||||
last = now
|
||||
if (prev == 0L) return
|
||||
val dt = now - prev
|
||||
// Under half a millisecond is a double present; over half a second is a pause, not a frame.
|
||||
if (dt < 500_000L || dt > 500_000_000L) return
|
||||
worker.execute {
|
||||
try { s.reportActualWorkDuration(dt) } catch (t: Throwable) { Log.w(TAG, "adpf: report failed, session dropped", t); session = null }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import android.os.SystemClock
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.gpu.FrameGen
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
@@ -86,20 +88,22 @@ class PerfHud(context: Context) {
|
||||
}
|
||||
|
||||
private fun line(base: Float): String {
|
||||
val engine = FrameGen.engine(context)
|
||||
if (engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val name = if (engine == FrameGen.ENGINE_LSFG) "LSFG" else "Win-FG"
|
||||
val multiplier = FrameGen.multiplier(context)
|
||||
val mode = FrameGen.mode(context)
|
||||
if (mode.engine == FrameGen.ENGINE_OFF) return fps(base)
|
||||
val lsfg = mode.engine == FrameGen.ENGINE_LSFG
|
||||
val name = if (lsfg) "LSFG" else "Win-FG"
|
||||
val label = FrameGen.label(context, mode)
|
||||
if (lsfg && Lossless.cacheFile(context)?.isFile != true) return context.getString(R.string.hud_fg_needs_lossless, name)
|
||||
val stats = try { WaylandCompositor.nativeFrameGenStats() } catch (t: Throwable) { null }
|
||||
val problem = try { WaylandCompositor.nativeFrameGenProblem() } catch (t: Throwable) { -1 }
|
||||
return when {
|
||||
problem == 1 -> "$name: unsupported"
|
||||
problem == 2 -> "$name: engine failed"
|
||||
problem == 1 -> context.getString(R.string.hud_fg_unsupported, name)
|
||||
problem == 2 -> context.getString(R.string.hud_fg_failed, name)
|
||||
stats != null && stats[3] > 1f -> {
|
||||
val source = if (stats[2] > 1f) stats[2] else base
|
||||
String.format(Locale.US, "%s %d× %.0f → %.0f fps", name, multiplier, source, stats[3])
|
||||
String.format(Locale.US, "%s %.0f → %.0f fps", label, source, stats[3])
|
||||
}
|
||||
else -> String.format(Locale.US, "%s %d× %s · FG starting", name, multiplier, fps(base))
|
||||
else -> context.getString(R.string.hud_fg_starting, label, fps(base))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.app.GameManager
|
||||
import android.app.GameState
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
@@ -13,10 +12,13 @@ import android.util.Log
|
||||
* The manifest already makes the app a game (appCategory + game_mode_config); this is the
|
||||
* per-window half, applied when the session activity is created:
|
||||
*
|
||||
* - sustained performance mode (Android 7+): a clock floor that does not throttle away after
|
||||
* two minutes, which suits an hour in Big Picture better than a boost that fades;
|
||||
* - NOT sustained performance mode: on Pixel and Qualcomm power HALs it caps the CPU and GPU at
|
||||
* a level the device can hold indefinitely rather than holding a floor, which cost games and
|
||||
* Big Picture their peak clocks. WinNative never asks for it; the thermal budget is left to
|
||||
* the game;
|
||||
* - the panel's fastest mode at its current size (Android 6+): a 120 Hz phone is switched to
|
||||
* 120 Hz for the menu, not only when a game votes for it;
|
||||
* 120 Hz for the menu, not only when a game votes for it. With a frame cap, the fastest mode
|
||||
* the cap divides evenly, so every capped frame is shown for the same number of refreshes;
|
||||
* - GameManager's game state (Android 13+): "in gameplay", so an OEM framework that releases
|
||||
* its boost on loading screens or menus keeps it up.
|
||||
*
|
||||
@@ -26,26 +28,20 @@ import android.util.Log
|
||||
object PerfMode {
|
||||
private const val TAG = "PerfMode"
|
||||
|
||||
fun apply(a: Activity): String {
|
||||
val parts = ArrayList<String>(3)
|
||||
|
||||
// Sustained performance mode.
|
||||
parts += if (Build.VERSION.SDK_INT >= 24) {
|
||||
val pm = a.getSystemService(PowerManager::class.java)
|
||||
if (pm?.isSustainedPerformanceModeSupported == true) {
|
||||
try { a.window.setSustainedPerformanceMode(true); "sustained mode on" } catch (t: Throwable) { Log.w(TAG, "sustained mode", t); "sustained mode refused" }
|
||||
} else "sustained mode unsupported"
|
||||
} else "sustained mode needs Android 7"
|
||||
fun apply(a: Activity, fpsLimit: Int = 0): String {
|
||||
val parts = ArrayList<String>(2)
|
||||
|
||||
// The fastest display mode at the panel's current size.
|
||||
parts += try {
|
||||
val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay
|
||||
if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else {
|
||||
val cur = display.mode
|
||||
val best = display.supportedModes
|
||||
val sameSize = display.supportedModes
|
||||
.filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight }
|
||||
.maxByOrNull { it.refreshRate }
|
||||
if (best != null && best.modeId != cur.modeId && best.refreshRate > cur.refreshRate + 0.5f) {
|
||||
val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate }
|
||||
?: sameSize.maxByOrNull { it.refreshRate }
|
||||
if (best != null && best.modeId != cur.modeId &&
|
||||
(best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) {
|
||||
a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId }
|
||||
"display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})"
|
||||
} else "display ${cur.refreshRate.toInt()} Hz"
|
||||
@@ -71,4 +67,11 @@ object PerfMode {
|
||||
|
||||
return parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
/** A panel rate that shows every capped frame for a whole number of refreshes (no cap: any). */
|
||||
fun cadenceFits(hz: Float, fpsLimit: Int): Boolean {
|
||||
if (fpsLimit <= 0) return true
|
||||
val ratio = hz / fpsLimit
|
||||
return ratio >= 0.98f && Math.abs(ratio - Math.round(ratio)) < 0.02f
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,13 @@ object SessionFiles {
|
||||
"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",
|
||||
// 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",
|
||||
// The user's own AppImages, extracted at import (AppImageManager).
|
||||
"usr/local/bin/bannerlator-appimage-run" to "usr/local/bin/bannerlator-appimage-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",
|
||||
|
||||
@@ -8,6 +8,8 @@ object SessionPrefs {
|
||||
const val SUSPEND_MANUAL = "manual"
|
||||
const val SUSPEND_NEVER = "never"
|
||||
|
||||
const val CONTROLLER_DECK = "deck"
|
||||
const val CONTROLLER_XBOX360 = "xbox360"
|
||||
const val OSC_AUTO = "auto"
|
||||
const val OSC_ALWAYS = "always"
|
||||
const val OSC_STEAM_QAM = "steam-qam"
|
||||
@@ -28,12 +30,37 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("launcherFullscreen", on).apply()
|
||||
}
|
||||
|
||||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean("hud", true)
|
||||
/** The Flathub Store (beta): its rail item and the Store's apps on the Desktop page. Off by default. */
|
||||
fun storeEnabled(context: Context): Boolean = prefs(context).getBoolean("storeEnabled", false)
|
||||
|
||||
fun setStoreEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("storeEnabled", on).apply()
|
||||
}
|
||||
|
||||
/** AppImage import (beta): the AppImages section on the Desktop page. Off by default. */
|
||||
fun appImagesEnabled(context: Context): Boolean = prefs(context).getBoolean("appImagesEnabled", false)
|
||||
|
||||
fun setAppImagesEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("appImagesEnabled", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's performance HUD (the fps box). A Deck-mode Steam session with the performance
|
||||
* overlay has Steam's own (mangoapp, from the QAM), so there the HUD is off unless turned on
|
||||
* in one - a choice kept apart from every other session's, where it stays on by default.
|
||||
*/
|
||||
fun hudEnabled(context: Context): Boolean = prefs(context).getBoolean(hudKey(context), !mangoappSession(context))
|
||||
|
||||
fun setHudEnabled(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("hud", on).apply()
|
||||
prefs(context).edit().putBoolean(hudKey(context), on).apply()
|
||||
}
|
||||
|
||||
private fun hudKey(context: Context) = if (mangoappSession(context)) "hudDeck" else "hud"
|
||||
|
||||
/** A Steam session that runs Deck mode with its performance overlay (mangoapp). */
|
||||
private fun mangoappSession(context: Context) =
|
||||
SessionState.mode == SessionService.MODE_STEAM && steamDeckMode(context) && mangoapp(context)
|
||||
|
||||
/** When enabled, a single Back opens Steam QAM and a double Back opens the session menu. */
|
||||
fun backActionsInverted(context: Context): Boolean = prefs(context).getBoolean("backActionsInverted", false)
|
||||
|
||||
@@ -85,15 +112,52 @@ object SessionPrefs {
|
||||
}
|
||||
|
||||
/**
|
||||
* The imported glibc Turnip a mode draws with inside the runtime, keyed by
|
||||
* SessionService.MODE_STEAM / MODE_DESKTOP so Steam and the desktop can differ; "" = the
|
||||
* driver built into the runtime. Resolved by LinuxVulkanDriver at session start.
|
||||
* The imported glibc Turnip every session draws with inside the runtime - Steam, its games and
|
||||
* the desktop alike, as they run on the same GPU; "" = the driver built into the runtime.
|
||||
* Resolved by LinuxVulkanDriver at session start. It was once chosen per mode: the Steam
|
||||
* session's choice, the one nearly everyone set, carries over.
|
||||
*/
|
||||
fun linuxDriver(context: Context, mode: String): String =
|
||||
prefs(context).getString("linuxDriver.$mode", "") ?: ""
|
||||
fun linuxDriver(context: Context): String =
|
||||
prefs(context).getString("linuxDriver", null)
|
||||
?: prefs(context).getString("linuxDriver.steam", null)
|
||||
?: prefs(context).getString("linuxDriver.desktop", "") ?: ""
|
||||
|
||||
fun setLinuxDriver(context: Context, mode: String, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver.$mode", id).apply()
|
||||
fun setLinuxDriver(context: Context, id: String) {
|
||||
prefs(context).edit().putString("linuxDriver", id).apply()
|
||||
}
|
||||
|
||||
const val GPU_DRIVERS_AUTO = "auto"
|
||||
const val GPU_DRIVERS_MANUAL = "manual"
|
||||
|
||||
/**
|
||||
* Who picks the GPU drivers: [GPU_DRIVERS_AUTO] (the app, the matched pair recommended for
|
||||
* this GPU, kept current - DriverPairs) or [GPU_DRIVERS_MANUAL] (the user). Auto for new
|
||||
* installs; see settleGpuDriverMode.
|
||||
*/
|
||||
fun gpuDriverMode(context: Context): String =
|
||||
prefs(context).getString("gpuDriverMode", GPU_DRIVERS_AUTO) ?: GPU_DRIVERS_AUTO
|
||||
|
||||
fun setGpuDriverMode(context: Context, mode: String) {
|
||||
prefs(context).edit().putString("gpuDriverMode", mode).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto arrived after people had picked drivers by hand: an install that chose either driver
|
||||
* keeps its choice (Manual), everyone else is Auto. Decided once, at process start.
|
||||
*/
|
||||
fun settleGpuDriverMode(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("gpuDriverMode")) return
|
||||
val chosen = androidDriver(context).isNotEmpty() || linuxDriver(context).isNotEmpty()
|
||||
p.edit().putString("gpuDriverMode", if (chosen) GPU_DRIVERS_MANUAL else GPU_DRIVERS_AUTO).apply()
|
||||
}
|
||||
|
||||
/** Drivers Auto downloaded: the only ones it removes when a newer pair replaces them. */
|
||||
fun gpuAutoInstalled(context: Context): Set<String> =
|
||||
prefs(context).getStringSet("gpuAutoInstalled", emptySet()).orEmpty()
|
||||
|
||||
fun setGpuAutoInstalled(context: Context, ids: Set<String>) {
|
||||
prefs(context).edit().putStringSet("gpuAutoInstalled", ids.toSet()).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,8 +207,8 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("directAudio", on).apply()
|
||||
}
|
||||
|
||||
/** The microphone for voice chat, on unless turned off; used only once RECORD_AUDIO is granted. */
|
||||
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", true)
|
||||
/** The microphone for voice chat, off until the user turns it on (which asks for RECORD_AUDIO). */
|
||||
fun micEnabled(context: Context): Boolean = prefs(context).getBoolean("mic", false)
|
||||
|
||||
/** Whether the app has already asked for the microphone once at start-up. */
|
||||
fun micAsked(context: Context): Boolean = prefs(context).getBoolean("micAsked", false)
|
||||
@@ -188,14 +252,28 @@ object SessionPrefs {
|
||||
* xalia is an x86 Windows program Proton launches to give Windows programs gamepad navigation.
|
||||
* Under FEX it cannot load the session's aarch64 preload shim, so its socket() and memfd calls
|
||||
* reach the vendor's seccomp filter raw; where that answers ENOSYS - a Galaxy Fold, measured -
|
||||
* it storms, and the session dies seconds after Big Picture appears.
|
||||
* it storms, and the session dies seconds after Big Picture appears. On by default: where it
|
||||
* does run, it sits beside every game under FEX for nothing a controller-first session needs
|
||||
* (about 10% of a core beside Once Upon a KATAMARI on an SD 8 Gen 2).
|
||||
*/
|
||||
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", false)
|
||||
fun noXalia(context: Context): Boolean = prefs(context).getBoolean("noXalia", true)
|
||||
|
||||
fun setNoXalia(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("noXalia", 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
|
||||
* Bannerlator's session: the compositor's queue preempting the game's buys nothing on a device
|
||||
* whose GPU is waiting on the CPU.
|
||||
*/
|
||||
fun gamescopeRealtime(context: Context): Boolean = prefs(context).getBoolean("gamescopeRealtime", false)
|
||||
|
||||
fun setGamescopeRealtime(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gamescopeRealtime", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1).
|
||||
*
|
||||
@@ -205,6 +283,13 @@ object SessionPrefs {
|
||||
* plainly exist is the signature - and the fallback is to trace everything instead: slower,
|
||||
* but correct. Max's advice for devices whose kernels "don't work well with it".
|
||||
*/
|
||||
/** Hold the GPU at its top clock during a session (GpuClockPin). Off by default: power and heat. */
|
||||
fun gpuClockPin(context: Context): Boolean = prefs(context).getBoolean("gpuClockPin", false)
|
||||
|
||||
fun setGpuClockPin(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("gpuClockPin", on).apply()
|
||||
}
|
||||
|
||||
fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false)
|
||||
|
||||
fun setProotNoSeccomp(context: Context, on: Boolean) {
|
||||
@@ -227,8 +312,13 @@ object SessionPrefs {
|
||||
return trimmed.takeIf { Regex("[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?").matches(it) }
|
||||
}
|
||||
|
||||
/** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */
|
||||
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false)
|
||||
/**
|
||||
* Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling.
|
||||
* On by default, as WinNative runs every Linux session: Chromium -> ANGLE -> Zink draws the
|
||||
* client's interface as many small render passes, each paying GMEM's load/store and binning,
|
||||
* and WinNative's A/B on an Adreno 840 put a game ahead with it too (Palworld 42.8 against 41.3).
|
||||
*/
|
||||
fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", true)
|
||||
|
||||
fun setTuSysmem(context: Context, on: Boolean) {
|
||||
prefs(context).edit().putBoolean("tuSysmem", on).apply()
|
||||
@@ -248,11 +338,43 @@ object SessionPrefs {
|
||||
fun noGlError(context: Context): Boolean = prefs(context).getBoolean("noGlError", true)
|
||||
fun setNoGlError(context: Context, on: Boolean) { prefs(context).edit().putBoolean("noGlError", on).apply() }
|
||||
|
||||
/** `steam -steamdeck -steamos3`: the client as SteamOS runs it. Expects Deck hardware; untested. */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", false)
|
||||
/**
|
||||
* What the pad is to the Steam client: [CONTROLLER_DECK], a Steam Deck controller (Quick Access
|
||||
* button, gyro, Steam Input's full treatment - SteamDeckPad), or [CONTROLLER_XBOX360], the plain
|
||||
* Xbox 360 pad of earlier versions (QAM by the Guide+A chord).
|
||||
*/
|
||||
fun steamController(context: Context): String =
|
||||
prefs(context).getString("steamController", CONTROLLER_DECK) ?: CONTROLLER_DECK
|
||||
fun setSteamController(context: Context, id: String) { prefs(context).edit().putString("steamController", id).apply() }
|
||||
|
||||
/** Runs the SteamOS gamepad client with its Quick Access performance controls. On for new installs (settleDeckModeDefault). */
|
||||
fun steamDeckMode(context: Context): Boolean = prefs(context).getBoolean("steamDeckMode", true)
|
||||
fun setSteamDeckMode(context: Context, on: Boolean) { prefs(context).edit().putBoolean("steamDeckMode", on).apply() }
|
||||
|
||||
/** Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) for the client's GL-on-Vulkan UI. On by default. */
|
||||
/**
|
||||
* Deck mode became the default for new installs; an install from before keeps what it ran with
|
||||
* (off), so an update never changes its interface or restarts the client on its own. Run once at
|
||||
* process start, before anything reads or writes these prefs: a new install has neither prefs
|
||||
* nor a runtime yet. The answer is written down, so it is decided once.
|
||||
*/
|
||||
fun settleDeckModeDefault(context: Context) {
|
||||
val p = prefs(context)
|
||||
if (p.contains("steamDeckMode")) return
|
||||
val existing = p.all.isNotEmpty() || java.io.File(context.filesDir, "linuxfs").exists()
|
||||
p.edit().putBoolean("steamDeckMode", !existing).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deck mode's performance overlay (mangoapp, beside gamescope): the QAM's Overlay Level draws
|
||||
* through it. Off is the way out where Valve's mangoapp crashes (one Turnip build did).
|
||||
*/
|
||||
fun mangoapp(context: Context): Boolean = prefs(context).getBoolean("mangoapp", true)
|
||||
fun setMangoapp(context: Context, on: Boolean) { prefs(context).edit().putBoolean("mangoapp", on).apply() }
|
||||
|
||||
/**
|
||||
* Zink's lazy descriptor mode (ZINK_DESCRIPTORS=lazy) with its compact set layout
|
||||
* (ZINK_DEBUG=compact) for the client's GL-on-Vulkan UI, as WinNative runs it. On by default.
|
||||
*/
|
||||
fun zinkLazy(context: Context): Boolean = prefs(context).getBoolean("zinkLazy", true)
|
||||
|
||||
fun setZinkLazy(context: Context, on: Boolean) {
|
||||
@@ -309,8 +431,15 @@ object SessionPrefs {
|
||||
fun resolutionChosen(context: Context, mode: String): Boolean =
|
||||
prefs(context).contains("resolutionCap.$mode") || customResolution(context, mode) != null
|
||||
|
||||
/** The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults. */
|
||||
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", "") ?: ""
|
||||
/**
|
||||
* The FEXCore preset for the games the client launches (core/FexPreset ids); "" = FEX's defaults.
|
||||
* Performance + TSO unless chosen, WinNative's default: FEX's own defaults keep half-barrier TSO
|
||||
* and full-precision x87, which cost every x86 game time; a game that needs them can still be
|
||||
* given another preset.
|
||||
*/
|
||||
fun fexPreset(context: Context): String = prefs(context).getString("fexPreset", DEFAULT_FEX_PRESET) ?: DEFAULT_FEX_PRESET
|
||||
|
||||
private const val DEFAULT_FEX_PRESET = "PERFORMANCE_TSO"
|
||||
|
||||
fun setFexPreset(context: Context, id: String) {
|
||||
prefs(context).edit().putString("fexPreset", id).apply()
|
||||
@@ -318,14 +447,18 @@ object SessionPrefs {
|
||||
.onFailure { android.util.Log.e("GameEnvironment", "Could not update game environment", it) }
|
||||
}
|
||||
|
||||
/** The Steam client branch forced on the command line: "publicbeta" (every session so far) or "steamdeck_publicbeta" (Armada's). */
|
||||
/**
|
||||
* The Steam client branch forced on the command line: "publicbeta" (every session so far) or
|
||||
* "steamdeck_publicbeta" (Armada's). Deck mode always takes the Deck branch, whatever was chosen:
|
||||
* with -steamos3 the client picks its own branch as SteamOS does, and on publicbeta it settled
|
||||
* on steamdeck_stable - an older client it then offered as a "Software Update" in every session,
|
||||
* which applying turns into the exit-42 restart loop (seen on device 2026-09-30). Earlier, Deck
|
||||
* mode on publicbeta also reinstalled the same client at every start (2026-09-23). On
|
||||
* steamdeck_publicbeta the client finds no update. The choice applies with Deck mode off.
|
||||
*/
|
||||
fun steamChannel(context: Context): String =
|
||||
prefs(context).getString("steamChannel", null)
|
||||
// Deck mode on the publicbeta channel reinstalls the same client at every start (the
|
||||
// client reports "installed version 0" against that manifest and exits 42 to apply it,
|
||||
// losing the launch URL each time); on steamdeck_publicbeta the second launch comes up
|
||||
// clean. Seen on device 2026-09-23. So Deck mode takes the Deck channel unless chosen.
|
||||
?: if (steamDeckMode(context)) "steamdeck_publicbeta" else "publicbeta"
|
||||
if (steamDeckMode(context)) "steamdeck_publicbeta"
|
||||
else prefs(context).getString("steamChannel", null) ?: "publicbeta"
|
||||
|
||||
fun setSteamChannel(context: Context, id: String) {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
@@ -430,6 +563,22 @@ object SessionPrefs {
|
||||
prefs(context).edit().putBoolean("hdr.$mode", on).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's frame cap, 0 for none. One number used everywhere a frame is paced: gamescope's
|
||||
* -r (what the client and its games see as the display's rate), the compositor's buffer release
|
||||
* pacer, the rate the display layer votes for, and the panel mode picked, which is the fastest
|
||||
* one the cap divides evenly (40 on a 120 Hz panel, not on a 144 Hz one). A 60 fps cap on a
|
||||
* 144 Hz panel with nothing else changed judders; this is what WinNative's per-shortcut limit
|
||||
* does. Applies next session.
|
||||
*/
|
||||
fun fpsLimit(context: Context, mode: String): Int = prefs(context).getInt("fpsLimit.$mode", 0)
|
||||
|
||||
fun setFpsLimit(context: Context, mode: String, fps: Int) {
|
||||
prefs(context).edit().putInt("fpsLimit.$mode", fps.coerceAtLeast(0)).apply()
|
||||
}
|
||||
|
||||
val fpsLimitChoices = listOf(0 to "Off", 30 to "30", 40 to "40", 45 to "45", 60 to "60", 90 to "90", 120 to "120")
|
||||
|
||||
/**
|
||||
* The mode whose per-mode settings apply: a program run under gamescope (MODE_RUN) is a
|
||||
* fullscreen session like Steam's, so it takes Steam's display, driver and HDR choices.
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.droiddeck.launcher.core.HostProcess
|
||||
import com.droiddeck.launcher.input.FakeInputWriter
|
||||
import com.droiddeck.launcher.runtime.LinuxNetworkLinkComponent
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
@@ -57,6 +58,8 @@ import java.util.Locale
|
||||
*/
|
||||
class SessionService : Service() {
|
||||
private val components = java.util.concurrent.CopyOnWriteArrayList<SessionPart>()
|
||||
/** The Steam Deck controller's sysfs binds (SteamDeckPad), when this session has one. */
|
||||
private var deckBinds: List<String> = emptyList()
|
||||
private val stopLock = Any()
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
@@ -267,6 +270,8 @@ class SessionService : Service() {
|
||||
val sessionDir = openSessionFolder()
|
||||
val sessionLog = File(sessionDir, "session.log")
|
||||
|
||||
Log.i(TAG, GpuClockPin.start(this))
|
||||
|
||||
val size = SessionState.outputSize
|
||||
val guest = ArrayList<String>()
|
||||
// The desktop's Steam launchers start the client there (bannerlator-steam-launch), through the
|
||||
@@ -285,13 +290,15 @@ class SessionService : Service() {
|
||||
guest.add("DXVK_HDR=1")
|
||||
Log.i(TAG, "hdr: gamescope --hdr-enabled, DXVK_HDR=1")
|
||||
}
|
||||
guest.add("BL_FPS=0")
|
||||
guest.add("BL_FPS=" + SessionState.fpsLimit)
|
||||
guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz))
|
||||
guest.add("BL_LOG=" + sessionLog.path)
|
||||
guest.add("BL_DEBUG_DIR=" + sessionDir.path)
|
||||
|
||||
val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() }
|
||||
val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists()
|
||||
SessionState.deckPad = false
|
||||
deckBinds = emptyList()
|
||||
if (controllersOn) addControllerEnvironment(guest, fakeInputDir)
|
||||
// The desktop is wlroots (labwc). Stock wlroots allocates through gbm on a real DRM render
|
||||
// node, and ours is a KGSL stand-in - labwc died at "unable to create allocator" - so the
|
||||
@@ -310,6 +317,7 @@ class SessionService : Service() {
|
||||
val listing = com.droiddeck.launcher.frontend.AddedGames.writeListing(this, added)
|
||||
guest.add("BL_ADDED_GAMES=" + listing.path)
|
||||
if (OfflineMode.enabled(this)) guest.add("BL_STEAM_OFFLINE=1")
|
||||
if (com.droiddeck.launcher.gpu.Lossless.owned(this)) guest.add("BL_LOSSLESS_OWNED=1")
|
||||
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
|
||||
}
|
||||
// Where the guest leaves a request for another session (the desktop's Steam launchers).
|
||||
@@ -431,6 +439,7 @@ class SessionService : Service() {
|
||||
}
|
||||
sessionPid = pid
|
||||
if (pid > 1) {
|
||||
raiseTracer(pid, gen)
|
||||
SessionEvents.guestStarted(pid)
|
||||
} else {
|
||||
SessionEvents.record("guest.start_failed", mapOf("pid" to pid))
|
||||
@@ -494,15 +503,12 @@ class SessionService : Service() {
|
||||
guest.add("GALLIUM_DRIVER=zink")
|
||||
guest.add("LIBGL_KOPPER_DRI2=true")
|
||||
LinuxRuntime.vulkanIcd(this)?.let { guest.add("VK_ICD_FILENAMES=" + it.path) }
|
||||
// An imported glibc Turnip for this mode, when the user chose one: the session script checks
|
||||
// the manifest and its library from inside and points the loader at it with VK_DRIVER_FILES,
|
||||
// so the runtime's own driver above stays untouched and is what a bad import falls back to.
|
||||
// A program from the rail is one of the desktop's emulators, so it draws with the desktop's
|
||||
// Linux driver, not the Steam session's: the driver's shader cache is keyed on the driver
|
||||
// build, and with two drivers every emulator compiled its shaders twice - once per way of
|
||||
// starting it (RPCS3's 6650 interpreter variants on each first boot).
|
||||
val driverMode = if (SessionState.mode == MODE_RUN) MODE_DESKTOP else SessionPrefs.prefMode(SessionState.mode)
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this, driverMode)
|
||||
// An imported glibc Turnip, when one is set (by the user, or by Auto): the session script
|
||||
// checks the manifest and its library from inside and points the loader at it with
|
||||
// VK_DRIVER_FILES, so the runtime's own driver above stays untouched and is what a bad
|
||||
// import falls back to. One driver for every session: the driver's shader cache is keyed on
|
||||
// its build, and with one per mode every emulator compiled its shaders twice.
|
||||
val linuxDriverId = SessionPrefs.linuxDriver(this)
|
||||
LinuxVulkanDriver.resolveIcdPath(this, linuxDriverId)
|
||||
?.let { guest.add(LinuxVulkanDriver.ENV + "=" + it) }
|
||||
// Turnip's own debug switches, for the runtime's driver and everything on it. The file in
|
||||
@@ -513,22 +519,36 @@ class SessionService : Service() {
|
||||
// Zink renders the client's UI (Chromium -> ANGLE -> Zink -> Turnip). Lazy descriptors is
|
||||
// the mode Zink recommends where the driver has no descriptor buffer, and what Ludashi ships
|
||||
// by default for its Zink path; a switch here because on one Fold the menus run at 14 fps.
|
||||
if (SessionPrefs.zinkLazy(this)) guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
// compact packs Zink's descriptor sets into fewer, so a draw binds less (WinNative's default).
|
||||
if (SessionPrefs.zinkLazy(this)) {
|
||||
guest.add("ZINK_DESCRIPTORS=lazy")
|
||||
guest.add("ZINK_DEBUG=compact")
|
||||
}
|
||||
// The rest of the client-interface switches (SessionPrefs): GL marshalled off the calling
|
||||
// thread, no GL error checks, and the client run as SteamOS runs it (the script reads
|
||||
// BL_STEAMDECK; it is the one that builds the command line).
|
||||
if (SessionPrefs.glThread(this)) guest.add("mesa_glthread=true")
|
||||
if (SessionPrefs.noGlError(this)) guest.add("MESA_NO_ERROR=1")
|
||||
// Mesa's shader cache as one database instead of a file per entry. Every lookup in the
|
||||
// file cache is an open and every store an open and a rename, each a proot stop (a rename
|
||||
// two), for every pipeline DXVK, vkd3d-proton and Zink compile or find; the database is
|
||||
// opened once and read and written with pread/pwrite, which proot never sees. Mesa removes
|
||||
// the old folder itself once it has gone a week untouched.
|
||||
guest.add("MESA_DISK_CACHE_DATABASE=1")
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_STEAMDECK=" + (if (SessionPrefs.steamDeckMode(this)) "1" else "0"))
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("BL_MANGOAPP=" + (if (SessionPrefs.mangoapp(this)) "1" else "0"))
|
||||
if (steamHere) guest.add("BL_STEAM_CHANNEL=" + SessionPrefs.steamChannel(this))
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
guest.add("BL_GAMESCOPE_FORCE_FULLSCREEN=" + (if (SessionPrefs.forceFullscreen(this)) "1" else "0"))
|
||||
SessionPrefs.writeForceFullscreenFlag(this)
|
||||
}
|
||||
// Proton's own gate for its xalia helper (its `proton` script reads this, and sets
|
||||
// XALIA_SUPPORTED_ONLY itself otherwise). Off by default: xalia is Valve's, and on a device
|
||||
// whose seccomp answers its syscalls normally there is no reason to take it away.
|
||||
// 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")
|
||||
// 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"))
|
||||
// Anything else, for a device that cannot be reached with a debugger: Downloads/droiddeck-env
|
||||
// holds KEY=VALUE lines that go into the session's environment as written, after ours, so a
|
||||
// line here wins. Zink and Turnip tunables (ZINK_DESCRIPTORS=lazy, MESA_*), gamescope's,
|
||||
@@ -598,6 +618,12 @@ class SessionService : Service() {
|
||||
/** The fake evdev pads: the ring files the app writes and the identity SDL and Steam see. */
|
||||
private fun addControllerEnvironment(guest: MutableList<String>, fakeInputDir: File) {
|
||||
FakeInputWriter.prepareRingSlots(fakeInputDir, 4)
|
||||
// Virtual pads a client made last session (event16 and up, and their hidden rings) are
|
||||
// not there any more; a client that crashed never took its own down.
|
||||
fakeInputDir.listFiles()?.forEach { file ->
|
||||
val node = Regex("event(\\d+)").matchEntire(file.name)?.groupValues?.get(1)?.toIntOrNull()
|
||||
if (file.name.startsWith(".uinput-") || (node != null && node >= FIRST_VIRTUAL_PAD)) file.delete()
|
||||
}
|
||||
guest.add("FAKE_EVDEV_DIR=" + fakeInputDir.path)
|
||||
val rings = FakeInputWriter.getRingEnv(fakeInputDir)
|
||||
if (!rings.isNullOrEmpty()) guest.add("FAKE_EVDEV_MEMFD_PATHS=$rings")
|
||||
@@ -605,14 +631,34 @@ class SessionService : Service() {
|
||||
// identity gets the standard layout without the user configuring the pad by hand.
|
||||
guest.add("FAKE_EVDEV_IDENTITY=xbox360")
|
||||
guest.add("FAKE_EVDEV_VIBRATION=1")
|
||||
// Steam Input's virtual-gamepad identity is for games the client starts, which are
|
||||
// meant to see that pad. Everywhere else (the desktop, a program from the rail) it
|
||||
// hides the pad: SDL ignores a Steam virtual gamepad unless it runs under Steam, so
|
||||
// every SDL emulator came up with no controller. There it is a plain Xbox 360 pad.
|
||||
if (SessionState.mode == MODE_STEAM) guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
|
||||
val uinput = !File(Environment.getExternalStorageDirectory(), NO_UINPUT_SWITCH).exists()
|
||||
// In Steam, the pad is what a Deck's is: a Steam Deck controller the client reads over
|
||||
// hidraw (SteamDeckPad), which only works with Steam Input's virtual pad for games to read.
|
||||
// Decided only once its sysfs is in place: without it the client would find no Deck, and
|
||||
// with the Deck asked for the pad's own nodes are withdrawn - no controller at all.
|
||||
val wantsDeck = uinput && SessionState.mode == MODE_STEAM &&
|
||||
SessionPrefs.steamController(this) == SessionPrefs.CONTROLLER_DECK &&
|
||||
!File(Environment.getExternalStorageDirectory(), NO_DECK_PAD_SWITCH).exists()
|
||||
deckBinds = if (wantsDeck) SteamDeckPad.prepare(this, fakeInputDir.parentFile!!.parentFile!!) else emptyList()
|
||||
SessionState.deckPad = deckBinds.isNotEmpty()
|
||||
if (wantsDeck && !SessionState.deckPad) Log.w(TAG, "deck pad: not available this session; the pad stays an Xbox 360 controller")
|
||||
if (uinput) {
|
||||
// /dev/uinput, stood in for by libfakeinput: the virtual pad Steam Input makes for a
|
||||
// game becomes a node the game reads, carrying the player's layout, as on a Deck.
|
||||
guest.add("FAKE_EVDEV_UINPUT=1")
|
||||
if (SessionState.deckPad) guest.add("FAKE_EVDEV_DECK=1")
|
||||
} else if (SessionState.mode == MODE_STEAM) {
|
||||
// Without it, games see the pad itself wearing Steam Input's virtual-gamepad identity -
|
||||
// for games the client starts only. Everywhere else (the desktop, a program from the
|
||||
// rail) that identity hides the pad: SDL ignores a Steam virtual gamepad unless it
|
||||
// runs under Steam, so every SDL emulator came up with no controller.
|
||||
guest.add("FAKE_EVDEV_STEAM_VIRTUAL=1")
|
||||
}
|
||||
guest.add("SDL_JOYSTICK_DISABLE_UDEV=1")
|
||||
guest.add("SDL_HIDAPI_JOYSTICK_DISABLE_UDEV=1")
|
||||
guest.add("SDL_JOYSTICK_HIDAPI=0")
|
||||
// The client reads a Deck controller through SDL's HIDAPI; a hint in the environment
|
||||
// outranks the client's own. Nothing else is shown the Deck (libfakeinput).
|
||||
if (!SessionState.deckPad) guest.add("SDL_JOYSTICK_HIDAPI=0")
|
||||
if (File(Environment.getExternalStorageDirectory(), PAD_LOG_SWITCH).exists()) {
|
||||
guest.add("FAKE_EVDEV_LOG=1")
|
||||
}
|
||||
@@ -623,6 +669,7 @@ class SessionService : Service() {
|
||||
private fun sessionBinds(controllersOn: Boolean, fakeInputDir: File): ArrayList<String> {
|
||||
val binds = ArrayList<String>()
|
||||
if (controllersOn) binds.add(fakeInputDir.path + ":/dev/input")
|
||||
if (controllersOn) binds.addAll(deckBinds)
|
||||
// The client's battery readout (the Quick Access Menu, the top bar) reads
|
||||
// /sys/class/power_supply/BAT<n>/..., a laptop's or a Deck's naming; Android's supply is
|
||||
// called "battery" and its files differ, so the client sees no battery at all. A directory
|
||||
@@ -928,6 +975,31 @@ class SessionService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* proot is one thread, and every syscall it traps anywhere in the session - a path lookup in
|
||||
* Proton's python, Wine's file opens, the Steam client's /proc scans - waits for that thread to
|
||||
* be scheduled, at nice 0 beside a game that keeps the big cores busy. It is raised to nice -6,
|
||||
* under the compositor's -8. After a moment, not at once: the first guest process is forked by
|
||||
* proot itself and would inherit the value, and with it everything the session starts.
|
||||
*/
|
||||
private fun raiseTracer(pid: Int, gen: Int) {
|
||||
Thread({
|
||||
try {
|
||||
Thread.sleep(2000)
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (gen != sessionGen || sessionPid != pid) return@Thread
|
||||
val nice = try {
|
||||
WaylandCompositor.nativeRaisePriority(pid, TRACER_NICE)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "tracer priority", t)
|
||||
return@Thread
|
||||
}
|
||||
Log.i(TAG, "proot tracer $pid: nice $nice (asked for $TRACER_NICE)")
|
||||
}, "tracer-priority").start()
|
||||
}
|
||||
|
||||
private fun stopSession(status: Int) {
|
||||
synchronized(stopLock) {
|
||||
if (!SessionState.running) return
|
||||
@@ -936,6 +1008,7 @@ class SessionService : Service() {
|
||||
val stoppedGen = sessionGen
|
||||
SessionState.stopRequested = false
|
||||
SessionEvents.record("guest.exited", mapOf("status" to status))
|
||||
GpuClockPin.stop(this)
|
||||
SessionEvents.transition(SessionPhase.STOPPING, "session.stopping", mapOf("status" to status))
|
||||
suspendOperationPending = false
|
||||
launchWatcher?.stopWatching()
|
||||
@@ -1112,6 +1185,8 @@ class SessionService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SessionService"
|
||||
/** proot's tracer: above everything in the guest, under the compositor thread's -8. */
|
||||
private const val TRACER_NICE = -6
|
||||
/** Downloads file whose contents become TU_DEBUG inside the session, e.g. "sysmem". */
|
||||
private const val TU_DEBUG_SWITCH = "Download/droiddeck-tu-debug"
|
||||
/** Downloads file of KEY=VALUE lines added to the session environment verbatim. */
|
||||
@@ -1138,6 +1213,10 @@ class SessionService : Service() {
|
||||
private const val STEAM_EXIT_MS = 10_000L
|
||||
private const val NO_PAD_SWITCH = "Download/droiddeck-no-pad"
|
||||
private const val PAD_LOG_SWITCH = "Download/droiddeck-pad-log"
|
||||
private const val NO_UINPUT_SWITCH = "Download/droiddeck-no-uinput"
|
||||
private const val NO_DECK_PAD_SWITCH = "Download/droiddeck-no-deck-pad"
|
||||
/** libfakeinput numbers the pads made through its /dev/uinput stand-in from here. */
|
||||
private const val FIRST_VIRTUAL_PAD = 16
|
||||
|
||||
const val EXTRA_MODE = "mode"
|
||||
const val MODE_STEAM = "steam"
|
||||
|
||||
@@ -63,6 +63,27 @@ object SessionState {
|
||||
* gets DXVK_HDR=1 and gamescope --hdr-enabled. Decided by the activity before the compositor starts. */
|
||||
@JvmStatic var hdr = false
|
||||
|
||||
/** The pad is presented to the Steam client as a Steam Deck controller this session
|
||||
* (SteamDeckPad): its Quick Access button is a real button, not the Guide+A chord. Decided by
|
||||
* the service as the session starts, which can be after the activity has resumed - hence
|
||||
* [deckPadListener]. */
|
||||
@Volatile private var deckPadValue = false
|
||||
@JvmStatic var deckPad: Boolean
|
||||
get() = deckPadValue
|
||||
set(value) {
|
||||
if (deckPadValue == value) return
|
||||
deckPadValue = value
|
||||
deckPadListener?.invoke()
|
||||
}
|
||||
|
||||
/** Told (on the thread that changed it) whenever [deckPad] changes. */
|
||||
@Volatile var deckPadListener: (() -> Unit)? = null
|
||||
|
||||
/** What the second screen shows, and on which display: kept for the session, so a screen
|
||||
* turned off (sleep, a closed lid) or an activity recreated brings it back as it was. */
|
||||
@Volatile var secondScreenMode = com.droiddeck.launcher.input.SecondScreenMode.NONE
|
||||
@Volatile var secondScreenDisplay = -1
|
||||
|
||||
/** Which session this is: SessionService.MODE_STEAM or MODE_DESKTOP. */
|
||||
@Volatile
|
||||
var mode = "steam"
|
||||
@@ -74,6 +95,10 @@ object SessionState {
|
||||
@Volatile
|
||||
var refreshHz: Float = 60f
|
||||
|
||||
/** The session's frame cap (SessionPrefs.fpsLimit), fixed when it starts; 0 = none. */
|
||||
@Volatile
|
||||
var fpsLimit: Int = 0
|
||||
|
||||
/** The compositor has presented a frame, so the loading panel is behind us for this session. */
|
||||
@Volatile
|
||||
var firstFrameSeen = false
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.droiddeck.launcher.session
|
||||
|
||||
import android.content.Context
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.core.FileUtils
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The session's pad, described to the Steam client as a Steam Deck controller.
|
||||
*
|
||||
* On a Deck - and on the handhelds InputPlumber serves as one - the client finds the controller
|
||||
* through libudev as a `hidraw` device under a `hid` device under a USB interface and reads it
|
||||
* itself. libfakeinput serves the device node (`/dev/hidraw16`, built from the pad's input ring);
|
||||
* what the client also needs is the sysfs it walks to find and identify that node, which a sandbox
|
||||
* shows nothing of. This writes that sysfs as the kernel lays out a Deck's controller interface and
|
||||
* returns the binds that put it in place:
|
||||
*
|
||||
* - `/sys/devices/droiddeck/usb1` - the USB device (Valve, 28de:1205), its interface 2, the HID
|
||||
* device on it (`HID_ID`, `report_descriptor`) and that device's `hidraw/hidraw16`;
|
||||
* - `/sys/class/hidraw/hidraw16` - what an enumeration of the `hidraw` subsystem lists;
|
||||
* - `/sys/dev/char/240:16` - how the node's device number is looked up (in the directory the
|
||||
* runtime already binds there for the GPU);
|
||||
* - `/run/udev/data/c240:16` - the udev database entry that marks the device initialised, without
|
||||
* which libudev's enumeration leaves a device node out.
|
||||
*
|
||||
* Symlinks are absolute guest paths; proot resolves them inside the session.
|
||||
*/
|
||||
object SteamDeckPad {
|
||||
private const val TAG = "SessionService"
|
||||
/** Must match libfakeinput (fakeinput_steam.cpp: DECK_HIDRAW_*). */
|
||||
private const val MAJOR = 240
|
||||
private const val MINOR = 16
|
||||
private const val NODE = "hidraw$MINOR"
|
||||
private const val GUEST_DEVICES = "/sys/devices/droiddeck"
|
||||
private const val USB = "$GUEST_DEVICES/usb1"
|
||||
private const val INTERFACE = "$USB/1-1:1.2"
|
||||
private const val HID = "$INTERFACE/0003:28DE:1205.0001"
|
||||
private const val HIDRAW = "$HID/hidraw/$NODE"
|
||||
private const val SERIAL = "DROIDDECK0001"
|
||||
|
||||
/** InputPlumber's CONTROLLER_DESCRIPTOR, as libfakeinput answers HIDIOCGRDESC. */
|
||||
private val REPORT_DESCRIPTOR = intArrayOf(
|
||||
0x06, 0xff, 0xff, 0x09, 0x01, 0xa1, 0x01, 0x09, 0x02, 0x09, 0x03, 0x15, 0x00,
|
||||
0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0x81, 0x02, 0x09, 0x06, 0x09, 0x07,
|
||||
0x15, 0x00, 0x26, 0xff, 0x00, 0x75, 0x08, 0x95, 0x40, 0xb1, 0x02, 0xc0,
|
||||
).map { it.toByte() }.toByteArray()
|
||||
|
||||
/** Writes the tree and returns the `host:guest` binds for it, or none if it could not be made. */
|
||||
fun prepare(context: Context, sessionRoot: File): List<String> {
|
||||
val base = File(sessionRoot, "sys/deck")
|
||||
val devices = File(base, "devices")
|
||||
val hidrawClass = File(base, "class-hidraw")
|
||||
return try {
|
||||
FileUtils.clear(base)
|
||||
fun dir(guest: String) = File(devices, guest.removePrefix("$GUEST_DEVICES/")).apply { mkdirs() }
|
||||
fun write(guest: String, name: String, text: String) = File(dir(guest), name).writeText(text)
|
||||
fun link(file: File, target: String) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.delete()
|
||||
Os.symlink(target, file.path)
|
||||
}
|
||||
|
||||
write(USB, "uevent", "DEVTYPE=usb_device\nPRODUCT=28de/1205/100\nTYPE=0/0/0\nBUSNUM=001\nDEVNUM=002\n")
|
||||
write(USB, "idVendor", "28de\n")
|
||||
write(USB, "idProduct", "1205\n")
|
||||
write(USB, "bcdDevice", "0100\n")
|
||||
write(USB, "manufacturer", "Valve Software\n")
|
||||
write(USB, "product", "Steam Deck Controller\n")
|
||||
write(USB, "serial", "$SERIAL\n")
|
||||
link(File(dir(USB), "subsystem"), "/sys/bus/usb")
|
||||
|
||||
write(INTERFACE, "uevent", "DEVTYPE=usb_interface\nPRODUCT=28de/1205/100\nINTERFACE=3/0/0\n")
|
||||
write(INTERFACE, "bInterfaceNumber", "02\n")
|
||||
write(INTERFACE, "bInterfaceClass", "03\n")
|
||||
link(File(dir(INTERFACE), "subsystem"), "/sys/bus/usb")
|
||||
|
||||
write(HID, "uevent", "DRIVER=hid-steam\nHID_ID=0003:000028DE:00001205\n" +
|
||||
"HID_NAME=Valve Software Steam Deck Controller\nHID_PHYS=usb-droiddeck-1/input2\n" +
|
||||
"HID_UNIQ=$SERIAL\nMODALIAS=hid:b0003g0001v000028DEp00001205\n")
|
||||
File(dir(HID), "report_descriptor").writeBytes(REPORT_DESCRIPTOR)
|
||||
link(File(dir(HID), "subsystem"), "/sys/bus/hid")
|
||||
|
||||
write(HIDRAW, "uevent", "MAJOR=$MAJOR\nMINOR=$MINOR\nDEVNAME=$NODE\n")
|
||||
write(HIDRAW, "dev", "$MAJOR:$MINOR\n")
|
||||
link(File(dir(HIDRAW), "subsystem"), "/sys/class/hidraw")
|
||||
link(File(dir(HIDRAW), "device"), HID)
|
||||
|
||||
hidrawClass.mkdirs()
|
||||
link(File(hidrawClass, NODE), HIDRAW)
|
||||
// The runtime binds this directory over /sys/dev/char for the GPU (LinuxRuntime.bindGpuNode).
|
||||
link(File(context.cacheDir, "drm/sys/$MAJOR:$MINOR"), HIDRAW)
|
||||
|
||||
val udevData = File(LinuxRuntime.rootDir(context), "run/udev/data").apply { mkdirs() }
|
||||
File(udevData, "c$MAJOR:$MINOR").writeText("I:1\nE:ID_INPUT=1\nE:ID_INPUT_JOYSTICK=1\n")
|
||||
|
||||
Log.i(TAG, "deck pad: /dev/$NODE described as a Steam Deck controller (28de:1205)")
|
||||
listOf(devices.path + ":" + GUEST_DEVICES, hidrawClass.path + ":/sys/class/hidraw")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "deck pad: could not describe the pad as a Deck controller: $e")
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.AppImageManager
|
||||
import java.io.File
|
||||
|
||||
/** The imported AppImages and the import in progress, for the Desktop page. */
|
||||
object AppImageState {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
var items by mutableStateOf<List<AppImageManager.Item>>(emptyList())
|
||||
private set
|
||||
/** The file being imported and what the import is doing; null when idle. */
|
||||
var importing by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
/** Why the last import was refused, shown on the page until the next one. */
|
||||
var lastError by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val list = AppImageManager.list(app)
|
||||
main.post { items = list }
|
||||
}, "appimage-refresh").start()
|
||||
}
|
||||
|
||||
fun import(context: Context, file: File) {
|
||||
if (importing != null) return
|
||||
val app = context.applicationContext
|
||||
importing = file.name; stage = "Starting…"; lastError = null
|
||||
Thread({
|
||||
val problem = try {
|
||||
AppImageManager.import(app, file) { s -> main.post { stage = s } }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
val list = AppImageManager.list(app)
|
||||
main.post {
|
||||
importing = null; stage = null; items = list
|
||||
lastError = problem?.let { "${file.name}: $it" }
|
||||
if (problem == null) Toast.makeText(app, "${file.name} imported", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "appimage-import").start()
|
||||
}
|
||||
|
||||
fun remove(context: Context, id: String) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
AppImageManager.remove(app, id)
|
||||
val list = AppImageManager.list(app)
|
||||
main.post { items = list }
|
||||
}, "appimage-remove").start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.util.Log
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
/**
|
||||
* Flathub's public API (flathub.org/api/v2): the collections its front page shows, search, and an
|
||||
* app's AppStream data. Only what this device can install is kept - apps built for aarch64.
|
||||
*/
|
||||
object FlathubApi {
|
||||
private const val TAG = "FlathubApi"
|
||||
private const val BASE = "https://flathub.org/api/v2"
|
||||
private const val ARCH = "aarch64"
|
||||
private const val TIMEOUT_MS = 20_000
|
||||
|
||||
class AppSummary(
|
||||
val id: String, val name: String, val summary: String, val icon: String?,
|
||||
val developer: String?, val verified: Boolean, val installs: Int,
|
||||
)
|
||||
|
||||
class Screenshot(val thumb: String, val full: String)
|
||||
|
||||
class AppDetails(
|
||||
val id: String, val name: String, val summary: String, val description: String,
|
||||
val developer: String?, val icon: String?, val version: String?, val license: String?,
|
||||
val homepage: String?, val screenshots: List<Screenshot>, val categories: List<String>,
|
||||
val runtime: String?, val downloadSize: Long, val installedSize: Long, val arches: List<String>,
|
||||
)
|
||||
|
||||
/** A category as Flathub's search filters it, with the name shown for it. */
|
||||
class Category(val id: String, val label: String)
|
||||
|
||||
val categories = listOf(
|
||||
Category("Game", "Games"), Category("AudioVideo", "Audio & Video"), Category("Graphics", "Graphics"),
|
||||
Category("Network", "Internet"), Category("Office", "Office"), Category("Development", "Developer"),
|
||||
Category("Education", "Education"), Category("Science", "Science"), Category("System", "System"),
|
||||
Category("Utility", "Utilities"),
|
||||
)
|
||||
|
||||
/** Front-page collections: popular, trending, recently-updated, recently-added. */
|
||||
fun collection(name: String, count: Int = 30): List<AppSummary>? {
|
||||
// The collections are not filtered by architecture; ask for more and keep what fits.
|
||||
val body = get("$BASE/collection/$name?page=1&per_page=${count * 2}") ?: return null
|
||||
return hits(body)?.take(count)
|
||||
}
|
||||
|
||||
/** Search, optionally within one category (Flathub's main_categories facet). */
|
||||
fun search(query: String, category: String? = null, count: Int = 48): List<AppSummary>? {
|
||||
val filters = JSONArray().put(JSONObject().put("filterType", "arches").put("value", ARCH))
|
||||
if (category != null) filters.put(JSONObject().put("filterType", "main_categories").put("value", category))
|
||||
val payload = JSONObject().put("query", query).put("filters", filters)
|
||||
.put("hits_per_page", count).put("page", 1)
|
||||
val body = post("$BASE/search", payload.toString()) ?: return null
|
||||
return hits(body)
|
||||
}
|
||||
|
||||
/** Flathub's own copy of an app's icon: for apps that export only an SVG, which is not drawn here. */
|
||||
fun iconUrl(id: String) = "https://dl.flathub.org/media/icons/128x128/$id.png"
|
||||
|
||||
fun details(id: String): AppDetails? {
|
||||
val app = get("$BASE/appstream/${enc(id)}")?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
|
||||
val summary = get("$BASE/summary/${enc(id)}?arch=$ARCH")?.let { runCatching { JSONObject(it) }.getOrNull() }
|
||||
val shots = app.optJSONArray("screenshots")?.let { arr ->
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val sizes = arr.optJSONObject(i)?.optJSONArray("sizes") ?: return@mapNotNull null
|
||||
val all = (0 until sizes.length()).mapNotNull { sizes.optJSONObject(it) }
|
||||
.map { (it.optString("width").toIntOrNull() ?: 0) to it.optString("src") }
|
||||
.filter { it.second.startsWith("http") }.sortedBy { it.first }
|
||||
if (all.isEmpty()) null
|
||||
else Screenshot(all.firstOrNull { it.first >= 600 }?.second ?: all.last().second,
|
||||
all.firstOrNull { it.first >= 1200 }?.second ?: all.last().second)
|
||||
}
|
||||
} ?: emptyList()
|
||||
val release = app.optJSONArray("releases")?.optJSONObject(0)
|
||||
val cats = app.optJSONArray("categories")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
|
||||
val arches = summary?.optJSONArray("arches")?.let { a -> (0 until a.length()).map { a.optString(it) } } ?: emptyList()
|
||||
return AppDetails(
|
||||
id = app.optString("id", id),
|
||||
name = app.optString("name", id),
|
||||
summary = app.optString("summary"),
|
||||
description = plainText(app.optString("description")),
|
||||
developer = app.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
|
||||
icon = app.optString("icon").takeIf { it.startsWith("http") },
|
||||
version = release?.optString("version")?.takeIf { it.isNotEmpty() && it != "null" },
|
||||
license = app.optString("project_license").takeIf { it.isNotEmpty() && it != "null" },
|
||||
homepage = app.optJSONObject("urls")?.optString("homepage")?.takeIf { it.startsWith("http") },
|
||||
screenshots = shots,
|
||||
categories = cats,
|
||||
runtime = summary?.optJSONObject("metadata")?.optString("runtime")?.takeIf { it.isNotEmpty() },
|
||||
downloadSize = summary?.optLong("download_size") ?: 0L,
|
||||
installedSize = summary?.optLong("installed_size") ?: 0L,
|
||||
arches = arches,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hits(body: String): List<AppSummary>? = try {
|
||||
val arr = JSONObject(body).getJSONArray("hits")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
val arches = o.optJSONArray("arches")
|
||||
if (arches != null && (0 until arches.length()).none { arches.optString(it) == ARCH }) return@mapNotNull null
|
||||
if (o.optString("type", "desktop-application") != "desktop-application") return@mapNotNull null
|
||||
AppSummary(
|
||||
id = o.optString("app_id"),
|
||||
name = o.optString("name"),
|
||||
summary = o.optString("summary"),
|
||||
icon = o.optString("icon").takeIf { it.startsWith("http") },
|
||||
developer = o.optString("developer_name").takeIf { it.isNotEmpty() && it != "null" },
|
||||
verified = o.optBoolean("verification_verified"),
|
||||
installs = o.optInt("installs_last_month"),
|
||||
)
|
||||
}.filter { it.id.isNotEmpty() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "hits: $e"); null
|
||||
}
|
||||
|
||||
/** AppStream descriptions are a small HTML subset: paragraphs and lists become lines. */
|
||||
internal fun plainText(html: String): String = html
|
||||
.replace(Regex("(?i)<li>\\s*"), "• ")
|
||||
.replace(Regex("(?i)</(p|li|ul|ol)>"), "\n")
|
||||
.replace(Regex("<[^>]+>"), "")
|
||||
.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'")
|
||||
.lines().map { it.trim().replace(Regex("\\s+"), " ") }
|
||||
.joinToString("\n").replace(Regex("\n{3,}"), "\n\n").trim()
|
||||
|
||||
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||
|
||||
private fun get(url: String): String? = request(url, null)
|
||||
|
||||
private fun post(url: String, json: String): String? = request(url, json)
|
||||
|
||||
private fun request(url: String, json: String?): String? {
|
||||
var c: HttpURLConnection? = null
|
||||
return try {
|
||||
c = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = TIMEOUT_MS
|
||||
readTimeout = TIMEOUT_MS
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (json != null) {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
outputStream.use { it.write(json.toByteArray()) }
|
||||
}
|
||||
}
|
||||
if (c.responseCode / 100 != 2) { Log.w(TAG, "$url -> HTTP ${c.responseCode}"); null }
|
||||
else c.inputStream.bufferedReader().use { it.readText() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "$url: $e"); null
|
||||
} finally {
|
||||
c?.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.droiddeck.launcher.store
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.runtime.FlatpakManager
|
||||
|
||||
/**
|
||||
* The store's state for the life of the process, so an install carries on - and its progress
|
||||
* shows - while the user moves around the launcher or plays something meanwhile.
|
||||
*/
|
||||
object StoreState {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
var ready by mutableStateOf(false)
|
||||
private set
|
||||
var installed by mutableStateOf<List<FlatpakManager.App>>(emptyList())
|
||||
private set
|
||||
var updates by mutableStateOf<Set<String>>(emptySet())
|
||||
private set
|
||||
var checkingUpdates by mutableStateOf(false)
|
||||
private set
|
||||
/** Flathub has been asked this session, so an empty [updates] means up to date. */
|
||||
var updatesChecked by mutableStateOf(false)
|
||||
private set
|
||||
/** What is running: "setup", an app id, or "update-all"; null when idle. */
|
||||
var busy by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var stage by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var percent by mutableStateOf(-1)
|
||||
private set
|
||||
|
||||
/** The front page's sections, by collection name; absent until loaded, empty list on failure. */
|
||||
val sections = mutableStateMapOf<String, List<FlathubApi.AppSummary>>()
|
||||
var sectionsFailed by mutableStateOf(false)
|
||||
private set
|
||||
val details = mutableStateMapOf<String, FlathubApi.AppDetails>()
|
||||
val detailsFailed = mutableStateMapOf<String, Boolean>()
|
||||
|
||||
var searchResults by mutableStateOf<List<FlathubApi.AppSummary>?>(null)
|
||||
private set
|
||||
var searching by mutableStateOf(false)
|
||||
private set
|
||||
private var searchSeq = 0
|
||||
|
||||
val SECTIONS = listOf("popular" to "Popular", "trending" to "Trending", "recently-added" to "New", "recently-updated" to "Updated")
|
||||
|
||||
fun refresh(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val r = FlatpakManager.ready(app)
|
||||
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
|
||||
main.post { ready = r; installed = list; updates = updates.filter { id -> list.any { it.id == id } }.toSet() }
|
||||
}, "store-refresh").start()
|
||||
}
|
||||
|
||||
fun loadSections(force: Boolean = false) {
|
||||
if (!force && sections.isNotEmpty()) return
|
||||
sectionsFailed = false
|
||||
Thread({
|
||||
var failed = false
|
||||
for ((key, _) in SECTIONS) {
|
||||
val apps = FlathubApi.collection(key, 18)
|
||||
if (apps == null) failed = true
|
||||
main.post { sections[key] = apps ?: emptyList() }
|
||||
}
|
||||
main.post { sectionsFailed = failed }
|
||||
}, "store-sections").start()
|
||||
}
|
||||
|
||||
fun loadDetails(id: String) {
|
||||
if (details.containsKey(id)) return
|
||||
detailsFailed.remove(id)
|
||||
Thread({
|
||||
val d = FlathubApi.details(id)
|
||||
main.post { if (d != null) details[id] = d else detailsFailed[id] = true }
|
||||
}, "store-details").start()
|
||||
}
|
||||
|
||||
fun search(query: String, category: String?) {
|
||||
val seq = ++searchSeq
|
||||
searching = true
|
||||
Thread({
|
||||
val r = FlathubApi.search(query, category)
|
||||
main.post { if (seq == searchSeq) { searchResults = r ?: emptyList(); searching = false } }
|
||||
}, "store-search").start()
|
||||
}
|
||||
|
||||
fun clearSearch() { searchSeq++; searchResults = null; searching = false }
|
||||
|
||||
private fun run(context: Context, what: String, label: String, work: (Context, (String, Int) -> Unit) -> String?) {
|
||||
if (busy != null) return
|
||||
val app = context.applicationContext
|
||||
busy = what; stage = "Starting…"; percent = -1
|
||||
Thread({
|
||||
val problem = try {
|
||||
work(app) { s, p -> main.post { stage = s; percent = p } }
|
||||
} catch (e: Exception) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
val r = FlatpakManager.ready(app)
|
||||
val list = if (r) FlatpakManager.installedApps(app) else emptyList()
|
||||
main.post {
|
||||
busy = null; stage = null; percent = -1
|
||||
ready = r; installed = list
|
||||
if (problem != null) Toast.makeText(app, "$label: $problem", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}, "store-$what").start()
|
||||
}
|
||||
|
||||
fun setup(context: Context) = run(context, "setup", "Flatpak setup") { c, p -> FlatpakManager.setup(c, p) }
|
||||
|
||||
fun install(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.install(c, id, p) }
|
||||
|
||||
fun uninstall(context: Context, id: String, name: String) =
|
||||
run(context, id, name) { c, p -> FlatpakManager.uninstall(c, id, p).also { if (it == null) main.post { updates = updates - id } } }
|
||||
|
||||
fun update(context: Context, id: String?, name: String) =
|
||||
run(context, id ?: "update-all", name) { c, p ->
|
||||
FlatpakManager.update(c, id, p).also { if (it == null) main.post { updates = if (id == null) emptySet() else updates - id } }
|
||||
}
|
||||
|
||||
fun checkUpdates(context: Context) {
|
||||
if (checkingUpdates || busy != null) return
|
||||
val app = context.applicationContext
|
||||
checkingUpdates = true
|
||||
Thread({
|
||||
val u = FlatpakManager.updates(app)
|
||||
main.post {
|
||||
checkingUpdates = false
|
||||
if (u != null) { updates = u; updatesChecked = true } else Toast.makeText(app, "Could not check Flathub for updates", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}, "store-updates").start()
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,9 @@ fun ComponentsPage(
|
||||
onImport: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
requestInitialFocus: Boolean = true,
|
||||
/** The GPU drivers tab ([GPU_TAB]): what it shows and does. */
|
||||
gpu: GpuDriversState = GpuDriversState(),
|
||||
gpuActions: GpuDriversActions = GpuDriversActions(),
|
||||
) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
@@ -112,23 +115,55 @@ fun ComponentsPage(
|
||||
var about by remember { mutableStateOf(false) }
|
||||
var protonMenu by remember { mutableStateOf(false) }
|
||||
fun ask(title: String, body: String, action: () -> Unit) { confirmTitle = title; confirm = body to action }
|
||||
// The GPU drivers tab's Advanced pages: one driver list on its own, full page.
|
||||
var driverPage by remember { mutableStateOf<String?>(null) }
|
||||
val gpuTab = comp == GPU_TAB
|
||||
when (driverPage) {
|
||||
"rt" -> {
|
||||
DriverPage(
|
||||
title = "Runtime driver", hint = "Steam, its games and the desktop draw with it. Applies next session.",
|
||||
rows = gpu.linuxRows, selected = gpu.linuxSelected, downloads = gpu.linuxDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import Turnip zip…", canRestore = false,
|
||||
onSelect = gpuActions.onSelectLinux, onDelete = gpuActions.onRemoveLinux, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportLinux, onRestore = {}, onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
"panel" -> {
|
||||
DriverPage(
|
||||
title = "Display driver", hint = "Puts frames on the screen in both modes. Restart the app to apply.",
|
||||
rows = gpu.androidRows, selected = gpu.androidSelected, downloads = gpu.androidDownloads,
|
||||
status = gpu.releaseStatus, checking = gpu.checking, importLabel = "Import an AdrenoTools zip…",
|
||||
canRestore = gpu.canRestoreBundled,
|
||||
onSelect = gpuActions.onSelectAndroid, onDelete = gpuActions.onRemoveAndroid, onRefresh = gpuActions.onRefresh,
|
||||
onDownload = gpuActions.onDownloadDriver, onImport = gpuActions.onImportAndroid, onRestore = gpuActions.onRestoreBundled,
|
||||
onBack = { driverPage = null },
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val views = snapshot?.protons ?: emptyList()
|
||||
val view = views.firstOrNull { it.proton.id == protonId } ?: views.firstOrNull()
|
||||
val label = ComponentsManager.LABEL[comp] ?: comp
|
||||
val checked = if (catalogAt > 0) DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(catalogAt * 1000)) else "never"
|
||||
val nightlies = if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
val nightlies = if (gpuTab) gpu.releaseStatus else if (busy != null) "$busy…" else "Nightlies checked $checked"
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = if (narrow) 16.dp else 22.dp, vertical = if (narrow) 12.dp else 18.dp)) {
|
||||
// ---- title, and the one thing that goes online -----------------------------------------
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"Components", fontSize = if (narrow) 22.sp else 26.sp, fontWeight = FontWeight.Bold, color = colors.onBackground,
|
||||
maxLines = 1, modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
if (!narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
// The status gives way to the title, not the other way round.
|
||||
if (!narrow) Text(
|
||||
nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
) else Spacer(Modifier.weight(1f))
|
||||
if (!gpuTab) ToolIcon(Icons.Outlined.Info, "About Components") { about = true }
|
||||
if (gpuTab) ToolIcon(Icons.Outlined.Refresh, "Check for new drivers", busy = gpu.checking, enabled = !gpu.checking && gpu.busy == null, onClick = gpuActions.onRefresh)
|
||||
else ToolIcon(Icons.Outlined.Refresh, "Check the Nightlies for new packages", busy = checking, enabled = !checking, onClick = onRefresh)
|
||||
}
|
||||
if (narrow) Text(nightlies, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
@@ -138,7 +173,7 @@ fun ComponentsPage(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) {
|
||||
if (view != null) Box {
|
||||
if (view != null && !gpuTab) Box {
|
||||
ValueChip(view.proton.name, protonMenu, modifier = Modifier.widthIn(max = 300.dp).heightIn(min = 44.dp)) { protonMenu = !protonMenu }
|
||||
AnchoredMenu(protonMenu, onDismiss = { protonMenu = false }, title = "Proton",
|
||||
note = "Valve's own Proton is replaced when Steam updates it; its originals are kept per build.") { first ->
|
||||
@@ -152,11 +187,12 @@ fun ComponentsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
TabStrip(comps.map { ComponentsManager.LABEL.getValue(it) }, comps.indexOf(comp).coerceAtLeast(0), { onComp(comps[it]) })
|
||||
val tabs = listOf(GPU_TAB) + comps
|
||||
TabStrip(tabs.map { ComponentsManager.LABEL[it] ?: "GPU drivers" }, tabs.indexOf(comp).coerceAtLeast(0), { onComp(tabs[it]) })
|
||||
}
|
||||
|
||||
// ---- what is in use -----------------------------------------------------------------------
|
||||
if (view != null) {
|
||||
if (view != null && !gpuTab) {
|
||||
val st = view.components.getValue(comp)
|
||||
val fixedAt = if (view.reappliedAt > 0) " · re-applied at launch " + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(view.reappliedAt * 1000)) else ""
|
||||
Row(
|
||||
@@ -179,6 +215,12 @@ fun ComponentsPage(
|
||||
|
||||
// ---- the lists ------------------------------------------------------------------------------
|
||||
when {
|
||||
gpuTab -> Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 16.dp)) {
|
||||
GpuDriversPanel(gpu, gpuActions) { driverPage = it }
|
||||
Row(modifier = Modifier.padding(top = 14.dp)) {
|
||||
SmallButton("Import .zip", onClick = gpuActions.onImportZip)
|
||||
}
|
||||
}
|
||||
snapshot == null -> Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(16.dp)) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -295,7 +337,7 @@ fun ComponentsPage(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
|
||||
val colors = MaterialTheme.colorScheme
|
||||
val pal = LocalPalette.current
|
||||
val src = remember { MutableInteractionSource() }
|
||||
@@ -304,7 +346,7 @@ private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = fal
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(12.dp))
|
||||
.background(if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent)
|
||||
.border(if (hot) 2.dp else 1.dp, if (hot) pal.signal else pal.line2, RoundedCornerShape(12.dp))
|
||||
.glideBorder(hot, RoundedCornerShape(12.dp), pal.signal, pal.line2)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick),
|
||||
) {
|
||||
@@ -327,7 +369,7 @@ private fun SmallButton(text: String, enabled: Boolean = true, accent: Boolean =
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(10.dp))
|
||||
.background(if (hot) pal.signal else colors.surfaceVariant)
|
||||
.border(2.dp, if (hot) colors.onBackground else pal.line2, RoundedCornerShape(10.dp))
|
||||
.glideBorder(hot, RoundedCornerShape(10.dp), colors.onBackground, pal.line2, restWidth = 2.dp)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, enabled = enabled, onClick = onClick)
|
||||
.controllerConfirm(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
@@ -369,7 +411,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.weight(1f).heightIn(min = 56.dp).then(modifier)
|
||||
.border(2.dp, if (hot) pal.signal else Color.Transparent, ROW_SHAPE)
|
||||
.glideBorder(hot, ROW_SHAPE, pal.signal)
|
||||
.hoverable(src).clickable(interactionSource = src, indication = null, onClick = onSelect)
|
||||
.controllerConfirm(onClick = onSelect)
|
||||
.padding(start = 14.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
@@ -398,7 +440,7 @@ private fun InstalledLine(item: InstalledItem, modifier: Modifier = Modifier, on
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(end = 6.dp).size(40.dp).clip(ROW_SHAPE)
|
||||
.background(if (delHot) colors.error.copy(alpha = 0.14f) else Color.Transparent)
|
||||
.border(2.dp, if (delHot) colors.error else Color.Transparent, ROW_SHAPE)
|
||||
.glideBorder(delHot, ROW_SHAPE, colors.error)
|
||||
.hoverable(delSrc).clickable(interactionSource = delSrc, indication = null, onClick = onDelete)
|
||||
.controllerConfirm(onClick = onDelete),
|
||||
) { Icon(Icons.Outlined.Delete, contentDescription = "Delete ${item.name}", tint = if (delHot) colors.error else colors.onSurfaceVariant, modifier = Modifier.size(19.dp)) }
|
||||
@@ -432,3 +474,6 @@ private fun AvailableLine(d: CatalogItem, progress: Int?, enabled: Boolean, onDo
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(pal.line))
|
||||
}
|
||||
|
||||
/** The Components page's tab for the GPU drivers, before the Proton components' own. */
|
||||
const val GPU_TAB = "gpu"
|
||||
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user