Merge commit 'e1a7d527fd412b6bd78833e01fe80f515e324971' into merge/main-into-non-adreno-all

# Conflicts:
#	.github/workflows/build.yml
#	app/build.gradle
#	app/src/main/java/com/droiddeck/launcher/core/DeviceReport.kt
#	app/src/main/java/com/droiddeck/launcher/gpu/DriverPairs.kt
#	app/src/main/java/com/droiddeck/launcher/gpu/GpuInfo.kt
#	app/src/main/java/com/droiddeck/launcher/gpu/TurnipDriver.java
#	app/src/main/java/com/droiddeck/launcher/gpu/TurnipReleases.kt
#	app/src/main/java/com/droiddeck/launcher/runtime/LinuxRuntime.java
#	app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt
#	app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
#	app/src/main/res/values/strings.xml
#	app/src/test/java/com/droiddeck/launcher/ui/ComponentsPageProfileTest.kt
#	tools/build_local.sh
#	tools/gamescope/PATCHES.md
#	tools/gamescope/release.env
#	tools/linuxfs/overlay/usr/local/bin/droiddeck-game-env
#	tools/linuxfs/preload/netif.c
#	tools/linuxfs/preload/opens.c
#	tools/release/runtime_inputs.py
This commit is contained in:
Kurt Himebauch committed 2026-10-22 12:22:36 -04:00
commit e7b523d653
574 files changed
+41749 -2616

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
name: Build validation
on:
pull_request_target:
branches: [main]
types: [opened, synchronize, reopened, edited, ready_for_review]
push:
branches: [main]
workflow_run:
workflows: [Build APK]
types: [completed]
workflow_dispatch:
inputs:
pr_number:
description: Pull request to validate; empty for main
required: false
permissions:
contents: read
actions: read
pull-requests: read
statuses: write
jobs:
validate:
name: Build APK
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.workflow_sha }}
fetch-depth: 0
persist-credentials: false
- name: Validate source and APK compilation
env:
GH_TOKEN: ${{ github.token }}
run: |
gh auth setup-git
python3 tools/release/validate_apk.py
+5 -1
View File
@@ -64,6 +64,7 @@ jobs:
KDE_KWIN_OVERRIDE="$PWD/out/kwin" tools/desktop-kde/build.sh out
cp out/kwin/desktop-kde.kwin.txt out/
echo "size $(stat -c %s out/desktop-kde.tar.zst) sha256 $(cat out/desktop-kde.sha256)"
echo "language packs $(ls out/desktop-kde-i18n-*.tar.zst | wc -l)"
- uses: actions/upload-artifact@v4
with:
name: desktop-kde
@@ -74,6 +75,8 @@ jobs:
out/desktop-kde.packages.txt
out/desktop-kde.kwin.txt
out/desktop-kde.sha256
out/desktop-kde.i18n.txt
out/desktop-kde-i18n-*.tar.zst
- name: Publish
if: github.event_name == 'workflow_dispatch' && inputs.tag != ''
env:
@@ -88,5 +91,6 @@ jobs:
fi
gh release create "$TAG" --latest=false --prerelease=false --title "$TAG" \
--notes "KDE Plasma desktop package for DroidDeck's Linux runtime ($GITHUB_REPOSITORY tools/desktop-kde, $GITHUB_SHA). size $(stat -c %s out/desktop-kde.tar.zst), sha256 $(cat out/desktop-kde.sha256). desktop-kde.packages.txt lists the signature-checked Arch Linux ARM packages it was built from." \
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt
out/desktop-kde.tar.zst out/desktop-kde.sha256 out/desktop-kde.packages.txt out/desktop-kde.kwin.txt \
out/desktop-kde.i18n.txt out/desktop-kde-i18n-*.tar.zst
gh release edit "$TAG" --latest=false --prerelease=false
+134 -22
View File
@@ -29,8 +29,65 @@ env:
PRELOAD_CACHE_VERSION: v6
jobs:
xbox:
name: Xbox ARM64 backend
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
outputs:
base-sha: ${{ steps.merge.outputs.base-sha }}
tree-sha: ${{ steps.source.outputs.tree-sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Pin and merge the APK source
id: merge
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
git fetch --no-tags origin main
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git merge --no-ff --no-edit origin/main
- name: Record backend source tree
id: source
run: echo "tree-sha=$(git rev-parse 'HEAD^{tree}')" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
id: xbox-cache
with:
path: |
app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
key: xbox-arm64-${{ hashFiles('tools/xbox/**') }}
- name: Build Xbox backend
if: steps.xbox-cache.outputs.cache-hit != 'true'
run: |
rustup toolchain install 1.99.0 --profile minimal
sudo apt-get update -qq
sudo apt-get install -y build-essential pkg-config libssl-dev protobuf-compiler cmake xz-utils
bash tools/xbox/build.sh
- uses: actions/upload-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
retention-days: 14
- uses: actions/upload-artifact@v4
with:
name: xbox-corresponding-source
path: |
app/build/xbox/xodus-droiddeck-source.tar.xz
app/build/xbox/runtime/*-source.tar.xz
if-no-files-found: error
retention-days: 14
build:
name: Build APK
needs: xbox
name: Compile and test APK
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -42,18 +99,24 @@ jobs:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
submodules: true
persist-credentials: false
- name: Check co-authors
if: github.event_name == 'pull_request'
- name: Check human authorship
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
messages=$(git log --format=%B "$base..HEAD")
messages+=$'\n'"$(jq -r '.pull_request.title, (.pull_request.body // "")' "$GITHUB_EVENT_PATH")"
if grep -Eiq '^[[:space:]]*Co-authored-by:.*(Claude|Anthropic|OpenAI|ChatGPT|GPT|Copilot|Cursor|Gemini|Codex|Devin|Windsurf|Codeium|Amazon Q|Tabnine|Cody|\[bot\])' <<<"$messages"; then
echo "::error::AI and bot identities cannot be co-authors; remove the Co-authored-by trailer."
exit 1
if [ "$EVENT_NAME" = pull_request ]; then
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
checker="$RUNNER_TEMP/droiddeck-check-authorship.py"
git show origin/main:tools/release/check_authorship.py > "$checker"
python3 "$checker" "$base..HEAD" --event "$GITHUB_EVENT_PATH"
elif [ "$EVENT_NAME" = push ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then
python3 tools/release/check_authorship.py "$BEFORE..HEAD"
else
python3 tools/release/check_authorship.py HEAD
fi
- name: The packages a signed build ships as
@@ -87,14 +150,17 @@ jobs:
- name: Merge onto main, or say what conflicts
if: github.event_name == 'pull_request'
env:
BUILD_BASE_SHA: ${{ needs.xbox.outputs.base-sha }}
run: |
set -uo pipefail
git config user.name "ci"
git config user.email "ci@users.noreply.github.com"
git fetch --no-tags origin main
BASE=$(git rev-parse --short origin/main)
git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD)
if git merge --no-ff --no-edit origin/main >/tmp/merge.out 2>&1; then
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{
echo "### Mergeable"
echo
@@ -141,6 +207,11 @@ jobs:
echo "::error::the pull request conflicts with main: $(echo $FILES | tr '\n' ' ')"
exit 1
- name: Verify backend and APK source match
env:
XBOX_SOURCE_TREE: ${{ needs.xbox.outputs.tree-sha }}
run: test "$(git rev-parse 'HEAD^{tree}')" = "$XBOX_SOURCE_TREE"
- uses: actions/setup-java@v4
with:
distribution: temurin
@@ -185,15 +256,14 @@ jobs:
uses: actions/cache@v4
with:
path: app/src/main/assets/linuxfs
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/linuxfs/xclipse-layer/*', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'external/MaliBridge/client/**', 'external/MaliBridge/common/**', 'external/MaliBridge/tests/**', 'external/MaliBridge/scripts/build-client-linux.sh', 'tools/venus/glprobe.c', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
key: preloads-${{ env.PRELOAD_CACHE_VERSION }}-${{ hashFiles('app/src/main/cpp/fakeinput_steam.cpp', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'tools/quest/*', 'tools/linuxfs/preload/*.c', 'tools/linuxfs/build-x86-preloads.sh', 'tools/linuxfs/fex/*', 'tools/linuxfs/clipboard/*.c', 'tools/linuxfs/ssbs/*.c', 'tools/linuxfs/xclipse-layer/*', 'tools/proot/fastpath/*.c', 'tools/linuxfs/overlay/usr/local/bin/*', 'tools/linuxfs/overlay/usr/bin/**', 'tools/linuxfs/desktop/*', 'tools/gamescope/release.env', 'tools/wlroots/release.env', 'tools/linuxfs/uruntime.env', 'tools/linuxfs/licenses/*', 'tools/mangoapp/*', 'tools/msitools/**', 'tools/comet/**', 'tools/xbox/**', 'external/MaliBridge/client/**', 'external/MaliBridge/common/**', 'external/MaliBridge/tests/**', 'external/MaliBridge/scripts/build-client-linux.sh', 'tools/venus/glprobe.c', 'tools/linuxfs/debian-image.env', 'tools/vr/**') }}
- name: Build libfakeinput.so and libblsession.so (glibc aarch64)
if: steps.preloads.outputs.cache-hit != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get -o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources \
-o Dir::Etc::sourceparts=- -o Acquire::Retries=3 update -qq
sudo apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libvulkan-dev libvulkan1 mesa-vulkan-drivers >/dev/null
D=app/src/main/assets/linuxfs
mkdir -p "$D"
@@ -237,7 +307,7 @@ jobs:
done
if [ -f tools/gamescope/release.env ]; then
. tools/gamescope/release.env
gh release download "${GAMESCOPE_TAG}" -R "${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}" -p gamescope.tzst -O gamescope.tzst
curl -fsSL --retry 3 -o gamescope.tzst "https://github.com/${GAMESCOPE_REPO:-${GITHUB_REPOSITORY}}/releases/download/${GAMESCOPE_TAG}/gamescope.tzst"
echo "${GAMESCOPE_SHA256} gamescope.tzst" | sha256sum -c -
tar --use-compress-program=unzstd -xf gamescope.tzst -C "$D"
test -f "$D/usr/local/bin/gamescope"
@@ -268,6 +338,17 @@ jobs:
install -Dm644 tools/mangoapp/mangoapp "$D/usr/local/bin/mangoapp"
rm -rf mango-pkgs
ls -l "$M"
mkdir -p pw-pkgs
grep -v '^#' tools/pipewire/packages.txt | while read -r sum url; do
[ -n "$url" ] || continue
curl -fsSL --retry 3 -o pw-pkgs/pkg.tar.zst "$url"
echo "$sum pw-pkgs/pkg.tar.zst" | sha256sum -c -
tar --use-compress-program=unzstd -xf pw-pkgs/pkg.tar.zst -C pw-pkgs
rm pw-pkgs/pkg.tar.zst
done
tools/pipewire/assemble.sh pw-pkgs "$D/usr/local/lib/droiddeck-pipewire.tzst"
rm -rf pw-pkgs
ls -l "$D/usr/local/lib/droiddeck-pipewire.tzst"
T="$D/usr/local/lib/droiddeck-msitools"
mkdir -p "$T" msi-pkgs
grep -v '^#' tools/msitools/packages.txt | while read -r sum url; do
@@ -340,18 +421,17 @@ jobs:
ls -l app/src/main/assets/linuxfs/x86_64 app/src/main/assets/linuxfs/i386
- name: Bundle the droiddeck-esync packs
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -f tools/droiddeck-esync/release.env ]; then
. tools/droiddeck-esync/release.env
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p "$SYNC_BUNDLE_ASSET" -O sync-bundle.tzst
release_url="https://github.com/${SYNC_BUNDLE_REPO}/releases/download/${SYNC_BUNDLE_TAG}"
curl -fsSL --retry 3 -o sync-bundle.tzst "$release_url/$SYNC_BUNDLE_ASSET"
echo "$SYNC_BUNDLE_SHA256 sync-bundle.tzst" | sha256sum -c -
mkdir -p app/src/main/assets/droiddeck-esync
tar --use-compress-program=unzstd -xf sync-bundle.tzst -C app/src/main/assets/droiddeck-esync
rm sync-bundle.tzst
gh release download "$SYNC_BUNDLE_TAG" -R "$SYNC_BUNDLE_REPO" -p index.json -O sync-index.json
curl -fsSL --retry 3 -o sync-index.json "$release_url/index.json"
revoked=$(python3 -c 'import json, sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1]))["packs"] if p.get("revoked") is True))' sync-index.json)
for id in $revoked; do
if [ -e "app/src/main/assets/droiddeck-esync/packs/$id.tzst" ]; then
@@ -450,6 +530,8 @@ jobs:
/tmp/test-xr-tracking
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_xr_touch.cpp -o /tmp/test-xr-touch
/tmp/test-xr-touch
g++ -std=c++17 -Wall -Wextra -Werror tools/vr/tests/test_pad_controller_config.cpp -o /tmp/test-pad-controller-config
/tmp/test-pad-controller-config
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_vsync.cpp -o /tmp/test-xr-vsync
/tmp/test-xr-vsync
g++ -std=c++17 -Wall -Wextra -Werror -pthread tools/vr/tests/test_xr_frames.cpp -o /tmp/test-xr-frames
@@ -499,6 +581,11 @@ jobs:
restore-keys: |
cxx-${{ env.NDK_VERSION }}-
- uses: actions/download-artifact@v4
with:
name: xbox-arm64
path: app/src/main/assets/linuxfs/usr/local/lib/droiddeck-xbox
- name: Build GOG Galaxy companion
if: steps.preloads.outputs.cache-hit != 'true'
run: |
@@ -506,6 +593,9 @@ jobs:
rustup target add --toolchain 1.90.0 aarch64-unknown-linux-gnu
RUSTUP_TOOLCHAIN=1.90.0 bash tools/comet/build.sh
- name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs
- name: Build
id: build
env:
@@ -545,6 +635,11 @@ jobs:
path: app/src/main/rust/stores/Cargo.lock
if-no-files-found: ignore
- name: Install Python test dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
- name: Steam import and game environment tests
run: |
# The Xclipse layer regression test compiles the layer against the distribution's headers.
@@ -608,6 +703,14 @@ jobs:
retention-days: 30
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: droiddeck-packs
path: app/build/packs/*.tar.zst
if-no-files-found: error
retention-days: 30
compression-level: 0
- name: Report on the pull request
if: always() && github.event_name == 'pull_request'
continue-on-error: true
@@ -710,8 +813,17 @@ jobs:
variants: ${{ needs.build.outputs.variants }}
secrets: inherit
packs:
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
secrets: inherit
announce:
needs: [build, sign]
needs: [build, sign, packs]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
+22
View File
@@ -176,6 +176,27 @@ jobs:
pattern: droiddeck-pr-signed-*
merge-multiple: true
path: signed
- name: The add-on packs main has not published
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ needs.authorize.outputs.run_id }}
run: |
set -euo pipefail
gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
mkdir -p pr-packs
for file in packs/*.tar.zst; do
name=$(basename "$file" .tar.zst)
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$({ gh api "repos/Droid-Deck/DroidDeck-Components/releases/tags/$tag" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$name.tar.zst" '.assets // [] | .[] | select(.name == $n) | .digest // empty')
if [ "$digest" = "sha256:$sha" ]; then
echo "$name: served from DroidDeck-Components $tag"
else
cp "$file" "pr-packs/$tag.tar.zst"
echo "$name: attached to the PR release as $tag.tar.zst"
fi
done
- name: Describe the change
env:
GH_TOKEN: ${{ github.token }}
@@ -233,6 +254,7 @@ jobs:
first=true
fi
echo "first=$first" >> "$GITHUB_OUTPUT"
cp pr-packs/* signed/ 2>/dev/null || true
gh release upload "$tag" signed/* -R "$repo" --clobber
gh release edit "$tag" -R "$repo" --title "PR #$PR_NUMBER" --notes "$notes" --prerelease
keep=$(cd signed && ls | jq -R . | jq -sc .)
+83
View File
@@ -0,0 +1,83 @@
name: Publish add-on packs
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
# Only main and release builds publish here; a PR test build attaches the packs main has not published
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
on:
workflow_call:
inputs:
run_id:
description: The Build APK run whose packs to publish; empty for the calling run's own
type: string
default: ''
permissions:
contents: read
actions: read
jobs:
publish:
name: publish add-on packs
runs-on: ubuntu-24.04
timeout-minutes: 15
concurrency:
group: publish-packs
cancel-in-progress: false
steps:
- name: The packs (this run's build)
if: inputs.run_id == ''
uses: actions/download-artifact@v4
with:
name: droiddeck-packs
path: packs
- name: The packs (another Build APK run)
if: inputs.run_id != ''
env:
GH_TOKEN: ${{ github.token }}
RUN: ${{ inputs.run_id }}
run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-packs -D packs
- uses: actions/create-github-app-token@v2
id: components-token
with:
app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
owner: Droid-Deck
repositories: DroidDeck-Components
- name: Publish
env:
GH_TOKEN: ${{ steps.components-token.outputs.token }}
REPO: Droid-Deck/DroidDeck-Components
BUILD_RUN: ${{ inputs.run_id || github.run_id }}
run: |
set -euo pipefail
published_digest() {
{ gh api "repos/$REPO/releases/tags/$1" 2>/dev/null || echo '{}'; } |
jq -r --arg n "$2" '.assets // [] | .[] | select(.name == $n) | .digest // empty'
}
commit=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN" --jq .head_sha 2>/dev/null || echo "$GITHUB_SHA")
for name in proot directaudio tracking xbox comet; do
file="packs/$name.tar.zst"
[ -f "$file" ] || { echo "::error::$name.tar.zst is missing from Build APK run $BUILD_RUN"; exit 1; }
sha=$(sha256sum "$file" | cut -d' ' -f1)
tag="pack-$name-${sha:0:16}"
digest=$(published_digest "$tag" "$name.tar.zst")
if [ -z "$digest" ]; then
notes="DroidDeck add-on pack \`$name\`, built by [Build APK run $BUILD_RUN]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$BUILD_RUN) from \`$commit\` with tools/packs/build.sh and published by [run $GITHUB_RUN_ID]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID).
Size: $(stat -c %s "$file") bytes. SHA-256: $sha. APKs built in the same run pin this digest."
gh release create "$tag" -R "$REPO" --title "$tag" --notes "$notes" --latest=false "$file" \
|| echo "::warning::creating $tag failed; checking whether another run published it"
digest=$(published_digest "$tag" "$name.tar.zst")
fi
if [ "$digest" != "sha256:$sha" ]; then
echo "::error::$tag holds ${digest:-nothing} for $name.tar.zst, not sha256:$sha"
exit 1
fi
echo "- \`$name\`: [$tag]($GITHUB_SERVER_URL/$REPO/releases/tag/$tag) \`$sha\`" >> "$GITHUB_STEP_SUMMARY"
done
+11 -1
View File
@@ -63,9 +63,19 @@ jobs:
artifact: droiddeck-release
secrets: inherit
packs:
needs: find
permissions:
contents: read
actions: read
uses: ./.github/workflows/publish-packs.yml
with:
run_id: ${{ needs.find.outputs.run_id }}
secrets: inherit
publish:
name: publish the release
needs: [find, sign]
needs: [find, sign, packs]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
+5 -3
View File
@@ -21,7 +21,7 @@ On Android 14 and newer, before Steam launches, you must turn on **Disable child
## Smart glasses
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, and XREAL connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
Use **Setup → Smart glasses** for app-wide virtual-screen settings across DroidDeck menus, launchers, desktop sessions, and games. Supported VITURE, RayNeo, XREAL, and Rokid connections provide 3DoF; Luma Ultra adds optional experimental 6DoF and stereo-assisted hand/Joy-Con tracking. Model support and SDK requirements differ: see the [smart-glasses support matrix](docs/smart-glasses-support.md) for supported devices, controls, setup, and limitations.
## Community
@@ -47,10 +47,12 @@ Compatibility and performance vary by device; hardware validation is limited. De
## Credits and licence
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. The registry entries a GOG game's install script (`goggame-<id>.script`) sets are read the way [Heroic Games Launcher](https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher)'s GOG setup (GPL-3.0) reads them. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence, which also starts the arm64 emulator AppImages whose own runtime cannot run its extractor on Android. The Steam client's audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink` with its request size kept to its ring so clients are not asked to queue 100 ms ahead. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). Why the x86-64 Steam client refused the arm64 client's sign-in was checked by reading Steam's saved token with the `ConnectCache` format from [deadlock-api-ingest](https://github.com/deadlock-api/deadlock-api-ingest). x86 programs that link OpenSSL 3 under FEX use the copy in the [Steam Linux Runtime 4.0](https://gitlab.steamos.cloud/steamrt/steamrt) platform. Hooks written with `WriteProcessMemory` into code [FEX](https://github.com/FEX-Emu/FEX) has translated go through `/proc/<pid>/mem` again when the kernel refuses `process_vm_writev`, as [Wine](https://gitlab.winehq.org/wine/wine)'s `server/ptrace.c` wrote them before Wine 11. x86 Linux games under FEX get the same `/etc/asound.conf` that [pressure-vessel](https://gitlab.steamos.cloud/steamrt/steam-runtime-tools) writes into the Steam Linux Runtime, so ALSA plays through PulseAudio. Games render with [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's own per-render-pass choice between tile memory and system memory, while the Steam client keeps `TU_DEBUG=sysmem`; [FEX](https://github.com/FEX-Emu/FEX)'s code disk cache was checked in its source and is already on through Proton. See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). Rokid support follows the Rokid protocol notes in [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/), and [Monado](https://gitlab.freedesktop.org/monado/monado)'s Rokid driver. The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL, Rokid and REDMAGIC are trademarks of their owners; this project is not affiliated with them.
Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo.
Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md).
The Store installs and runs apps with [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1). Its sandbox goes through DroidDeck's own bubblewrap shim, which follows how Flatpak hands bwrap its environment. Chromium and Electron apps from Flathub start their browser processes through [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause); its source was read to see that it always spawns them through Flatpak's spawn portal, which is why DroidDeck starts them directly without Chromium's sandbox instead.
+33 -3
View File
@@ -84,6 +84,23 @@ def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with
def desktopKde = rootProject.file('tools/desktop-kde/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def trackingPack = rootProject.file('tools/packs/tracking/release.env').readLines()
.findAll { it.contains('=') && !it.startsWith('#') }
.collectEntries { def kv = it.split('=', 2); [(kv[0].trim()): kv[1].trim()] }
def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet'].collectEntries { name ->
def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
return [(name): [sha256: '', size: 0L]]
}
def digest = java.security.MessageDigest.getInstance('SHA-256')
pack.withInputStream { input ->
byte[] buffer = new byte[1 << 20]
int n
while ((n = input.read(buffer)) > 0) digest.update(buffer, 0, n)
}
[(name): [sha256: digest.digest().encodeHex().toString(), size: pack.length()]]
}
def desktopRemovalSources = new groovy.json.JsonSlurper().parse(file('src/main/assets/desktop-removal/sources.json'))
if (desktopRemovalSources.archives[desktopKde.DESKTOP_KDE_TAG] != desktopKde.DESKTOP_KDE_SHA256) {
throw new GradleException('Desktop removal inventories do not match the pinned archive')
@@ -119,6 +136,10 @@ android {
buildConfigField "String", "DESKTOP_KDE_URL", "\"https://github.com/${desktopKde.DESKTOP_KDE_REPO}/releases/download/${desktopKde.DESKTOP_KDE_TAG}/desktop-kde.tar.zst\""
buildConfigField "String", "DESKTOP_KDE_SHA256", "\"${desktopKde.DESKTOP_KDE_SHA256}\""
buildConfigField "long", "DESKTOP_KDE_SIZE", "${desktopKde.DESKTOP_KDE_SIZE}L"
addonPacks.each { name, pack ->
buildConfigField "String", "PACK_${name.toUpperCase()}_SHA256", "\"${pack.sha256}\""
buildConfigField "long", "PACK_${name.toUpperCase()}_SIZE", "${pack.size}L"
}
ndk {
abiFilters 'arm64-v8a'
@@ -175,6 +196,11 @@ android {
disable 'ExpiredTargetSdkVersion'
}
androidResources {
// aapt's default ignore list, plus the trees shipped as add-on packs (tools/packs).
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
}
packagingOptions {
resources {
excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']
@@ -182,6 +208,8 @@ android {
jniLibs {
useLegacyPackaging = true
pickFirsts += ['**/libtermux.so']
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
}
}
@@ -195,6 +223,8 @@ android {
unitTests.returnDefaultValues = true
unitTests.includeAndroidResources = true
unitTests.all {
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g'
def logTarget = providers.systemProperty('robolectric.logging').orNull
if (logTarget != null) systemProperty 'robolectric.logging', logTarget
}
@@ -316,9 +346,8 @@ def verifyHandModel = tasks.register('verifyHandModel') {
}
}
tasks.named('preBuild').configure { dependsOn(verifyHandModel) }
dependencies {
implementation 'org.opencv:opencv:4.13.0'
implementation "org.opencv:opencv:${trackingPack.OPENCV_VERSION}"
implementation 'org.khronos.openxr:openxr_loader_for_android:1.1.49'
implementation 'com.github.luben:zstd-jni:1.5.7-4@aar'
implementation 'org.apache.commons:commons-compress:1.27.1'
@@ -337,7 +366,8 @@ dependencies {
implementation 'com.termux.termux-app:terminal-view:0.118.0'
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation('com.google.mediapipe:tasks-vision:1.0.0') {
implementation 'androidx.webkit:webkit:1.12.1'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
+29 -5
View File
@@ -65,11 +65,6 @@
android:exported="true"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -88,6 +83,31 @@
</intent-filter>
</activity>
<activity-alias
android:name=".LauncherDark"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".LauncherLight"
android:targetActivity=".MainActivity"
android:icon="@mipmap/ic_launcher_light"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.CAR_LAUNCHER" />
</intent-filter>
</activity-alias>
<activity
android:name=".HomeActivity"
android:enabled="false"
@@ -127,6 +147,10 @@
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxLoginActivity" android:exported="false"
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Holger Bartnick
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+18 -14
View File
@@ -11,23 +11,23 @@ add_subdirectory(adrenotools)
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc)
set(WINFG_UPSTREAM "v0.3.0")
set(WINFG_CHAIN_SRC
framegen/winfg/framegen.cpp
framegen/winfg/framegen.hpp
framegen/winfg/record_impl.inc
framegen/winfg/config.hpp
framegen/winfg/embedded_shaders.hpp
framegen/winfg/winfg_engine.cpp
set(DDFG_UPSTREAM "v0.3.0")
set(DDFG_CHAIN_SRC
framegen/ddfg/framegen.cpp
framegen/ddfg/framegen.hpp
framegen/ddfg/record_impl.inc
framegen/ddfg/config.hpp
framegen/ddfg/embedded_shaders.hpp
framegen/ddfg/ddfg_engine.cpp
)
set(WINFG_HASH_INPUT "")
foreach(f ${WINFG_CHAIN_SRC})
set(DDFG_HASH_INPUT "")
foreach(f ${DDFG_CHAIN_SRC})
file(SHA256 "${CMAKE_CURRENT_SOURCE_DIR}/${f}" _h)
string(APPEND WINFG_HASH_INPUT "${_h}")
string(APPEND DDFG_HASH_INPUT "${_h}")
endforeach()
string(SHA256 WINFG_CHAIN_HASH_FULL "${WINFG_HASH_INPUT}")
string(SUBSTRING "${WINFG_CHAIN_HASH_FULL}" 0 12 WINFG_CHAIN_HASH)
message(STATUS "win-fg chain: ${WINFG_UPSTREAM} (src ${WINFG_CHAIN_HASH})")
string(SHA256 DDFG_CHAIN_HASH_FULL "${DDFG_HASH_INPUT}")
string(SUBSTRING "${DDFG_CHAIN_HASH_FULL}" 0 12 DDFG_CHAIN_HASH)
message(STATUS "dd-fg chain: win-fg ${DDFG_UPSTREAM} (src ${DDFG_CHAIN_HASH})")
add_subdirectory(waylandcomp)
add_subdirectory(questxr)
@@ -39,6 +39,10 @@ target_link_libraries(deviceinfo dl)
add_library(termux SHARED termux/termux.c)
add_library(ddvenc SHARED venc/venc_server.cpp)
target_compile_features(ddvenc PRIVATE cxx_std_17)
target_link_libraries(ddvenc mediandk log)
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
+214 -14
View File
@@ -21,6 +21,9 @@
#include <dirent.h>
#include <dlfcn.h>
#ifdef __GLIBC__
#include <gnu/lib-names.h>
#endif
#include <fcntl.h>
#include <linux/input.h>
#include <linux/joystick.h>
@@ -1181,8 +1184,9 @@ struct TritonSharedData {
uint32_t input_length;
uint8_t input[64];
uint64_t output_seq;
uint32_t output_feature;
uint32_t output_length;
uint16_t output_feature;
uint16_t output_length;
uint32_t output_ack;
uint8_t output[64];
uint64_t get_request;
uint64_t get_reply;
@@ -1199,6 +1203,8 @@ static_assert(offsetof(TritonSharedData, report_id) == 16, "Triton report ID off
static_assert(offsetof(TritonSharedData, input) == 24, "Triton input offset");
static_assert(offsetof(TritonSharedData, output_seq) == 88, "Triton output sequence offset");
static_assert(offsetof(TritonSharedData, output_feature) == 96, "Triton output feature offset");
static_assert(offsetof(TritonSharedData, output_length) == 98, "Triton output length offset");
static_assert(offsetof(TritonSharedData, output_ack) == 100, "Triton output ack offset");
static_assert(offsetof(TritonSharedData, output) == 104, "Triton output offset");
static_assert(offsetof(TritonSharedData, get_request) == 168, "Triton feature request offset");
static_assert(offsetof(TritonSharedData, get_reply) == 176, "Triton feature reply offset");
@@ -1216,6 +1222,8 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1;
static constexpr int TRITON_STATE_BYTES = 45;
static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting.
static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50;
static constexpr const char *TRITON_NAME = "Steam Controller";
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
static const uint8_t kTritonReportDescriptor[] = {
@@ -2571,11 +2579,21 @@ __attribute__((visibility("hidden"))) static void triton_queue_output(DeckHidraw
if (!data || !size || size > 64 || triton.mapping_size < TRITON_SHARED_OFFSET + TRITON_SHARED_BLOCK_SIZE) return;
auto *shared = reinterpret_cast<TritonSharedData *>(reinterpret_cast<uint8_t *>(triton.ring) + TRITON_SHARED_OFFSET);
if (__atomic_load_n(&shared->magic, __ATOMIC_ACQUIRE) != TRITON_SHARED_MAGIC) return;
static std::mutex one_at_a_time;
std::lock_guard<std::mutex> guard(one_at_a_time);
for (int waited = 0;
static_cast<uint32_t>(__atomic_load_n(&shared->output_seq, __ATOMIC_ACQUIRE)) !=
__atomic_load_n(&shared->output_ack, __ATOMIC_ACQUIRE) &&
__atomic_load_n(&shared->connected, __ATOMIC_ACQUIRE) != 0 && waited < TRITON_OUTPUT_TIMEOUT_MS * 4;
waited++) {
struct timespec interval = {0, 250 * 1000L};
nanosleep(&interval, nullptr);
}
uint64_t seq = __atomic_load_n(&shared->output_seq, __ATOMIC_RELAXED);
if (seq & 1) seq++;
__atomic_store_n(&shared->output_seq, seq + 1, __ATOMIC_RELEASE);
shared->output_feature = feature ? 1 : 0;
shared->output_length = static_cast<uint32_t>(size);
shared->output_length = static_cast<uint16_t>(size);
memcpy(shared->output, data, size);
if (size < sizeof(shared->output)) memset(shared->output + size, 0, sizeof(shared->output) - size);
__atomic_thread_fence(__ATOMIC_RELEASE);
@@ -2632,6 +2650,38 @@ __attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw &
static constexpr uint32_t TRITON_HWID = 72;
static constexpr uint32_t TRITON_FW_TIMESTAMP = 0x6AC686B3;
// Steam compares the controller's firmware with TRITON_FW_TS in its bundled hardwareupdater.cfg (and blocks
// with a required update below MUST_UPDATE_TRITON_FW_TS), so the emulated pad reports at least the build
// Steam ships.
static uint32_t triton_fw_timestamp() {
static const uint32_t timestamp = [] {
std::vector<std::string> roots;
char exe[PATH_MAX];
ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
if (n > 0) {
std::string path(exe, static_cast<size_t>(n));
size_t bin = path.rfind('/');
size_t root = bin == std::string::npos || bin == 0 ? std::string::npos : path.rfind('/', bin - 1);
if (bin != std::string::npos && bin > 0) roots.push_back(path.substr(0, bin));
if (root != std::string::npos) roots.push_back(path.substr(0, root));
}
if (const char *home = getenv("HOME")) roots.push_back(std::string(home) + "/.local/share/Steam");
uint32_t newest = TRITON_FW_TIMESTAMP;
for (const std::string &root : roots) {
std::ifstream cfg(root + "/bin/hardwareupdater/hardwareupdater.cfg");
std::string line;
while (std::getline(cfg, line)) {
if (line.rfind("TRITON_FW_TS:", 0) != 0) continue;
uint32_t shipped = static_cast<uint32_t>(strtoul(line.c_str() + 13, nullptr, 16));
if (shipped > newest) newest = shipped;
}
}
if (newest != TRITON_FW_TIMESTAMP) Logger::log("triton: reporting firmware %08x, the build Steam ships\n", newest);
return newest;
}();
return timestamp;
}
__attribute__((visibility("hidden"))) static int triton_pad_feature(DeckHidraw &triton, uint8_t *buf, size_t size, bool set) {
if (!buf || size < 2) {
errno = EINVAL;
@@ -2652,7 +2702,7 @@ __attribute__((visibility("hidden"))) static int triton_pad_feature(DeckHidraw &
switch (triton.pending_feature) {
case DECK_GET_ATTRIBUTES_VALUES: {
static const uint8_t kIds[] = {0x01, 0x02, 0x04, 0x09, 0x0a, 0x0b};
const uint32_t values[] = {static_cast<uint32_t>(TRITON_WIRED_PRODUCT), 0, TRITON_FW_TIMESTAMP, TRITON_HWID, 0, 4000};
const uint32_t values[] = {static_cast<uint32_t>(TRITON_WIRED_PRODUCT), 0, triton_fw_timestamp(), TRITON_HWID, 0, 4000};
reply[2] = sizeof(kIds) * 5;
for (size_t i = 0; i < sizeof(kIds); i++) {
reply[3 + i * 5] = kIds[i];
@@ -3577,7 +3627,21 @@ EXPORT int close(int fd) {
static constexpr int kMaxRingWaitMs = 2;
static constexpr int kLowLatencyRingWaitUs = 250;
EXPORT ssize_t read(int fd, void *buf, size_t count) {
static ssize_t copy_input_events(void *buf, const struct input_event *events, size_t count) {
size_t bytes = count * sizeof(struct input_event);
struct iovec local = {const_cast<struct input_event *>(events), bytes};
struct iovec remote = {buf, bytes};
ssize_t copied = syscall(SYS_process_vm_writev, getpid(), &local, 1, &remote, 1, 0);
if (copied < 0) return -1;
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
copied -= copied % sizeof(struct input_event);
if (!copied) return -1;
}
return copied;
}
static ssize_t read_input(int fd, void *buf, size_t count) {
if (!watched(fd)) return syscall(SYS_read, fd, buf, count);
std::unique_lock<std::recursive_mutex> guard(controller_mutex());
auto controller = controller_map().find(fd);
@@ -3622,42 +3686,44 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
if (events == 0) break;
const uint8_t *ring_events = reinterpret_cast<const uint8_t *>(fake.ring) +
FAKE_INPUT_RING_HEADER_SIZE;
std::vector<struct input_event> input_events(events);
for (size_t i = 0; i < events; ++i) {
size_t index = (fake.read_seq + i) % FAKE_INPUT_RING_CAPACITY;
RingEvent stored;
memcpy(&stored, ring_events + index * FAKE_INPUT_EVENT_SIZE, FAKE_INPUT_EVENT_SIZE);
struct input_event ev = {};
struct input_event &ev = input_events[i];
ev.time.tv_sec = stored.sec;
ev.time.tv_usec = stored.usec;
ev.type = stored.type;
ev.code = stored.code;
ev.value = stored.value;
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
}
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (snap.sequence != __atomic_load_n(&fake.ring->snapshot_seq, __ATOMIC_RELAXED))
continue;
fake.read_seq += events;
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events.data(), events);
if (copied > 0) fake.read_seq += copied / sizeof(struct input_event);
return copied;
}
}
size_t events = std::min(requested, fake.keyframe_remaining);
struct input_event input_events[kNeutralEventCount] = {};
struct timeval now = {};
gettimeofday(&now, nullptr);
for (size_t i = 0; i < events; ++i) {
size_t index = kNeutralEventCount - fake.keyframe_remaining;
struct input_event ev = {};
size_t index = kNeutralEventCount - fake.keyframe_remaining + i;
struct input_event &ev = input_events[i];
ev.time = now;
ev.type = kNeutralEvents[index].type;
ev.code = kNeutralEvents[index].code;
ev.value = keyframe_value(fake, ev.type, ev.code);
if (ev.type == EV_ABS) ev.code = presented_abs_code(fake, ev.code);
memcpy(static_cast<uint8_t *>(buf) + i * sizeof(ev), &ev, sizeof(ev));
--fake.keyframe_remaining;
}
return static_cast<ssize_t>(events * sizeof(struct input_event));
ssize_t copied = copy_input_events(buf, input_events, events);
if (copied > 0) fake.keyframe_remaining -= copied / sizeof(struct input_event);
return copied;
}
if (nonblock) {
errno = EAGAIN;
@@ -3673,6 +3739,52 @@ EXPORT ssize_t read(int fd, void *buf, size_t count) {
}
}
EXPORT ssize_t read(int fd, void *buf, size_t count) {
return read_input(fd, buf, count);
}
EXPORT ssize_t readv(int fd, const struct iovec *iov, int iovcnt) {
if (!is_fake_input_fd(fd)) return syscall(SYS_readv, fd, iov, iovcnt);
if (iovcnt < 0 || iovcnt > IOV_MAX) {
errno = EINVAL;
return -1;
}
if (iovcnt && !iov) {
errno = EFAULT;
return -1;
}
std::vector<struct iovec> copied_iov(static_cast<size_t>(iovcnt));
if (iovcnt) {
size_t bytes = copied_iov.size() * sizeof(struct iovec);
struct iovec local = {copied_iov.data(), bytes};
struct iovec remote = {const_cast<struct iovec *>(iov), bytes};
ssize_t copied = syscall(SYS_process_vm_readv, getpid(), &local, 1, &remote, 1, 0);
if (copied != static_cast<ssize_t>(bytes)) {
errno = EFAULT;
return -1;
}
}
iov = copied_iov.data();
size_t size = 0;
for (int i = 0; i < iovcnt; ++i) {
if (iov[i].iov_len > static_cast<size_t>(SSIZE_MAX) - size) {
errno = EINVAL;
return -1;
}
size += iov[i].iov_len;
}
ssize_t total = 0;
for (int i = 0; i < iovcnt; ++i) {
if (!iov[i].iov_len) continue;
ssize_t got = read_input(fd, iov[i].iov_base, iov[i].iov_len);
if (got < 0) return total ? total : -1;
total += got;
if (static_cast<size_t>(got) != iov[i].iov_len) break;
}
return total;
}
EXPORT ssize_t write(int fd, const void *buf, size_t count) {
static auto my_write = reinterpret_cast<decltype(&::write)>(dlsym(RTLD_NEXT, "write"));
@@ -4103,4 +4215,92 @@ EXPORT int __fxstat64(int version, int fd, struct stat64 *buf) {
if (!real) return fstat64(fd, buf);
return fake_stat_fd(fd, buf, [version](int f, struct stat64 *b) { return real(version, f, b); });
}
#if defined(__x86_64__) || defined(__i386__)
using dlsym_fn = void *(*)(void *, const char *);
static const char *const interposed_names[] = {
"open", "open64", "openat", "openat64", "close", "read", "readv", "write", "writev", "ioctl",
"poll", "ppoll", "select", "stat", "stat64", "fstat", "fstat64", "__xstat", "__xstat64",
"__fxstat", "__fxstat64", "statx", "statfs", "statfs64", "fstatfs", "fstatfs64", "access",
"faccessat", "opendir", "scandir", "inotify_add_watch",
};
static dlsym_fn next_dlsym() {
static const dlsym_fn fn = [] {
#ifdef __x86_64__
const char *base = "GLIBC_2.2.5";
#else
const char *base = "GLIBC_2.0";
#endif
for (const char *version : {base, "GLIBC_2.34"}) {
if (auto found = reinterpret_cast<dlsym_fn>(dlvsym(RTLD_NEXT, "dlsym", version))) return found;
}
return dlsym_fn{};
}();
return fn;
}
static void *global_dlsym(void *, const char *name) { return next_dlsym()(RTLD_DEFAULT, name); }
static void *missing_dlsym(void *, const char *) { return nullptr; }
static bool interposed(const char *name) {
for (const char *interposed_name : interposed_names) {
if (strcmp(interposed_name, name) == 0) return true;
}
return false;
}
extern "C" __attribute__((visibility("hidden"), used)) void *fakeinput_dlsym_target(void *handle, const char *name) {
dlsym_fn next = next_dlsym();
if (!next) return reinterpret_cast<void *>(missing_dlsym);
if (handle == RTLD_DEFAULT || handle == RTLD_NEXT || name == nullptr) return reinterpret_cast<void *>(next);
static void *const libc = dlopen(LIBC_SO, RTLD_LAZY | RTLD_NOLOAD);
if (handle != libc || !interposed(name)) return reinterpret_cast<void *>(next);
return reinterpret_cast<void *>(global_dlsym);
}
#ifdef __x86_64__
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"pushq %rdi\n"
".cfi_adjust_cfa_offset 8\n"
"pushq %rsi\n"
".cfi_adjust_cfa_offset 8\n"
"subq $8, %rsp\n"
".cfi_adjust_cfa_offset 8\n"
"call fakeinput_dlsym_target\n"
"addq $8, %rsp\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rsi\n"
".cfi_adjust_cfa_offset -8\n"
"popq %rdi\n"
".cfi_adjust_cfa_offset -8\n"
"jmp *%rax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#else
__asm__(".text\n"
".globl dlsym\n"
".type dlsym, @function\n"
"dlsym:\n"
".cfi_startproc\n"
"subl $12, %esp\n"
".cfi_adjust_cfa_offset 12\n"
"movl 20(%esp), %eax\n"
"movl %eax, 4(%esp)\n"
"movl 16(%esp), %eax\n"
"movl %eax, (%esp)\n"
"call fakeinput_dlsym_target\n"
"addl $12, %esp\n"
".cfi_adjust_cfa_offset -12\n"
"jmp *%eax\n"
".cfi_endproc\n"
".size dlsym, .-dlsym\n");
#endif
#endif
#endif
@@ -131,7 +131,7 @@ struct VkTable {
#include <string>
namespace lsfg { class Engine; }
namespace winfg { class Engine; }
namespace ddfg { class Engine; }
static constexpr uint32_t MAX_FRAMES_IN_FLIGHT = 2;
@@ -280,7 +280,7 @@ public:
static constexpr int32_t kFgCaptureGame = -1;
void setFrameGenTuning(float flowScale, float refreshHz, int32_t captureHeight = 0);
void setFrameGenEngine(int kind);
void setWinFgTuning(int model, int perfPreset);
void setDdFgTuning(int model, int perfPreset);
private:
struct WinTex {
@@ -468,12 +468,12 @@ private:
uint64_t fgSourceFrames_ = 0;
std::string lsfgCachePath_;
bool lsfgEngineTried_ = false;
std::unique_ptr<winfg::Engine> winfgEngine_;
bool winfgEngineTried_ = false;
std::unique_ptr<ddfg::Engine> ddfgEngine_;
bool ddfgEngineTried_ = false;
std::atomic<int> fgEngineKind_{0};
std::atomic<int> fgModel_{4};
std::atomic<int> fgPerfPreset_{1};
bool ensureWinFgEngine();
bool ensureDdFgEngine();
bool fgCapsOk() const;
uint32_t syncSlot(uint32_t k) const { return currentFrame * kMaxPresentsPerFrame + k; }
@@ -6,7 +6,7 @@
#include <fstream>
#include <sstream>
namespace winfg {
namespace ddfg {
struct Config {
bool enabled = false;
@@ -213,48 +213,48 @@ static inline void apply_toml(Config& c, const std::string& path) {
}
static inline std::string conf_path() {
if (const char* p = std::getenv("WIN_FG_CONF")) return p;
if (const char* p = std::getenv("DD_FG_CONF")) return p;
const char* home = std::getenv("HOME");
return home ? std::string(home) + "/.config/win-fg/conf.toml" : std::string();
return home ? std::string(home) + "/.config/dd-fg/conf.toml" : std::string();
}
static inline std::string capture_root(const Config& c) {
if (!c.capDir.empty()) return c.capDir;
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/winfg-capture";
return std::string("/tmp/winfg-capture");
if (const char* home = std::getenv("HOME")) return std::string(home) + "/.cache/ddfg-capture";
return std::string("/tmp/ddfg-capture");
}
static inline Config load_config() {
Config c;
c.enabled = envi("WIN_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("WIN_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("WIN_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("WIN_FG_MODEL", c.model);
c.multiplier = envi("WIN_FG_MULT", c.multiplier);
c.extraImages = envi("WIN_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("WIN_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("WIN_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("WIN_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("WIN_FG_FR_ITERS", c.frIters);
c.frLambda = envf("WIN_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("WIN_FG_FR_DT", c.frDt);
c.frEdge = envf("WIN_FG_FR_EDGE", c.frEdge);
c.frEps = envf("WIN_FG_FR_EPS", c.frEps);
c.flowScale = envf("WIN_FG_FLOWSCALE", c.flowScale);
c.beta = envf("WIN_FG_BETA", c.beta);
c.lambda = envf("WIN_FG_LAMBDA", c.lambda);
c.epsilon = envf("WIN_FG_EPSILON", c.epsilon);
c.photoScale = envf("WIN_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("WIN_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("WIN_FG_CAPTURE_W", c.captureW);
c.captureH = envi("WIN_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("WIN_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("WIN_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("WIN_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("WIN_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("WIN_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("WIN_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("WIN_FG_HUD_RECT")) {
c.enabled = envi("DD_FG_ENABLE", 0) != 0;
if (const char* d = std::getenv("DD_FG_DEBUG")) c.debug = parse_bool(d, c.debug);
if (const char* pc = std::getenv("DD_FG_PACING")) c.pacing = parse_bool(pc, c.pacing);
c.model = envi("DD_FG_MODEL", c.model);
c.multiplier = envi("DD_FG_MULT", c.multiplier);
c.extraImages = envi("DD_FG_EXTRA_IMAGES", c.extraImages);
c.perfPreset = envi("DD_FG_PERF_PRESET", c.perfPreset);
if (const char* g = std::getenv("DD_FG_GM")) c.gmMode = parse_tristate(g, c.gmMode);
if (const char* r = std::getenv("DD_FG_FLOWREG")) c.frMode = parse_tristate(r, c.frMode);
c.frIters = envi("DD_FG_FR_ITERS", c.frIters);
c.frLambda = envf("DD_FG_FR_LAMBDA", c.frLambda);
c.frDt = envf("DD_FG_FR_DT", c.frDt);
c.frEdge = envf("DD_FG_FR_EDGE", c.frEdge);
c.frEps = envf("DD_FG_FR_EPS", c.frEps);
c.flowScale = envf("DD_FG_FLOWSCALE", c.flowScale);
c.beta = envf("DD_FG_BETA", c.beta);
c.lambda = envf("DD_FG_LAMBDA", c.lambda);
c.epsilon = envf("DD_FG_EPSILON", c.epsilon);
c.photoScale = envf("DD_FG_PHOTOSCALE", c.photoScale);
if (const char* cc = std::getenv("DD_FG_CAPTURE")) c.capture = parse_bool(cc, c.capture);
c.captureW = envi("DD_FG_CAPTURE_W", c.captureW);
c.captureH = envi("DD_FG_CAPTURE_H", c.captureH);
if (const char* cm = std::getenv("DD_FG_CAPTURE_MODE")) c.capMode = parse_capmode(cm, c.capMode);
c.capPatches = envi("DD_FG_CAPTURE_PATCHES", c.capPatches);
c.capPatchSize = envi("DD_FG_CAPTURE_PATCH", c.capPatchSize);
c.capMotion = envf("DD_FG_CAPTURE_MOTION", c.capMotion);
c.capShardMB = envi("DD_FG_CAPTURE_SHARD_MB", c.capShardMB);
if (const char* cd = std::getenv("DD_FG_CAPTURE_DIR")) c.capDir = cd;
if (const char* h = std::getenv("DD_FG_HUD_RECT")) {
float r[4] = {0,0,0,0}; int n = 0;
std::string s = h; size_t start = 0;
for (; n < 4; ++n) {
@@ -266,8 +266,8 @@ static inline Config load_config() {
c.hudX0 = r[0]; c.hudY0 = r[1]; c.hudX1 = r[2]; c.hudY1 = r[3];
}
const char* home = std::getenv("HOME");
if (home) apply_toml(c, std::string(home) + "/.config/win-fg/conf.toml");
if (const char* p = std::getenv("WIN_FG_CONF")) apply_toml(c, p);
if (home) apply_toml(c, std::string(home) + "/.config/dd-fg/conf.toml");
if (const char* p = std::getenv("DD_FG_CONF")) apply_toml(c, p);
c.sanitize();
return c;
}
@@ -1,10 +1,10 @@
#include "winfg_engine.h"
#include "winfg_vkd.h"
#include "ddfg_engine.h"
#include "ddfg_vkd.h"
#include "log.hpp"
#include <algorithm>
#include <cstring>
namespace winfg {
namespace ddfg {
namespace {
float alphaFor(uint32_t k, uint32_t n) {
@@ -31,18 +31,18 @@ Engine::~Engine() { destroyRing(); fg_.destroy(); }
bool Engine::init(const VkTable& table, VkPhysicalDevice phys, VkDevice dev,
uint32_t queueFamily, VkQueue queue) {
if (!winfgVkdInit(table, dd_, id_)) {
WFG_LOGE("native: dispatch incomplete; frame generation unavailable");
if (!ddfgVkdInit(table, dd_, id_)) {
DDFG_LOGE("native: dispatch incomplete; frame generation unavailable");
return false;
}
phys_ = phys; dev_ = dev; qf_ = queueFamily; queue_ = queue;
id_.GetPhysicalDeviceMemoryProperties(phys_, &memProps_);
if (!fg_.init(&dd_, &id_, phys_, dev_, qf_, queue_)) {
WFG_LOGE("native: FrameGen::init failed");
DDFG_LOGE("native: FrameGen::init failed");
unavailable_ = true;
return false;
}
WFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
DDFG_LOGI("native: chain ready (10 pipelines, embedded shaders)");
return true;
}
@@ -61,7 +61,7 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
if (!fg_.onResize(VkExtent2D{w, h}, fmt, false)) {
WFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
DDFG_LOGE("native: onResize %ux%u failed; frame generation unavailable", w, h);
unavailable_ = true;
return false;
}
@@ -71,11 +71,13 @@ bool Engine::prepare(uint32_t w, uint32_t h, VkFormat fmt) {
}
builtW_ = w; builtH_ = h; builtFmt_ = fmt;
history_ = 0; curr_ = 0; frames_ = 0;
WFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
DDFG_LOGI("native: built %ux%u fmt=%d model=%d preset=%d flow=%.2f",
w, h, (int)fmt, cfg_.model, cfg_.perfPreset, (double)cfg_.flowScale);
return true;
}
static_assert(kMaxGenerations <= (uint32_t)FrameGen::kSynthSlots, "each generation needs its own synth slot");
uint32_t Engine::plan(uint32_t capacity) const {
if (!valid() || history_ < 2) return 0;
const uint32_t want = (uint32_t)std::max(cfg_.multiplier - 1, 0);
@@ -131,7 +133,8 @@ void Engine::generateInto(VkCommandBuffer cmd, uint32_t generation, uint32_t cou
dd_.CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &toWrite);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_);
fg_.record(cmd, prev.view, curr.view, targetView, alphaFor(generation, count), gmSlot_,
generation, generation > 0);
if (generation + 1 == count) gmSlot_ = (gmSlot_ + 1) % (uint32_t)FrameGen::kGmSlots;
(void)w; (void)h;
@@ -6,7 +6,7 @@
struct VkTable;
namespace winfg {
namespace ddfg {
constexpr uint32_t kMaxGenerations = 3;
@@ -1,14 +1,14 @@
#include "winfg_vkd.h"
#include "ddfg_vkd.h"
#include "../VulkanRendererContext.h"
#include <android/log.h>
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id) {
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id) {
bool ok = true;
#define COPY(dst, fn) \
do { \
dst.fn = table.fn; \
if (!dst.fn) { \
__android_log_print(ANDROID_LOG_ERROR, "WinFgVkd", \
__android_log_print(ANDROID_LOG_ERROR, "DdFgVkd", \
"entry point vk" #fn " did not resolve"); \
ok = false; \
} \
@@ -0,0 +1,4 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool ddfgVkdInit(const VkTable& table, ddfg::DeviceDispatch& dd, ddfg::InstanceDispatch& id);
@@ -1,7 +1,7 @@
#pragma once
#include <cstdint>
#include <cstddef>
namespace winfg { namespace embedded {
namespace ddfg { namespace embedded {
static const uint32_t OF3_LUMA_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000054u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00030005u,0x00000030u,0x00007675u,0x00030005u,0x0000003bu,0x00626772u,0x00040005u,0x0000003fu,0x63725375u,0x00000000u,0x00030005u,0x00000047u,0x00005966u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x0000003fu,0x00000021u,0x00000020u,0x00040047u,0x0000003fu,0x00000022u,0x00000000u,0x00040047u,0x00000053u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x0004002bu,0x00000013u,0x00000033u,0x3f000000u,0x00040017u,0x00000039u,0x00000013u,0x00000003u,0x00040020u,0x0000003au,0x00000007u,0x00000039u,0x00090019u,0x0000003cu,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x0000003du,0x0000003cu,0x00040020u,0x0000003eu,0x00000000u,0x0000003du,0x0004003bu,0x0000003eu,0x0000003fu,0x00000000u,0x0004002bu,0x00000013u,0x00000042u,0x00000000u,0x00040017u,0x00000043u,0x00000013u,0x00000004u,0x00040020u,0x00000046u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000049u,0x3e59b3d0u,0x0004002bu,0x00000013u,0x0000004au,0x3f371759u,0x0004002bu,0x00000013u,0x0000004bu,0x3d93dd98u,0x0006002cu,0x00000039u,0x0000004cu,0x00000049u,0x0000004au,0x0000004bu,0x0004002bu,0x0000000au,0x00000052u,0x00000010u,0x0006002cu,0x0000000bu,0x00000053u,0x00000052u,0x00000052u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000003au,0x0000003bu,0x00000007u,0x0004003bu,0x00000046u,0x00000047u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000007u,0x00000031u,0x00000009u,0x0004006fu,0x0000002eu,0x00000032u,0x00000031u,0x00050050u,0x0000002eu,0x00000034u,0x00000033u,0x00000033u,0x00050081u,0x0000002eu,0x00000035u,0x00000032u,0x00000034u,0x0004003du,0x00000007u,0x00000036u,0x00000012u,0x0004006fu,0x0000002eu,0x00000037u,0x00000036u,0x00050088u,0x0000002eu,0x00000038u,0x0Line truncated
static const size_t OF3_LUMA_LEN = 481;
static const uint32_t OF3_DOWNSAMPLE_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x0000008au,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000000du,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00030005u,0x00000009u,0x00646967u,0x00080005u,0x0000000du,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000016u,0x74734475u,0x00000000u,0x00040005u,0x00000030u,0x44637273u,0x00006d69u,0x00040005u,0x00000034u,0x63725375u,0x00000000u,0x00040005u,0x0000003au,0x65736162u,0x00000000u,0x00030005u,0x00000044u,0x00000068u,0x00030005u,0x0000004au,0x00000061u,0x00030005u,0x00000059u,0x00000062u,0x00030005u,0x00000065u,0x00000063u,0x00030005u,0x00000071u,0x00000064u,0x00040047u,0x0000000du,0x0000000bu,0x0000001cu,0x00030047u,0x00000016u,0x00000019u,0x00040047u,0x00000016u,0x00000021u,0x00000030u,0x00040047u,0x00000016u,0x00000022u,0x00000000u,0x00040047u,0x00000034u,0x00000021u,0x00000020u,0x00040047u,0x00000034u,0x00000022u,0x00000000u,0x00040047u,0x00000089u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00040015u,0x0000000au,0x00000020u,0x00000000u,0x00040017u,0x0000000bu,0x0000000au,0x00000003u,0x00040020u,0x0000000cu,0x00000001u,0x0000000bu,0x0004003bu,0x0000000cu,0x0000000du,0x00000001u,0x00040017u,0x0000000eu,0x0000000au,0x00000002u,0x00030016u,0x00000013u,0x00000020u,0x00090019u,0x00000014u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000002u,0x00000003u,0x00040020u,0x00000015u,0x00000000u,0x00000014u,0x0004003bu,0x00000015u,0x00000016u,0x00000000u,0x00020014u,0x00000019u,0x0004002bu,0x0000000au,0x0000001au,0x00000000u,0x00040020u,0x0000001bu,0x00000007u,0x00000006u,0x0004002bu,0x0000000au,0x00000024u,0x00000001u,0x00040017u,0x0000002eu,0x00000013u,0x00000002u,0x00040020u,0x0000002fu,0x00000007u,0x0000002eu,0x00090019u,0x00000031u,0x00000013u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000032u,0x00000031u,0x00040020u,0x00000033u,0x00000000u,0x00000032u,0x0004003bu,0x00000033u,0x00000034u,0x00000000u,0x0004002bu,0x00000006u,0x00000036u,0x00000000u,0x0004002bu,0x00000013u,0x0000003du,0x40000000u,0x0004002bu,0x00000013u,0x0000003fu,0x3f800000u,0x0004002bu,0x00000013u,0x00000045u,0x3f000000u,0x00040020u,0x00000049u,0x00000007u,0x00000013u,0x0004002bu,0x00000013u,0x00000055u,0x00000000u,0x00040017u,0x00000056u,0x00000013u,0x00000004u,0x0004002bu,0x00000013u,0x00000085u,0x3e800000u,0x0004002bu,0x0000000au,0x00000088u,0x00000010u,0x0006002cu,0x0000000bu,0x00000089u,0x00000088u,0x00000088u,0x00000024u,0x00050036u,0x00000002u,0x00000004u,0x00000000u,0x00000003u,0x000200f8u,0x00000005u,0x0004003bu,0x00000008u,0x00000009u,0x00000007u,0x0004003bu,0x00000008u,0x00000012u,0x00000007u,0x0004003bu,0x0000002fu,0x00000030u,0x00000007u,0x0004003bu,0x0000002fu,0x0000003au,0x00000007u,0x0004003bu,0x0000002fu,0x00000044u,0x00000007u,0x0004003bu,0x00000049u,0x0000004au,0x00000007u,0x0004003bu,0x00000049u,0x00000059u,0x00000007u,0x0004003bu,0x00000049u,0x00000065u,0x00000007u,0x0004003bu,0x00000049u,0x00000071u,0x00000007u,0x0004003du,0x0000000bu,0x0000000fu,0x0000000du,0x0007004fu,0x0000000eu,0x00000010u,0x0000000fu,0x0000000fu,0x00000000u,0x00000001u,0x0004007cu,0x00000007u,0x00000011u,0x00000010u,0x0003003eu,0x00000009u,0x00000011u,0x0004003du,0x00000014u,0x00000017u,0x00000016u,0x00040068u,0x00000007u,0x00000018u,0x00000017u,0x0003003eu,0x00000012u,0x00000018u,0x00050041u,0x0000001bu,0x0000001cu,0x00000009u,0x0000001au,0x0004003du,0x00000006u,0x0000001du,0x0000001cu,0x00050041u,0x0000001bu,0x0000001eu,0x00000012u,0x0000001au,0x0004003du,0x00000006u,0x0000001fu,0x0000001eu,0x000500afu,0x00000019u,0x00000020u,0x0000001du,0x0000001fu,0x000400a8u,0x00000019u,0x00000021u,0x00000020u,0x000300f7u,0x00000023u,0x00000000u,0x000400fau,0x00000021u,0x00000022u,0x00000023u,0x000200f8u,0x00000022u,0x00050041u,0x0000001bu,0x00000025u,0x00000009u,0x00000024u,0x0004003du,0x00000006u,0x00000026u,0x00000025u,0x00050041u,0x0000001bu,0x00000027u,0x00000012u,0x00000024u,0x0004003du,0x00000006u,0x00000028u,0x00000027u,0x000500afu,0x00000019u,0x00000029u,0x00000026u,0x00000028u,0x000200f9u,0x00000023u,0x000200f8u,0x00000023u,0x000700f5u,0x00000019u,0x0000002au,0x00000020u,0x00000005u,0x00000029u,0x00000022u,0x000300f7u,0x0000002cu,0x00000000u,0x000400fau,0x0000002au,0x0000002bu,0x0000002cu,0x000200f8u,0x0000002bu,0x000100fdu,0x000200f8u,0x0000002cu,0x0004003du,0x00000032u,0x00000035u,0x00000034u,0x00040064u,0x00000031u,0x00000037u,0x00000035u,0x0005006Line truncated
@@ -20,10 +20,10 @@ static const uint32_t OF3_GM_PREWARP_SPV[] = {0x07230203u,0x00010300u,0x0008000b
static const size_t OF3_GM_PREWARP_LEN = 706;
static const uint32_t OF3_FLOWREG_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000144u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x00000037u,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00060005u,0x0000000eu,0x63746566u,0x76285568u,0x763b3269u,0x003b3269u,0x00030005u,0x0000000cu,0x00000070u,0x00030005u,0x0000000du,0x006d6964u,0x00060005u,0x00000013u,0x63746566u,0x76284c68u,0x763b3269u,0x003b3269u,0x00030005u,0x00000011u,0x00000070u,0x00030005u,0x00000012u,0x006d6964u,0x00040005u,0x00000018u,0x6f6c4675u,0x006e4977u,0x00040005u,0x00000026u,0x6d754c75u,0x00000061u,0x00030005u,0x00000034u,0x00646967u,0x00080005u,0x00000037u,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x0000003cu,0x006d6964u,0x00030005u,0x00000055u,0x00007464u,0x00030005u,0x00000056u,0x00000055u,0x00050006u,0x00000056u,0x00000000u,0x61726170u,0x0000736du,0x00030005u,0x00000058u,0x00000000u,0x00040005u,0x0000005cu,0x626d616cu,0x00006164u,0x00050005u,0x0000005fu,0x65676465u,0x68706c41u,0x00000061u,0x00040005u,0x00000063u,0x54737065u,0x00000056u,0x00030005u,0x00000068u,0x00004375u,0x00040005u,0x00000069u,0x61726170u,0x0000006du,0x00040005u,0x0000006bu,0x61726170u,0x0000006du,0x00030005u,0x0000006eu,0x00004c75u,0x00040005u,0x00000073u,0x61726170u,0x0000006du,0x00040005u,0x00000074u,0x61726170u,0x0000006du,0x00030005u,0x00000077u,0x00005275u,0x00040005u,0x0000007bu,0x61726170u,0x0000006du,0x00040005u,0x0000007cu,0x61726170u,0x0000006du,0x00030005u,0x0000007fu,0x00005575u,0x00040005u,0x00000083u,0x61726170u,0x0000006du,0x00040005u,0x00000084u,0x61726170u,0x0000006du,0x00030005u,0x00000087u,0x00004475u,0x00040005u,0x0000008bu,0x61726170u,0x0000006du,0x00040005u,0x0000008cu,0x61726170u,0x0000006du,0x00040005u,0x0000008fu,0x6d69646cu,0x00000000u,0x00030005u,0x00000093u,0x00004c6cu,0x00040005u,0x00000096u,0x61726170u,0x0000006du,0x00040005u,0x00000097u,0x61726170u,0x0000006du,0x00030005u,0x0000009au,0x0000526cu,0x00040005u,0x0000009du,0x61726170u,0x0000006du,0x00040005u,0x0000009eu,0x61726170u,0x0000006du,0x00030005u,0x000000a1u,0x0000556cu,0x00040005u,0x000000a4u,0x61726170u,0x0000006du,0x00040005u,0x000000a5u,0x61726170u,0x0000006du,0x00030005u,0x000000a8u,0x0000446cu,0x00040005u,0x000000abu,0x61726170u,0x0000006du,0x00040005u,0x000000acu,0x61726170u,0x0000006du,0x00040005u,0x000000afu,0x67616d67u,0x00000000u,0x00030005u,0x000000bbu,0x00000067u,0x00030005u,0x000000c4u,0x00004c64u,0x00030005u,0x000000c8u,0x00005264u,0x00030005u,0x000000ccu,0x00005564u,0x00030005u,0x000000d0u,0x00004464u,0x00030005u,0x000000d4u,0x00003265u,0x00030005u,0x000000d8u,0x00004c77u,0x00030005u,0x000000e1u,0x00005277u,0x00030005u,0x000000eau,0x00005577u,0x00030005u,0x000000f3u,0x00004477u,0x00040005u,0x000000fcu,0x576d7573u,0x00000000u,0x00040005u,0x00000104u,0x576d7573u,0x00000055u,0x00040005u,0x00000114u,0x66694475u,0x00000066u,0x00030005u,0x00000120u,0x00003066u,0x00050005u,0x00000121u,0x6f6c4675u,0x73624f77u,0x00000000u,0x00040005u,0x0000012au,0x74656874u,0x00000061u,0x00030005u,0x0000012eu,0x00000072u,0x00040005u,0x00000132u,0x77654e75u,0x00000000u,0x00050005u,0x0000013eu,0x6f6c4675u,0x74754f77u,0x00000000u,0x00040047u,0x00000018u,0x00000021u,0x00000020u,0x00040047u,0x00000018u,0x00000022u,0x00000000u,0x00040047u,0x00000026u,0x00000021u,0x00000022u,0x00040047u,0x00000026u,0x00000022u,0x00000000u,0x00040047u,0x00000037u,0x0000000bu,0x0000001cu,0x00030047u,0x00000056u,0x00000002u,0x00050048u,0x00000056u,0x00000000u,0x00000023u,0x00000000u,0x00040047u,0x00000058u,0x00000021u,0x00000000u,0x00040047u,0x00000058u,0x00000022u,0x00000000u,0x00040047u,0x00000121u,0x00000021u,0x00000021u,0x00040047u,0x00000121u,0x00000022u,0x00000000u,0x00030047u,0x0000013eu,0x00000019u,0x00040047u,0x0000013eu,0x00000021u,0x00000030u,0x00040047u,0x0000013eu,0x00000022u,0x00000000u,0x00040047u,0x00000143u,0x0000000bu,0x00000019u,0x00020013u,0x00000002u,0x00030021u,0x00000003u,0x00000002u,0x00040015u,0x00000006u,0x00000020u,0x00000001u,0x00040017u,0x00000007u,0x00000006u,0x00000002u,0x00040020u,0x00000008u,0x00000007u,0x00000007u,0x00030016u,0x00000009u,0x00000020u,0x00040017u,0x0000000au,0x00000009u,0x00000004u,0x00050021u,0x0000000bu,0x0000000au,0x00000008u,0x00000008u,0x00050021u,0x00000010u,0x00000009u,0x00000008u,0x00000008u,0x00090019u,0x00000015u,0x00000009u,0x00000001u,0x00000000u,0x00000000u,0x00000000u,0x00000001u,0x00000000u,0x0003001bu,0x00000016u,0x00000015u,0x00040020u,0x00000017u,0x00000000u,0x00000016u,0x0004003bu,0x00000017u,0x00000018u,0x00000000u,0x0004002bu,0x00000006u,0x0000001bu,0x00000000u,0x0005002cu,0x00000007u,0x0000001cu,0x0000001bu,0x0000001bu,0x0004002bu,0x00000006u,0x0000001eu,0x00000001u,0x0004003bu,0x00000017u,0x00000026u,0x00000000u,Line truncated
static const size_t OF3_FLOWREG_LEN = 1936;
static const uint32_t WFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0xLine truncated
static const size_t WFG_SYNTH_LEN = 3082;
static const uint32_t DDFG_SYNTH_SPV[] = {0x07230203u,0x00010300u,0x0008000bu,0x00000208u,0x00000000u,0x00020011u,0x00000001u,0x00020011u,0x00000032u,0x0006000bu,0x00000001u,0x4c534c47u,0x6474732eu,0x3035342eu,0x00000000u,0x0003000eu,0x00000000u,0x00000001u,0x0006000fu,0x00000005u,0x00000004u,0x6e69616du,0x00000000u,0x0000001cu,0x00060010u,0x00000004u,0x00000011u,0x00000010u,0x00000010u,0x00000001u,0x00030003u,0x00000002u,0x000001c2u,0x00040005u,0x00000004u,0x6e69616du,0x00000000u,0x00050005u,0x0000000bu,0x616d756cu,0x33667628u,0x0000003bu,0x00030005u,0x0000000au,0x00000063u,0x00030005u,0x00000018u,0x00646967u,0x00080005u,0x0000001cu,0x475f6c67u,0x61626f6cu,0x766e496cu,0x7461636fu,0x496e6f69u,0x00000044u,0x00030005u,0x00000021u,0x006d6964u,0x00040005u,0x00000024u,0x74754f75u,0x00000000u,0x00040005u,0x0000003eu,0x44766e69u,0x00006d69u,0x00030005u,0x00000044u,0x00007675u,0x00030005u,0x0000004du,0x00000055u,0x00060006u,0x0000004du,0x00000000u,0x776f6c66u,0x6c616353u,0x00000065u,0x00050006u,0x0000004du,0x00000001u,0x68706c61u,0x00000061u,0x00050006u,0x0000004du,0x00000002u,0x61746562u,0x00000000u,0x00050006u,0x0000004du,0x00000003u,0x626d616cu,0x00006164u,0x00050006u,0x0000004du,0x00000004u,0x69737065u,0x006e6f6cu,0x00060006u,0x0000004du,0x00000005u,0x746f6870u,0x6163536fu,0x0000656cu,0x00050006u,0x0000004du,0x00000006u,0x30646170u,0x00000000u,0x00050006u,0x0000004du,0x00000007u,0x31646170u,0x00000000u,0x00050006u,0x0000004du,0x00000008u,0x52647568u,0x00746365u,0x00030005u,0x0000004fu,0x00000000u,0x00040005u,0x0000008cu,0x72754375u,0x00000072u,0x00030005u,0x00000098u,0x00676264u,0x00030005u,0x0000009fu,0x00007366u,0x00040005u,0x000000a9u,0x64774273u,0x00000000u,0x00050005u,0x000000aau,0x6f6c4675u,0x64774277u,0x00000000u,0x00040005u,0x000000aeu,0x64774673u,0x00000000u,0x00050005u,0x000000afu,0x6f6c4675u,0x64774677u,0x00000000u,0x00030005u,0x000000b3u,0x00313046u,0x00030005u,0x000000bau,0x00303146u,0x00040005u,0x000000c1u,0x31306663u,0x00000000u,0x00040005u,0x000000c5u,0x30316663u,0x00000000u,0x00030005u,0x000000c9u,0x0031306du,0x00030005u,0x000000d5u,0x0030316du,0x00030005u,0x000000e0u,0x00004170u,0x00030005u,0x000000e8u,0x00004270u,0x00030005u,0x000000f0u,0x00003043u,0x00040005u,0x000000f1u,0x65725075u,0x00000076u,0x00030005u,0x000000f6u,0x00003143u,0x00030005u,0x000000fbu,0x00004165u,0x00040005u,0x000000fcu,0x61726170u,0x0000006du,0x00040005u,0x0000010cu,0x61726170u,0x0000006du,0x00030005u,0x00000111u,0x00004265u,0x00040005u,0x00000112u,0x61726170u,0x0000006du,0x00040005u,0x00000122u,0x61726170u,0x0000006du,0x00040005u,0x00000127u,0x76556266u,0x00000000u,0x00040005u,0x0000012bu,0x65526266u,0x00646973u,0x00040005u,0x00000138u,0x736e6f63u,0x00747369u,0x00030005u,0x00000149u,0x0000305au,0x00030005u,0x00000157u,0x0000315au,0x00040005u,0x00000163u,0x30756174u,0x00000000u,0x00040005u,0x00000167u,0x31756174u,0x00000000u,0x00040005u,0x0000016au,0x78616d7au,0x00000000u,0x00030005u,0x0000016eu,0x00003057u,0x00030005u,0x00000179u,0x00003157u,0x00040005u,0x00000183u,0x6d757357u,0x00000000u,0x00040005u,0x00000187u,0x70726177u,0x00006465u,0x00050005u,0x000001a0u,0x736f7263u,0x64616673u,0x00000065u,0x00050005u,0x000001adu,0x746f6870u,0x7272456fu,0x00000000u,0x00050005u,0x000001b1u,0x746f6870u,0x7572546fu,0x00007473u,0x00040005u,0x000001b7u,0x73757274u,0x00000074u,0x00040005u,0x000001c3u,0x76657270u,0x00000043u,0x00040005u,0x000001c8u,0x72727563u,0x00000043u,0x00050005u,0x000001cdu,0x746e6f63u,0x44746e65u,0x00666669u,0x00050005u,0x000001d2u,0x72746e75u,0x65747375u,0x00000064u,0x00040005u,0x000001d7u,0x44676962u,0x00666669u,0x00050005u,0x000001dcu,0x70616e73u,0x75436f54u,0x00007272u,0x00040005u,0x000001e5u,0x6374756fu,0x00000000u,0x00030005u,0x000001eeu,0x0067616du,0x00030005u,0x000001f2u,0x0000006eu,0x00030005u,0x000001f6u,0x00746173u,0x00040047u,0x0000001cu,0x0000000bu,0x0000001cu,0x00030047u,0x00000024u,0x00000019u,0x00040047u,0x00000024u,0x00000021u,0x00000030u,0x00040047u,0x00000024u,0x00000022u,0x00000000u,0x00030047u,0x0000004du,0x00000002u,0x00050048u,0x0000004du,0x00000000u,0x00000023u,0x00000000u,0x00050048u,0x0000004du,0x00000001u,0x00000023u,0x00000004u,0x00050048u,0x0000004du,0x00000002u,0x00000023u,0x00000008u,0x00050048u,0x0000004du,0x00000003u,0x00000023u,0x0000000cu,0x00050048u,0x0000004du,0x00000004u,0x00000023u,0x00000010u,0x00050048u,0x0000004du,0x00000005u,0x00000023u,0x00000014u,0x00050048u,0x0000004du,0x00000006u,0x00000023u,0x00000018u,0x00050048u,0x0000004du,0x00000007u,0x00000023u,0x0000001cu,0x00050048u,0x0000004du,0x00000008u,0x00000023u,0x00000020u,0x00040047u,0x0000004fu,0x00000021u,0x00000000u,0x00040047u,0x0000004fu,0x00000022u,0x00000000u,0x00040047u,0x0000008cu,0x00000021u,0x00000021u,0x00040047u,0x0000008cu,0x00000022u,0x00000000u,0x00040047u,0x000000aau,0x00000021u,0x00000023u,0x00040047u,0x000000aau,0x00000022u,0x00000000u,0x00040047u,0x000000afu,0x00000021u,0x00000022u,0x00040047u,0x000000afu,0x00000022u,0x00000000u,0x00040047u,0x000000f1u,0x00000021u,0x00000020u,0x00040047u,0x000000f1u,0Line truncated
static const size_t DDFG_SYNTH_LEN = 3082;
struct Blob { const uint32_t* code; size_t words; };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, WFG_SYNTH, SHADER_COUNT };
enum Shader { OF3_LUMA, OF3_DOWNSAMPLE, OF3_FLOW, OF3_EXPAND, OF3_FLOW_M4, OF3_EXPAND_M4, OF3_GM_REDUCE, OF3_GM_PREWARP, OF3_FLOWREG, DDFG_SYNTH, SHADER_COUNT };
static inline Blob blob(Shader s) {
switch (s) {
case OF3_LUMA: return { OF3_LUMA_SPV, OF3_LUMA_LEN };
@@ -35,7 +35,7 @@ static inline Blob blob(Shader s) {
case OF3_GM_REDUCE: return { OF3_GM_REDUCE_SPV, OF3_GM_REDUCE_LEN };
case OF3_GM_PREWARP: return { OF3_GM_PREWARP_SPV, OF3_GM_PREWARP_LEN };
case OF3_FLOWREG: return { OF3_FLOWREG_SPV, OF3_FLOWREG_LEN };
case WFG_SYNTH: return { WFG_SYNTH_SPV, WFG_SYNTH_LEN };
case DDFG_SYNTH: return { DDFG_SYNTH_SPV, DDFG_SYNTH_LEN };
default: return { nullptr, 0 };
}
}
@@ -4,9 +4,9 @@
#include <cmath>
#include <cstdio>
namespace winfg {
namespace ddfg {
#define VKOK(x) do { if ((x) != VK_SUCCESS) { WFG_LOGE("vk fail: %s", #x); return false; } } while (0)
#define VKOK(x) do { if ((x) != VK_SUCCESS) { DDFG_LOGE("vk fail: %s", #x); return false; } } while (0)
uint32_t FrameGen::findMemType(uint32_t bits, VkMemoryPropertyFlags props) const {
for (uint32_t i = 0; i < memProps_.memoryTypeCount; ++i)
@@ -153,9 +153,9 @@ bool FrameGen::init(const DeviceDispatch* dd, const InstanceDispatch* id,
if (!makePipe(pGmReduce_, embedded::OF3_GM_REDUCE, unused)) return false;
if (!makePipe(pGmPrewarp_, embedded::OF3_GM_PREWARP, unused)) return false;
if (!makePipe(pFlowReg_, embedded::OF3_FLOWREG, unused)) return false;
if (!makePipe(pSynth_, embedded::WFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { WFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
WFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
if (!makePipe(pSynth_, embedded::DDFG_SYNTH, unused)) return false;
if (!makeGmBuffers()) { DDFG_LOGE("framegen: GM SSBO alloc failed - C1 disabled"); }
DDFG_LOGI("framegen init ok (queueFamily=%u, 10 pipelines, model=%d, C1 gm=%d, C2 flow_reg=%d iters=%d)",
queueFamily_, cfg_.model, cfg_.gmMode, cfg_.frMode, cfg_.frIters);
return true;
}
@@ -194,7 +194,7 @@ bool FrameGen::onResize(VkExtent2D extent, VkFormat , bool force) {
if (!makeImage(flowRegA_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
if (!makeImage(flowRegB_, levelExtent(extent, flowFinest_), VK_FORMAT_R16G16B16A16_SFLOAT, regUsage)) return false;
ready_ = true;
WFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
DDFG_LOGI("framegen resized to %ux%u (%d pyramid levels, kFlowFinest=%d perf_preset=%d)",
extent.width, extent.height, kLevels, flowFinest_, cfg_.perfPreset);
return true;
}
@@ -3,7 +3,7 @@
#include "config.hpp"
#include <vector>
namespace winfg {
namespace ddfg {
class FrameGen {
public:
@@ -12,7 +12,8 @@ public:
void configure(const Config& c);
bool onResize(VkExtent2D extent, VkFormat colorFormat, bool force = false);
void record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot = 0);
VkImageView outView, float alpha, uint32_t gmSlot = 0,
uint32_t synthSlot = 0, bool reuseFlow = false);
void destroy();
bool valid() const { return device_ != VK_NULL_HANDLE && ready_; }
const Config& config() const { return cfg_; }
@@ -25,6 +26,7 @@ public:
static constexpr int kGmSlots = 3;
static constexpr int kSynthSlots = 3;
static constexpr int kGmLevel = 3;
static constexpr int kGmThreads = 512;
static constexpr int kGmStride = 32;
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define DDFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "dd-fg", __VA_ARGS__)
#define DDFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "dd-fg", __VA_ARGS__)
#else
#include <cstdio>
#define DDFG_LOGI(...) do { std::fprintf(stderr, "[dd-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define DDFG_LOGE(...) do { std::fprintf(stderr, "[dd-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define DDFG_LOGD(cond, ...) do { if (cond) DDFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,31 +1,34 @@
namespace {
struct ScratchState {
VkBuffer synthUbo = VK_NULL_HANDLE; VkDeviceMemory synthUboMem = VK_NULL_HANDLE; void* synthUboPtr = nullptr;
VkBuffer synthUbo[ddfg::FrameGen::kSynthSlots]{};
VkDeviceMemory synthUboMem[ddfg::FrameGen::kSynthSlots]{};
void* synthUboPtr[ddfg::FrameGen::kSynthSlots]{};
std::vector<VkBuffer> flowUbo; std::vector<VkDeviceMemory> flowUboMem; std::vector<void*> flowUboPtr;
VkBuffer expandUbo = VK_NULL_HANDLE; VkDeviceMemory expandUboMem = VK_NULL_HANDLE; void* expandUboPtr = nullptr;
VkBuffer flowRegUbo = VK_NULL_HANDLE; VkDeviceMemory flowRegUboMem = VK_NULL_HANDLE; void* flowRegUboPtr = nullptr;
VkBuffer gmUbo[winfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[winfg::FrameGen::kGmSlots]{};
void* gmUboPtr[winfg::FrameGen::kGmSlots]{};
VkBuffer gmUbo[ddfg::FrameGen::kGmSlots]{};
VkDeviceMemory gmUboMem[ddfg::FrameGen::kGmSlots]{};
void* gmUboPtr[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sLumaCurr = VK_NULL_HANDLE, sLumaPrev = VK_NULL_HANDLE;
std::vector<VkDescriptorSet> sDownA, sDownB, sFlow;
VkDescriptorSet sExpand[winfg::FrameGen::kGmSlots]{};
VkDescriptorSet sExpand[ddfg::FrameGen::kGmSlots]{};
VkDescriptorSet sFlowReg0 = VK_NULL_HANDLE, sFlowRegAB = VK_NULL_HANDLE, sFlowRegBA = VK_NULL_HANDLE;
VkDescriptorSet sSynth = VK_NULL_HANDLE;
VkDescriptorSet sGmReduce[winfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[winfg::FrameGen::kGmSlots];
VkDescriptorSet sSynth[ddfg::FrameGen::kSynthSlots]{};
VkDescriptorSet sGmReduce[ddfg::FrameGen::kGmSlots]{};
std::vector<VkDescriptorSet> sGmPrewarp[ddfg::FrameGen::kGmSlots];
VkExtent2D builtFor{0,0};
bool built = false;
bool flowInited = false;
bool flowReady = false;
};
ScratchState g_scratch;
}
static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
static bool makeUbo(const ddfg::DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp,
VkDeviceSize size, VkBuffer& buf, VkDeviceMemory& mem, void*& ptr) {
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
@@ -41,21 +44,21 @@ static bool makeUbo(const winfg::DeviceDispatch* dd, VkDevice dev,
return dd->MapMemory(dev, mem, 0, size, 0, &ptr) == VK_SUCCESS;
}
static VkDescriptorSet allocSet(const winfg::DeviceDispatch* dd, VkDevice dev,
static VkDescriptorSet allocSet(const ddfg::DeviceDispatch* dd, VkDevice dev,
VkDescriptorPool pool, VkDescriptorSetLayout layout) {
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
ai.descriptorPool = pool; ai.descriptorSetCount = 1; ai.pSetLayouts = &layout;
VkDescriptorSet s = VK_NULL_HANDLE; dd->AllocateDescriptorSets(dev, &ai, &s); return s;
}
static void wImg(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wImg(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkDescriptorType type, VkImageView view, VkSampler samp, VkImageLayout layout) {
VkDescriptorImageInfo ii{}; ii.imageView = view; ii.sampler = samp; ii.imageLayout = layout;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w.dstSet = set; w.dstBinding = binding; w.descriptorCount = 1; w.descriptorType = type; w.pImageInfo = &ii;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wUbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -63,7 +66,7 @@ static void wUbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet
w.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; w.pBufferInfo = &bi;
dd->UpdateDescriptorSets(dev, 1, &w, 0, nullptr);
}
static void wSsbo(const winfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
static void wSsbo(const ddfg::DeviceDispatch* dd, VkDevice dev, VkDescriptorSet set, uint32_t binding,
VkBuffer buf, VkDeviceSize range) {
VkDescriptorBufferInfo bi{}; bi.buffer = buf; bi.range = range;
VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
@@ -117,7 +120,7 @@ static void mulAff(const double A[3][3], const double B[3][3], double out[3][3])
out[2][0]=0.0; out[2][1]=0.0; out[2][2]=1.0;
}
namespace winfg {
namespace ddfg {
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
const VkPhysicalDeviceMemoryProperties& mp, VkDescriptorPool pool,
@@ -140,7 +143,7 @@ static void destroyScratch(const DeviceDispatch* dd, VkDevice dev, VkDescriptorP
b = VK_NULL_HANDLE; m = VK_NULL_HANDLE; p = nullptr;
};
auto& S = g_scratch;
freeUbo(S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) freeUbo(S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
freeUbo(S.expandUbo, S.expandUboMem, S.expandUboPtr);
freeUbo(S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
for (size_t i = 0; i < S.flowUbo.size(); ++i) freeUbo(S.flowUbo[i], S.flowUboMem[i], S.flowUboPtr[i]);
@@ -154,7 +157,7 @@ void FrameGen::configure(const Config& c) {
int want = flowFinestForPreset(cfg_.perfPreset);
const int maxFinest = kLevels - 2;
if (want > maxFinest) {
WFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
DDFG_LOGE("perf-preset=%d wants kFlowFinest=%d but only %d pyramid levels exist "
"(need >=2 solved) - clamping to %d", cfg_.perfPreset, want, kLevels, maxFinest);
want = maxFinest;
}
@@ -163,7 +166,7 @@ void FrameGen::configure(const Config& c) {
const int prevFinest = flowFinest_;
flowFinest_ = want;
const bool live = (flowFinest_ != prevFinest) && ready_;
WFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
DDFG_LOGI("perf-preset=%d -> kFlowFinest=%d (was %d) live-rebuild=%d ready=%d",
cfg_.perfPreset, flowFinest_, prevFinest, live ? 1 : 0, ready_ ? 1 : 0);
if (!live) return;
@@ -174,12 +177,12 @@ void FrameGen::configure(const Config& c) {
dd_->DeviceWaitIdle(device_);
destroyScratch(dd_, device_, descPool_);
if (!onResize(extent_, VK_FORMAT_UNDEFINED, true))
WFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
DDFG_LOGE("perf-preset live rebuild: onResize FAILED at %ux%u - FG disabled until next resize",
extent_.width, extent_.height);
for (int i = 0; i < 6; ++i) gmRun_[i] = 0.0;
for (int s = 0; s < kGmSlots; ++s) { gmSlotValid_[s] = false; for (int i = 0; i < 6; ++i) gmLinAt_[s][i] = 0.0; }
gmHaveEst_ = false; gmEngaged_ = false; gmStableCount_ = 0; gmLastTransPx_ = 0.0f;
WFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
DDFG_LOGI("perf-preset live rebuild DONE (kFlowFinest=%d, flow predictor reset)", flowFinest_);
}
bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
@@ -249,9 +252,10 @@ bool FrameGen::updateGlobalMotion(uint32_t gmSlot, VkExtent2D fullExtent) {
}
void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView currView,
VkImageView outView, float alpha, uint32_t gmSlot) {
VkImageView outView, float alpha, uint32_t gmSlot, uint32_t synthSlot, bool reuseFlow) {
if (!valid()) return;
if (gmSlot >= (uint32_t)kGmSlots) gmSlot = 0;
if (synthSlot >= (uint32_t)kSynthSlots) synthSlot = 0;
const bool m4 = (cfg_.model == 4);
const Pipe& flowP = m4 ? pFlowM4_ : pFlow_;
const Pipe& expandP = m4 ? pExpandM4_ : pExpand_;
@@ -263,35 +267,42 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
}
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
// Every generation of one real frame is recorded into the same command buffer, so the flow
// between prev and curr is computed once and each generation gets its own synth UBO and set.
if (reuseFlow && !g_scratch.flowReady) return;
if (!reuseFlow) {
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
if (g_scratch.gmUboPtr[gmSlot]) {
GMUBO g{};
g.lin[0]=(float)gmRun_[0]; g.lin[1]=(float)gmRun_[1]; g.lin[2]=(float)gmRun_[2]; g.lin[3]=(float)gmRun_[3];
g.linT[0]=(float)gmRun_[4]; g.linT[1]=(float)gmRun_[5]; g.linT[2]=gmEngaged?1.0f:0.0f; g.linT[3]=0.0f;
if (gmEngaged) {
g.app[0]=(float)gmRun_[0]; g.app[1]=(float)gmRun_[1]; g.app[2]=(float)gmRun_[2]; g.app[3]=(float)gmRun_[3];
g.appT[0]=(float)gmRun_[4]; g.appT[1]=(float)gmRun_[5];
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
std::memcpy(g_scratch.gmUboPtr[gmSlot], &g, sizeof(g));
}
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
{
static int lastEng = -1;
if (gmEngaged != (lastEng == 1) || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C1 global-motion: engaged=%d mode=%d stable=%d transPx=%.1f (model=%d)",
gmEngaged ? 1 : 0, cfg_.gmMode, gmStableCount_, gmLastTransPx_, cfg_.model);
lastEng = gmEngaged ? 1 : 0;
}
}
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
}
SynthUBO su{cfg_.flowScale, alpha, cfg_.beta, cfg_.lambda, cfg_.epsilon, cfg_.photoScale, 0, 0,
cfg_.hudX0, cfg_.hudY0, cfg_.hudX1, cfg_.hudY1};
if (g_scratch.synthUboPtr) std::memcpy(g_scratch.synthUboPtr, &su, sizeof(su));
if (g_scratch.synthUboPtr[synthSlot]) std::memcpy(g_scratch.synthUboPtr[synthSlot], &su, sizeof(su));
wImg(dd_, device_, g_scratch.sLumaCurr, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sLumaPrev, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, g_scratch.sSynth, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
const VkDescriptorSet synthSet = g_scratch.sSynth[synthSlot];
wImg(dd_, device_, synthSet, 32, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, prevView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 33, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, currView, linear_, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
wImg(dd_, device_, synthSet, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, outView, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_GENERAL);
auto disp = [&](const Pipe& p, VkDescriptorSet set, VkExtent2D e) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, p.pipeline);
@@ -300,106 +311,109 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
};
auto bar = [&](FrameGen::Img& i){ barrier(cmd, i.image, VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_GENERAL); };
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
if (!reuseFlow) {
for (auto& i : pyrA_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (auto& i : pyrB_) barrier(cmd, i.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
for (int l = flowFinest_; l < kLevels; ++l) barrier(cmd, pyrAs_[l].image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowExpB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegA_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
barrier(cmd, flowRegB_.image, VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_GENERAL);
{
VkClearColorValue zero{};
VkImageSubresourceRange rng{VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
VkImageMemoryBarrier b{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = 0; b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.image = flowLvl_[l].image; b.subresourceRange = rng;
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
dd_->CmdClearColorImage(cmd, flowLvl_[l].image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &zero, 1, &rng);
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; b.newLayout = VK_IMAGE_LAYOUT_GENERAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
}
disp(pLuma_, g_scratch.sLumaCurr, levelExtent(extent_, 0));
disp(pLuma_, g_scratch.sLumaPrev, levelExtent(extent_, 0));
bar(pyrA_[0]); bar(pyrB_[0]);
for (int l = 1; l < kLevels; ++l) {
disp(pDown_, g_scratch.sDownA[l - 1], levelExtent(extent_, l));
disp(pDown_, g_scratch.sDownB[l - 1], levelExtent(extent_, l));
bar(pyrA_[l]); bar(pyrB_[l]);
}
if (gmBuffersReady_ && g_scratch.sGmReduce[gmSlot]) {
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pGmReduce_.layout, 0, 1, &g_scratch.sGmReduce[gmSlot], 0, nullptr);
dd_->CmdDispatch(cmd, (uint32_t)(kGmThreads / 64), 1, 1);
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; mb.dstAccessMask = VK_ACCESS_HOST_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_HOST_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
for (int l = flowFinest_; l < kLevels; ++l) {
disp(pGmPrewarp_, g_scratch.sGmPrewarp[gmSlot][l], levelExtent(extent_, l));
bar(pyrAs_[l]);
}
for (int l = kLevels - 1; l >= flowFinest_; --l) {
disp(flowP, g_scratch.sFlow[l], levelExtent(extent_, l));
bar(flowLvl_[l]);
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
DDFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
const bool frEngaged = (cfg_.frMode != 2) && (cfg_.frIters > 0);
if (frEngaged) {
const int F = flowFinest_;
const VkExtent2D fe = levelExtent(extent_, F);
if (g_scratch.flowRegUboPtr) {
FlowRegUBO fu{cfg_.frDt, cfg_.frLambda, cfg_.frEdge, cfg_.frEps};
std::memcpy(g_scratch.flowRegUboPtr, &fu, sizeof(fu));
}
FrameGen::Img* last = nullptr;
for (int it = 0; it < cfg_.frIters; ++it) {
VkDescriptorSet set; FrameGen::Img* outImg;
if (it == 0) { set = g_scratch.sFlowReg0; outImg = &flowRegA_; }
else if (it % 2 == 1) { set = g_scratch.sFlowRegAB; outImg = &flowRegB_; }
else { set = g_scratch.sFlowRegBA; outImg = &flowRegA_; }
dd_->CmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.pipeline);
dd_->CmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pFlowReg_.layout, 0, 1, &set, 0, nullptr);
dd_->CmdDispatch(cmd, (fe.width + 15) / 16, (fe.height + 15) / 16, 1);
bar(*outImg);
last = outImg;
}
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
VkImageCopy region{};
region.srcSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.dstSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.extent = {fe.width, fe.height, 1};
dd_->CmdCopyImage(cmd, last->image, VK_IMAGE_LAYOUT_GENERAL,
flowLvl_[F].image, VK_IMAGE_LAYOUT_GENERAL, 1, &region);
VkMemoryBarrier mb2{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb2.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
mb2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
dd_->CmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, 1, &mb2, 0, nullptr, 0, nullptr);
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
g_scratch.flowReady = true;
}
{
static int lastFr = -1;
int fe = frEngaged ? 1 : 0;
if (fe != lastFr || (gmFrames_ % 300ull) == 0) {
WFG_LOGI("C2 flow-reg: engaged=%d mode=%d iters=%d lambda=%.2f dt=%.2f edge=%.1f eps=%.3f (model=%d)",
fe, cfg_.frMode, cfg_.frIters, cfg_.frLambda, cfg_.frDt, cfg_.frEdge, cfg_.frEps, cfg_.model);
lastFr = fe;
}
}
disp(expandP, g_scratch.sExpand[gmSlot], levelExtent(extent_, 0));
bar(flowExpA_); bar(flowExpB_);
disp(pSynth_, g_scratch.sSynth, levelExtent(extent_, 0));
disp(pSynth_, synthSet, levelExtent(extent_, 0));
}
static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
@@ -418,7 +432,8 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
S = ScratchState{};
const auto GEN = VK_IMAGE_LAYOUT_GENERAL;
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo, S.synthUboMem, S.synthUboPtr);
for (int s = 0; s < FrameGen::kSynthSlots; ++s)
makeUbo(dd, dev, mp, sizeof(SynthUBO), S.synthUbo[s], S.synthUboMem[s], S.synthUboPtr[s]);
makeUbo(dd, dev, mp, sizeof(FlowUBO), S.expandUbo, S.expandUboMem, S.expandUboPtr);
if (S.expandUboPtr) { FlowUBO u{cfg.flowScale, 0, 0.6f, 1.6f}; std::memcpy(S.expandUboPtr, &u, sizeof(u)); }
makeUbo(dd, dev, mp, sizeof(FlowRegUBO), S.flowRegUbo, S.flowRegUboMem, S.flowRegUboPtr);
@@ -509,10 +524,12 @@ static bool ensureScratch(FrameGen* fg, const DeviceDispatch* dd, VkDevice dev,
wImg(dd, dev, S.sFlowRegBA, 48, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, flowRegA.view, VK_NULL_HANDLE, GEN);
}
S.sSynth = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth, 0, S.synthUbo, sizeof(SynthUBO));
wImg(dd, dev, S.sSynth, 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth, 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
for (int s = 0; s < FrameGen::kSynthSlots; ++s) {
S.sSynth[s] = allocSet(dd, dev, pool, pSynth.setLayout);
wUbo(dd, dev, S.sSynth[s], 0, S.synthUbo[s], sizeof(SynthUBO));
wImg(dd, dev, S.sSynth[s], 34, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expA.view, samp, GEN);
wImg(dd, dev, S.sSynth[s], 35, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, expB.view, samp, GEN);
}
S.builtFor = ext; S.built = true;
return true;
@@ -3,7 +3,7 @@
#include <vulkan/vk_layer.h>
#include <cstring>
namespace winfg {
namespace ddfg {
static inline void* dispatch_key(void* handle) { return *reinterpret_cast<void**>(handle); }
-12
View File
@@ -1,12 +0,0 @@
#pragma once
#if defined(__ANDROID__)
#include <android/log.h>
#define WFG_LOGI(...) __android_log_print(ANDROID_LOG_INFO, "win-fg", __VA_ARGS__)
#define WFG_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "win-fg", __VA_ARGS__)
#else
#include <cstdio>
#define WFG_LOGI(...) do { std::fprintf(stderr, "[win-fg] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#define WFG_LOGE(...) do { std::fprintf(stderr, "[win-fg][E] " __VA_ARGS__); std::fprintf(stderr, "\n"); } while(0)
#endif
#define WFG_LOGD(cond, ...) do { if (cond) WFG_LOGI(__VA_ARGS__); } while(0)
@@ -1,4 +0,0 @@
#pragma once
#include "vk_dispatch.hpp"
struct VkTable;
bool winfgVkdInit(const VkTable& table, winfg::DeviceDispatch& dd, winfg::InstanceDispatch& id);
+10
View File
@@ -1,5 +1,8 @@
#include <errno.h>
#include <jni.h>
#include <stdatomic.h>
#include <sys/syscall.h>
#include <unistd.h>
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env, jclass clazz) {
@@ -7,3 +10,10 @@ Java_com_droiddeck_launcher_input_FakeInputWriter_nativeStoreFence(JNIEnv *env,
(void)clazz;
atomic_thread_fence(memory_order_release);
}
JNIEXPORT jint JNICALL
Java_com_droiddeck_launcher_session_ThreadSignals_tgkill(JNIEnv *env, jclass clazz, jint tgid, jint tid, jint signal) {
(void)env;
(void)clazz;
return syscall(SYS_tgkill, tgid, tid, signal) == 0 ? 0 : errno;
}
+55
View File
@@ -0,0 +1,55 @@
#ifndef DROIDDECK_VENC_PROTO_H
#define DROIDDECK_VENC_PROTO_H
#include <stdint.h>
/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
* MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
* every frame gets one reply, touches get none. */
#define VENC_MAX_BUFFERS 16
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5 };
enum venc_touch_action { VENC_TOUCH_DOWN = 0, VENC_TOUCH_MOVE = 1, VENC_TOUCH_UP = 2 };
enum venc_codec { VENC_H264 = 0, VENC_HEVC = 1 };
struct venc_config {
uint32_t type;
uint32_t codec;
uint32_t width, height, stride, rows;
uint32_t fps_num, fps_den;
uint32_t bitrate, peak_bitrate, vbr;
uint32_t gop;
int32_t profile, level;
uint32_t inputs, outputs;
uint32_t input_size, output_size;
};
struct venc_frame {
uint32_t type;
uint32_t input, output;
uint32_t bytes;
uint32_t keyframe;
int64_t pts_us;
};
struct venc_done {
uint32_t type;
int32_t status;
uint32_t bytes;
uint32_t keyframe;
};
struct venc_bitrate {
uint32_t type;
uint32_t bitrate;
};
/* Remote Play's touch-as-mouse turned back into a touch; x and y are fractions of the screen in 1/65535. */
struct venc_touch {
uint32_t type;
uint32_t action;
uint32_t x, y;
};
#endif
+346
View File
@@ -0,0 +1,346 @@
#include <android/log.h>
#include <dlfcn.h>
#include <jni.h>
#include <media/NdkMediaCodec.h>
#include <media/NdkMediaFormat.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
#include "venc_proto.h"
#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckVenc", __VA_ARGS__)
namespace {
struct Mapping {
uint8_t *data = nullptr;
size_t size = 0;
};
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
constexpr uint32_t kBufferFlagKeyFrame = 1;
constexpr uint32_t kBufferFlagCodecConfig = 2;
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
int32_t avc_profile(int32_t v4l2) {
switch (v4l2) {
case 0: return 1;
case 1: return 0x10000;
case 2: return 2;
case 3: return 4;
case 4: return 8;
default: return 0;
}
}
class Session {
public:
explicit Session(int fd) : fd_(fd) {}
~Session() {
release();
close(fd_);
}
void run() {
for (;;) {
uint8_t buf[256];
int fds[VENC_MAX_BUFFERS * 2];
int nfds = 0;
ssize_t n = receive(buf, sizeof buf, fds, &nfds);
if (n <= 0) return;
uint32_t type;
memcpy(&type, buf, sizeof type);
if (type == VENC_CONFIG && n == (ssize_t)sizeof(venc_config)) {
venc_config c;
memcpy(&c, buf, sizeof c);
bool ok = configure(c, fds, nfds);
for (int i = 0; i < nfds; i++) close(fds[i]);
reply(ok ? 0 : -1, 0, false);
} else if (type == VENC_FRAME && n == (ssize_t)sizeof(venc_frame)) {
venc_frame f;
memcpy(&f, buf, sizeof f);
uint32_t bytes = 0;
bool key = false;
bool ok = encode(f, &bytes, &key);
reply(ok ? 0 : -1, bytes, key);
} else if (type == VENC_TOUCH && n == (ssize_t)sizeof(venc_touch)) {
venc_touch t;
memcpy(&t, buf, sizeof t);
touch(t);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else if (type == VENC_BITRATE && n == (ssize_t)sizeof(venc_bitrate)) {
venc_bitrate b;
memcpy(&b, buf, sizeof b);
set_bitrate(b.bitrate);
for (int i = 0; i < nfds; i++) close(fds[i]);
} else {
LOG("unexpected message %u of %zd bytes; closing", type, n);
for (int i = 0; i < nfds; i++) close(fds[i]);
return;
}
}
}
private:
ssize_t receive(uint8_t *buf, size_t len, int *fds, int *nfds) {
char control[CMSG_SPACE(sizeof(int) * VENC_MAX_BUFFERS * 2)];
iovec iov = {buf, len};
msghdr m = {};
m.msg_iov = &iov;
m.msg_iovlen = 1;
m.msg_control = control;
m.msg_controllen = sizeof control;
ssize_t n = recvmsg(fd_, &m, MSG_CMSG_CLOEXEC);
for (cmsghdr *c = CMSG_FIRSTHDR(&m); n > 0 && c; c = CMSG_NXTHDR(&m, c)) {
if (c->cmsg_level != SOL_SOCKET || c->cmsg_type != SCM_RIGHTS) continue;
int count = (int)((c->cmsg_len - CMSG_LEN(0)) / sizeof(int));
memcpy(fds, CMSG_DATA(c), sizeof(int) * std::min(count, VENC_MAX_BUFFERS * 2));
*nfds = std::min(count, VENC_MAX_BUFFERS * 2);
}
return n;
}
void reply(int32_t status, uint32_t bytes, bool key) {
venc_done d = {VENC_DONE, status, bytes, key ? 1u : 0u};
send(fd_, &d, sizeof d, MSG_NOSIGNAL);
}
void release() {
if (codec_) {
AMediaCodec_stop(codec_);
AMediaCodec_delete(codec_);
codec_ = nullptr;
}
for (auto &m : inputs_) munmap(m.data, m.size);
for (auto &m : outputs_) munmap(m.data, m.size);
inputs_.clear();
outputs_.clear();
header_.clear();
}
AMediaCodec *create(const venc_config &c, bool with_profile) {
const char *mime = c.codec == VENC_HEVC ? "video/hevc" : "video/avc";
AMediaCodec *codec = AMediaCodec_createEncoderByType(mime);
if (!codec) return nullptr;
float fps = c.fps_num ? (float)c.fps_den / (float)c.fps_num : 60.0f;
AMediaFormat *f = AMediaFormat_new();
AMediaFormat_setString(f, AMEDIAFORMAT_KEY_MIME, mime);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_WIDTH, (int32_t)c.width);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_HEIGHT, (int32_t)c.height);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_COLOR_FORMAT, kColorFormatYuv420SemiPlanar);
AMediaFormat_setInt32(f, AMEDIAFORMAT_KEY_BIT_RATE, (int32_t)std::max(c.bitrate, 500000u));
AMediaFormat_setInt32(f, "bitrate-mode", c.vbr ? 1 : 2);
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_FRAME_RATE, fps);
AMediaFormat_setFloat(f, "operating-rate", fps);
AMediaFormat_setInt32(f, "priority", 0);
AMediaFormat_setInt32(f, "latency", 1);
AMediaFormat_setInt32(f, "max-bframes", 0);
AMediaFormat_setInt32(f, "prepend-sps-pps-to-idr-frames", 1);
float gop_seconds = c.gop && c.gop < 0xffff ? (float)c.gop / fps : 3600.0f;
AMediaFormat_setFloat(f, AMEDIAFORMAT_KEY_I_FRAME_INTERVAL, std::max(gop_seconds, 0.1f));
if (with_profile) {
int32_t profile = c.codec == VENC_HEVC ? (c.profile == 2 ? 2 : 1) : avc_profile(c.profile);
if (profile) AMediaFormat_setInt32(f, "profile", profile);
}
media_status_t s = AMediaCodec_configure(codec, f, nullptr, nullptr, AMEDIACODEC_CONFIGURE_FLAG_ENCODE);
AMediaFormat_delete(f);
if (s == AMEDIA_OK) s = AMediaCodec_start(codec);
if (s != AMEDIA_OK) {
AMediaCodec_delete(codec);
return nullptr;
}
return codec;
}
bool configure(const venc_config &c, const int *fds, int nfds) {
release();
if (c.inputs + c.outputs != (uint32_t)nfds || c.width < 64 || c.height < 64 || c.stride < c.width ||
c.rows < c.height || (uint64_t)c.stride * c.rows * 3 / 2 > c.input_size) {
LOG("bad config: %u+%u buffers for %d fds, %ux%u stride %u", c.inputs, c.outputs, nfds, c.width, c.height,
c.stride);
return false;
}
for (int i = 0; i < nfds; i++) {
bool input = (uint32_t)i < c.inputs;
size_t size = input ? c.input_size : c.output_size;
void *p = mmap(nullptr, size, input ? PROT_READ : PROT_READ | PROT_WRITE, MAP_SHARED, fds[i], 0);
if (p == MAP_FAILED) {
LOG("could not map buffer %d: %s", i, strerror(errno));
release();
return false;
}
(input ? inputs_ : outputs_).push_back({(uint8_t *)p, size});
}
codec_ = create(c, true);
if (!codec_) codec_ = create(c, false);
if (!codec_) {
LOG("no %s encoder for %ux%u", c.codec == VENC_HEVC ? "HEVC" : "H.264", c.width, c.height);
release();
return false;
}
config_ = c;
dst_stride_ = (int32_t)c.width;
dst_rows_ = (int32_t)c.height;
using InputFormat = AMediaFormat *(*)(AMediaCodec *);
static auto input_format = (InputFormat)dlsym(RTLD_DEFAULT, "AMediaCodec_getInputFormat");
if (AMediaFormat *in = input_format ? input_format(codec_) : nullptr) {
int32_t v;
if (AMediaFormat_getInt32(in, "stride", &v) && v >= (int32_t)c.width) dst_stride_ = v;
if (AMediaFormat_getInt32(in, "slice-height", &v) && v >= (int32_t)c.height) dst_rows_ = v;
AMediaFormat_delete(in);
}
LOG("%s %ux%u at %.2f fps, %u kbit/s %s, input stride %d rows %d", c.codec == VENC_HEVC ? "HEVC" : "H.264",
c.width, c.height, c.fps_num ? (double)c.fps_den / c.fps_num : 0.0, c.bitrate / 1000,
c.vbr ? "VBR" : "CBR", dst_stride_, dst_rows_);
return true;
}
static void touch(const venc_touch &t) {
using SendTouch = void (*)(int, int, int, int);
static SendTouch send_touch = [] {
void *wayland = dlopen("libdroiddeckwayland.so", RTLD_NOW | RTLD_NOLOAD);
auto fn = wayland ? (SendTouch)dlsym(wayland, "droiddeck_wayland_send_touch") : nullptr;
if (!fn) LOG("remote touch unavailable: %s", dlerror());
return fn;
}();
if (!send_touch || t.action > VENC_TOUCH_UP) return;
int x = (int)((uint64_t)std::min(t.x, 65535u) * (kTouchWidth - 1) / 65535);
int y = (int)((uint64_t)std::min(t.y, 65535u) * (kTouchHeight - 1) / 65535);
send_touch((int)t.action, kRemoteTouchId, x, y);
}
void set_bitrate(uint32_t bitrate) {
if (!codec_) return;
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "video-bitrate", (int32_t)std::max(bitrate, 500000u));
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
bool encode(const venc_frame &f, uint32_t *bytes, bool *key) {
if (!codec_ || f.input >= inputs_.size() || f.output >= outputs_.size()) return false;
if (f.keyframe) {
AMediaFormat *p = AMediaFormat_new();
AMediaFormat_setInt32(p, "request-sync", 0);
AMediaCodec_setParameters(codec_, p);
AMediaFormat_delete(p);
}
ssize_t in = AMediaCodec_dequeueInputBuffer(codec_, 100000);
if (in < 0) return false;
size_t capacity = 0;
uint8_t *dst = AMediaCodec_getInputBuffer(codec_, (size_t)in, &capacity);
size_t need = (size_t)dst_stride_ * dst_rows_ + (size_t)dst_stride_ * ((config_.height + 1) / 2);
if (!dst || capacity < need) return false;
const uint8_t *src = inputs_[f.input].data;
for (uint32_t y = 0; y < config_.height; y++)
memcpy(dst + (size_t)y * dst_stride_, src + (size_t)y * config_.stride, config_.width);
const uint8_t *src_uv = src + (size_t)config_.stride * config_.rows;
uint8_t *dst_uv = dst + (size_t)dst_stride_ * dst_rows_;
for (uint32_t y = 0; y < (config_.height + 1) / 2; y++)
memcpy(dst_uv + (size_t)y * dst_stride_, src_uv + (size_t)y * config_.stride, config_.width);
if (AMediaCodec_queueInputBuffer(codec_, (size_t)in, 0, need, (uint64_t)std::max<int64_t>(f.pts_us, 0), 0) !=
AMEDIA_OK)
return false;
Mapping &out = outputs_[f.output];
for (int tries = 0; tries < 20; tries++) {
AMediaCodecBufferInfo info;
ssize_t o = AMediaCodec_dequeueOutputBuffer(codec_, &info, 20000);
if (o == AMEDIACODEC_INFO_TRY_AGAIN_LATER || o == AMEDIACODEC_INFO_OUTPUT_FORMAT_CHANGED ||
o == AMEDIACODEC_INFO_OUTPUT_BUFFERS_CHANGED)
continue;
if (o < 0) return false;
size_t size = 0;
uint8_t *data = AMediaCodec_getOutputBuffer(codec_, (size_t)o, &size);
const uint8_t *payload = data ? data + info.offset : nullptr;
size_t length = data ? (size_t)info.size : 0;
if (info.flags & kBufferFlagCodecConfig) {
header_.assign(payload, payload + length);
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
continue;
}
*key = (info.flags & kBufferFlagKeyFrame) != 0;
bool prepend = *key && !header_.empty() &&
(length < header_.size() || memcmp(payload, header_.data(), header_.size()) != 0);
size_t total = length + (prepend ? header_.size() : 0);
bool fits = payload && total <= out.size;
if (fits) {
if (prepend) memcpy(out.data, header_.data(), header_.size());
memcpy(out.data + (prepend ? header_.size() : 0), payload, length);
*bytes = (uint32_t)total;
}
AMediaCodec_releaseOutputBuffer(codec_, (size_t)o, false);
return fits;
}
return false;
}
int fd_;
AMediaCodec *codec_ = nullptr;
venc_config config_ = {};
int32_t dst_stride_ = 0, dst_rows_ = 0;
std::vector<Mapping> inputs_, outputs_;
std::vector<uint8_t> header_;
};
std::atomic<int> g_listener{-1};
void serve(int listener) {
for (;;) {
int fd = accept4(listener, nullptr, nullptr, SOCK_CLOEXEC);
if (fd < 0) {
if (errno == EINTR) continue;
return;
}
std::thread([fd] {
Session s(fd);
s.run();
}).detach();
}
}
} // namespace
extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
if (g_listener.load() >= 0) return JNI_TRUE;
const char *path = env->GetStringUTFChars(jpath, nullptr);
sockaddr_un addr = {};
addr.sun_family = AF_UNIX;
bool fits = strlen(path) < sizeof addr.sun_path;
if (fits) strcpy(addr.sun_path, path);
int fd = fits ? socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0) : -1;
if (fd >= 0) {
unlink(path);
if (bind(fd, (sockaddr *)&addr, sizeof addr) != 0 || listen(fd, 4) != 0) {
LOG("could not listen on %s: %s", path, strerror(errno));
close(fd);
fd = -1;
}
}
env->ReleaseStringUTFChars(jpath, path);
if (fd < 0) return JNI_FALSE;
g_listener.store(fd);
std::thread(serve, fd).detach();
return JNI_TRUE;
}
extern "C" JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStop(JNIEnv *, jobject) {
int fd = g_listener.exchange(-1);
if (fd >= 0) {
shutdown(fd, SHUT_RDWR);
close(fd);
}
}
+8 -8
View File
@@ -45,10 +45,10 @@ target_include_directories(droiddeckwayland PRIVATE
)
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
set(DROIDDECK_FRAMEGEN_WINFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/winfg)
set(DROIDDECK_FRAMEGEN_DDFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/ddfg)
if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
AND EXISTS ${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, Win-FG Native)")
AND EXISTS ${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp)
message(STATUS "droiddeckwayland: frame-generation engines built in (LSFG Native, DD-FG Native)")
target_sources(droiddeckwayland PRIVATE
src/framegen_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_probe.cpp
@@ -68,9 +68,9 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_governor.cpp
${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_jni.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_vkd.cpp
${DROIDDECK_FRAMEGEN_WINFG_DIR}/winfg_engine.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/framegen.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_vkd.cpp
${DROIDDECK_FRAMEGEN_DDFG_DIR}/ddfg_engine.cpp
)
target_include_directories(droiddeckwayland PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/..
@@ -79,8 +79,8 @@ if (TARGET dxbc AND EXISTS ${DROIDDECK_FRAMEGEN_LSFG_DIR}/lsfg_engine.cpp
target_compile_options(droiddeckwayland PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-std=c++17 -fvisibility=hidden>)
target_compile_definitions(droiddeckwayland PRIVATE
WINFG_UPSTREAM="${WINFG_UPSTREAM}"
WINFG_CHAIN_HASH="${WINFG_CHAIN_HASH}"
DDFG_UPSTREAM="${DDFG_UPSTREAM}"
DDFG_CHAIN_HASH="${DDFG_CHAIN_HASH}"
)
target_link_libraries(droiddeckwayland dxbc atomic)
else()
+34 -1
View File
@@ -228,6 +228,8 @@ struct surface {
enum surface_role role;
struct wl_resource *idle_buffer;
struct wl_listener idle_buffer_destroy;
uint32_t *idle_px;
int idle_w, idle_h;
struct wl_resource *xdg_surface, *xdg_toplevel;
struct wl_resource *viewport;
@@ -979,8 +981,30 @@ static void cursor_publish_buffer(struct surface *s, struct wl_resource *buffer)
g_cursor_shown = s;
}
static void surface_forget_idle_px(struct surface *s) {
free(s->idle_px);
s->idle_px = NULL;
s->idle_w = s->idle_h = 0;
}
static void on_idle_buffer_destroyed(struct wl_listener *l, void *data) {
struct surface *s = wl_container_of(l, s, idle_buffer_destroy);
struct wl_shm_buffer *shm = wl_shm_buffer_get(s->idle_buffer);
if (shm) {
int32_t w = wl_shm_buffer_get_width(shm), h = wl_shm_buffer_get_height(shm);
int32_t stride = wl_shm_buffer_get_stride(shm);
uint32_t *px = w > 0 && h > 0 && (int64_t)w * h <= CURSOR_MAX_PX && (int64_t)stride >= (int64_t)w * 4
? realloc(s->idle_px, (size_t)w * h * 4) : NULL;
if (px) {
wl_shm_buffer_begin_access(shm);
const uint8_t *src = wl_shm_buffer_get_data(shm);
for (int y = 0; y < h; y++) memcpy(&px[(size_t)y * w], src + (size_t)y * stride, (size_t)w * 4);
wl_shm_buffer_end_access(shm);
s->idle_px = px;
s->idle_w = w;
s->idle_h = h;
}
}
wl_list_remove(&s->idle_buffer_destroy.link);
wl_list_init(&s->idle_buffer_destroy.link);
s->idle_buffer = NULL;
@@ -1007,6 +1031,7 @@ static struct wl_resource *surface_take_idle(struct surface *s) {
}
static void surface_drop_idle(struct surface *s) {
surface_forget_idle_px(s);
struct wl_resource *buffer = surface_take_idle(s);
if (buffer) send_buffer_release(buffer);
}
@@ -1136,6 +1161,7 @@ static void surface_resource_destroy(struct wl_resource *r) {
if (g_cursor_surface == s) { g_cursor_surface = NULL; cursor_publish_hidden(); }
if (g_cursor_shown == s) g_cursor_shown = NULL;
surface_take_idle(s);
surface_forget_idle_px(s);
if (g_grab == s) g_grab = NULL;
if (g_key_target == s) g_key_target = NULL;
if (g_ime_click == s) g_ime_click = NULL;
@@ -2123,6 +2149,9 @@ static void pointer_set_cursor(struct wl_client *c, struct wl_resource *r, uint3
send_buffer_release(buffer);
} else if (g_cursor_shown == s) {
cursor_publish_shown();
} else if (s->idle_px) {
cursor_publish_pixels((const uint8_t *)s->idle_px, s->idle_w, s->idle_h, (size_t)s->idle_w * 4, hx, hy);
g_cursor_shown = s;
}
}
static void pointer_release(struct wl_client *c, struct wl_resource *r) { wl_resource_destroy(r); }
@@ -2674,7 +2703,11 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy);
if (!(k && k->is_lock))
// A button-only input must not replay a cached absolute position. A game
// may have warped its cursor since the last motion (before taking lock,
// or while releasing it), so that replay can become an unintended pan.
// Absolute touch events still carry their position, including on clicks.
if (!(k && k->is_lock) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
send_relative_motion(client, dx, dy);
for_each_pointer_of(client, sp) {
@@ -86,7 +86,7 @@ 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;
if (kind != VKP_FG_ENGINE_LSFG && kind != VKP_FG_ENGINE_DDFG) kind = VKP_FG_ENGINE_LSFG;
atomic_store(&g_kind, kind);
atomic_store(&g_failed_kind, -1);
atomic_store(&g_cfg_dirty, 1);
@@ -125,7 +125,7 @@ void vkp_framegen_set_tuning(float flow_scale, float refresh_hz) {
atomic_store(&g_cfg_dirty, 1);
}
void vkp_framegen_set_winfg_tuning(int model, int perf_preset) {
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset) {
atomic_store(&g_model, model == 3 ? 3 : 4);
atomic_store(&g_preset, perf_preset < 0 ? 0 : perf_preset > 2 ? 2 : perf_preset);
atomic_store(&g_cfg_dirty, 1);
@@ -180,9 +180,9 @@ void vkp_framegen_device_ready(VkDevice dev, VkQueue queue, uint32_t qfam, int f
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
fge_device_ready(g_gipa, g_inst, g_pd, dev, queue, qfam, features_enabled);
g_dev_ready = vk.CreateImage && vk.CreateImageView && vk.CmdPipelineBarrier;
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), Win-FG Native %s",
FGLOG("engines ready on the compositor's device: LSFG Native %s (%s), DD-FG Native %s",
fge_caps_ok(VKP_FG_ENGINE_LSFG) ? "available" : "unavailable", fge_caps_reason(),
fge_caps_ok(VKP_FG_ENGINE_WINFG) ? "available" : "unavailable");
fge_caps_ok(VKP_FG_ENGINE_DDFG) ? "available" : "unavailable");
}
void vkp_framegen_device_lost(void) { g_dev_lost = 1; }
@@ -6,14 +6,14 @@
#endif
#include <vulkan/vulkan.h>
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_WINFG = 1 };
enum { VKP_FG_ENGINE_LSFG = 0, VKP_FG_ENGINE_DDFG = 1 };
#define VKP_FG_MAX_GENERATIONS 3
void vkp_framegen_set_engine(int kind);
void vkp_framegen_set_armed(int armed, int multiplier, int target_fps);
void vkp_framegen_set_lsfg_cache_path(const char *path);
void vkp_framegen_set_tuning(float flow_scale, float refresh_hz);
void vkp_framegen_set_winfg_tuning(int model, int perf_preset);
void vkp_framegen_set_ddfg_tuning(int model, int perf_preset);
int vkp_framegen_problem(void);
const char *vkp_framegen_caps_reason(void);
void vkp_framegen_stats(float out[6]);
@@ -12,7 +12,7 @@
#include "lsfg/lsfg_engine.h"
#include "lsfg/lsfg_probe.h"
#include "lsfg/lsfg_vkd.h"
#include "winfg/winfg_engine.h"
#include "ddfg/ddfg_engine.h"
#define ELOG(...) __android_log_print(ANDROID_LOG_INFO, "DroidDeckWayland", "framegen: " __VA_ARGS__)
#define ELOGE(...) __android_log_print(ANDROID_LOG_ERROR, "DroidDeckWayland", "framegen: " __VA_ARGS__)
@@ -30,7 +30,7 @@ uint32_t g_qfam = 0;
bool g_probed = false, g_ready = false;
std::unique_ptr<lsfg::Engine> g_lsfg;
std::unique_ptr<winfg::Engine> g_winfg;
std::unique_ptr<ddfg::Engine> g_ddfg;
int g_kind = -1;
char g_build[96] = "";
@@ -143,15 +143,15 @@ int fge_start(int kind, const char *cache_path) {
fge_stop();
if (!g_ready) return -1;
if (kind == 1) {
auto e = std::make_unique<winfg::Engine>();
auto e = std::make_unique<ddfg::Engine>();
if (!e->init(g_table, g_pd, g_dev, g_qfam, g_queue)) {
ELOGE("winfg-native: engine init failed");
ELOGE("ddfg-native: engine init failed");
return -1;
}
g_winfg = std::move(e);
g_ddfg = std::move(e);
g_kind = 1;
#if defined(WINFG_UPSTREAM) && defined(WINFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", WINFG_UPSTREAM, WINFG_CHAIN_HASH);
#if defined(DDFG_UPSTREAM) && defined(DDFG_CHAIN_HASH)
std::snprintf(g_build, sizeof(g_build), "chain %s, src %s", DDFG_UPSTREAM, DDFG_CHAIN_HASH);
#else
std::snprintf(g_build, sizeof(g_build), "embedded chain");
#endif
@@ -176,46 +176,46 @@ int fge_start(int kind, const char *cache_path) {
}
void fge_stop(void) {
if ((g_lsfg || g_winfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
if ((g_lsfg || g_ddfg) && g_table.DeviceWaitIdle && g_dev) g_table.DeviceWaitIdle(g_dev);
g_lsfg.reset();
g_winfg.reset();
g_ddfg.reset();
g_kind = -1;
}
int fge_unavailable(void) {
if (g_kind == 1) return g_winfg && g_winfg->unavailable() ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->unavailable() ? 1 : 0;
return g_lsfg && g_lsfg->unavailable() ? 1 : 0;
}
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);
if (g_kind == 1 && g_ddfg) g_ddfg->configure(multiplier, model, perf_preset, flow_scale);
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) {
if (g_kind == 1) return g_winfg && g_winfg->prepare(w, h, fmt) ? 1 : 0;
if (g_kind == 1) return g_ddfg && g_ddfg->prepare(w, h, fmt) ? 1 : 0;
if (!g_lsfg) return 0;
g_lsfg->setGuestExtent(w, h);
return g_lsfg->prepare(w, h, fmt) ? 1 : 0;
}
uint32_t fge_plan(uint32_t capacity, uint64_t source_frames, float presented_rate) {
if (g_kind == 1) return g_winfg ? g_winfg->plan(capacity) : 0;
if (g_kind == 1) return g_ddfg ? g_ddfg->plan(capacity) : 0;
if (!g_lsfg) return 0;
g_lsfg->setPresentedRate(presented_rate);
return g_lsfg->plan(capacity, source_frames);
}
void fge_process(VkCommandBuffer cmd, VkImage source, uint32_t w, uint32_t h, uint32_t gens) {
if (g_kind == 1) { if (g_winfg) g_winfg->process(cmd, source, w, h, gens); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->process(cmd, source, w, h, gens); return; }
if (g_lsfg) g_lsfg->process(cmd, source, w, h, gens);
}
void fge_generate(VkCommandBuffer cmd, uint32_t g, uint32_t count, VkImage img, VkImageView view,
uint32_t w, uint32_t h) {
if (g_kind == 1) { if (g_winfg) g_winfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_kind == 1) { if (g_ddfg) g_ddfg->generateInto(cmd, g, count, img, view, w, h); return; }
if (g_lsfg) g_lsfg->generateInto(cmd, g, g, img, view, w, h);
}
@@ -232,7 +232,7 @@ void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
}
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return g_build; }
}
@@ -34,5 +34,5 @@ void fge_forget_targets(void) {}
void fge_telemetry(float *accepted, float *source_rate, float *thermal) {
*accepted = 0.0f; *source_rate = 0.0f; *thermal = -1.0f;
}
const char *fge_engine_name(int kind) { return kind == 1 ? "Win-FG Native" : "LSFG Native"; }
const char *fge_engine_name(int kind) { return kind == 1 ? "DD-FG Native" : "LSFG Native"; }
const char *fge_build_info(void) { return "stub"; }
@@ -31,10 +31,10 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetFrameGenTuning(JN
}
JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetWinFgTuning(JNIEnv *env, jclass clazz,
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetDdFgTuning(JNIEnv *env, jclass clazz,
jint model, jint perfPreset) {
(void)env; (void)clazz;
vkp_framegen_set_winfg_tuning((int)model, (int)perfPreset);
vkp_framegen_set_ddfg_tuning((int)model, (int)perfPreset);
}
JNIEXPORT jint JNICALL
@@ -16,16 +16,22 @@ class App : Application() {
override fun onCreate() {
super.onCreate()
com.droiddeck.launcher.ui.Themes.loadGradient(this)
// Helper processes must not initialize the main USB provider or guest graphics context.
val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
if (process == "$packageName:xboxlogin") {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
return
}
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this)
com.droiddeck.launcher.update.AppUpdates.init(this)
com.droiddeck.launcher.core.DownloadSource.init(this)
prefetchAddonPacks()
GpuClockPin.clearLeftover(this)
com.droiddeck.launcher.glasses.Glasses.init(this)
com.droiddeck.launcher.glasses.GlassesMenuMirror.initialize(this)
@@ -35,5 +41,24 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.gog.GogBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.epic.EpicBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
}
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() {
val app = this
Thread({
runCatching {
if (com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(app).isDirectory) {
com.droiddeck.launcher.core.AddonPack.PROOT.ensure(app)
com.droiddeck.launcher.core.AddonPack.DIRECTAUDIO.ensure(app)
}
if (com.droiddeck.launcher.stores.gog.GogComet.enabled(app)) com.droiddeck.launcher.core.AddonPack.COMET.ensure(app)
if (com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, com.droiddeck.launcher.stores.Store.XBOX) != null)
com.droiddeck.launcher.core.AddonPack.XBOX.ensure(app)
com.droiddeck.launcher.handtracking.HandTracking.load(app)
}.onFailure { android.util.Log.w("App", "add-on pack prefetch failed", it) }
}, "addon-packs").start()
}
}
@@ -11,29 +11,46 @@ import java.io.File
internal class DeckyMenu(private val activity: android.app.Activity, private val ui: android.os.Handler) {
var deckyInstalled by mutableStateOf<String?>(null)
var deckyReleases by mutableStateOf<List<DeckyManager.Release>>(emptyList())
var deckyLatest by mutableStateOf<DeckyManager.Release?>(null)
var deckyBranch by mutableIntStateOf(DeckyManager.BRANCH_STABLE)
private var deckyChannels = DeckyManager.ReleaseChannels(emptyList(), emptyList())
var deckyChecking by mutableStateOf(false)
var deckyStage by mutableStateOf<String?>(null)
var deckyPercent by mutableIntStateOf(-1)
var deckyDownloadingBinary by mutableStateOf(false)
var deckySupervisor by mutableStateOf(false)
var deckyPendingZip by mutableStateOf<File?>(null)
var deckyReleaseRequest = 0
fun refreshDecky() {
val request = ++deckyReleaseRequest
deckyBranch = DeckyManager.branch(activity)
deckyChecking = true
Thread({
val channels = runCatching { DeckyManager.releaseChannels(activity) }
.getOrElse { DeckyManager.ReleaseChannels(emptyList(), emptyList()) }
ui.post {
if (request == deckyReleaseRequest) {
deckyReleases = channels.stable.ifEmpty { channels.prerelease }
deckyChannels = channels
deckyLatest = DeckyManager.latest(channels, deckyBranch)
deckyChecking = false
}
}
}, "decky-releases").start()
}
fun switchDeckyBranch(branch: Int) {
if (branch == deckyBranch || deckyStage != null || SessionState.running) return
val saved = runCatching { DeckyManager.setBranch(activity, branch) }
if (saved.isFailure) {
android.widget.Toast.makeText(activity, R.string.decky_channel_failed, android.widget.Toast.LENGTH_LONG).show()
return
}
deckyBranch = branch
deckyLatest = DeckyManager.latest(deckyChannels, branch)
refreshDecky()
}
fun installDecky(release: DeckyManager.Release) {
if (deckyStage != null || SessionState.running) return
deckyStage = activity.getString(R.string.store_starting); deckyPercent = -1
@@ -49,6 +66,17 @@ internal class DeckyMenu(private val activity: android.app.Activity, private val
}
fun importPluginZip(archive: File) {
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyPendingZip = archive
}
fun cancelPluginZip() {
deckyPendingZip = null
}
fun confirmPluginZip() {
val archive = deckyPendingZip ?: return
deckyPendingZip = null
if (deckyStage != null || SessionState.running || deckyInstalled == null) return
deckyStage = activity.getString(R.string.decky_plugin_starting); deckyPercent = -1
Thread({
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableIntStateOf
import com.droiddeck.launcher.gpu.AutoDriverTrial
import com.droiddeck.launcher.gpu.AutoPicks
import com.droiddeck.launcher.gpu.DriverBundle
import com.droiddeck.launcher.core.DeviceSupport
import com.droiddeck.launcher.gpu.DriverPairs
@@ -184,7 +185,7 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
val lm = LinuxVulkanDriverManager(activity)
val td = TurnipDriver(activity)
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val recommended = if (QuestExperimental.isSupported()) null else DriverPairs.recommendedKey(gpu, pairs)
val recommended = if (QuestExperimental.isSupported()) null else recommendedKey(pairs)
pairRows = pairs.map { p ->
val nad = DriverPairs.isNonAdreno(p.key)
val bundle = p.bundle?.let { if (nad) installedBundleAnyBuild(it) else installedBundle(it) }
@@ -212,6 +213,11 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
}.sortedByDescending { it.recommended }
}
private fun recommendedKey(pairs: List<DriverPairs.DriverPair>) = DriverPairs.recommendedKey(
gpu, pairs, TurnipReleases.autoPicks(activity), AutoDriverTrial.bad(activity),
blob = AutoPicks.blobVersion(com.droiddeck.launcher.gpu.VulkanInfo.cachedOrNull()?.get("driver_version")),
)
fun chooseMode(next: String) {
pairModeOpen = next == GPU_MODE_PAIR
setMode(auto = next == GPU_MODE_AUTO, mix = next == GPU_MODE_MIX)
@@ -314,12 +320,13 @@ internal class DriverMenus(private val activity: Activity, private val ui: Handl
Thread({
val problem = if (force || releasesStale()) runCatching { TurnipReleases.refresh(activity) }.exceptionOrNull()?.message else null
val holdBack = !force && com.droiddeck.launcher.frontend.AutoComponents.constrained(activity)
runCatching { com.droiddeck.launcher.gpu.VulkanInfo.query() }
ui.post {
releaseChecking = false
refreshReleaseRows()
refreshPairs()
val pairs = DriverPairs.from(TurnipReleases.cached(activity))
val key = DriverPairs.recommendedKey(gpu, pairs)
val key = recommendedKey(pairs)
val pair = pairs.firstOrNull { it.key == key }
val row = pairRows.firstOrNull { it.key == key }
when {
@@ -0,0 +1,42 @@
package com.droiddeck.launcher
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
object LauncherIcon {
const val DARK = "dark"
const val LIGHT = "light"
private val aliases = linkedMapOf(DARK to "com.droiddeck.launcher.LauncherDark", LIGHT to "com.droiddeck.launcher.LauncherLight")
val options: List<Pair<String, Int>> = listOf(
DARK to R.string.setup_launcher_icon_dark,
LIGHT to R.string.setup_launcher_icon_light,
)
private fun component(context: Context, style: String) =
ComponentName(context, aliases.getValue(style))
private fun enabled(context: Context, style: String): Boolean =
when (context.packageManager.getComponentEnabledSetting(component(context, style))) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> style == DARK
else -> false
}
fun current(context: Context): String = aliases.keys.firstOrNull { enabled(context, it) } ?: DARK
fun activity(context: Context): ComponentName = component(context, current(context))
fun set(context: Context, style: String) {
if (style !in aliases || style == current(context)) return
val pm = context.packageManager
pm.setComponentEnabledSetting(component(context, style),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
aliases.keys.filter { it != style }.forEach {
pm.setComponentEnabledSetting(component(context, it),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
}
}
}
@@ -109,6 +109,7 @@ class MainActivity : ComponentActivity() {
private var frameGenLabel by mutableStateOf("")
private var lossless by mutableStateOf(Lossless.State.NONE)
private var showRemove by mutableStateOf(false)
private var showRuntimeActions by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var pendingSwitch by mutableStateOf<Intent?>(null)
private var switchingTo: Intent? = null
private var switchPoll: Runnable? = null
@@ -118,11 +119,13 @@ class MainActivity : ComponentActivity() {
private var showNonAdreno by mutableStateOf<LinuxRuntimeInstaller.Release?>(null)
private var glThread by mutableStateOf(true)
private var noGlError by mutableStateOf(true)
private var returnAfterGame by mutableStateOf(true)
private var steamRepairQueued by mutableStateOf(false)
private var mangoapp by mutableStateOf(true)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
private var showComponents by mutableStateOf(false)
private var focusComponentsContent by mutableStateOf(true)
private var componentsGameKey by mutableStateOf<String?>(null)
private var showMapping by mutableStateOf(false)
private var saveBusy: String? = null
private var onSavePicked: ((File) -> Unit)? = null
@@ -163,12 +166,13 @@ class MainActivity : ComponentActivity() {
private var directAudio by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private var stretch16x9 by mutableStateOf(false)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE)
private var orientation by mutableStateOf(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE)
private var launcherFullscreen by mutableStateOf(true)
private var downloadSource by mutableStateOf(DownloadSource.MODE_AUTO)
private var downloadMirror by mutableStateOf("")
private var downloadPrefix by mutableStateOf("")
private var animationsEnabled by mutableStateOf(true)
private var launcherIcon by mutableStateOf(LauncherIcon.DARK)
private var gameStoresEnabled by mutableStateOf(true)
private var gameStoresSpeedTier by mutableStateOf("fast")
private var storesOpenTab by mutableStateOf(SessionPrefs.STORES_OPEN_LIBRARY)
@@ -493,6 +497,20 @@ class MainActivity : ComponentActivity() {
pickSaveDir.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.game_file_folder), gameSyncFolder))
}
private fun switchLauncherIcon(style: String): Boolean {
val before = LauncherIcon.current(this)
LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false
// Android finishes activities started through the alias that was just disabled.
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
.putExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV, "setup:launcher"))
finishAndRemoveTask()
return true
}
private fun readGameIntent(request: Intent) {
request.getStringExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV)?.let { navRequest = it; request.removeExtra(com.droiddeck.launcher.stores.download.StoreDownloadService.EXTRA_NAV) }
shortcutPicker = request.action == Intent.ACTION_CREATE_SHORTCUT
@@ -748,6 +766,7 @@ class MainActivity : ComponentActivity() {
downloadPrefix = DownloadSource.custom(this)
DownloadSource.probe()
animationsEnabled = SessionPrefs.animationsEnabled(this)
launcherIcon = LauncherIcon.current(this)
gameStoresEnabled = SessionPrefs.gameStoresEnabled(this)
gameStoresSpeedTier = SessionPrefs.gameStoresSpeedTier(this)
storesOpenTab = SessionPrefs.storesOpenTab(this)
@@ -759,6 +778,9 @@ class MainActivity : ComponentActivity() {
setContent {
DroidDeckTheme(theme, appScale = appScale) {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize().exposeTestTags()) {
if (decky.deckyPendingZip != null) com.droiddeck.launcher.ui.DeckyZipWarningDialog(
onConfirm = { decky.confirmPluginZip() }, onCancel = { decky.cancelPluginZip() },
)
val page: (@Composable () -> Unit)? = when {
showPerformance -> { { PerformanceHost() } }
showComponents -> { { ComponentsHost() } }
@@ -810,6 +832,7 @@ class MainActivity : ComponentActivity() {
downloadMirror = downloadMirror,
downloadPrefix = downloadPrefix,
animationsEnabled = animationsEnabled,
launcherIcon = launcherIcon,
gameStoresEnabled = gameStoresEnabled,
gameStoresSpeedTier = gameStoresSpeedTier,
storesOpenTab = storesOpenTab,
@@ -853,6 +876,7 @@ class MainActivity : ComponentActivity() {
},
onSteamGameArtChanged = { appId -> refreshSteamGameArt(appId) },
onAddedGameRemoved = { folder, appId, name -> removeAddedGame(folder, appId, name) },
onGameRemoved = { g -> removeGame(g) },
onAddedGamesArt = { on -> SessionPrefs.setAddedGamesArt(this, on); addedGamesArt = on; if (on) refreshAddedGames() },
onSgdbKey = { key ->
Thread({
@@ -925,6 +949,7 @@ class MainActivity : ComponentActivity() {
},
onProtons = { openProtons() },
onComponents = { focusContent -> openComponents(focusContent) },
onGameComponents = { game -> openComponents(gameKey = game.profileKey) },
onComponentsTab = { tab -> openComponents(focusContent = true, tab = tab) },
onSaveImport = { sg -> importSaves(sg.name) { GameSaves.game(sg) } },
onSaveExport = { sg, layout -> exportSaves(sg.name, layout) { GameSaves.game(sg) } },
@@ -982,6 +1007,7 @@ class MainActivity : ComponentActivity() {
onPageBack = { showPerformance = false; showComponents = false; showMapping = false },
onGameStoresEnabled = { on -> SessionPrefs.setGameStoresEnabled(this, on); gameStoresEnabled = on },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherIcon = { style -> switchLauncherIcon(style) },
onLanguage = { tag ->
if (tag != com.droiddeck.launcher.core.AppLanguage.chosen(this)) {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, tag)
@@ -1136,6 +1162,13 @@ class MainActivity : ComponentActivity() {
onDismiss = { pendingSwitch = null },
)
}
showRuntimeActions?.let { release ->
com.droiddeck.launcher.ui.RuntimeActionsDialog(
onRepair = { showRuntimeActions = null; if (!runtimeChangesBlocked()) install(release) },
onRemove = { showRuntimeActions = null; showRemove = true },
onDismiss = { showRuntimeActions = null },
)
}
if (showRemove) ConfirmDialog(
title = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_title),
text = androidx.compose.ui.res.stringResource(R.string.main_remove_runtime_text),
@@ -1295,6 +1328,7 @@ class MainActivity : ComponentActivity() {
syncLossless()
updates.onResume()
drivers.checkOnOpen()
resumeInterruptedPackages()
protons.checkUpdates()
if (!SessionState.running) Thread({
val applied = runCatching { ComponentsManager.applyQueued(this) }.getOrDefault(emptyList())
@@ -1439,8 +1473,9 @@ class MainActivity : ComponentActivity() {
)
}
private fun openComponents(focusContent: Boolean = true, tab: String? = null) {
private fun openComponents(focusContent: Boolean = true, tab: String? = null, gameKey: String? = null) {
focusComponentsContent = focusContent
componentsGameKey = gameKey
if (tab != null) components.compComp = tab
showPerformance = false
showMapping = false
@@ -1489,6 +1524,7 @@ class MainActivity : ComponentActivity() {
onImport = { pickComponent.launch(InAppFilePicker.buildIntent(this, WCP_EXT, getString(R.string.comp_pick_wcp))) },
onBack = { if (!com.droiddeck.launcher.ui.Hops.back { showComponents = false }) showComponents = false },
games = steamGames,
initialGameKey = componentsGameKey,
generalFexPreset = fexPreset,
onGeneralFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
gpu = drivers.state(),
@@ -1549,6 +1585,27 @@ class MainActivity : ComponentActivity() {
.putExtra(SessionService.EXTRA_PROGRAM, path))
}
private fun resumeInterruptedPackages() {
if (runtimeChangesBlocked()) return
val first = DesktopCatalog.interrupted(this).firstOrNull()
if (first == null) {
Thread({ DesktopCatalog.pruneDownloads(this) }, "prune-pkg").start()
return
}
pkgId = first.id; pkgStage = getString(R.string.deskpkg_installing, first.name); pkgPercent = -1
Thread({
val problem = DesktopCatalog.resumeInterrupted(this) { stage, percent ->
ui.post { pkgStage = stage; pkgPercent = percent }
}
ui.post {
pkgStage = null; pkgId = null
if (problem != null) android.widget.Toast.makeText(this, getString(R.string.main_pkg_problem, first.name, problem), android.widget.Toast.LENGTH_LONG).show()
refreshPackages()
refresh()
}
}, "resume-pkg").start()
}
private fun installPackage(id: String) {
val entry = catalog?.firstOrNull { it.id == id } ?: return
if (busy || LinuxRuntimeInstaller.isBusy() || pkgStage != null || SessionState.running) return
@@ -1599,6 +1656,7 @@ class MainActivity : ComponentActivity() {
pipAutoEnter = pipAutoEnter,
unfoldedControls = if (mode == SessionService.MODE_STEAM &&
com.droiddeck.launcher.session.SessionDisplay.foldable(this)) unfoldedControls else null,
unfoldedControlsWithController = unfoldedControlsWithController,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
@@ -1612,6 +1670,7 @@ class MainActivity : ComponentActivity() {
forceSsbs = forceSsbs,
syncBackend = if (mode == SessionService.MODE_STEAM) SessionPrefs.syncBackendOf(fastSync, fsyncFirst, syncFallback) else null,
steamClientArch = if (mode == SessionService.MODE_STEAM) steamClientArch else null,
returnAfterGame = if (mode == SessionService.MODE_STEAM) returnAfterGame else null,
steamRepairQueued = steamRepairQueued,
mangoapp = mangoapp,
steamController = if (mode == SessionService.MODE_STEAM) steamController else null,
@@ -1627,7 +1686,8 @@ class MainActivity : ComponentActivity() {
wifiDiscoveryAsked = wifiDiscoveryAsked,
wifiDiscoveryBlocked = wifiDiscoveryBlocked,
deckyInstalled = if (mode == SessionService.MODE_STEAM) decky.deckyInstalled else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyReleases.firstOrNull() else null,
deckyLatestRelease = if (mode == SessionService.MODE_STEAM) decky.deckyLatest else null,
deckyBranch = decky.deckyBranch,
deckyChecking = decky.deckyChecking, deckyStage = decky.deckyStage, deckyPercent = decky.deckyPercent,
deckyDownloadingBinary = decky.deckyDownloadingBinary,
deckyEnabled = decky.deckySupervisor, deckySessionRunning = SessionState.running,
@@ -1663,6 +1723,9 @@ class MainActivity : ComponentActivity() {
onZramCompression = { on -> SessionPrefs.setZramCompression(this, on); zramCompression = on },
onPipAutoEnter = { on -> SessionPrefs.setPipAutoEnter(this, on); pipAutoEnter = on },
onUnfoldedControls = { on -> SessionPrefs.setUnfoldedControls(this, on); unfoldedControls = on },
onUnfoldedControlsWithController = { on ->
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
},
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted)
@@ -1697,19 +1760,21 @@ class MainActivity : ComponentActivity() {
onSteamRepair = { steamRepairQueued = SteamRepair.queue(this) },
onSteamClientArch = { id -> SessionPrefs.setSteamClientArch(this, id); steamClientArch = id; refresh() },
onMangoapp = { on -> SessionPrefs.setMangoapp(this, on); mangoapp = on },
onReturnAfterGame = { on -> SessionPrefs.setReturnAfterGame(this, on); returnAfterGame = on },
onSteamController = { id -> SessionPrefs.setSteamController(this, id); steamController = id },
onRunSteamAtStartup = { on ->
SessionPrefs.setRunSteamAtStartup(this, on)
runSteamAtStartup = on
},
onBootMovie = { id -> SessionPrefs.setBootMovie(this, id); loadBootMovie() },
onBootMovie = { id -> com.droiddeck.launcher.session.BootMovie.select(this, id); loadBootMovie() },
onBootMovieSound = { on -> SessionPrefs.setBootMovieSound(this, on); bootMovieSound = on },
onImportBootMovie = {
pickBootMovie.launch(InAppFilePicker.buildIntent(this, com.droiddeck.launcher.session.BootMovie.PICKER_EXTENSIONS, getString(R.string.boot_movie_pick)))
},
onRemoveBootMovie = { com.droiddeck.launcher.session.BootMovie.removeCustom(this); loadBootMovie() },
onBootMovieChanged = { loadBootMovie() },
onDeckyInstall = { release -> decky.installDecky(release) },
onDeckyCheck = { decky.refreshDecky() },
onDeckyBranch = { branch -> decky.switchDeckyBranch(branch) },
onDeckyEnabled = { enabled -> DeckyManager.setSupervisorEnabled(this, enabled); decky.deckySupervisor = enabled },
onDeckyUninstall = {
DeckyManager.uninstall(this, wipeData = false)
@@ -1792,6 +1857,30 @@ class MainActivity : ComponentActivity() {
}, "steam-art-update").start()
}
private fun removeGame(g: Library.SteamGame) {
val folder = g.gameFiles ?: return
when (com.droiddeck.launcher.frontend.GameRemoval.kind(g)) {
com.droiddeck.launcher.frontend.GameRemoval.Kind.LAUNCHER -> Unit
com.droiddeck.launcher.frontend.GameRemoval.Kind.SHORTCUT -> removeAddedGame(folder.path, g.gameId, g.name)
com.droiddeck.launcher.frontend.GameRemoval.Kind.STORE -> {
val installed = com.droiddeck.launcher.frontend.GameRemoval.storeGame(g)
if (installed != null) {
com.droiddeck.launcher.stores.StoresState.uninstall(this, installed) { refreshAddedGames(); refresh() }
return
}
if (g.library == Library.ADDED) {
removeAddedGame(folder.path, g.gameId, g.name)
return
}
val target = com.droiddeck.launcher.frontend.GameRemoval.steamFiles(g) ?: return
Thread({
com.droiddeck.launcher.frontend.GameRemoval.delete(target)
ui.post { refreshAddedGames(); refresh() }
}, "game-remove").start()
}
}
}
private fun removeAddedGame(folder: String, appId: Long?, name: String) {
steamGames = steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path == folder }
Thread({ com.droiddeck.launcher.frontend.AddedGames.remove(this, java.io.File(folder), appId, name) }, "added-game-remove").start()
@@ -1825,10 +1914,12 @@ class MainActivity : ComponentActivity() {
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private var unfoldedControlsWithController by mutableStateOf(false)
private fun loadModeSettings(mode: String) {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this)
resolution = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
fexPreset = SessionPrefs.fexPreset(this)
forceSsbs = SessionPrefs.forceSsbs(this)
@@ -1838,6 +1929,7 @@ class MainActivity : ComponentActivity() {
steamClientArch = SessionPrefs.steamClientArch(this)
steamRepairQueued = SteamRepair.queued(this)
mangoapp = SessionPrefs.mangoapp(this)
returnAfterGame = SessionPrefs.returnAfterGame(this)
steamController = SessionPrefs.steamController(this)
runSteamAtStartup = SessionPrefs.runSteamAtStartup(this)
loadBootMovie()
@@ -1920,7 +2012,7 @@ class MainActivity : ComponentActivity() {
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) {
steamGamesX64 = listX64
steamGames = steamGames.filterNot(Library::fromSteamClient)
steamGames = steamGames.filterNot { Library.fromSteamClient(it) || (it.library == Library.ADDED && it.gameId in 1..0xFFFFFFFFL) }
}
if (!busy && LinuxRuntimeInstaller.isBusy()) followRuntimeOperation { LinuxRuntimeInstaller.attach(it) }
if (showComponents) drivers.refreshDrivers()
@@ -1962,12 +2054,13 @@ class MainActivity : ComponentActivity() {
ui.post { if (scanGeneration == libraryScanGeneration) emulatorList = emus }
if (ready) {
val quick = runCatching { Library.steamGames(this) }.getOrNull()
val claimed = quick?.let { runCatching { Library.claimedFolders(it) }.getOrDefault(emptySet()) }.orEmpty()
if (quick != null) ui.post {
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
val before = steamGames.map { it.gameId }.toSet()
val fresh = quick.filter { it.gameId !in before }
if (fresh.isNotEmpty()) {
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
steamGames = (quick + steamGames.filter { it.library == Library.ADDED && it.gameFiles?.absolutePath.let { path -> path == null || path !in claimed } }).distinctBy { it.gameId }
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
}
}
@@ -2006,10 +2099,14 @@ class MainActivity : ComponentActivity() {
private fun onRuntimeButton() {
if (runtimeChangesBlocked()) return
val release = available
if (removalPending || installed != null && release?.version == installed) {
if (removalPending) {
showRemove = true
return
}
if (installed != null && release?.version == installed) {
showRuntimeActions = release
return
}
if (release == null) {
Thread({ checkCatalog() }, "catalog").start()
return
@@ -2093,7 +2190,7 @@ class MainActivity : ComponentActivity() {
com.droiddeck.launcher.core.AppLanguage.setChosen(this, com.droiddeck.launcher.core.AppLanguage.SYSTEM)
if (!ok) android.widget.Toast.makeText(this, R.string.setup_reset_settings_partial, android.widget.Toast.LENGTH_LONG).show()
com.droiddeck.launcher.ui.Motion.refresh(this)
recreate()
if (!switchLauncherIcon(LauncherIcon.DARK)) recreate()
}
}, "reset-settings").start()
}
@@ -29,6 +29,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -75,11 +76,13 @@ import com.droiddeck.launcher.input.PadBridge
import com.droiddeck.launcher.input.PadMouse
import com.droiddeck.launcher.input.PadState
import com.droiddeck.launcher.input.SteamControllerBle
import com.droiddeck.launcher.input.DirectTouchHold
import com.droiddeck.launcher.input.PointerGestures
import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays
import com.droiddeck.launcher.input.SecondScreenMode
import com.droiddeck.launcher.input.TouchpadGestures
import com.droiddeck.launcher.input.TouchscreenGestures
import com.droiddeck.launcher.input.ScrollTouchGestures
import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.LoadingState
@@ -131,6 +134,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var pipUi by mutableStateOf(false)
private var pipAutoEnter by mutableStateOf(false)
private var unfoldedControls by mutableStateOf(false)
private var unfoldedControlsWithController by mutableStateOf(false)
private var picturePosition by mutableStateOf("center")
private fun applyGameAlignment() {
@@ -188,7 +192,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private var watching = true
private lateinit var touchpad: TouchpadGestures
private lateinit var directTouchHold: DirectTouchHold
private lateinit var threeFingerTap: com.droiddeck.launcher.input.ThreeFingerTap
private lateinit var touchscreenGestures: TouchscreenGestures
private var gestureConfig = com.droiddeck.launcher.input.TouchGestureConfig()
private var gestureGame: String? = null
private var gestureSettingsOpen by mutableStateOf(false)
private var recognizedGesture by mutableStateOf<TouchscreenGestures.Gesture?>(null)
private var gesturePointerX = 0f
private var gesturePointerY = 0f
private var gesturePointerReady = false
private val clearRecognizedGesture = Runnable { recognizedGesture = null }
private val gestureKeysDown = mutableSetOf<Int>()
private lateinit var scrollTouch: ScrollTouchGestures
private lateinit var padMouse: PadMouse
@Volatile private var padMouseOn = false
@@ -209,6 +224,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val pointerLockListener = WaylandCompositor.PointerLockListener { locked, _, _ ->
uiHandler.post {
if (pointerLocked == locked) return@post
gesturePointerReady = false
pointerLocked = locked
Log.i(TAG, "pointer lock " + if (locked) "taken by a program" else "released")
padMouseCheckedAt = 0L
@@ -292,6 +308,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var achievementsInFront by mutableStateOf<AchievementsInFront?>(null)
private var achievementsOpen by mutableStateOf(false)
private var hudOn by mutableStateOf(true)
private var hudFps by mutableStateOf(true)
private var hudBattery by mutableStateOf(true)
private var hudGraph by mutableStateOf(true)
private var hudColor by mutableStateOf(SessionPrefs.HUD_COLOR_MINT)
private var hudOpacity by mutableStateOf(SessionPrefs.HUD_OPACITY_DEFAULT)
private var upscaler by mutableStateOf(0)
private var upscaleSharpness by mutableStateOf(75)
private var effects by mutableStateOf(ScreenEffects.OFF)
@@ -346,17 +367,46 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var secondScreenDisplays by mutableStateOf<List<SecondScreenDisplay>>(emptyList())
private var selectedSecondScreenDisplay by mutableStateOf(SessionState.secondScreenDisplay)
private var started = false
private var sessionActivityDisplayId = Display.DEFAULT_DISPLAY
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 onDisplayAdded(displayId: Int) {
notifyActivityDisplayStateChanged(displayId)
refreshSecondScreenDisplays()
}
override fun onDisplayRemoved(displayId: Int) {
if (refreshActivityDisplayId() && started) {
SessionService.setActivityDisplay(this@SessionActivity, sessionActivityDisplayId)
}
refreshSecondScreenDisplays()
}
override fun onDisplayChanged(displayId: Int) {
notifyActivityDisplayStateChanged(displayId)
followPanelRefresh(displayId)
refreshSecondScreenDisplays()
}
}
@Suppress("DEPRECATION")
private fun currentActivityDisplayId(): Int = windowManager.defaultDisplay.displayId
private fun refreshActivityDisplayId(): Boolean {
val current = if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: currentActivityDisplayId()
else currentActivityDisplayId()
val changed = sessionActivityDisplayId != current
sessionActivityDisplayId = current
SessionState.activityDisplayId = current
return changed
}
private fun notifyActivityDisplayStateChanged(changedDisplayId: Int) {
val changed = refreshActivityDisplayId()
if (started && (changed || changedDisplayId == sessionActivityDisplayId)) {
SessionService.setActivityDisplay(this, sessionActivityDisplayId)
}
}
private fun followPanelRefresh(displayId: Int) {
if (!CompositorHost.isStarted) return
val output = glassesScreen?.display
@@ -455,6 +505,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val bridge = PadBridge(File(LinuxRuntime.sessionRoot(this), "dev/input"))
bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam)
bridge.setBuiltinPad(SessionState.mode != SessionService.MODE_STEAM || SessionPrefs.builtinPad(this))
padBridge = bridge
SessionState.padBridge = bridge
if (Build.VERSION.SDK_INT >= 31) {
@@ -483,7 +534,20 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onScreenControls = OnScreenControls(this, bridge, onKeyboard = ::togglePcKeyboard).also { root.addView(it) }
keyboard = KeyboardHost(this).also { root.addView(it) }
touchpad = TouchpadGestures(PointerGestures.slop(this), pointerListener)
directTouchHold = DirectTouchHold(PointerGestures.slop(this), pointerListener) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
}
threeFingerTap = com.droiddeck.launcher.input.ThreeFingerTap(PointerGestures.slop(this) * 2)
touchscreenGestures = TouchscreenGestures(
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
)
refreshGestureConfig()
// A real touch target lets FrameLayout split fingers between the
// game surface and on-screen buttons, in either finger-down order.
root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event ->
routedTouchMode == SessionPrefs.TOUCH_GESTURES && onTouchEvent(event)
}
padMouse = PadMouse(padMouseListener)
bridge.setTouchRoute(object : PadBridge.TouchRoute {
override fun active(): Boolean = if (Looper.myLooper() == Looper.getMainLooper()) padMouseMode() else padMouseOn
@@ -500,12 +564,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
loading = LoadingState(this, steam = loadingMode() == SessionService.MODE_STEAM)
if (!SessionState.running) {
val needRuntime = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val repair = intent.getBooleanExtra(EXTRA_REPAIR_RUNTIME, false)
intent.removeExtra(EXTRA_REPAIR_RUNTIME)
val needRuntime = repair || com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.installedVersion(this) == null
val needResume = !needRuntime && com.droiddeck.launcher.runtime.DesktopCatalog.interrupted(this).isNotEmpty()
val needDesktop = intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_DESKTOP &&
!com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this)
(repair || !com.droiddeck.launcher.runtime.DesktopCatalog.desktopInstalled(this))
val needProton = (intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) == SessionService.MODE_STEAM &&
com.droiddeck.launcher.runtime.DesktopCatalog.protonSeedNeeded(this)
if (needRuntime || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton)
if (needRuntime || needResume || needDesktop || needProton) installThenStart(needRuntime, needDesktop, needProton, needResume)
}
hud = PerfHud(this)
hud.onPresentingWindowChanged = {
@@ -593,6 +660,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onDispose { com.droiddeck.launcher.glasses.GlassesMenuMirror.setSessionMenuVisible(this@SessionActivity, false) }
}
androidx.compose.runtime.LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, pipUi) { updatePointerCapture() }
androidx.compose.runtime.LaunchedEffect(AgentGuest.steamKeyboard) { updateOnScreenControls() }
androidx.compose.runtime.LaunchedEffect(picturePosition, SessionState.running, pipUi, cardboard.enabled) { applyGameAlignment() }
DroidDeckTheme {
if (pipUi) {
@@ -605,10 +673,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
} else {
com.droiddeck.launcher.ui.FocusGlideHost(androidx.compose.ui.Modifier.fillMaxSize()) {
CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density,
CursorOverlay({ cursorPos }, cursorVisible, resources.displayMetrics.density,
cursorImage, cursorHotX, cursorHotY, cursorImageScale)
if (hud.text.isNotEmpty()) HudText(hud.text)
if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model)
if (hud.text.isNotEmpty() || hud.graph.size >= 2) HudText(hud.text, hud.graph, hudColor, hudOpacity)
val phonePanel = glassesOutputActive && !loading.visible && !drawerOpen && !pcKeyboardOpen
if (phonePanel) GlassesPhonePanel(glasses, steamVrStatus == SteamVr.Status.RUNNING, headTracking)
if (trackingRecovery != null && !drawerOpen && !pcKeyboardOpen && !loading.visible) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { com.droiddeck.launcher.ui.TrackingRecoveryView(it) },
@@ -616,14 +685,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
update = { it.update(trackingRecovery, !glassesOutputActive && glasses.usable && glasses.sbs) },
)
}
if (GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
if (!phonePanel && GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) &&
headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton(
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100,
onRecenterJoyCons = if (steamVrStatus == SteamVr.Status.RUNNING &&
glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
) { headTracker?.recenter() }
if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
if (!phonePanel && glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot,
controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) {
drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES
drawerOpen = true
@@ -635,6 +704,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onCancel = { SessionService.stop(this@SessionActivity); finish() },
endedDetail = loading.endedDetail,
onRetry = { retrySession() },
onRepair = if (repairOffered) ({ repairRuntime() }) else null,
onShareLogs = { shareCurrentSessionLogs() },
onClose = { finish() },
flood = floodColor?.let { androidx.compose.ui.graphics.Color(it) },
@@ -643,6 +713,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
},
)
LaunchedEffect(AgentGuest.focusRevision) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
else {
val snapshot = AgentGuest.focus(this@SessionActivity)
val focused = snapshot?.optLong("focusedApp")?.takeIf { it > 0 && it != 769L }
val windows = snapshot?.optJSONArray("focusableApps")
focused?.toString() ?: windows?.let { values ->
(0 until values.length()).map { values.optLong(it) }
.filter { it > 0 && it != 769L }.distinct().singleOrNull()?.toString()
}
}
}
if (nextGame != gestureGame) { gestureGame = nextGame; refreshGestureConfig() }
}
LaunchedEffect(drawerOpen, pcKeyboardOpen, achievementsOpen, gestureSettingsOpen, pipUi, SessionState.suspended) {
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) {
cancelTouchGestures()
}
}
com.droiddeck.launcher.ui.GestureFeedback(recognizedGesture)
if (gestureSettingsOpen) com.droiddeck.launcher.ui.TouchGestureDialog(
game = gestureGame,
onDismiss = { gestureSettingsOpen = false },
onSaved = { gestureSettingsOpen = false; refreshGestureConfig() },
)
androidx.compose.runtime.LaunchedEffect(drawerOpen) {
if (drawerOpen) {
syncLossless()
@@ -678,7 +774,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
achievementsInFront = found
if (found == null) achievementsOpen = false
}
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() }
androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads(); updateOnScreenControls() }
LaunchedEffect(pcKeyboardOpen, drawerOpen, achievementsOpen) {
if (pcKeyboardOpen || drawerOpen || achievementsOpen) padMouse.reset()
}
@@ -696,6 +792,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
isHomeApp = isHomeApp,
androidApps = androidApps,
hudOn = hudOn,
hudFps = hudFps,
hudBattery = hudBattery,
hudGraph = hudGraph,
hudColor = hudColor,
hudOpacity = hudOpacity,
upscaler = upscaler, upscaleSharpness = upscaleSharpness,
effects = effects,
cardboard = cardboard,
@@ -723,6 +824,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
onHudFps = { on -> SessionPrefs.setHudShowFps(this@SessionActivity, on); hudFps = on; hud.refresh() },
onHudBattery = { on -> SessionPrefs.setHudShowBattery(this@SessionActivity, on); hudBattery = on; hud.refresh() },
onHudGraph = { on -> SessionPrefs.setHudShowGraph(this@SessionActivity, on); hudGraph = on; hud.refresh() },
onHudColor = { color -> SessionPrefs.setHudColor(this@SessionActivity, color); hudColor = color },
onHudOpacity = { percent -> SessionPrefs.setHudOpacity(this@SessionActivity, percent); hudOpacity = percent },
onUpscaler = { m ->
SessionPrefs.setUpscaler(this@SessionActivity, m); upscaler = m
WaylandCompositor.nativeSetUpscaler(m)
@@ -779,6 +885,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionPrefs.setSteamController(this@SessionActivity, id)
com.droiddeck.launcher.session.SteamDeckPad.showPad(SessionPrefs.padType(id))
com.droiddeck.launcher.input.FakeInputWriter.setPadType(SessionPrefs.padType(id))
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id
},
backActionsInverted = backActionsInverted,
@@ -820,6 +927,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.suspendPolicyChanged(this@SessionActivity)
},
onTouch = { v -> SessionPrefs.setTouchMode(this@SessionActivity, v); readPrefs() },
onGestureSettings = { drawerOpen = false; gestureSettingsOpen = true },
onFexPreset = { v -> SessionPrefs.setFexPreset(this@SessionActivity, v); readPrefs() },
onSecondScreenMode = ::selectSecondScreenMode,
onSecondScreenDisplay = ::selectSecondScreenDisplay,
@@ -847,6 +955,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
},
unfoldedControls = if (com.droiddeck.launcher.session.SessionDisplay.foldable(this@SessionActivity)) unfoldedControls else null,
onUnfoldedControls = ::chooseUnfoldedControls,
unfoldedControlsWithController = unfoldedControlsWithController,
onUnfoldedControlsWithController = ::chooseUnfoldedControlsWithController,
onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) },
onShareLogs = { drawerOpen = false; shareCurrentSessionLogs() },
onStop = { drawerOpen = false; SessionService.stop(this@SessionActivity); finish() },
@@ -940,6 +1050,54 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, 90)
}
@androidx.compose.runtime.Composable
private fun GlassesPhonePanel(glasses: com.droiddeck.launcher.glasses.GlassesState, vr: Boolean, headTracking: Boolean) {
val battery by androidx.compose.runtime.produceState<String?>(null) {
while (true) {
val b = registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED))
val level = b?.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = b?.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, 100) ?: 100
val tenths = b?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, -1) ?: -1
value = if (level < 0) null else getString(R.string.glasses_phone_battery, level * 100 / scale.coerceAtLeast(1), tenths / 10)
kotlinx.coroutines.delay(30_000)
}
}
val pads = com.droiddeck.launcher.input.VrControllers.settings.collectAsState().value
com.droiddeck.launcher.ui.GlassesPhonePanel(glasses.model, battery, com.droiddeck.launcher.ui.GlassesPhoneActions(
onRecenter = if (headTracking) ({ headTracker?.recenter() }) else null,
onRecenterJoyCons = if (vr && glasses.usesSpatialControllers(glassesSettings) && glassesSettings.joyConTracking)
joyConVr?.let { { it.recenter() } } else null,
onSteamVrMenu = if (vr && SessionState.mode == SessionService.MODE_STEAM) ({ sendSteamGuide() }) else null,
onSwitchSticks = if (vr && pads.emulate != com.droiddeck.launcher.input.VrControllers.Emulate.OFF) ({ switchVrSticks() }) else null,
controllers = getString(when (pads.emulate) {
com.droiddeck.launcher.input.VrControllers.Emulate.OFF -> R.string.vr_controllers_off
com.droiddeck.launcher.input.VrControllers.Emulate.INDEX -> R.string.vr_controllers_index
com.droiddeck.launcher.input.VrControllers.Emulate.TOUCH -> R.string.vr_controllers_touch
com.droiddeck.launcher.input.VrControllers.Emulate.VIVE -> R.string.vr_controllers_vive
}),
onNextControllers = if (vr) ({
val all = com.droiddeck.launcher.input.VrControllers.Emulate.entries
com.droiddeck.launcher.input.VrControllers.setEmulate(this, all[(pads.emulate.ordinal + 1) % all.size])
}) else null,
onKeyboard = { keyboard?.toggle() },
onSessionMenu = { drawerOpen = true },
onStopSteamVr = if (vr) ({ steamVrAction("stop") }) else null,
brightness = glasses.brightness,
brightnessMax = glasses.brightnessMax,
onBrightness = if (glasses.canControl && glasses.hardwareControls) Glasses::setBrightness else null,
))
}
/** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */
private fun switchVrSticks() {
val chord = { down: Boolean -> padBridge?.applyTouch { state ->
state.press(com.droiddeck.launcher.input.PadState.L3, down)
state.press(com.droiddeck.launcher.input.PadState.R3, down)
} }
chord(true)
uiHandler.postDelayed({ chord(false) }, 120)
}
private fun triggerSteamQam() {
drawerOpen = false
padBridge?.triggerQam()
@@ -1283,8 +1441,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun readPrefs() {
pipAutoEnter = SessionPrefs.pipAutoEnter(this)
unfoldedControls = SessionPrefs.unfoldedControls(this)
unfoldedControlsWithController = SessionPrefs.unfoldedControlsWithController(this)
picturePosition = SessionPrefs.picturePosition(this)
hudOn = SessionPrefs.hudEnabled(this)
hudFps = SessionPrefs.hudShowFps(this)
hudBattery = SessionPrefs.hudShowBattery(this)
hudGraph = SessionPrefs.hudShowGraph(this)
hudColor = SessionPrefs.hudColor(this)
hudOpacity = SessionPrefs.hudOpacity(this)
upscaler = SessionPrefs.upscaler(this)
upscaleSharpness = SessionPrefs.upscaleSharpness(this)
effects = SessionPrefs.screenEffects(this)
@@ -1298,6 +1462,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val nextTouchMode = SessionPrefs.touchMode(this)
if (touchMode != nextTouchMode) {
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) {
WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
@@ -1322,17 +1487,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
@Volatile private var installingRuntime = false
private var repairOffered by mutableStateOf(false)
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false) {
private fun installThenStart(runtime: Boolean, desktop: Boolean, proton: Boolean = false, resume: Boolean = false) {
if (SessionState.stopRequested) return
installingRuntime = true
SessionState.installing = if (runtime) "runtime" else if (desktop) "desktop" else "proton"
SessionState.installing = if (runtime) "runtime" else if (resume) "package" else if (desktop) "desktop" else "proton"
SessionEvents.transition(
SessionPhase.INSTALLING_RUNTIME,
"${SessionState.installing}.installing",
mapOf("component" to SessionState.installing),
)
val first = if (runtime) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
val first = if (runtime || resume) RUNTIME_LINES else if (desktop) DESKTOP_LINES else PROTON_LINES
loading.say(getString(first.downloading), first.topic, readable = true)
loading.percent = -1
Thread({
@@ -1342,6 +1508,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
problem = installRuntime()
if (problem == null) SessionEvents.record("runtime.ready") else failedComponent = "runtime"
}
if (problem == null && resume && !SessionState.stopRequested) {
SessionState.installing = "package"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "package.resuming", mapOf("component" to "package"))
problem = com.droiddeck.launcher.runtime.DesktopCatalog.resumeInterrupted(this, progressAsIs())
?.let { getString(R.string.session_package_resume_failed, it) }
if (problem == null) SessionEvents.record("package.ready") else failedComponent = "package"
}
if (problem == null && desktop && !SessionState.stopRequested) {
SessionState.installing = "desktop"
SessionEvents.transition(SessionPhase.INSTALLING_RUNTIME, "desktop.installing", mapOf("component" to "desktop"))
@@ -1366,7 +1539,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return@post
}
if (problem != null) {
val code = if (failedComponent == "runtime") "RUNTIME_INSTALL_FAILED" else "DESKTOP_INSTALL_FAILED"
val code = when (failedComponent) {
"runtime" -> "RUNTIME_INSTALL_FAILED"
"package" -> "PACKAGE_RESUME_FAILED"
else -> "DESKTOP_INSTALL_FAILED"
}
SessionEvents.fail(code, problem)
collectStartArtifacts("session start failed")
loading.showEnded(problem)
@@ -1414,9 +1591,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
}
private fun progressAsIs() = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.ProgressListener { stage, percent ->
uiHandler.post {
loading.percent = percent
loading.say(stage, LoadingState.Topic.RUNTIME, readable = true)
}
}
private fun installRuntime(): String? {
val release = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.fetchRelease()
?: return getString(R.string.session_runtime_catalog_unreachable)
runCatching { com.droiddeck.launcher.runtime.RuntimeInstallService.start(this, release) }
val ok = com.droiddeck.launcher.runtime.LinuxRuntimeInstaller.install(this, release,
progressFor(RUNTIME_LINES, release.size / 1_000_000))
return if (ok) null else getString(R.string.session_runtime_install_failed)
@@ -1446,10 +1631,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
val runtimeDir = File(filesDir, ".wayland-rt").apply { mkdirs() }
FileUtils.copyAsset(this, "wayland/keymap.xkb", File(runtimeDir, "keymap.xkb"))
com.droiddeck.launcher.input.VrControllers.publish(this)
val turnip = TurnipDriver(this)
com.droiddeck.launcher.gpu.DriverSetupLog.write(this, "surface.driver-setup")
if (!CompositorHost.isStarted) CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
if (!CompositorHost.isStarted) {
CompositorHost.displayChoice = SessionPrefs.androidDriver(this)
com.droiddeck.launcher.gpu.AutoDriverTrial.displayStarting(this, SessionPrefs.androidDriver(this))
}
val driverId = if (CompositorHost.isStarted) null else turnip.install()
val previous = SessionState.outputSize
@@ -1524,6 +1713,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
intent.getStringExtra(SessionService.EXTRA_STEAM_UI),
intent.getStringExtra(SessionService.EXTRA_STEAM_URL),
intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS),
sessionActivityDisplayId,
)
}
applyFrameGen()
@@ -1539,6 +1729,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
surfaceW = width
surfaceH = height
if (resized) {
cancelTouchGestures()
Log.i(TAG, "surface resized to ${width}x$height - rebinding the compositor")
CompositorHost.resize(holder.surface) { applyFrameGen() }
followWindow(width, height)
@@ -1557,7 +1748,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
private fun followChoice(): Boolean {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
}
@@ -1573,6 +1764,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (refreshActivityDisplayId() && started) {
SessionService.setActivityDisplay(this, sessionActivityDisplayId)
}
cancelTouchGestures()
applyFollowOrientation(newConfig)
applyUnfoldedControls()
}
@@ -1581,12 +1776,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Hiding the phone surface after a handoff delivers a late destruction
// callback. It must not reset the new output's size or attachment.
if (holder !== outputHolder()) return
cancelTouchGestures()
surfaceW = 0
surfaceH = 0
glassesOutputActive = false
CompositorHost.detach(holder.surface)
}
private fun cancelTouchGestures() {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
scrollTouchGesture = false
gesturePointerReady = false
}
private fun panelBounds(): android.graphics.Rect = if (Build.VERSION.SDK_INT >= 30) {
windowManager.maximumWindowMetrics.bounds
} else {
@@ -1596,8 +1800,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
android.graphics.Rect(0, 0, metrics.widthPixels, metrics.heightPixels)
}
private fun displayMode(): String = SessionPrefs.displayMode(this,
intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM, intent.getStringExtra(SessionService.EXTRA_PROGRAM))
private fun outputSize(): Pair<Int, Int> {
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val panel = com.droiddeck.launcher.session.SessionDisplay.panelSize(this)
val program = intent.getStringExtra(SessionService.EXTRA_PROGRAM)
val choice = if (intent.getStringExtra(SessionService.EXTRA_MODE) == SessionService.MODE_RUN &&
@@ -1611,8 +1818,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
glassesScreen?.hz?.takeIf { it > 1f }?.let { return it }
val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay
val wanted = window.attributes.preferredDisplayModeId
val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
?: display?.refreshRate ?: 60f
val requested = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate
val running = display?.refreshRate?.takeIf { it > 1f }
val hz = listOfNotNull(requested, running).minOrNull() ?: 60f
val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY
val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap)
return if (capped > 1f) capped else 60f
@@ -1716,7 +1924,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return
}
Thread({
val hint = sessionEndHint(SessionState.logFile)
val hint = runtimeDamageHint() ?: sessionEndHint(SessionState.logFile)
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
showEnded(status, hint)
@@ -1735,6 +1943,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.MODE_DESKTOP -> getString(R.string.session_ended_desktop)
else -> getString(R.string.session_ended_named, "Steam")
}
repairOffered = hint?.repair == true
loading.showEnded(
hint?.text ?: stopped,
getString(R.string.session_ended_detail, status, SessionState.logFile?.path ?: getString(R.string.session_ended_no_log)),
@@ -1753,6 +1962,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
uiHandler.post { sessionOverlay.requestFocus() }
}
private fun repairRuntime() {
val again = Intent(intent).putExtra(EXTRA_REPAIR_RUNTIME, true)
closeAtOnce = true
finish()
startActivity(again)
}
private fun retrySession() {
val again = Intent(intent)
closeAtOnce = true
@@ -1760,7 +1976,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
startActivity(again)
}
private class EndHint(val text: String, val evenOnSuccess: Boolean = false)
private class EndHint(val text: String, val evenOnSuccess: Boolean = false, val repair: Boolean = false)
private fun runtimeDamageHint(): EndHint? {
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(this) }.getOrDefault(emptyList())
if (broken.isEmpty()) return null
Log.w(TAG, "runtime has ${broken.size} broken library link(s): ${broken.take(8).joinToString()}")
return EndHint(resources.getQuantityString(R.plurals.session_end_runtime_damaged, broken.size, broken.size, broken.first()), repair = true)
}
private fun presentationFault(): String? {
com.droiddeck.launcher.wayland.WaylandCompositor.compositorFault().takeIf { it.isNotEmpty() }?.let { return it }
@@ -1791,17 +2014,22 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
var enosys = 0
var fexMissing = false
val size = log.length()
java.io.RandomAccessFile(log, "r").use { f ->
val text = java.io.RandomAccessFile(log, "r").use { f ->
val start = maxOf(0L, size - 512L * 1024)
f.seek(start)
val bytes = ByteArray((size - start).toInt())
f.readFully(bytes)
String(bytes, Charsets.ISO_8859_1).lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
if (line.contains(FEX_MISSING)) fexMissing = true
}
String(bytes, Charsets.ISO_8859_1)
}
if (fexMissing) {
text.lineSequence().forEach { line ->
if (line.contains("Function not implemented")) enosys++
if (line.contains(FEX_MISSING)) fexMissing = true
}
val steamSetup = com.droiddeck.launcher.session.SteamClientSetupFailure.find(text.lineSequence())
if (steamSetup != null) {
val message = getString(if (steamSetup.download) R.string.session_end_steam_download else R.string.session_end_steam_setup)
EndHint(listOfNotNull(message, steamSetup.curlError).joinToString("\n"))
} else if (fexMissing) {
val what = com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: getString(R.string.session_end_program)
EndHint(getString(R.string.session_end_fex_missing, what), evenOnSuccess = true)
} else if (enosys >= 8) {
@@ -1844,7 +2072,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty(), glasses.present)
drawerPage = sessionDrawerPages(secondScreenDisplays.isNotEmpty(), glasses.present)
.step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1)
releaseDrawerDirection()
}
@@ -2007,6 +2235,55 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.dispatchKeyEvent(event)
}
private fun refreshGestureConfig() {
gestureConfig = com.droiddeck.launcher.input.GesturePrefs.read(this, gestureGame)
if (::touchscreenGestures.isInitialized) touchscreenGestures.configure(gestureConfig)
gesturePointerReady = false
recognizedGesture = null
}
private val gestureOutput = object : TouchscreenGestures.Output {
override fun moveTo(x: Float, y: Float) {
val rect = drawnRect() ?: return
if (pointerLocked) {
if (gesturePointerReady) moveBy(x - gesturePointerX, y - gesturePointerY, true)
} else movePointer(x, y)
gesturePointerX = if (pointerLocked) x else x.coerceIn(rect.left, rect.right)
gesturePointerY = if (pointerLocked) y else y.coerceIn(rect.top, rect.bottom)
gesturePointerReady = true
cursorVisible = false
}
override fun moveBy(dx: Float, dy: Float, relative: Boolean) {
val rect = drawnRect() ?: return
if (relative || pointerLocked) {
val scale = SessionState.outputSize.first / rect.width()
WaylandCompositor.sendPointerDelta(dx * scale, dy * scale)
} else moveTo(gesturePointerX + dx, gesturePointerY + dy)
}
override fun button(code: Int, down: Boolean) {
WaylandCompositor.nativeSendSceneInput(3, code, if (down) 1 else 0)
}
override fun prepareRelativePan() {
if (pointerLocked) return
val rect = drawnRect() ?: return
// Gamescope retains the last absolute position while a game can warp
// its own cursor to the center. Its first unlocked motion must start
// from that same origin, before the game switches to pointer lock.
moveTo(rect.centerX(), rect.centerY())
}
override fun key(code: Int, down: Boolean) {
if (down) gestureKeysDown.add(code) else gestureKeysDown.remove(code)
if (code !in guestKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
override fun wheel(steps: Int) { WaylandCompositor.nativeSendSceneInput(4, steps, 0) }
override fun showKeyboard() { uiHandler.post { touchscreenGestures.cancel(); togglePcKeyboard() } }
override fun gesture(gesture: TouchscreenGestures.Gesture) {
recognizedGesture = gesture
uiHandler.removeCallbacks(clearRecognizedGesture)
uiHandler.postDelayed(clearRecognizedGesture, 1200)
}
}
private val pointerListener = object : PointerGestures.Listener {
override fun onMove(x: Float, y: Float) {
if (pointerLocked) moveLockedPointer(x, y) else movePointer(x, y)
@@ -2141,9 +2418,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return true
}
private val shownOnDisplayId: Int get() =
if (Build.VERSION.SDK_INT >= 30) display?.displayId ?: windowManager.defaultDisplay.displayId
else windowManager.defaultDisplay.displayId
private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return
val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId
val primaryId = shownOnDisplayId
val candidates = SecondScreenDisplays.available(displayManager, primaryId)
.filter { glassesScreen == null || it.id != glasses.displayId }
val oldIds = secondScreenDisplays.map { it.id }.toSet()
@@ -2235,6 +2516,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
applyUnfoldedControls()
}
private fun chooseUnfoldedControlsWithController(on: Boolean) {
SessionPrefs.setUnfoldedControlsWithController(this, on)
unfoldedControlsWithController = on
applyUnfoldedControls()
}
private fun refreshControls() {
applyUnfoldedControls()
updateOnScreenControls()
@@ -2244,11 +2531,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!::sessionRoot.isInitialized) return
val width = sessionRoot.width
val height = sessionRoot.height
val wanted = unfoldedControls && com.droiddeck.launcher.session.SessionDisplay.foldable(this) &&
!PadBridge.anyControllerConnected() && !SteamControllerBle.connected &&
SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad &&
!pipUi && !isInPictureInPictureMode && width > 0 && height > 0 &&
resources.configuration.smallestScreenWidthDp >= com.droiddeck.launcher.session.SessionDisplay.UNFOLDED_MIN_DP
val wanted = com.droiddeck.launcher.session.SessionDisplay.unfoldedControlsWanted(
enabled = unfoldedControls,
foldable = com.droiddeck.launcher.session.SessionDisplay.foldable(this),
controllerConnected = PadBridge.anyControllerConnected() || SteamControllerBle.connected,
keepWithController = unfoldedControlsWithController,
steamDeckPad = SessionState.mode == SessionService.MODE_STEAM && SessionState.deckPad,
pip = pipUi || isInPictureInPictureMode,
width = width,
height = height,
smallestWidthDp = resources.configuration.smallestScreenWidthDp,
)
val gameHeight = if (wanted) com.droiddeck.launcher.session.SessionDisplay.unfoldedGameHeight(width, height)
else FrameLayout.LayoutParams.MATCH_PARENT
val params = surfaceView.layoutParams as FrameLayout.LayoutParams
@@ -2297,6 +2590,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (options == null || steamTouchControls?.visibility != View.VISIBLE) return SessionPrefs.touchMode(this)
return when {
options.inputMode == com.droiddeck.launcher.input.SteamTouchConfig.INPUT_CONTROLLER -> SessionPrefs.TOUCH_OFF
SessionPrefs.touchMode(this) == SessionPrefs.TOUCH_GESTURES -> SessionPrefs.TOUCH_GESTURES
options.mouseMode == com.droiddeck.launcher.input.SteamTouchConfig.MOUSE_RELATIVE -> SessionPrefs.TOUCH_PAD
else -> SessionPrefs.TOUCH_DIRECT
}
@@ -2309,7 +2603,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
touchpad.sensitivity = 1.15f * speed
}
if (next == routedTouchMode) return
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
gesturePointerReady = false
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
if (::scrollTouch.isInitialized) scrollTouch.cancel()
if (CompositorHost.isStarted) WaylandCompositor.nativeSendTouch(3, -1, 0, 0)
if (next == SessionPrefs.TOUCH_OFF) cursorVisible = false
@@ -2344,6 +2641,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) return onMouse(event)
if (drawerOpen || pcKeyboardOpen || achievementsOpen || gestureSettingsOpen || pipUi || SessionState.suspended) return true
if (routedTouchMode == SessionPrefs.TOUCH_GESTURES) {
if (event.actionMasked == MotionEvent.ACTION_DOWN) gesturePointerReady = false
return touchscreenGestures.onTouch(event)
}
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
scrollTouchGesture = scrollTouchMode()
scrollRestX = 0f
@@ -2377,6 +2679,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) ignoreGameTouch = false
return true
}
if (directTouchHold.onTouch(event)) return true
fun sendTouch(action: Int, index: Int) {
val x = event.getX(index).coerceIn(rect.left, rect.right)
val y = event.getY(index).coerceIn(rect.top, rect.bottom)
@@ -2520,12 +2823,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
?.let { FileUtils.readString(it)?.trim()?.lowercase() }
?: SessionPrefs.oscMode(this)
val controls = onScreenControls ?: return
if (pipUi || unfoldedPanel != null) {
if (pipUi || unfoldedPanel != null || pcKeyboardOpen ||
(SessionState.mode == SessionService.MODE_STEAM && !SessionPrefs.builtinPad(this))) {
onScreenButtonsVisible = false
controls.releaseAll()
controls.visibility = View.GONE
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
return
}
val buttonsOnly = forced == SessionPrefs.OSC_STEAM_QAM
@@ -2538,6 +2843,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (steam == null) {
steamTouchControls?.let { it.releaseAll(); it.visibility = View.GONE }
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = false
applyTouchRouting()
}
controls.setButtonsOnly(buttonsOnly)
val show = when (forced) {
@@ -2550,29 +2856,33 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionState.mode != SessionService.MODE_DESKTOP
}
onScreenButtonsVisible = show
val drawn = show && !keyboardCoversPad()
if (steam != null) {
if (controls.visibility == View.VISIBLE) { controls.releaseAll(); controls.visibility = View.GONE }
if (!show) steam.releaseAll()
steam.visibility = if (show) View.VISIBLE else View.GONE
if (!drawn) steam.releaseAll()
steam.visibility = if (drawn) View.VISIBLE else View.GONE
com.droiddeck.launcher.input.SteamTouchDevice.current?.plugged = show
applyTouchRouting()
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (show) "shown" else "hidden")
Log.i(TAG, "on-screen controls: Steam's touch controller " + if (drawn) "shown" else "hidden")
return
}
if (show == (controls.visibility == View.VISIBLE)) return
if (!show) controls.releaseAll()
controls.visibility = if (show) View.VISIBLE else View.GONE
if (drawn == (controls.visibility == View.VISIBLE)) return
if (!drawn) controls.releaseAll()
controls.visibility = if (drawn) View.VISIBLE else View.GONE
Log.i(TAG, "on-screen controls " + when {
!show -> "hidden"
!drawn -> "hidden"
buttonsOnly -> "Steam + QAM"
else -> "full pad"
})
}
private fun keyboardCoversPad() =
SessionState.mode == SessionService.MODE_STEAM && AgentGuest.steamKeyboard
private fun sendGuestKey(code: Int, down: Boolean) {
if (down && pipUi) return
if (down) guestKeysDown.add(code) else guestKeysDown.remove(code)
WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
}
private fun pipBounds(): android.graphics.Rect {
@@ -2600,6 +2910,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
releasePads()
touchpad.cancel()
scrollTouch.cancel()
directTouchHold.cancel()
guestKeysDown.toList().forEach { sendGuestKey(it, false) }
keyboard?.hide()
sessionClipboard.stop()
@@ -2649,11 +2960,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onStart()
if (runtimeRemovalBlocked) return
started = true
refreshActivityDisplayId()
displayManager.registerDisplayListener(displayListener, Handler(Looper.getMainLooper()))
refreshSecondScreenDisplays()
if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode)
startedScreens++
SessionService.setActivityVisible(this, true)
SessionService.setActivityVisible(this, true, sessionActivityDisplayId)
Glasses.rescan()
updateGlassesScreen()
updateHandCamera()
@@ -2667,7 +2979,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
closeSecondScreen(reset = false)
startedScreens = (startedScreens - 1).coerceAtLeast(0)
updateGlassesScreen()
if (startedScreens == 0) SessionService.setActivityVisible(this, false)
if (startedScreens == 0) SessionService.setActivityVisible(this, false, sessionActivityDisplayId)
super.onStop()
}
@@ -2698,6 +3010,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onResume() {
super.onResume()
if (refreshActivityDisplayId() && started) {
SessionService.setActivityDisplay(this, sessionActivityDisplayId)
}
applyFollowOrientation(resources.configuration)
if (runtimeRemovalBlocked) return
com.droiddeck.launcher.ui.Motion.refresh(this)
@@ -2726,6 +3041,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun onPause() {
if (runtimeRemovalBlocked) { super.onPause(); return }
sessionClipboard.stop()
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
if (::directTouchHold.isInitialized) directTouchHold.cancel()
uiHandler.removeCallbacks(clearRecognizedGesture)
gestureSettingsOpen = false
drawerOpen = false
pcKeyboardOpen = false
achievementsOpen = false
@@ -2975,7 +3295,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun glassesOutputSize(): Pair<Int, Int>? {
if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null
val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
val mode = displayMode()
val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice)
@@ -3101,6 +3421,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
super.onWindowFocusChanged(hasFocus)
if (!hasFocus && ::padMouse.isInitialized) padMouse.reset()
if (runtimeRemovalBlocked) return
if (!hasFocus) {
if (::touchscreenGestures.isInitialized) touchscreenGestures.cancel()
if (::touchpad.isInitialized) touchpad.cancel()
}
if (hasFocus && !pipUi) {
goFullscreen()
refreshClipboard()
@@ -3170,7 +3494,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun finishFlooded() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
val signal = com.droiddeck.launcher.ui.Themes.current(this).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
@@ -3201,7 +3525,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun completeLeave() {
if (isFinishing) return
val signal = com.droiddeck.launcher.ui.Themes.byId(SessionPrefs.theme(this)).signal
val signal = com.droiddeck.launcher.ui.Themes.current(this).signal
com.droiddeck.launcher.ui.QuitFlood.mark(signal.toArgb())
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(signal.toArgb()))
quitFlooded = true
@@ -3211,6 +3535,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
companion object {
const val EXTRA_RETURN_HOME = "returnHome"
const val EXTRA_STEAMVR = "steamVr"
const val EXTRA_REPAIR_RUNTIME = "repairRuntime"
private const val LEAVE_TIMEOUT_MS = 1_500L
private const val TAG = "SessionActivity"
private var startedScreens = 0
@@ -5,6 +5,7 @@ import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import android.os.FileObserver
import android.util.Log
@@ -19,6 +20,8 @@ object AgentGuest {
private val main by lazy { Handler(Looper.getMainLooper()) }
var focusRevision by mutableIntStateOf(0)
private set
var steamKeyboard by mutableStateOf(false)
private set
private const val TAG = "AgentGuest"
private const val POLL_MS = 50L
private val lock = Any()
@@ -32,6 +35,7 @@ object AgentGuest {
observer?.stopWatching()
observer = null
lastFocusKey = null
main.post { steamKeyboard = false }
val dir = dir(context)
dir.deleteRecursively()
dir.mkdirs()
@@ -43,6 +47,7 @@ object AgentGuest {
SessionEvents.record("agent.ready", mapOf("pid" to it.optInt("pid"), "focus" to it.optBoolean("focus")))
}
"focus.json" -> readJson(File(dir, path))?.let(::recordFocus)
"keyboard.json" -> readJson(File(dir, path))?.let { recordKeyboard(this, it) }
}
}
}.also { it.startWatching() }
@@ -53,9 +58,19 @@ object AgentGuest {
synchronized(lock) {
observer?.stopWatching()
observer = null
main.post { steamKeyboard = false }
}
}
private fun recordKeyboard(source: FileObserver, snapshot: JSONObject) {
val showing = snapshot.optBoolean("steamKeyboard")
synchronized(lock) {
if (source !== observer) return
main.post { steamKeyboard = showing }
}
SessionEvents.record("keyboard.changed", mapOf("steamKeyboard" to showing, "seq" to snapshot.optInt("seq")))
}
private fun recordFocus(snapshot: JSONObject) {
val fields = linkedMapOf<String, Any?>(
"focusedApp" to snapshot.opt("focusedApp"),
@@ -0,0 +1,150 @@
package com.droiddeck.launcher.core
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileInputStream
/**
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
* was made with install, whichever host serves them.
*/
internal class AddonPack(
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
) {
sealed interface State {
data object Missing : State
data class Downloading(val progress: Float) : State
data object Failed : State
data object Ready : State
}
private val mutableState = MutableStateFlow<State>(State.Missing)
val state: StateFlow<State> = mutableState.asStateFlow()
private val lock = Any()
val available: Boolean get() = sha256.isNotEmpty()
private val tag get() = "pack-$name-${sha256.take(16)}"
private val urls get() = listOfNotNull(
"https://github.com/$REPO/releases/download/$tag/$name.tar.zst",
BuildConfig.PR_NUMBER.takeIf { it > 0 }?.let { "https://github.com/$PR_REPO/releases/download/pr-$it/$tag.tar.zst" },
)
private fun root(context: Context) = File(context.filesDir, "addons/$name")
/** App-private and written only after the archive matched the pinned digest. */
fun dir(context: Context) = File(root(context), version(context))
fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
// A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
.filter { dir ->
dir.isDirectory && runCatching { File(dir, COMPLETE).readText().trim() }.getOrNull() == dir.name &&
required.all { File(dir, it).isFile }
}
.maxByOrNull { it.lastModified() }?.name
fun refresh(context: Context): State {
val now = mutableState.value
if (now is State.Downloading) return now
return (if (installed(context)) State.Ready else if (now == State.Failed) State.Failed else State.Missing)
.also { mutableState.value = it }
}
/** Starts the download in the background unless the pack is already present or on its way. */
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext
synchronized(lock) {
if (installed(app)) { mutableState.value = State.Ready; return }
if (mutableState.value is State.Downloading) return
mutableState.value = State.Downloading(0f)
}
scope.launch {
val ok = runCatching { install(app) }.onFailure { Log.w(TAG, "$name: install failed", it) }.getOrDefault(false)
mutableState.value = if (ok) State.Ready else State.Failed
if (ok) onReady(app)
}
}
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true
ensure(context)
while (running()) {
val now = state.value
onState(now)
when (now) {
State.Ready -> return installed(context)
State.Failed -> return false
else -> try { Thread.sleep(250) } catch (e: InterruptedException) { return false }
}
}
return false
}
private fun install(context: Context): Boolean {
if (!available) {
Log.w(TAG, if (reuseDeviceCopy) "$name: this build has no pack and none is installed" else "$name: this build has no pack")
return false
}
val root = root(context).apply { mkdirs() }
val archive = File(root, "$sha256.tar.zst")
val downloaded = urls.any { url ->
Downloader.downloadFile(url, archive, true, sha256) { p -> mutableState.value = State.Downloading(p.coerceIn(0f, 1f)) }
.also { if (!it) archive.delete() }
}
if (!downloaded) return false
val staging = File(root, "$sha256.partial")
staging.deleteRecursively()
try {
if (!FileInputStream(archive).use { TarZst.extract(it, staging) }) return false
check(required.all { File(staging, it).isFile }) { "$name pack is incomplete" }
File(staging, COMPLETE).writeText(sha256)
val target = dir(context)
target.deleteRecursively()
check(staging.renameTo(target)) { "could not move the $name pack into place" }
} finally {
staging.deleteRecursively()
archive.delete()
}
root.listFiles().orEmpty().filter { it.name != sha256 }.forEach { it.deleteRecursively() }
return true
}
companion object {
private const val TAG = "AddonPack"
private const val REPO = "Droid-Deck/DroidDeck-Components"
private const val PR_REPO = "Droid-Deck/DroidDeck-CI"
const val COMPLETE = ".complete"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@JvmField
val PROOT = AddonPack("proot", BuildConfig.PACK_PROOT_SHA256, BuildConfig.PACK_PROOT_SIZE,
listOf("libproot.so", "libproot-loader.so"))
@JvmField
val DIRECTAUDIO = AddonPack("directaudio", BuildConfig.PACK_DIRECTAUDIO_SHA256, BuildConfig.PACK_DIRECTAUDIO_SIZE,
listOf("linux-wine11/version.txt", "linux-wine11-systhread/version.txt"))
val TRACKING = AddonPack("tracking", BuildConfig.PACK_TRACKING_SHA256, BuildConfig.PACK_TRACKING_SIZE, listOf(
"hand_landmarker.task", "hand_landmarker_lite.task", "pose_landmarker_lite.task",
"libopencv_java4.so", "libmediapipe_tasks_jni.so"))
val XBOX = AddonPack("xbox", BuildConfig.PACK_XBOX_SHA256, BuildConfig.PACK_XBOX_SIZE,
listOf("version", "xodus-cli", "xodus-service"))
val COMET = AddonPack("comet", BuildConfig.PACK_COMET_SHA256, BuildConfig.PACK_COMET_SIZE,
listOf("version", "comet"))
}
}
@@ -207,9 +207,11 @@ object DeviceReport {
h("Drivers")
val gpu = com.droiddeck.launcher.gpu.GpuInfo.detect()
k("GPU", "${gpu.name} · ${gpu.family.label(gpu.name)} · ${gpu.supportText}" + if (gpu.oneUi8Gen2) " · One UI 8 Gen 2" else "")
if (gpu.model == 740) k("TP UBWC hint", com.droiddeck.launcher.gpu.TpUbwcHint.forThisDevice() ?: "driver default")
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)" } ?: ""))
com.droiddeck.launcher.gpu.TurnipReleases.cached(context)), com.droiddeck.launcher.gpu.TurnipReleases.autoPicks(context),
com.droiddeck.launcher.gpu.AutoDriverTrial.bad(context))?.let { " (recommended pair: $it)" } ?: ""))
val turnip = TurnipDriver(context)
val androidChoice = SessionPrefs.androidDriver(context)
val displayId = androidChoice.ifEmpty { turnip.autoId() }
@@ -236,6 +238,9 @@ object DeviceReport {
k("Installed version", LinuxRuntimeInstaller.installedVersion(context))
k("Runtime ready", LinuxRuntime.isInstalled(context))
k("Desktop installed", DesktopCatalog.desktopInstalled(context))
k("Interrupted installs", DesktopCatalog.interrupted(context).joinToString { "${it.id} ${it.version}" }.ifEmpty { "none" })
val broken = runCatching { com.droiddeck.launcher.runtime.RuntimeHealth.brokenLibraryLinks(context) }.getOrDefault(emptyList())
k("Broken library links", if (broken.isEmpty()) "none" else "${broken.size}: ${broken.take(8).joinToString()}")
k("Runtime root", LinuxRuntime.rootDir(context).path)
h("Settings in effect")
@@ -260,6 +265,7 @@ object DeviceReport {
k("Guest host name", SessionPrefs.guestHostname(context))
k("DirectAudio for games", SessionPrefs.directAudio(context))
k("Stretch 16:9 to panel", SessionPrefs.stretch16x9(context))
k("Return to launcher after a game", SessionPrefs.returnAfterGame(context))
k("Microphone", SessionPrefs.micEnabled(context))
k("On-screen controls", SessionPrefs.oscMode(context))
k("Touch mode", SessionPrefs.touchMode(context))
@@ -38,6 +38,7 @@ object DownloadSource {
private val CATALOGS = listOf(
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-CI/",
"https://raw.githubusercontent.com/Droid-Deck/DroidDeck-Components/",
"https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/",
)
data class Mirror(val host: String, val api: Boolean)
@@ -54,7 +54,12 @@ public final class Downloader {
java.util.function.Consumer<Float> progress) {
if (expectedSha256 != null && !expectedSha256.matches("(?i)[0-9a-f]{64}"))
throw new IllegalArgumentException("Expected SHA-256 must be 64 hex characters");
return downloadCandidates(url, DownloadSource.INSTANCE.candidates(url), destination, resume, expectedSha256, progress);
return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, progress);
}
/** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */
static List<String> sources(String url, String expectedSha256) {
return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url);
}
// Package-private for the local multi-source HTTP test.
@@ -29,6 +29,7 @@ object GameEnvironment {
fun defaults(preset: String, forceSsbs: Boolean = false): Map<String, String?> = linkedMapOf<String, String?>(
"MESA_SHADER_CACHE_DISABLE" to "false",
"DROIDDECK_ANTICHEAT_OFFLINE" to "1",
"VKD3D_FEATURE_LEVEL" to "12_2",
"VKD3D_SHADER_MODEL" to "6_9",
"DOTNET_GCRegionRange" to "800000000",
@@ -32,6 +32,7 @@ object GameEnvironmentOptions {
Option("MESA_EXTENSION_MAX_YEAR", "", 0, Type.TEXT),
Option("MESA_GL_VERSION_OVERRIDE", "", 0, Type.TEXT),
Option("PULSE_LATENCY_MSEC", "", 0, Type.NUMBER),
Option("DROIDDECK_ANTICHEAT_OFFLINE", "1", R.string.game_env_anticheat_offline, Type.TOGGLE, listOf("0", "1")),
Option("WINE_LARGE_ADDRESS_AWARE", "0", 0, Type.TOGGLE, listOf("0", "1")),
Option("WINEDLLOVERRIDES", "", 0, Type.TEXT),
Option("GALLIUM_HUD", "simple", 0, Type.MULTIPLE, listOf("simple", "fps", "frametime")),
@@ -2,14 +2,51 @@ package com.droiddeck.launcher.core
object LogRedactor {
private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}")
private val HIDL_SHARED_LIBRARY = Regex(
"(?:android\\.(?:hardware|frameworks|hidl|system)|vendor\\.[A-Za-z0-9_]+\\.hardware)" +
"(?:\\.[A-Za-z0-9_]+)+@[0-9]+\\.[0-9]+(?:-[A-Za-z0-9_]+)*\\.so"
)
private val hidlMatcher = object : ThreadLocal<java.util.regex.Matcher>() {
override fun initialValue(): java.util.regex.Matcher = HIDL_SHARED_LIBRARY.toPattern().matcher("")
}
private fun isHidlLoaderDiagnostic(input: String, match: MatchResult): Boolean {
val matcher = hidlMatcher.get()!!
val isLibrary = try { matcher.reset(match.value).matches() } finally { matcher.reset("") }
if (!isLibrary) return false
val start = match.range.first
val end = match.range.last + 1
val prefix = "library \""
val suffix = "\" not found"
val earliestQuote = (start - 4096).coerceAtLeast(0)
var quote = start - 1
while (quote >= earliestQuote && input[quote] != '"') {
val char = input[quote]
if (!char.isLetterOrDigit() && char !in "/._-") return false
quote--
}
if (quote < earliestQuote) return false
val pathStart = quote + 1
if (pathStart < start && (input[start - 1] != '/' ||
!(input.regionMatches(pathStart, "/vendor/", 0, 8) ||
input.regionMatches(pathStart, "/system/", 0, 8)))) return false
val prefixStart = quote - prefix.length + 1
val suffixEnd = end + suffix.length
return prefixStart >= 0 && suffixEnd <= input.length &&
input.regionMatches(prefixStart, prefix, 0, prefix.length, ignoreCase = true) &&
input.regionMatches(end, suffix, 0, suffix.length, ignoreCase = true) &&
(prefixStart == 0 || !(input[prefixStart - 1].isLetterOrDigit() || input[prefixStart - 1] == '_')) &&
(suffixEnd == input.length || !(input[suffixEnd].isLetterOrDigit() || input[suffixEnd] == '_'))
}
private val JWT_LABELLED = Regex("(?i)(\\bJWT[\\s=:]+)(\\d{8,})")
private val JWT_BASE64 = Regex("ey[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}")
private val GUID = Regex("\\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\b")
private val SECRET_KV = Regex(
"(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
"(?i)(\\b(?:access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" +
"auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" +
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" +
"(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})"
"machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)[\"']?\\s*[=:]\\s*)" +
"(?:([\"'])((?:\\\\.|(?!\\2)[^\\\\\\r\\n]){4,})\\2|([^\\s\"'<>&;,]{4,}))"
)
private val GUARD_CODE = Regex(
"(?i)((?:steam\\s*)?guard\\s*code[\\s:=]*|two[\\s-]?factor[\\s:=]*|2fa[\\s:=]*)([A-Za-z0-9]{5})"
@@ -146,16 +183,21 @@ object LogRedactor {
return out
}
fun isText(file: java.io.File): Boolean = try {
file.isFile && file.length() < 64L * 1024 * 1024 && file.inputStream().use { input ->
/** The at-rest rewrite reads a file into memory twice; larger logs are only scrubbed on the way out. */
const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
fun isBinary(file: java.io.File): Boolean = try {
file.inputStream().use { input ->
val buf = ByteArray(4096)
val n = input.read(buf)
n <= 0 || (0 until n).none { buf[it].toInt() == 0 }
n > 0 && (0 until n).any { buf[it].toInt() == 0 }
}
} catch (e: Exception) {
false
true
}
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
const val RULES_VERSION = 3
private val LAN_IPV4 = Regex(
@@ -176,8 +218,62 @@ object LogRedactor {
}
}
private const val SHARE_LINE_MAX = 64 * 1024
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
src.inputStream().buffered().use { input ->
input.mark(2)
val first = input.read()
val second = input.read()
input.reset()
if ((first == 0xff && second == 0xfe) || (first == 0xfe && second == 0xff)) {
out.write("<unsupported encoding; file withheld>\n")
return
}
val decoder = Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
java.io.InputStreamReader(input, decoder).use { reader ->
val buffer = CharArray(8192)
val line = StringBuilder()
var withheld = false
var afterCr = false
fun emit() {
out.write(if (withheld) "<unsupported or oversized line withheld>" else redactForShare(line.toString()))
out.write("\n")
line.setLength(0)
withheld = false
}
try {
while (true) {
val size = reader.read(buffer)
if (size < 0) break
for (i in 0 until size) {
val ch = buffer[i]
if (afterCr) {
afterCr = false
if (ch == '\n') continue
}
if (ch == '\r' || ch == '\n') {
emit()
afterCr = ch == '\r'
} else if (!withheld) {
// Splitting a line can separate a credential from its label.
if (ch == '\u0000' || line.length == SHARE_LINE_MAX) {
withheld = true
line.setLength(0)
} else {
line.append(ch)
}
}
}
}
if (withheld || line.isNotEmpty()) emit()
} catch (_: java.nio.charset.CharacterCodingException) {
out.write("<unsupported encoding; file withheld>\n")
}
}
}
}
private fun quoted(value: String): String {
@@ -232,7 +328,7 @@ object LogRedactor {
if (out.has("jwt")) out.set(JWT_LABELLED.replace(out.text) { "${it.groupValues[1]}<redacted:jwt>" })
if ("ey" in out.text) out.set(JWT_BASE64.replace(out.text, "<redacted:jwt>"))
if (out.has("token", "auth", "ticket", "sessionid", "steamloginsecure", "api", "machine", "pass", "pwd", "secret")) {
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>" })
out.set(SECRET_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}<redacted:token>${it.groupValues[2]}" })
}
if (out.has("guard", "factor", "2fa")) out.set(GUARD_CODE.replace(out.text) { "${it.groupValues[1]}<redacted:code>" })
if (out.hasRun(32, ::isHex)) out.set(WEBAPI_KEY.replace(out.text, "<redacted:key>"))
@@ -250,7 +346,9 @@ object LogRedactor {
}
if (out.has("login")) out.set(LOGIN_KV.replace(out.text) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" })
for (r in accounts) if (r.needs == null || out.has(r.needs)) out.set(r.regex.replace(out.text, "<redacted:account>"))
if ('@' in out.text) out.set(EMAIL.replace(out.text, "<redacted:email>"))
if ('@' in out.text) out.set(EMAIL.replace(out.text) { m ->
m.value.takeIf { isHidlLoaderDiagnostic(out.text, m) } ?: "<redacted:email>"
})
if (out.has("jwt", "token", "ticket", "sessionid", "steamloginsecure", "machineauth")) {
out.set(RESIDUAL.replace(out.text) { "${it.groupValues[1]}=<redacted:token>" })
}
@@ -85,7 +85,7 @@ class AppFilesProvider : DocumentsProvider() {
override fun deleteDocument(documentId: String) {
val file = fileFor(documentId)
if (file.absolutePath == base.absolutePath) throw FileNotFoundException("the app folder itself is not deleted")
if (file.canonicalPath == baseCanonical) throw FileNotFoundException("the app folder itself is not deleted")
try {
deleteTree(file.toPath())
} catch (e: IOException) {
@@ -154,7 +154,7 @@ class AppFilesProvider : DocumentsProvider() {
private fun fileFor(documentId: String): File {
val f = File(documentId)
if (!f.isAbsolute || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.isAbsolute || f.normalize().path != documentId || !inside(f)) throw FileNotFoundException("$documentId is outside the app's files")
if (!f.exists()) throw FileNotFoundException("$documentId not found")
return f
}
@@ -6,6 +6,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -249,11 +250,11 @@ fun CollapsibleRail(
if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape,
)
.clickable(onClick = item.onClick)
.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = item.onClick)
.padding(horizontal = if (collapsed) 2.dp else 11.dp, vertical = if (collapsed) 6.dp else 9.dp)
} else {
base
.clickable(onClick = item.onClick)
.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = item.onClick)
.background(if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent)
.padding(horizontal = if (collapsed) 2.dp else 13.dp, vertical = if (collapsed) 7.dp else 11.dp)
}
@@ -337,7 +338,7 @@ fun RailTopTabs(
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)),
)
links.forEach { link ->
TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, modifier = Modifier.weight(1f))
TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, isTab = false, modifier = Modifier.weight(1f))
}
}
}
@@ -352,6 +353,7 @@ private fun TopTabCell(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isTab: Boolean = true,
) {
val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
Column(
@@ -359,7 +361,13 @@ private fun TopTabCell(
modifier = modifier
.clip(RoundedCornerShape(9.dp))
.background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent)
.clickable(onClick = onClick)
.then(
if (isTab) {
Modifier.selectable(selected = selected, role = androidx.compose.ui.semantics.Role.Tab, onClick = onClick)
} else {
Modifier.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onClick)
},
)
.padding(vertical = 6.dp, horizontal = 1.dp),
) {
Icon(icon, contentDescription = label, tint = tint, modifier = Modifier.size(20.dp))
@@ -193,7 +193,7 @@ internal fun FileItemRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.combinedClickable(
.combinedClickable(role = androidx.compose.ui.semantics.Role.Button,
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
@@ -302,7 +302,7 @@ internal fun ParentFolderRow(compact: Boolean = false, onTap: () -> Unit) {
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onTap),
.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onTap),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
@@ -422,7 +422,7 @@ private fun FavoriteCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.clickable(onClick = onJump),
.clickable(role = androidx.compose.ui.semantics.Role.Button, onClick = onJump),
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
@@ -526,7 +526,7 @@ internal fun FileGridTile(
Card(
modifier = Modifier
.padding(4.dp)
.combinedClickable(
.combinedClickable(role = androidx.compose.ui.semantics.Role.Button,
onClick = { if (selectionMode) onToggleSelect() else onTap() },
onLongClick = onLongPress,
),
@@ -90,6 +90,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.droiddeck.launcher.session.SessionPrefs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
@@ -151,8 +152,11 @@ fun FileManagerScreen(
var conflictChoice by remember { mutableStateOf<ConflictChoice?>(null) }
var conflictApplyToAll by remember { mutableStateOf(false) }
var operationJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var cancelledJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
var pendingBulkDelete by remember { mutableStateOf<List<File>>(emptyList()) }
var searchQuery by remember { mutableStateOf("") }
val visibleEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
var showSearch by remember { mutableStateOf(false) }
var sortBy by remember { mutableStateOf(browsePrefs.getString("fmSortBy", "name") ?: "name") }
var sortDesc by remember { mutableStateOf(browsePrefs.getBoolean("fmSortDesc", false)) }
@@ -174,16 +178,20 @@ fun FileManagerScreen(
var showFavorites by remember { mutableStateOf(false) }
var favTick by remember { mutableIntStateOf(0) }
var listingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
fun loadDirectory(dir: File, resetScroll: Boolean = true) {
currentDir = dir
if (pickMode) pickPrefs.edit().putString("lastFilePickerDir", dir.absolutePath).apply()
scope.launch {
listingJob?.cancel()
listingJob = scope.launch {
val list = withContext(Dispatchers.IO) {
dir.listFiles()?.toList()
?.filter { if (pickDirMode) it.isDirectory else !pickMode || it.isDirectory || matchesPickExt(it) }
?.filter { showHidden || !it.name.startsWith(".") }
?.sortedWith(comparatorFor(sortBy, sortDesc)) ?: emptyList()
}
if (currentDir != dir) return@launch
entries = list
if (resetScroll) listState.scrollToItem(0)
}
@@ -220,7 +228,7 @@ fun FileManagerScreen(
fun uniqueDestination(dir: File, name: String): File {
var candidate = File(dir, name)
if (!candidate.exists()) return candidate
if (!java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) return candidate
val dot = name.lastIndexOf('.')
val base = if (dot > 0) name.substring(0, dot) else name
val ext = if (dot > 0) name.substring(dot) else ""
@@ -228,7 +236,7 @@ fun FileManagerScreen(
do {
candidate = File(dir, "$base ($i)$ext")
i++
} while (candidate.exists())
} while (java.nio.file.Files.exists(candidate.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS))
return candidate
}
@@ -252,7 +260,7 @@ fun FileManagerScreen(
fun performPaste() {
val sources = clipboardFiles
if (sources.isEmpty()) return
if (sources.isEmpty() || isOperationRunning || operationJob?.isActive == true || cancelledJob?.isCompleted == false) return
val dstDir = currentDir
val cut = isCutOperation
@@ -261,6 +269,7 @@ fun FileManagerScreen(
operationDeterminate = true
operationLabel = context.getString(if (cut) R.string.fm_op_moving else R.string.fm_op_copying)
isOperationRunning = true
val job = coroutineContext[kotlinx.coroutines.Job]
var applyToAll: ConflictChoice? = null
var failed = 0
@@ -278,7 +287,7 @@ fun FileManagerScreen(
}
var dst = File(dstDir, src.name)
if (dst.exists()) {
if (java.nio.file.Files.exists(dst.toPath(), java.nio.file.LinkOption.NOFOLLOW_LINKS)) {
val choice = applyToAll ?: askConflict(src)?.also {
if (conflictApplyToAll) applyToAll = it
} ?: run { skipped++; null } ?: continue
@@ -296,6 +305,7 @@ fun FileManagerScreen(
}
var lastPct = -1
val onProgress = FileOps.ProgressCallback { copied, total ->
job?.ensureActive()
val pct = if (total > 0) ((copied * 100) / total).toInt() else 100
if (pct != lastPct) {
lastPct = pct
@@ -304,8 +314,8 @@ fun FileManagerScreen(
}
val target = dst
val ok = withContext(Dispatchers.IO) {
if (cut) FileOps.moveWithProgress(src, target, onProgress)
else FileOps.copyWithProgress(src, target, onProgress)
if (cut) FileOps.moveWithProgress(src, target, onProgress) { job?.isActive == false }
else FileOps.copyWithProgress(src, target, onProgress) { job?.isActive == false }
}
if (ok) done++ else failed++
}
@@ -791,19 +801,21 @@ fun FileManagerScreen(
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
val allVisibleSelected = visibleEntries.isNotEmpty() && visibleEntries.all { it.absolutePath in selectedPaths }
val anyVisibleSelected = visibleEntries.any { it.absolutePath in selectedPaths }
val selBarPadding = PaddingValues(horizontal = 10.dp, vertical = 2.dp)
OutlinedButton(
onClick = {
selectedPaths = if (selectedPaths.size == entries.size) emptySet()
else entries.map { it.absolutePath }.toSet()
val shown = visibleEntries.map { it.absolutePath }
selectedPaths = if (allVisibleSelected) selectedPaths - shown.toSet() else selectedPaths + shown
},
contentPadding = selBarPadding,
) { Text(if (selectedPaths.size == entries.size) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
) { Text(if (allVisibleSelected) stringResource(R.string.fm_select_none) else stringResource(R.string.fm_select_all), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = false
selectionMode = false
selectedPaths = emptySet()
@@ -812,9 +824,9 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_copy), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
enabled = anyVisibleSelected,
onClick = {
clipboardFiles = entries.filter { it.absolutePath in selectedPaths }
clipboardFiles = visibleEntries.filter { it.absolutePath in selectedPaths }
isCutOperation = true
selectionMode = false
selectedPaths = emptySet()
@@ -823,8 +835,8 @@ fun FileManagerScreen(
) { Text(stringResource(R.string.fm_cut), fontSize = 12.sp) }
Spacer(Modifier.width(4.dp))
OutlinedButton(
enabled = selectedPaths.isNotEmpty(),
onClick = { pendingBulkDelete = entries.filter { it.absolutePath in selectedPaths } },
enabled = anyVisibleSelected,
onClick = { pendingBulkDelete = visibleEntries.filter { it.absolutePath in selectedPaths } },
contentPadding = selBarPadding,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error),
) { Text(stringResource(R.string.common_delete), color = MaterialTheme.colorScheme.error, fontSize = 12.sp) }
@@ -842,7 +854,7 @@ fun FileManagerScreen(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f))
.clickable { performPaste() }
.clickable(enabled = !isOperationRunning) { performPaste() }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Icon(Icons.Filled.ContentPaste, stringResource(R.string.fm_paste), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp))
@@ -878,6 +890,7 @@ fun FileManagerScreen(
)
if (operationJob != null) {
TextButton(onClick = {
cancelledJob = operationJob
operationJob?.cancel()
operationJob = null
isOperationRunning = false
@@ -964,8 +977,7 @@ fun FileManagerScreen(
.fillMaxSize()
.nestedScroll(pullState.nestedScrollConnection),
) {
val shownEntries = if (searchQuery.isBlank()) entries
else entries.filter { it.name.contains(searchQuery, ignoreCase = true) }
val shownEntries = visibleEntries
if (showGrid && entries.isNotEmpty()) {
LazyVerticalGrid(
@@ -45,23 +45,26 @@ object FileOps {
return f.length()
}
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun moveWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
if (sameFile(src, dst)) return true
if (isCancelled()) return false
if (!dst.exists() && src.renameTo(dst)) {
progress?.onProgress(1, 1)
return true
}
if (!copyWithProgress(src, dst, progress)) return false
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
if (isCancelled()) return false
return delete(src)
}
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?): Boolean {
fun copyWithProgress(src: File, dst: File, progress: ProgressCallback?, isCancelled: () -> Boolean = { false }): Boolean {
val total = maxOf(totalSize(src), 1L)
return copyWithProgress(src, dst, longArrayOf(0), total, progress)
return copyWithProgress(src, dst, longArrayOf(0), total, progress, isCancelled)
}
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?): Boolean {
if (isSymlink(src)) return true
private fun copyWithProgress(src: File, dst: File, done: LongArray, total: Long, progress: ProgressCallback?, isCancelled: () -> Boolean): Boolean {
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
if (isSymlink(src)) return copyLink(src, dst)
if (sameFile(src, dst)) return true
if (src.isDirectory && isWithin(dst, src)) {
Log.e(TAG, "Refusing to copy directory into itself: $src -> $dst")
@@ -71,7 +74,7 @@ object FileOps {
if (!dst.exists() && !dst.mkdirs()) return false
var allOk = true
src.list()?.forEach { name ->
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress)) {
if (!copyWithProgress(File(src, name), File(dst, name), done, total, progress, isCancelled)) {
Log.e(TAG, "Failed to copy: ${File(src, name)}")
allOk = false
}
@@ -81,13 +84,41 @@ object FileOps {
val parent = dst.parentFile
if (!src.exists() || (parent != null && !parent.exists() && !parent.mkdirs())) return false
val chunk = 4L * 1024 * 1024
var staging: File? = null
var created = false
var output: java.nio.channels.FileChannel? = null
try {
output = try {
java.nio.channels.FileChannel.open(dst.toPath(), java.nio.file.StandardOpenOption.WRITE, java.nio.file.StandardOpenOption.CREATE_NEW)
.also { created = true }
} catch (e: java.nio.file.FileAlreadyExistsException) {
null
}
val target = if (!created && isSymlink(dst)) dst.canonicalFile else dst
var permissions: Set<java.nio.file.attribute.PosixFilePermission>? = null
if (output == null) {
if (isSymlink(target) || target.parentFile?.isDirectory != true) {
throw IOException("Destination symlink target is not a writable directory entry: $dst")
}
if (target.isDirectory) throw IOException("Destination is a directory: $target")
permissions = try {
Files.getPosixFilePermissions(target.toPath())
} catch (e: java.nio.file.NoSuchFileException) {
null
} catch (e: UnsupportedOperationException) {
null
}
if (target.exists() && !target.canWrite()) throw IOException("Destination is not writable: $target")
val directory = Files.createTempDirectory(checkNotNull(target.absoluteFile.parentFile).toPath(), ".droiddeck-copy-")
staging = directory.toFile()
output = FileOutputStream(File(staging, "file")).channel
}
FileInputStream(src).channel.use { input ->
FileOutputStream(dst).channel.use { output ->
checkNotNull(output).use { channel ->
val size = input.size()
var position = 0L
while (position < size) {
val transferred = input.transferTo(position, minOf(chunk, size - position), output)
val transferred = input.transferTo(position, minOf(chunk, size - position), channel)
if (transferred <= 0) break
position += transferred
done[0] += transferred
@@ -96,11 +127,54 @@ object FileOps {
if (position < size) throw IOException("Incomplete copy: $position/$size bytes")
}
}
staging?.let { directory ->
val copied = File(directory, "file").toPath()
if (permissions != null && Files.getPosixFilePermissions(copied) != permissions) {
Files.setPosixFilePermissions(copied, permissions)
}
if (isCancelled()) throw java.util.concurrent.CancellationException("Copy cancelled: $src")
Files.move(copied, target.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
}
return true
} catch (e: java.util.concurrent.CancellationException) {
if (created) dst.delete()
throw e
} catch (e: IOException) {
Log.e(TAG, "Failed to copy file: $src to $dst", e)
dst.delete()
if (created) dst.delete()
return false
} finally {
output?.close()
staging?.let { directory ->
File(directory, "file").delete()
directory.delete()
}
}
}
private fun copyLink(src: File, dst: File): Boolean {
if (src.absoluteFile == dst.absoluteFile) return true
return try {
val target = Files.readSymbolicLink(src.toPath())
val parent = dst.parentFile
if (parent != null && !parent.exists() && !parent.mkdirs()) return false
try {
Files.createSymbolicLink(dst.toPath(), target)
} catch (e: java.nio.file.FileAlreadyExistsException) {
val staging = Files.createTempDirectory(checkNotNull(dst.absoluteFile.parentFile).toPath(), ".droiddeck-link-")
val tmp = staging.resolve("link")
try {
Files.createSymbolicLink(tmp, target)
Files.move(tmp, dst.toPath(), java.nio.file.StandardCopyOption.ATOMIC_MOVE)
} finally {
Files.deleteIfExists(tmp)
Files.deleteIfExists(staging)
}
}
true
} catch (e: Exception) {
Log.e(TAG, "Failed to copy link: $src to $dst", e)
false
}
}
@@ -323,8 +323,8 @@ object AddedGameArt {
if (dst.isFile) continue
val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue
val tmp = File(cache, "$localName.tmp")
tmp.writeBytes(bytes)
if (tmp.renameTo(dst)) { got = true; File(cache, "$localName.src").writeText(STEAM) }
if (runCatching { tmp.writeBytes(bytes) }.isFailure) { tmp.delete(); continue }
if (tmp.renameTo(dst)) { got = true; runCatching { File(cache, "$localName.src").writeText(STEAM) } }
}
return got
}
@@ -41,10 +41,20 @@ object AddedGames {
return SessionPrefs.gameFolderBinds(context).map { (host, guest) -> Root(File(host), guest) }
}
private val SKIP = Regex(
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
private val SKIP_PREFIX = Regex("(?i)^(unins|setup|vc_?redist|dxsetup|dxwebsetup|dotnet|directx|easyanticheat|battleye).*")
private val SKIP_PART = Regex("(?i)redist|prereq|installer|uninstall|crashreport|crashhandler|crashpad|crashsender|launchhelper")
private val SKIP_WORDS = setOf(
"eac", "report", "reporter", "editor", "server", "benchmark", "helper",
"update", "updater", "config", "configure", "configurator", "configuration", "settings",
)
private val WORD = Regex("[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+")
internal fun skipExe(name: String): Boolean {
if (!name.endsWith(".exe", ignoreCase = true)) return false
val base = name.dropLast(4)
if (SKIP_PREFIX.matches(base) || SKIP_PART.containsMatchIn(base)) return true
return WORD.findAll(base).any { it.value.lowercase() in SKIP_WORDS }
}
const val GUEST_DIR = "/root/Games"
@@ -75,7 +85,7 @@ object AddedGames {
val key = "${folder.path}|${folder.lastModified()}"
val now = System.currentTimeMillis()
ranked[key]?.takeIf { now - it.first < RANK_FRESH_MS }?.let { return it.second }
val guessed = GameExePicker.rank(folder) { !SKIP.matches(it.name) }
val guessed = GameExePicker.rank(folder) { !skipExe(it.name) }
val declared = declaredExes(folder)
val preferred = declared.map { it.path }.toSet()
val result = if (declared.isEmpty()) guessed else GameExePicker.Ranking(
@@ -128,7 +138,7 @@ object AddedGames {
val root = runCatching { folder.canonicalPath + File.separator }.getOrNull() ?: return emptyList()
return names.asSequence()
.map { File(folder, it.trim().replace('\\', '/').trimStart('/')) }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(it.name) && it.isFile }
.filter { it.name.endsWith(".exe", ignoreCase = true) && !skipExe(it.name) && it.isFile }
.filter { runCatching { it.canonicalPath.startsWith(root) }.getOrDefault(false) }
.distinctBy { it.path }
.toList()
@@ -204,8 +214,10 @@ object AddedGames {
GamesFolderMigration.run(context)
val path = canonical(exe)
fun holds(f: File) = inside(path, canonical(f))
AddedExes.list(context).map { File(it.folder) }.firstOrNull(::holds)?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.firstOrNull(::holds)?.let { return it }
val entries = AddedExes.list(context)
fun owns(f: File) = holds(f) && sameGame(f, currentExe(context, entries, f), exe)
entries.map { File(it.folder) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
SessionPrefs.removedAddedGames(context).map { File(it) }.filter(::owns).maxByOrNull { canonical(it).length }?.let { return it }
StoreInstallRoot.gameFolders(context).firstOrNull(::holds)?.let { return it }
val dirs = listOfNotNull(
GameStorage.effective(context)?.let { File(it.path, "steamapps/common") },
@@ -220,6 +232,24 @@ object AddedGames {
return null
}
private fun currentExe(context: Context, entries: List<AddedExes.Entry>, folder: File): File? =
SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let(::File)
?: entries.firstOrNull { it.folder == folder.path }?.let { File(it.exe) }
?: rank(folder).exes.firstOrNull()
private fun sameGame(folder: File, current: File?, pick: File): Boolean {
val base = canonical(folder)
if (current == null || pick.parentFile?.let(::canonical) == base) return true
val known = canonical(current)
if (!inside(known, base) || known.substringBeforeLast('/') == base) return true
val picked = canonical(pick)
fun top(p: String) = p.removePrefix("$base/").substringBefore('/')
fun stem(p: String) = p.substringAfterLast('/').lowercase().removeSuffix(".exe").replace(EXE_BUILD, "").filter { it.isLetterOrDigit() }
return top(known) == top(picked) || stem(known).let { it.isNotEmpty() && it == stem(picked) }
}
private val EXE_BUILD = Regex("([-_. ](x64|x86|win64|win32|64|32|shipping))+$")
fun addExe(context: Context, exe: File): AddResult {
val app = context.applicationContext
if (!exe.isFile) return AddResult.NotFound
@@ -228,7 +258,10 @@ object AddedGames {
if (owner != null && owner.path !in SessionPrefs.removedAddedGames(app)) {
single(app, owner.path)?.takeIf { canonical(it.exe) == path }?.let { return AddResult.Existing(it) }
}
if (owner == null && guestPath(app, exe) == null) return AddResult.Unreachable
if (guestPath(app, exe) == null) {
(owner ?: exe.parentFile)?.takeIf { guestPath(app, it) == null }?.let { bindFolder(app, it) }
if (guestPath(app, exe) == null) return AddResult.Unreachable
}
val folder = owner ?: exe.parentFile ?: return AddResult.NotFound
SessionPrefs.setAddedGameRemoved(app, folder.path, false)
SessionPrefs.setAddedGameExe(app, folder.path, exe.path)
@@ -243,6 +276,8 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}): FolderResult {
val app = context.applicationContext
// Binds are mounted at session start, so a new one reaches the session from the next start.
if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed)
var already = 0
@@ -266,6 +301,20 @@ object AddedGames {
return FolderResult(added, already)
}
@Synchronized
internal fun bindFolder(context: Context, dir: File) {
if (!dir.isDirectory || !dir.canRead()) return
val binds = SessionPrefs.gameFolderBinds(context)
if (binds.any { it.first == dir.path }) return
val taken = binds.map { it.second }.toSet()
val base = GamesFolderMigration.legacyRoots(binds.map { it.first } + dir.path).last().guest
var guest = base
var n = 2
while (guest in taken) guest = "$base-${n++}"
SessionPrefs.setGameFolderBinds(context, binds + (dir.path to guest))
Log.i(TAG, "games: bound $dir at $guest")
}
private fun listedFolders(context: Context, removed: Set<String>): List<String> {
val out = ArrayList<String>()
GamesFolderMigration.run(context)
@@ -63,7 +63,8 @@ object CoverArt {
missFile(context, rom).delete()
found = true
} else {
missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") }
runCatching { missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } }
.onFailure { Log.w(TAG, "${rom.name}: miss marker not written: $it") }
}
}
return found
@@ -14,6 +14,7 @@ import java.io.InputStream
object GameFiles {
const val EXTENSION = "steam"
private const val MAX_BYTES = 128
private const val MAX_NAME_BYTES = 200
private const val INVENTORY = ".droiddeck-sync.json"
private const val FORMAT = "droiddeck-game-files-v1"
@@ -50,8 +51,15 @@ object GameFiles {
fun filename(game: Library.SteamGame, disambiguate: Boolean = false): String {
val sanitized = game.name.replace(Regex("[\\p{Cntrl}\\\\/:*?\"<>|]"), "_")
.trim().trim('.').ifBlank { "Game" }
val points = sanitized.codePoints().limit(40).toArray()
val title = String(points, 0, points.size)
val points = sanitized.codePoints().toArray()
var end = 0
var bytes = 0
while (end < points.size) {
bytes += String(points, end, 1).toByteArray().size
if (bytes > MAX_NAME_BYTES) break
end++
}
val title = String(points, 0, end).trimEnd { it.isWhitespace() || it == '.' }.ifBlank { "Game" }
val suffix = if (disambiguate) " (${game.gameIdString})" else ""
return "$title$suffix.$EXTENSION"
}
@@ -185,7 +185,7 @@ object GameIdentifier {
}
private val JUNK_NAME_RE = Regex(
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*)$"""
"""(?i)^(gse|steam|steam client|goldberg.*|launcher|game|play|start|setup|.* launcher.*|.*launcher redirector|.* redirector|rockstar games launcher.*|epic games launcher|smartsteamemu|coldclient.*|steamemu.*|bootstrappackagedgame)$"""
)
fun isJunkPeName(name: String): Boolean {
@@ -0,0 +1,52 @@
package com.droiddeck.launcher.frontend
import com.droiddeck.launcher.stores.InstalledStoreGame
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstalls
import com.droiddeck.launcher.stores.StoresState
import java.io.File
object GameRemoval {
enum class Kind { STORE, SHORTCUT, LAUNCHER }
fun kind(game: Library.SteamGame): Kind = when {
game.library == Library.EPIC_LAUNCHER -> Kind.LAUNCHER
game.library == Library.ADDED && game.source == Library.ADDED -> Kind.SHORTCUT
else -> Kind.STORE
}
class SteamFiles(val dir: File, val manifest: File, val partial: File?)
fun steamFiles(game: Library.SteamGame): SteamFiles? {
val dir = game.gameFiles ?: return null
val common = dir.parentFile ?: return null
if (common.name != "common") return null
val steamapps = common.parentFile ?: return null
if (steamapps.name != "steamapps") return null
val manifest = File(steamapps, "appmanifest_${game.appId}.acf")
if (!manifest.isFile) return null
if (!declares(manifest, game.appId)) return null
return SteamFiles(dir, manifest, File(steamapps, "downloading/${game.appId}"))
}
fun delete(target: SteamFiles): Boolean {
StoreInstalls.deleteTree(target.dir)
if (target.dir.exists()) return false
target.partial?.let { StoreInstalls.deleteTree(it) }
target.manifest.delete()
return !target.manifest.exists()
}
fun storeGame(game: Library.SteamGame): InstalledStoreGame? {
val folder = game.gameFiles ?: return null
val store = Store.byId(game.source)
val id = game.storeId
if (store != null && id != null) StoresState.installedGame(store, id)?.let { return it }
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled } ?: return null
return InstalledStoreGame(sidecar, folder)
}
private fun declares(manifest: File, appId: Int): Boolean =
runCatching { manifest.readText() }.getOrNull()?.let { Regex("\"appid\"\\s+\"$appId\"").containsMatchIn(it) } == true
}
@@ -10,6 +10,7 @@ import android.graphics.BitmapFactory
import android.graphics.drawable.Icon
import android.net.Uri
import android.widget.Toast
import com.droiddeck.launcher.LauncherIcon
import com.droiddeck.launcher.MainActivity
import com.droiddeck.launcher.R
import java.io.File
@@ -23,7 +24,7 @@ object GameShortcuts {
val name = game.name.ifBlank { context.getString(R.string.app_name) }
val icon = iconBitmap(game)?.let(Icon::createWithBitmap) ?: Icon.createWithResource(context, R.mipmap.ic_launcher)
return ShortcutInfo.Builder(context, "game:${game.gameIdString}")
.setActivity(android.content.ComponentName(context, MainActivity::class.java))
.setActivity(LauncherIcon.activity(context))
.setShortLabel(name.take(40)).setLongLabel(name).setIcon(icon)
.setIntent(launchIntent(context, game.gameIdString)).build()
}
@@ -72,6 +72,7 @@ object Library {
private val NAME = Regex("^\\s*\"name\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
private val STATE = Regex("^\\s*\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val LAST_PLAYED = Regex("^\\s*\"LastPlayed\"\\s*\"(\\d+)\"", RegexOption.MULTILINE)
private val PRIVATE_APPS = Regex("\"PrivateApps_\\d+\"\\s*\"\\[([^\\]]*)\\]\"")
private val INSTALL_DIR = Regex("^\\s*\"installdir\"\\s*\"([^\"]*)\"", RegexOption.MULTILINE)
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
@@ -137,13 +138,35 @@ object Library {
}
fun steamLibraryArt(context: Context): List<Pair<Int, File>> {
val cache = File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache")
val root = SessionPrefs.activeSteamRoot(context)
val cache = File(root, "appcache/librarycache")
val concealed = concealedSteamApps(root)
return cache.listFiles().orEmpty().mapNotNull { dir ->
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES } ?: return@mapNotNull null
val appId = dir.name.toIntOrNull()?.takeIf { it !in NOT_GAMES && it.toLong() !in concealed } ?: return@mapNotNull null
steamCacheImage(cache, appId, STEAM_CAPSULES)?.let { appId to it }
}
}
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
internal fun concealedSteamApps(root: File): Set<Long> {
val config = AddedGames.accountConfig(root) ?: return emptySet()
val hidden = runCatching {
val entries = org.json.JSONArray(File(config, "cloudstorage/cloud-storage-namespace-1.json").readText())
val entry = (0 until entries.length()).map { entries.getJSONArray(it) }
.firstOrNull { it.optString(0) == "user-collections.hidden" }?.optJSONObject(1)
if (entry == null || entry.optBoolean("is_deleted")) emptySet() else {
val collection = org.json.JSONObject(entry.optString("value"))
fun ids(key: String) = collection.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optLong(it) } }.orEmpty()
ids("added").toSet() - ids("removed").toSet()
}
}.getOrDefault(emptySet())
val private = runCatching {
PRIVATE_APPS.find(File(config, "localconfig.vdf").readText())?.groupValues?.get(1)
?.split(',')?.mapNotNull { it.trim().toLongOrNull() }?.toSet()
}.getOrNull().orEmpty()
return hidden + private
}
fun fromSteamClient(g: SteamGame): Boolean = g.library != ADDED && g.source == SOURCE_STEAM
fun launchableGames(
@@ -155,9 +178,23 @@ object Library {
var steam = steamGames(context, strictRead, arch)
val targets = steam.filter(::fromSteamClient).map { SteamGrid.target(it, context) } + list.map { SteamGrid.target(it, context) }
if (runCatching { SteamGrid.sync(context, targets) }.getOrDefault(false)) steam = steamGames(context, strictRead, arch)
return (steam + list.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
val claimed = claimedFolders(steam)
val own = list.filter { g ->
g.steamAppId != null || claimed.isEmpty() ||
(runCatching { g.folder.canonicalPath }.getOrDefault(g.folder.absolutePath) !in claimed &&
AddedGames.guestPath(context, g.folder).let { it == null || it !in claimed })
}
return (steam + own.map { g -> addedGame(context, g, roots) }).distinctBy { it.gameId }
}
fun claimedFolders(steam: List<SteamGame>): Set<String> = steam.filter(::fromSteamClient).flatMap { g ->
val dir = g.gameFiles ?: return@flatMap emptyList()
listOfNotNull(
runCatching { dir.canonicalPath }.getOrNull(),
runCatching { java.nio.file.Files.readSymbolicLink(dir.toPath()).toString() }.getOrNull(),
)
}.toSet()
fun addedGame(context: Context, g: AddedGames.Game, roots: List<File> = steamLibraries(context).map { it.first }): SteamGame =
AddedGameArt.resolve(context, g).let { art ->
SteamGame(
@@ -153,7 +153,7 @@ object SteamAppInfo {
return String(bytes, Charsets.UTF_8)
}
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
internal fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
val out = LinkedHashMap<String, Any>()
while (b.hasRemaining()) {
val type = b.get().toInt() and 0xFF
@@ -43,6 +43,7 @@ data class GlassesState(
val volumeMax: Int = 8,
val sunblock: Boolean = false,
val hardwareControls: Boolean = true,
val supportsDisplayModeControl: Boolean = true,
val hasSunblock: Boolean = true,
val controlError: String? = null,
val tracking: Boolean = false,
@@ -50,6 +51,7 @@ data class GlassesState(
val noSignal: Boolean = false,
val sbsSupport: Int = SBS_UNKNOWN,
val sbs90: Boolean = false,
val rates2d: List<Int> = listOf(60, 90, 120),
val experimental: Boolean = false,
val supportsScreen3dof: Boolean = false,
val supports30Hz: Boolean = false,
@@ -63,7 +65,7 @@ data class GlassesState(
val spatialConnected: Boolean get() = canControl && supportsSpatial
fun usesSpatialControllers(settings: GlassesSettings) = spatialConnected && settings.positionalTracking &&
(settings.handControllers || settings.joyConTracking)
val can3d: Boolean get() = canControl && !noSignal && sbsSupport != SBS_FAILS
val can3d: Boolean get() = canControl && supportsDisplayModeControl && !noSignal && sbsSupport != SBS_FAILS
val verticalFovDeg: Float get() {
val half = Math.toRadians(diagonalFovDeg / 2.0)
val aspect = if (width > 0 && height > 0)
@@ -212,7 +214,7 @@ object Glasses {
}
fun sendMode(mode: Int) = post {
if (!_state.value.canControl) return@post
if (!_state.value.canControl || !_state.value.supportsDisplayModeControl) return@post
if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) lastModeSentAt = SystemClock.uptimeMillis()
}
@@ -469,6 +471,7 @@ object Glasses {
.put("controlError", s.controlError ?: JSONObject.NULL)
.put("experimental", s.experimental)
.put("supportsScreen3dof", s.supportsScreen3dof)
.put("supportsDisplayModeControl", s.supportsDisplayModeControl)
.put("supportsSpatial", s.supportsSpatial)
.put("firmware", (link as? VitureSdkLink)?.firmware ?: JSONObject.NULL)
.put("spatialSdk", (link as? VitureSdkLink)?.spatialDiagnostics()?.let {
@@ -613,13 +616,16 @@ object Glasses {
switching3d = modeState.pendingStereo,
brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8,
sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock,
hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel,
hardwareControls = m?.hardwareControls == true &&
(device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel || m is RokidModel),
supportsDisplayModeControl = m?.supportsDisplayModeControl == true,
hasSunblock = m?.hasSunblock == true,
diagonalFovDeg = m?.diagonalFovDeg ?: 46f,
tracking = it.tracking && link != null,
noSignal = noSignal,
sbs90 = m?.sbs90 == true,
experimental = m === XrealModel,
rates2d = m?.rates2d ?: listOf(60, 90, 120),
experimental = m === XrealModel || (m is VitureProtocol.Model && m.family == VitureProtocol.Family.PRO2 && !m.needsSdk),
supportsScreen3dof = link?.hasImu == true,
supports30Hz = display?.let(::supports30Hz) == true,
supportsSpatial = (link as? VitureSdkLink)?.isCarina == true,
@@ -634,7 +640,8 @@ object Glasses {
scheduleModeCheck()
}
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID
internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) ||
vid == XrealModel.VENDOR_ID || vid == RokidModel.VENDOR_ID
internal fun supports30Hz(display: Display): Boolean {
val current = display.mode
@@ -650,15 +657,18 @@ object Glasses {
internal fun deviceModel(device: UsbDevice): GlassesModel? {
val name = runCatching { device.productName }.getOrNull()
return if (device.vendorId == VitureProtocol.VENDOR_ID) {
VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel)
if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) VitureProtocol.model(device.productId, name)
else VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel)
?: VitureProtocol.model(device.productId, name)
}
else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel
else RayNeoModel.model(device.vendorId, device.productId, name)
else RokidModel.model(device.vendorId, device.productId, name)
?: RayNeoModel.model(device.vendorId, device.productId, name)
}
internal fun isGlassesDisplayName(name: String) =
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name)
name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) ||
RokidModel.isDisplay(name)
private fun findDisplay(usbPresent: Boolean): Display? {
return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent)
@@ -715,7 +725,9 @@ object Glasses {
calibrationKey = calibrationStore.key(device.vendorId, device.productId,
runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty())
val l = if (device.vendorId == VitureProtocol.VENDOR_ID) {
if ((model as? VitureProtocol.Model)?.needsSdk == true) {
if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) {
VitureV2Link.open(usb, device, v2Listener(generation))
} else if ((model as? VitureProtocol.Model)?.needsSdk == true) {
// Android can evict cacheDir while the SDK is running.
Log.i(TAG, "opening VITURE SDK pid=0x%04X spatial=%s".format(device.productId, spatial))
VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation),
@@ -723,6 +735,8 @@ object Glasses {
} else VitureLink.open(usb, device, linkListener(generation))
} else if (model === XrealModel) {
XrealLink.open(app, xrealListener(generation))
} else if (model is RokidModel) {
RokidLink.open(usb, device, rokidListener(generation))
} else {
val baseKey = calibrationKey!!
val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId)
@@ -790,6 +804,33 @@ object Glasses {
}
}
private fun rokidListener(generation: Long) = object : RokidLink.Listener {
override fun onReady() = post {
if (generation != linkGeneration) return@post
publish { it.copy(connected = true, controlError = null) }
updateImu()
applyMode(force = false)
}
override fun onValue(message: Int, value: Int) = post {
if (generation != linkGeneration) return@post
when (message) {
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) }
}
}
override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) {
if (generation != linkGeneration) return
imuPackets++
probe?.invoke(raw, pose)
if (simulating.get() == null) deliver(pose, timeNs)
}
override fun onLost() = post {
if (generation != linkGeneration) return@post
closeLink()
refresh()
}
}
private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener {
override fun onSpatial(pose: FloatArray, timeNs: Long, valid: Boolean) {
if (generation == linkGeneration) SpatialTracking.accept(pose, timeNs, generation, valid)
@@ -817,6 +858,40 @@ object Glasses {
}
}
private fun v2Listener(generation: Long) = object : VitureV2Link.Listener {
override fun onReady() = post {
if (generation != linkGeneration) return@post
val current = link as? VitureV2Link ?: return@post
publish { it.copy(connected = current.ready, supportsScreen3dof = current.hasImu,
tracking = current.trackingActive, controlError = null) }
updateImu()
}
override fun onValue(msgId: Int, value: Int) = post {
if (generation != linkGeneration) return@post
when (msgId) {
VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value
VitureProtocol.MSG_GET_BRIGHTNESS -> publish { it.copy(brightness = value) }
VitureProtocol.MSG_GET_VOLUME -> publish { it.copy(volume = value) }
}
}
override fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long) {
if (generation != linkGeneration) return
imuPackets++
probe?.invoke(raw, q)
if (simulating.get() == null) deliver(q, timeNs)
}
override fun onTracking(active: Boolean) = post {
if (generation != linkGeneration) return@post
val current = link as? VitureV2Link ?: return@post
publish { it.copy(tracking = active && current.trackingActive) }
}
override fun onLost() = post {
if (generation != linkGeneration) return@post
closeLink()
publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) }
}
}
private fun rayNeoListener(generation: Long) = object : RayNeoLink.Listener {
override fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) = post {
if (generation != linkGeneration) return@post
@@ -892,7 +967,7 @@ object Glasses {
private fun applyMode(force: Boolean) {
val s = _state.value
val m = model ?: return
if (!s.canControl) return
if (!s.canControl || !s.supportsDisplayModeControl) return
if (modeState.restorePending) {
sendMode(false)
return
@@ -914,6 +989,7 @@ object Glasses {
private fun sendMode(sbs: Boolean): Boolean {
val m = model ?: return false
if (!m.supportsDisplayModeControl) return false
if (sbs && !_settings.value.gameOnGlasses) update { it.copy(gameOnGlasses = true) }
val g = _settings.value
val resolution = GlassesDisplayModes.selected(g, _state.value, sbs)
@@ -946,11 +1022,11 @@ object Glasses {
val l = link ?: return
if (!l.ready) return
val want = (sink != null || poseObservers.isNotEmpty() || probe != null || _calibration.value.running || SpatialTracking.enabled) && l.hasImu
if (want == _state.value.tracking) return
if (l !is VitureV2Link && want == _state.value.tracking) return
val accepted = l.send(VitureProtocol.MSG_IMU, byteArrayOf(if (want) 1 else 0))
if (want && !accepted) return
if (want) l.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(VitureProtocol.IMU_RATE_240.toByte()))
publish { it.copy(tracking = want) }
publish { it.copy(tracking = if (l is VitureV2Link) l.trackingActive else want) }
}
private fun linkListener(generation: Long) = object : VitureLink.Listener {
@@ -4,10 +4,13 @@ package com.droiddeck.launcher.glasses
interface GlassesModel {
val name: String
val controllable: Boolean
val hardwareControls: Boolean get() = true
val supportsDisplayModeControl: Boolean get() = true
val brightnessMax: Int
val volumeMax: Int
val diagonalFovDeg: Float
val sbs90: Boolean
val rates2d: List<Int> get() = listOf(60, 90, 120)
val hasSunblock: Boolean get() = true
val resolutions: List<GlassesResolution> get() = emptyList()
fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = mode(hz, sbs)
@@ -12,8 +12,8 @@ internal object GlassesRendering {
fun refreshRates(settings: GlassesSettings, state: GlassesState): List<Int> = buildList {
if (native30HzAvailable(settings, state)) add(30)
add(60)
if (!state.sbs || state.sbs90) add(90)
if (!state.sbs) add(120)
if (state.sbs) { if (state.sbs90) add(90) }
else addAll(state.rates2d.filter { it > 60 })
}
fun outputHz(settings: GlassesSettings, state: GlassesState): Int {
@@ -21,6 +21,7 @@ internal object GlassesRendering {
return when {
requested == 30 -> if (native30HzAvailable(settings, state)) 30 else 60
state.sbs && !state.sbs90 -> 60
!state.sbs && requested !in state.rates2d -> state.rates2d.filter { it <= requested }.maxOrNull() ?: 60
else -> requested
}
}
@@ -1,8 +1,10 @@
package com.droiddeck.launcher.glasses
internal enum class HandModel(val asset: String, val sha256: String) {
FULL("models/hand_landmarker.task", "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE("models/hand_landmarker_lite.task", "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
import com.droiddeck.launcher.handtracking.TrackingPack
internal enum class HandModel(val file: String, val sha256: String) {
FULL(TrackingPack.HAND, "fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1"),
LITE(TrackingPack.HAND_LITE, "173c0f47ecee7e935bd4a1e93aa580756b4180c1307c07d709313e82c28672c8");
companion object {
val DEFAULT = FULL
@@ -0,0 +1,148 @@
package com.droiddeck.launcher.glasses
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.os.SystemClock
import android.util.Log
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
internal class RokidLink private constructor(
private val connection: UsbDeviceConnection,
private val usbInterface: UsbInterface,
private val endpoint: UsbEndpoint,
private val listener: Listener,
) : GlassesLink {
interface Listener {
fun onReady()
fun onValue(message: Int, value: Int)
fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long)
fun onLost()
}
@Volatile private var running = true
@Volatile override var ready = false
private set
@Volatile private var tracking = false
private val controlLock = Any()
private lateinit var worker: Thread
override val hasImu = true
override fun send(msgId: Int, payload: ByteArray): Boolean {
if (!running) return false
val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: -1
return when (msgId) {
VitureProtocol.MSG_IMU -> (value in 0..1).also { if (it) tracking = value == 1 }
VitureProtocol.MSG_GET_DISPLAY -> display()?.let { listener.onValue(msgId, it) } != null
VitureProtocol.MSG_DISPLAY -> value in RokidModel.MODES &&
control(RokidModel.REQUEST_SET_DISPLAY, value, RokidModel.INDEX_DISPLAY) &&
true.also { listener.onValue(VitureProtocol.MSG_GET_DISPLAY, value) }
VitureProtocol.MSG_GET_BRIGHTNESS -> read(RokidModel.REQUEST_GET_BRIGHTNESS, RokidModel.INDEX_BRIGHTNESS)
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
}
}
private fun display(): Int? = read(RokidModel.REQUEST_GET_DISPLAY, RokidModel.INDEX_DISPLAY)
private fun read(request: Int, index: Int): Int? = synchronized(controlLock) {
if (!running) return null
val buffer = ByteArray(64)
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
// Display mode is reported in the second byte, brightness in the first.
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
if (count > offset) buffer[offset].toInt() and 0xff else null
}
private fun control(request: Int, value: Int, index: Int): Boolean = synchronized(controlLock) {
running && connection.controlTransfer(VENDOR_OUT, request, value, index, byteArrayOf(1), 1, TIMEOUT_MS) >= 0
}
private fun run() {
val buffer = ByteArray(maxOf(endpoint.maxPacketSize, 64))
val parser = RokidProtocol.Parser()
var fusion = ImuFusion()
var wasTracking = false
var brightness = -1
var lastSensor = SystemClock.elapsedRealtime()
try {
ready = true
listener.onReady()
while (running) {
val count = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
val now = SystemClock.elapsedRealtime()
val sample = if (count > 0) parser.parse(buffer, count) else null
if (sample == null) {
if (now - lastSensor > 3000) error("Rokid IMU stream stalled")
continue
}
lastSensor = now
sample.brightness?.takeIf { it != brightness }?.let {
brightness = it
listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it))
}
val enabled = tracking
if (enabled && !wasTracking) fusion = ImuFusion()
wasTracking = enabled
if (!enabled) continue
// Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
}
}
} catch (e: Exception) {
if (running) Log.w(TAG, "Rokid stream failed", e)
} finally {
ready = false
if (running) listener.onLost()
}
}
override fun close() {
synchronized(controlLock) {
if (!running) return
running = false
}
if (Thread.currentThread() !== worker) worker.join(500)
runCatching { connection.releaseInterface(usbInterface) }
connection.close()
}
companion object {
private const val TAG = "RokidLink"
private const val TIMEOUT_MS = 250
private const val VENDOR_IN = UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_VENDOR
private const val VENDOR_OUT = UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_VENDOR
internal fun sensorEndpoint(device: UsbDevice): Pair<UsbInterface, UsbEndpoint>? {
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue
for (e in 0 until iface.endpointCount) {
val endpoint = iface.getEndpoint(e)
if (endpoint.direction == UsbConstants.USB_DIR_IN &&
endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) return iface to endpoint
}
}
return null
}
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): RokidLink? {
val (iface, endpoint) = sensorEndpoint(device) ?: return null
val conn = usb.openDevice(device) ?: return null
if (!conn.claimInterface(iface, true)) {
conn.close()
return null
}
return RokidLink(conn, iface, endpoint, listener).also { link ->
link.worker = Thread({ link.run() }, "rokid-imu").apply { isDaemon = true }
link.worker.start()
}
}
}
}
@@ -0,0 +1,104 @@
package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Rokid Air/Max share one USB identity; the product string distinguishes them. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS
override val volumeMax = 0
override val hasSunblock = false
override val diagonalFovDeg = if (max) 50f else 43f
override val sbs90 = max
override val rates2d = if (max) listOf(60, 120) else listOf(60)
override val resolutions = if (max) listOf(GlassesResolution(1920, 1080), GlassesResolution(1920, 1200))
else listOf(GlassesResolution(1920, 1080))
override fun mode(hz: Int, sbs: Boolean): Int = mode(hz, sbs, null)
override fun mode(hz: Int, sbs: Boolean, resolution: GlassesResolution?): Int = when {
!max -> if (sbs) MODE_SBS_1080_60 else MODE_2D_60
sbs && resolution?.height == 1080 -> MODE_SBS_1080_60
sbs -> if (hz >= 90) MODE_SBS_1200_90 else MODE_SBS_1200_60
hz >= 120 -> MODE_2D_120
else -> MODE_2D_60
}
override fun modeForStep(step: Int): Int? = null
override fun equals(other: Any?) = other is RokidModel && other.name == name && other.max == max
override fun hashCode() = name.hashCode()
companion object {
const val VENDOR_ID = 0x04d2
const val PRODUCT_ID = 0x162f
const val BRIGHTNESS_STEPS = 10
// Vendor control requests, device recipient.
const val REQUEST_SET_DISPLAY = 0x01
const val REQUEST_GET_DISPLAY = 0x81
const val REQUEST_SET_BRIGHTNESS = 0x02
const val REQUEST_GET_BRIGHTNESS = 0x82
const val INDEX_DISPLAY = 0x01
const val INDEX_BRIGHTNESS = 0x02
const val MODE_2D_60 = 0
const val MODE_SBS_1080_60 = 1
const val MODE_2D_120 = 3
const val MODE_SBS_1200_90 = 4
const val MODE_SBS_1200_60 = 5
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || pid != PRODUCT_ID) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
return RokidModel(product, product.contains("Max", ignoreCase = true))
}
fun isDisplay(name: String) = name.contains("Rokid", ignoreCase = true)
/** Device brightness is 0..100; the shared slider uses ten steps. */
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
}
}
internal object RokidProtocol {
data class Sample(val timeNs: Long, val accel: Vec3, val gyro: Vec3, val brightness: Int?)
private const val COMBINED = 17
private const val SEPARATE = 4
private const val SENSOR_ACCEL = 1
private const val SENSOR_GYRO = 2
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
class Parser {
private var accel: Vec3? = null
private var accelTime = -1L
private var gyro: Vec3? = null
private var gyroTime = -2L
fun parse(packet: ByteArray, length: Int = packet.size): Sample? {
if (length < 64) return null
val b = ByteBuffer.wrap(packet, 0, length).order(ByteOrder.LITTLE_ENDIAN)
return when (packet[0].toInt() and 0xff) {
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
SEPARATE -> {
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
when (packet[1].toInt() and 0xff) {
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
else -> return null
}
if (accelTime != gyroTime) return null
Sample(time, accel!!, gyro!!, null).also { accelTime = -1L; gyroTime = -2L }
}
else -> null
}
}
private fun vec(b: ByteBuffer, offset: Int) = Vec3(b.getFloat(offset), b.getFloat(offset + 4), b.getFloat(offset + 8))
}
}
@@ -1,7 +1,9 @@
package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.core.AddonPack
import android.content.Context
import com.droiddeck.launcher.R
import com.droiddeck.launcher.handtracking.TrackingPack
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
@@ -11,7 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.*
import org.opencv.imgproc.Imgproc
@@ -170,11 +171,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
} finally { calibrationProgress = calibrationProgress.copy(solving = false) }
} }
private fun detector(selected: HandModel=model, useGpu: Boolean=gpu, maxHands: Int=2): HandLandmarker {
val bytes=context.assets.open(selected.asset).use { it.readBytes() }
val bytes=TrackingPack.file(context,selected.file).readBytes()
val hash=MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(hash==selected.sha256) { context.getString(R.string.spatial_model_checksum_error) }
return HandLandmarker.createFromOptions(context,HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetPath(selected.asset)
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(ByteBuffer.allocateDirect(bytes.size).put(bytes).apply { rewind() })
.setDelegate(if(useGpu) Delegate.GPU else Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO).setNumHands(maxHands).setMinHandDetectionConfidence(0.6f)
.setMinHandPresenceConfidence(0.6f).setMinTrackingConfidence(0.6f).build())
@@ -325,7 +326,11 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.session.setHands(generation,SpatialHand(),SpatialHand())
}
private fun loop() {
try { check(OpenCVLoader.initLocal()) } catch(e: Throwable) { message=context.getString(R.string.spatial_opencv_error, e.message.orEmpty());return }
val loaded=TrackingPack.awaitNative(context,{ running }) { state ->
if(state is AddonPack.State.Downloading)
message=context.getString(R.string.tracking_pack_downloading,(state.progress*100).toInt())
}
if(!loaded) { if(running) message=context.getString(R.string.tracking_pack_failed);return }
var grayL=Mat();var grayR=Mat();val rgba=Mat()
var bmpL: Bitmap?=null;var bmpR: Bitmap?=null
var leftGray=ByteArray(0);var rightGray=ByteArray(0)
@@ -336,7 +341,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
var lastSource=0L;var lastStamp=0L;var captureOn=false;var captureRate=1000;var lastInputSeq=0L
var rate=0f
try {
check(OpenCVLoader.initLocal()) { context.getString(R.string.spatial_opencv_load_error) }
check(TrackingPack.loadNative(context)) { context.getString(R.string.spatial_opencv_load_error) }
joyConFlow=StereoJoyConFlow()
while(running) {
while(true) {
@@ -6,6 +6,7 @@ import kotlin.math.sqrt
object VitureProtocol {
const val VENDOR_ID = 0x35CA
const val PRO2_PRODUCT_ID = 0x1301
const val PACKET_SIZE = 64
private const val HEADER = 0x12
@@ -33,13 +34,19 @@ object VitureProtocol {
enum class Family { ONE, PRO, LUMA, ULTRA, BEAST, PRO2 }
class Model(override val name: String, val family: Family) : GlassesModel {
class Model(
override val name: String,
val family: Family,
val needsSdk: Boolean = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2,
) : GlassesModel {
override val controllable: Boolean get() = true
val needsSdk: Boolean get() = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2
private val directPro2: Boolean get() = family == Family.PRO2 && !needsSdk
override val hardwareControls: Boolean get() = !directPro2
override val supportsDisplayModeControl: Boolean get() = !directPro2
val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA || family == Family.BEAST
override val sbs90: Boolean get() = family == Family.ULTRA
override val brightnessMax: Int get() = 8
override val volumeMax: Int get() = if (family == Family.BEAST) 15 else 8
override val brightnessMax: Int get() = if (directPro2) 0 else 8
override val volumeMax: Int get() = if (directPro2) 0 else if (family == Family.BEAST) 15 else 8
override val hasSunblock: Boolean get() = family != Family.PRO2
override val diagonalFovDeg: Float get() = when (family) {
Family.ONE -> 43f
@@ -89,6 +96,7 @@ object VitureProtocol {
}
fun model(productId: Int, productName: String?): Model? {
if (productId == PRO2_PRODUCT_ID) return Model("VITURE Pro 2", Family.PRO2, needsSdk = false)
val family = when (productId) {
0x1011, 0x1013, 0x1015, 0x1017, 0x101b -> Family.ONE
0x1019, 0x101d -> Family.PRO
@@ -0,0 +1,288 @@
package com.droiddeck.launcher.glasses
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.util.Log
import java.io.IOException
import java.nio.ByteBuffer
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeoutException
import java.util.concurrent.atomic.AtomicLong
internal class VitureV2Link internal constructor(
private val transport: Transport,
private val listener: Listener,
) : GlassesLink {
interface Listener {
fun onReady()
fun onValue(msgId: Int, value: Int)
fun onPose(raw: ByteArray, q: FloatArray, timeNs: Long)
fun onTracking(active: Boolean)
fun onLost()
}
internal data class Received(val bytes: ByteArray, val timeNs: Long)
internal interface Transport {
fun write(packet: ByteArray): Boolean
fun read(timeoutMs: Long): Received?
fun close()
}
private data class Command(val packet: ByteArray, val stream: Boolean? = null)
private val commands = ArrayBlockingQueue<Command>(16)
private val stateLock = Any()
private val bad = AtomicLong()
@Volatile private var running = true
@Volatile private var wanted = true
@Volatile override var ready = false
private set
@Volatile var trackingActive = false
private set
override val hasImu: Boolean get() = ready
override val badFrames: Long get() = bad.get()
@Volatile private var streamOn = false
private var lastPoseAt = 0L
private var lastTick: Long? = null
private var poseNotBefore = 0L
private val worker = Thread(::run, "viture-v2").apply { isDaemon = true; start() }
override fun send(msgId: Int, payload: ByteArray): Boolean {
synchronized(stateLock) {
if (!running || !ready) return false
if (msgId == VitureProtocol.MSG_IMU) {
if (payload.size != 1 || payload[0].toInt() !in 0..1) return false
val enabled = payload[0].toInt() == 1
if (wanted == enabled) return true
val command = Command(VitureV2Protocol.imuControl(
if (enabled) VitureV2Protocol.STREAM_POSE else VitureV2Protocol.STREAM_OFF), enabled)
if (!commands.offer(command)) return false
wanted = enabled
if (!enabled) {
streamOn = false
tracking(false)
}
return true
}
if (msgId == VitureProtocol.MSG_GET_IMU) return true
// Keep the measured 120 Hz mode; the generic controller requests 240 Hz.
if (msgId == VitureProtocol.MSG_IMU_RATE) return false
val query = when (msgId) {
VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS
VitureProtocol.MSG_GET_VOLUME -> VitureV2Protocol.MSG_GET_VOLUME
VitureProtocol.MSG_GET_DISPLAY -> VitureV2Protocol.MSG_GET_DISPLAY
else -> return false
}
return payload.isEmpty() && commands.offer(Command(VitureV2Protocol.packet(query)))
}
}
private fun tracking(active: Boolean) {
if (trackingActive != active) {
trackingActive = active
listener.onTracking(active)
}
}
private fun run() {
try {
if (!transport.write(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_POSE)))
throw IOException("Could not start pose stream")
poseNotBefore = System.nanoTime()
streamOn = true
lastPoseAt = System.nanoTime()
while (running) {
commands.poll()?.let { command ->
if (!transport.write(command.packet)) throw IOException("USB command failed")
val completedAt = System.nanoTime()
synchronized(stateLock) {
command.stream?.let {
streamOn = it
lastTick = null
poseNotBefore = completedAt
lastPoseAt = System.nanoTime()
}
}
}
transport.read(100)?.let(::receive)
if (streamOn && System.nanoTime() - lastPoseAt > POSE_TIMEOUT_NS)
throw IOException("No fresh valid pose samples")
}
} catch (e: Exception) {
if (running) {
ready = false
tracking(false)
Log.w(TAG, "V2 connection lost: ${e.message}")
listener.onLost()
}
} finally {
running = false
ready = false
tracking(false)
runCatching { transport.write(VitureV2Protocol.imuControl(VitureV2Protocol.STREAM_OFF)) }
transport.close()
}
}
private fun receive(received: Received) = synchronized(stateLock) {
if (!running) return
val frame = VitureV2Protocol.parse(received.bytes) ?: run { bad.incrementAndGet(); return }
if (frame.msgId == VitureV2Protocol.EVENT_POSE) {
val pose = VitureV2Protocol.pose(frame) ?: run { bad.incrementAndGet(); return }
if (!streamOn || !wanted || received.timeNs < poseNotBefore) return
val previous = lastTick
if (previous != null) {
val delta = (pose.deviceTick - previous) and 0xffffffffL
if (delta == 0L || delta >= 0x80000000L) return
}
lastTick = pose.deviceTick
lastPoseAt = System.nanoTime()
val becameReady = !ready
ready = true
tracking(true)
if (becameReady) listener.onReady()
val q = pose.quaternion
// Upstream right/up/back pose converted to the shared VITURE legacy basis.
val shared = floatArrayOf(-q[2], -q[0], q[1], q[3])
// Upstream does not establish device tick units; use host monotonic receipt time.
if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs)
return
}
VitureV2Protocol.reply(frame, VitureV2Protocol.MSG_IMU_CONTROL)?.let {
if (!it.success) throw IOException("Pose stream rejected with status ${it.status}")
}
val queries = arrayOf(
VitureV2Protocol.MSG_GET_BRIGHTNESS to VitureProtocol.MSG_GET_BRIGHTNESS,
VitureV2Protocol.MSG_GET_VOLUME to VitureProtocol.MSG_GET_VOLUME,
VitureV2Protocol.MSG_GET_DISPLAY to VitureProtocol.MSG_GET_DISPLAY,
)
for ((query, internalId) in queries) {
val reply = VitureV2Protocol.reply(frame, query) ?: continue
if (reply.success && reply.body.size == 1) listener.onValue(internalId, reply.body[0].toInt() and 255)
}
}
override fun close() {
synchronized(stateLock) {
running = false
ready = false
wanted = false
commands.clear()
}
if (Thread.currentThread() !== worker) worker.join(1500)
}
companion object {
private const val TAG = "VitureV2Link"
private const val POSE_TIMEOUT_NS = 3_000_000_000L
const val PRODUCT_ID = 0x1301
fun open(usb: UsbManager, device: UsbDevice, listener: Listener): VitureV2Link? {
if (device.vendorId != VitureProtocol.VENDOR_ID || device.productId != PRODUCT_ID) return null
val candidates = (0 until device.interfaceCount).map(device::getInterface).filter {
it.interfaceClass == UsbConstants.USB_CLASS_HID && it.alternateSetting == 0 && it.endpointCount == 2
}.mapNotNull { intf ->
val endpoints = (0 until intf.endpointCount).map(intf::getEndpoint)
val input = endpoints.singleOrNull { it.direction == UsbConstants.USB_DIR_IN &&
it.type == UsbConstants.USB_ENDPOINT_XFER_INT && it.maxPacketSize == 64 }
val output = endpoints.singleOrNull { it.direction == UsbConstants.USB_DIR_OUT &&
it.type == UsbConstants.USB_ENDPOINT_XFER_INT && it.maxPacketSize == 64 }
if (input != null && output != null) Triple(intf, input, output) else null
}
val (intf, input, output) = candidates.singleOrNull() ?: return null
val connection = usb.openDevice(device) ?: return null
if (!connection.claimInterface(intf, true)) { connection.close(); return null }
return try {
VitureV2Link(AndroidTransport(connection, intf, input, output), listener)
} catch (e: Exception) {
connection.releaseInterface(intf)
connection.close()
Log.w(TAG, "Could not initialize USB requests", e)
null
}
}
}
private class AndroidTransport(
private val connection: UsbDeviceConnection,
private val intf: UsbInterface,
input: UsbEndpoint,
output: UsbEndpoint,
) : Transport {
private val inputRequest = UsbRequest()
private val outputRequest = UsbRequest()
private val inputBuffer = ByteBuffer.allocateDirect(64)
private var outputBuffer: ByteBuffer? = null
private var outputPending = false
private val received = ArrayDeque<Received>()
init {
try {
if (!inputRequest.initialize(connection, input) || !outputRequest.initialize(connection, output))
throw IOException("USB request initialization failed")
queueInput()
} catch (e: Exception) {
inputRequest.close()
outputRequest.close()
throw e
}
}
private fun queueInput() {
inputBuffer.clear()
if (!inputRequest.queue(inputBuffer)) throw IOException("USB input queue failed")
}
private fun await(timeoutMs: Long) {
val completed = try { connection.requestWait(timeoutMs) } catch (_: TimeoutException) { return }
?: throw IOException("USB device disconnected")
if (completed === outputRequest) {
outputPending = false
} else if (completed === inputRequest) {
val count = inputBuffer.position()
if (count > 0) {
val bytes = ByteArray(count)
inputBuffer.flip()
inputBuffer.get(bytes)
if (received.size >= 512) throw IOException("USB input backlog overflow")
received.addLast(Received(bytes, System.nanoTime()))
}
queueInput()
}
}
override fun write(packet: ByteArray): Boolean {
if (outputPending) throw IOException("Previous USB output has not completed")
val buffer = ByteBuffer.allocateDirect(packet.size).apply { put(packet); flip() }
outputBuffer = buffer
outputPending = outputRequest.queue(buffer)
if (!outputPending) return false
val deadline = System.nanoTime() + 250_000_000L
while (outputPending && System.nanoTime() < deadline) await(25)
if (outputPending) {
outputRequest.cancel()
throw IOException("USB output timed out")
}
return buffer.position() == packet.size
}
override fun read(timeoutMs: Long): Received? {
if (received.isEmpty()) await(timeoutMs)
return received.removeFirstOrNull()
}
override fun close() {
inputRequest.cancel()
if (outputPending) outputRequest.cancel()
connection.releaseInterface(intf)
connection.close()
inputRequest.close()
outputRequest.close()
outputBuffer = null
}
}
}
@@ -0,0 +1,132 @@
// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick.
// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
package com.droiddeck.launcher.glasses
import kotlin.math.sqrt
object VitureV2Protocol {
const val PACKET_SIZE = 64
const val HEADER_SIZE = 8
const val RESPONSE_OFFSET = 0x2000
private const val PREAMBLE = 0x0010
private const val MAX_PAYLOAD_SIZE = PACKET_SIZE - HEADER_SIZE
const val MSG_IMU_CONTROL = 0x0301
const val MSG_GET_SERIAL = 0x3002
const val MSG_GET_VERSION = 0x3003
const val MSG_GET_BRIGHTNESS = 0x3122
const val MSG_GET_DUTY_CYCLE = 0x3125
const val MSG_GET_DISPLAY = 0x3141
const val MSG_GET_VOLUME = 0x3201
const val MSG_GET_WEAR = 0x3321
const val EVENT_POSE = 0x7308
const val EVENT_RAW = 0x7309
const val STREAM_OFF = 0
const val STREAM_POSE = 1
const val STREAM_RAW = 2
enum class Rate(val wireValue: Int, val hz: Int) {
HZ_60(0, 60), HZ_90(1, 90), HZ_120(2, 120),
HZ_240(3, 240), HZ_500(4, 500), HZ_1000(5, 1000),
}
class Frame internal constructor(val msgId: Int, val payload: ByteArray)
class Reply internal constructor(val status: Int, val body: ByteArray) {
val success: Boolean get() = status == 0
}
// Device ticks have no established time unit; quaternion is xyzw with no axis remapping.
class Pose internal constructor(
val deviceTick: Long,
val quaternion: FloatArray,
val unknown: Long,
)
class RawSample internal constructor(
val deviceTick: Long,
val gyroRadPerSec: FloatArray,
val accelG: FloatArray,
val payload: ByteArray,
)
fun packet(msgId: Int, payload: ByteArray = ByteArray(0)): ByteArray {
require(msgId in 0..0xFFFF)
require(payload.size <= MAX_PAYLOAD_SIZE)
return ByteArray(HEADER_SIZE + payload.size).also { buffer ->
putU16(buffer, 0, PREAMBLE)
putU16(buffer, 2, msgId)
putU16(buffer, 4, payload.size)
putU16(buffer, 6, checksum(payload, 0, payload.size))
payload.copyInto(buffer, HEADER_SIZE)
}
}
fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray {
// Upstream measured that stream mask 3 ACKs but produces no events on Pro 2.
require(streams == STREAM_OFF || streams == STREAM_POSE || streams == STREAM_RAW)
require(streams != STREAM_POSE || rate.hz <= 240)
val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue
return packet(MSG_IMU_CONTROL, byteArrayOf(streams.toByte(), wireRate.toByte()))
}
fun parse(buffer: ByteArray, size: Int = buffer.size): Frame? {
if (size < HEADER_SIZE || size > PACKET_SIZE || size > buffer.size) return null
if (u16(buffer, 0) != PREAMBLE) return null
val payloadSize = u16(buffer, 4)
if (payloadSize > MAX_PAYLOAD_SIZE || HEADER_SIZE + payloadSize > size) return null
if (checksum(buffer, HEADER_SIZE, payloadSize) != u16(buffer, 6)) return null
return Frame(u16(buffer, 2), buffer.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadSize))
}
fun reply(frame: Frame, requestMsgId: Int): Reply? {
if (requestMsgId !in 0..0xFFFF || frame.msgId != ((requestMsgId + RESPONSE_OFFSET) and 0xFFFF)) return null
if (frame.payload.isEmpty()) return null
return Reply(u8(frame.payload[0]), frame.payload.copyOfRange(1, frame.payload.size))
}
fun pose(frame: Frame): Pose? {
if (frame.msgId != EVENT_POSE || frame.payload.size != 24) return null
val payload = frame.payload
val w = float(payload, 8)
val x = float(payload, 12)
val y = float(payload, 16)
val z = float(payload, 20)
if (!w.isFinite() || !x.isFinite() || !y.isFinite() || !z.isFinite()) return null
val norm = sqrt(w.toDouble() * w + x.toDouble() * x + y.toDouble() * y + z.toDouble() * z)
if (norm !in 0.9..1.1) return null
val n = norm.toFloat()
return Pose(u32(payload, 4), floatArrayOf(x / n, y / n, z / n, w / n), u32(payload, 0))
}
fun raw(frame: Frame): RawSample? {
if (frame.msgId != EVENT_RAW || frame.payload.size != 56) return null
val payload = frame.payload
val gyro = floatArrayOf(float(payload, 10), float(payload, 14), float(payload, 18))
val accel = floatArrayOf(float(payload, 22), float(payload, 26), float(payload, 30))
if (gyro.any { !it.isFinite() } || accel.any { !it.isFinite() }) return null
return RawSample(u32(payload, 4), gyro, accel, payload.copyOf())
}
private fun checksum(buffer: ByteArray, offset: Int, size: Int): Int {
var sum = 0
for (i in offset until offset + size) sum += u8(buffer[i])
return sum and 0xFFFF
}
private fun u8(value: Byte) = value.toInt() and 0xFF
private fun u16(buffer: ByteArray, offset: Int) =
u8(buffer[offset]) or (u8(buffer[offset + 1]) shl 8)
private fun u32(buffer: ByteArray, offset: Int): Long =
u16(buffer, offset).toLong() or (u16(buffer, offset + 2).toLong() shl 16)
private fun float(buffer: ByteArray, offset: Int): Float = Float.fromBits(u32(buffer, offset).toInt())
private fun putU16(buffer: ByteArray, offset: Int, value: Int) {
buffer[offset] = value.toByte()
buffer[offset + 1] = (value ushr 8).toByte()
}
}
@@ -4,20 +4,27 @@ import kotlin.math.abs
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
internal class XrealOrientationFilter {
private val fusion = ImuFusion()
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
}
/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
internal class ImuFusion {
private var previous = 0L
private var rotation = Quaternion.IDENTITY
private var initialized = false
private var bias = Vec3(0f, 0f, 0f)
fun update(sample: XrealProtocol.Sample): FloatArray? {
if (sample.timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f
previous = sample.timeNs
// 1S reports right/down/forward. Rotate 180 degrees about X into the
// shared right/up/back basis before either gravity alignment or fusion.
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z)
val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z)
fun update(timeNs: Long, accel: Vec3, gyro: Vec3): FloatArray? {
if (timeNs <= previous) return null
val dt = if (previous == 0L) 0f else (timeNs - previous) * 1e-9f
previous = timeNs
val norm = accel.length()
val gravityValid = abs(norm - 9.81f) < 1.5f
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
@@ -28,7 +28,7 @@ object AutoDriverTrial {
release, p.getString("name", "").orEmpty(),
p.getString("display", "").orEmpty(), p.getString("linux", "").orEmpty(),
p.getString("previousDisplay", "").orEmpty(), p.getString("previousLinux", "").orEmpty(),
p.getInt("starts", 0),
maxOf(p.getInt("starts", 0), p.getInt("displayStarts", 0)),
)
}
@@ -37,7 +37,7 @@ object AutoDriverTrial {
.putString("release", release).putString("name", name)
.putString("display", display).putString("linux", linux)
.putString("previousDisplay", previousDisplay).putString("previousLinux", previousLinux)
.putInt("starts", 0)
.putInt("starts", 0).putInt("displayStarts", 0)
.commit()
}
@@ -48,13 +48,24 @@ object AutoDriverTrial {
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty()
/**
* Counted before the compositor loads the trial display driver, so a driver that kills the process during
* Vulkan init still counts towards a rollback; sessionStarting runs too late for that.
*/
fun displayStarting(context: Context, display: String) {
val p = prefs(context)
if (p.getString("release", null) == null || p.getString("display", "") != display) return
val counted = maxOf(p.getInt("displayStarts", 0), p.getInt("starts", 0))
p.edit().putInt("displayStarts", counted + 1).commit()
}
fun sessionStarting(context: Context, display: String, linux: String) {
val trial = current(context)
watching = null
if (trial == null || trial.display != display || trial.linux != linux) return
app = context.applicationContext
watching = trial
prefs(context).edit().putInt("starts", trial.starts + 1).commit()
prefs(context).edit().putInt("starts", prefs(context).getInt("starts", 0) + 1).commit()
}
fun sessionReady() {
@@ -0,0 +1,64 @@
package com.droiddeck.launcher.gpu
import com.droiddeck.launcher.gpu.GpuInfo.Family
import org.json.JSONArray
import org.json.JSONObject
object AutoPicks {
const val URL = "https://raw.githubusercontent.com/Droid-Deck/Drivers-CI/main/auto-drivers.json"
const val SCHEMA = 1
const val NEWEST_DD_TURNIP = DriverPairs.DD_TURNIP
class Rule(
val families: Set<Family>,
val models: Set<Int> = emptySet(),
val oneUi8Gen2: Boolean? = null,
val minSdk: Int = 0,
val blobMin: Int = 0,
val blobMax: Int = Int.MAX_VALUE,
val prefer: List<String>,
) {
fun matches(gpu: GpuInfo, sdk: Int, blob: Int?): Boolean =
gpu.family in families &&
(models.isEmpty() || gpu.model in models) &&
(oneUi8Gen2 == null || gpu.oneUi8Gen2 == oneUi8Gen2) &&
sdk >= minSdk &&
(blobMin == 0 && blobMax == Int.MAX_VALUE || blob != null && blob in blobMin..blobMax)
}
val BUILT_IN = listOf(
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
)
fun parse(text: String?): List<Rule>? = runCatching {
val json = JSONObject(text ?: return null)
if (json.optInt("schemaVersion") != SCHEMA) return null
val rules = json.getJSONArray("rules")
(0 until rules.length()).mapNotNull { i -> rule(rules.getJSONObject(i)) }.takeIf { it.isNotEmpty() }
}.getOrNull()
private fun rule(o: JSONObject): Rule? {
val families = strings(o.optJSONArray("families")).mapNotNull { name -> Family.values().firstOrNull { it.name == name } }.toSet()
val prefer = strings(o.optJSONArray("prefer"))
if (families.isEmpty() || prefer.isEmpty()) return null
val models = o.optJSONArray("models")?.let { a -> (0 until a.length()).map { a.getInt(it) }.toSet() } ?: emptySet()
return Rule(
families, models,
if (o.has("oneUi8Gen2")) o.getBoolean("oneUi8Gen2") else null,
o.optInt("minSdk", 0), o.optInt("blobMin", 0), o.optInt("blobMax", Int.MAX_VALUE), prefer,
)
}
private fun strings(a: JSONArray?): List<String> = a?.let { (0 until it.length()).map { i -> it.getString(i) } }.orEmpty()
fun preferences(gpu: GpuInfo, rules: List<Rule>, sdk: Int, blob: Int?): List<String> =
rules.firstOrNull { it.matches(gpu, sdk, blob) }?.prefer.orEmpty()
fun blobVersion(driverVersion: String?): Int? = driverVersion?.split('.')?.getOrNull(1)?.toIntOrNull()
}
@@ -57,10 +57,11 @@ object DeviceInfo {
add("Model found from", when (gpu.modelSource) {
"kernel" -> "the kernel's GPU name"
"vulkan" -> "the Vulkan driver (the kernel's name has no model)"
"platform" -> "the platform (the kernel's name has no model)"
"platform" -> "the platform or SoC (the kernel and Vulkan names have no model)"
else -> "unknown"
})
add("Kernel name", gpu.kgslName)
add("Kernel chip id", gpu.kgslChipId)
val kgsl = "/sys/class/kgsl/kgsl-3d0"
add("Clock range", gpuRange())
add("Load", read("$kgsl/gpu_busy_percentage")?.let { if (it.endsWith("%")) it else "$it %" })
@@ -111,15 +111,23 @@ object DriverPairs {
}
}
fun recommendedKey(gpu: GpuInfo, pairs: List<DriverPair>, sdk: Int = android.os.Build.VERSION.SDK_INT): String? = when (gpu.family) {
Family.POWERVR -> NAD_RADV_XCLIPSE.takeIf { gpu.name.contains("Xclipse", ignoreCase = true) }
Family.NOT_ADRENO, Family.MALI_OTHER -> null
Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST -> maliKey(gpu, pairs, sdk)
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
Family.ADRENO_UNKNOWN -> if (android.os.Build.VERSION.SDK_INT >= 36) WN_BALANCED else BANNER
fun recommendedKey(
gpu: GpuInfo, pairs: List<DriverPair>,
rules: List<AutoPicks.Rule> = AutoPicks.BUILT_IN, bad: Set<String> = emptySet(),
sdk: Int = android.os.Build.VERSION.SDK_INT, blob: Int? = null,
): String? {
when (gpu.family) {
Family.POWERVR -> return NAD_RADV_XCLIPSE.takeIf { gpu.name.contains("Xclipse", ignoreCase = true) }
Family.MALI_OTHER, Family.NOT_ADRENO -> return null
Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST ->
return maliKey(gpu, pairs, sdk)
else -> Unit
}
val keys = AutoPicks.preferences(gpu, rules, sdk, blob)
.ifEmpty { AutoPicks.preferences(gpu, AutoPicks.BUILT_IN, sdk, blob) }
.mapNotNull { if (it == AutoPicks.NEWEST_DD_TURNIP) newestDdTurnip(pairs) else it }
return keys.firstOrNull { key -> pairs.any { it.key == key && it.complete && AutoDriverTrial.release(it) !in bad } }
?: keys.firstOrNull()
}
private fun maliKey(gpu: GpuInfo, pairs: List<DriverPair>, sdk: Int): String? {
@@ -7,12 +7,13 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
object FrameGen {
const val ENGINE_OFF = "off"
const val ENGINE_WINFG = "winfg"
const val ENGINE_DDFG = "ddfg"
private const val LEGACY_ENGINE_WINFG = "winfg"
const val ENGINE_LSFG = "lsfg"
private const val WINFG_PERF_PRESET = 2
private const val WINFG_MODEL = 4
private const val FLOW_SCALE_WINFG = 0.60f
private const val DDFG_PERF_PRESET = 2
private const val DDFG_MODEL = 4
private const val FLOW_SCALE_DDFG = 0.60f
private const val FLOW_SCALE_LSFG = 0.80f
val ADAPTIVE_TARGETS = listOf(120, 90, 60)
@@ -29,7 +30,7 @@ object FrameGen {
fun mode(context: Context): Mode {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val engine = prefs.getString("engine", ENGINE_OFF) ?: ENGINE_OFF
val engine = prefs.getString("engine", ENGINE_OFF).let { if (it == LEGACY_ENGINE_WINFG) ENGINE_DDFG else it ?: 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))
@@ -43,7 +44,7 @@ object FrameGen {
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_DDFG -> context.getString(R.string.frame_gen_ddfg, 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)
@@ -68,10 +69,10 @@ object FrameGen {
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_LSFG, refreshHz)
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)
ENGINE_DDFG -> {
WaylandCompositor.nativeSetFrameGenEngine(WaylandCompositor.FG_ENGINE_DDFG)
WaylandCompositor.nativeSetDdFgTuning(DDFG_MODEL, DDFG_PERF_PRESET)
WaylandCompositor.nativeSetFrameGenTuning(FLOW_SCALE_DDFG, refreshHz)
WaylandCompositor.nativeSetFrameGenArmed(true, mode.multiplier, 0)
}
else -> WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
@@ -14,6 +14,8 @@ data class GpuInfo(
val oneUi8Gen2: Boolean,
val kgslName: String = "",
val modelSource: String = "",
val kgslChipId: String = "",
val platformGpuModel: Int = 0,
) {
enum class Family(val label: String) {
A8XX("Adreno 8xx"),
@@ -42,9 +44,12 @@ data class GpuInfo(
enum class Support { TESTED, UNTESTED, UNSUPPORTED }
private val isA850: Boolean get() = model == 850 || platformGpuModel == 850
val support: Support
get() = when {
family == Family.NOT_ADRENO -> Support.UNSUPPORTED
isA850 -> Support.UNTESTED
family == Family.A6XX && model == 650 -> Support.TESTED
family == Family.A8XX -> Support.TESTED
family == Family.A7XX && model >= 725 -> Support.TESTED
@@ -54,12 +59,13 @@ data class GpuInfo(
val supportText: String
get() = when (support) {
Support.TESTED -> "Supported"
Support.UNTESTED -> when (family) {
Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) "Experimental: RADV or the system Xclipse driver"
Support.UNTESTED -> when {
family == Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) "Experimental: RADV or the system Xclipse driver"
else "System PowerVR driver: compatibility varies by device"
Family.MALI_OTHER -> "Experimental: Mali through the phone's own driver (Venus)"
Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST -> "Experimental: open Mali driver (test builds)"
Family.A7XX_LOW -> "Experimental: its drivers are test builds"
family == Family.MALI_OTHER -> "Experimental: Mali through the phone's own driver (Venus)"
family.mali -> "Experimental: open Mali driver (test builds)"
isA850 -> "Experimental Adreno 850 support: requires compatible experimental display and game drivers; not verified on this device"
family == Family.A7XX_LOW -> "Experimental: its drivers are test builds"
else -> "Outside tested hardware (Adreno 650, 725 and newer): it may not run"
}
Support.UNSUPPORTED -> "Not supported: no compatible graphics backend is available for this GPU"
@@ -67,10 +73,10 @@ data class GpuInfo(
fun supportText(context: Context): String = context.getString(when (support) {
Support.TESTED -> R.string.gpuinfo_supported
Support.UNTESTED -> when (family) {
Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) R.string.gpuinfo_xclipse else R.string.gpuinfo_powervr
Family.A7XX_LOW -> R.string.gpuinfo_experimental
Family.MALI_JM, Family.MALI_G610_G710, Family.MALI_G615, Family.MALI_G720, Family.MALI_LATEST, Family.MALI_OTHER -> R.string.gpuinfo_experimental
Support.UNTESTED -> when {
family == Family.POWERVR -> if (name.contains("Xclipse", ignoreCase = true)) R.string.gpuinfo_xclipse else R.string.gpuinfo_powervr
isA850 -> R.string.gpuinfo_a850_experimental
family == Family.A7XX_LOW || family.mali -> R.string.gpuinfo_experimental
else -> R.string.gpuinfo_below_tested
}
Support.UNSUPPORTED -> R.string.gpuinfo_unsupported
@@ -83,19 +89,15 @@ data class GpuInfo(
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 raw = FileUtils.readString(File("/sys/class/kgsl/kgsl-3d0/gpu_model"))?.trim()
val chipId = FileUtils.readString(File("/sys/class/kgsl/kgsl-3d0/gpu_chipid"))?.trim().orEmpty()
val soc = if (adreno) SocNames.label() else ""
val fromKernel = raw?.let { threeDigits(it) } ?: 0
val fromVulkan = if (fromKernel > 0 || !adreno) 0 else VulkanInfo.cachedOrNull()?.get("device")?.let { threeDigits(it) } ?: 0
val fromPlatform = if (fromKernel > 0 || fromVulkan > 0 || !adreno) 0 else platformModel()
val model = maxOf(fromKernel, fromVulkan, fromPlatform)
val source = when {
fromKernel > 0 -> "kernel"
fromVulkan > 0 -> "vulkan"
fromPlatform > 0 -> "platform"
else -> ""
}
val platformGpuModel = if (adreno) platformModel() else 0
val (model, source) = if (adreno) modelFromNames(
raw,
{ VulkanInfo.cachedOrNull()?.get("device") },
{ platformGpuModel },
) else 0 to ""
// Mali, named by the vendor's Vulkan driver ("Mali-G720-Immortalis MC12") or by kbase.
val mali = if (adreno || !File("/dev/mali0").exists()) null else maliName()
val family = familyOf(adreno, model, SystemVulkanDriver.isPowerVr(), mali)
@@ -112,19 +114,34 @@ data class GpuInfo(
},
model = model, family = family, soc = soc,
oneUi8Gen2 = samsung && model == 740,
kgslName = raw.orEmpty(), modelSource = source,
kgslName = raw.orEmpty(), modelSource = source, kgslChipId = chipId,
platformGpuModel = platformGpuModel,
)
}
internal fun threeDigits(text: String): Int = Regex("""(\d{3})""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
internal fun threeDigits(text: String): Int = Regex("""(?i)\badreno\s*(?:\(TM\)\s*)?(\d{3})(?!\d)""").find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
internal fun modelFromNames(
kernelName: String?,
vulkanName: () -> String?,
fallback: () -> Int,
): Pair<Int, String> {
kernelName?.let { threeDigits(it) }?.takeIf { it > 0 }?.let { return it to "kernel" }
vulkanName()?.let { threeDigits(it) }?.takeIf { it > 0 }?.let { return it to "vulkan" }
return fallback().let { if (it > 0) it to "platform" else 0 to "" }
}
private val PLATFORMS = mapOf(
"msmnile" to 640, "kona" to 650, "lahaina" to 660, "taro" to 730, "cape" to 730,
"kalama" to 740, "pineapple" to 750, "sun" to 830,
)
internal fun platformModel(platform: String = systemProperty("ro.board.platform")): Int =
PLATFORMS[platform.lowercase()] ?: PLATFORMS[systemProperty("ro.vendor.qti.soc_name").lowercase()] ?: 0
internal fun platformModel(
platform: String = systemProperty("ro.board.platform"),
qtiSocName: String = systemProperty("ro.vendor.qti.soc_name"),
socModel: String = SocNames.model(),
): Int = if (SocNames.normalizedModel(socModel) == "SM8975" || SocNames.normalizedModel(qtiSocName) == "SM8975") 850
else PLATFORMS[platform.lowercase()] ?: PLATFORMS[qtiSocName.lowercase()] ?: 0
fun systemProperty(name: String): String = try {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java).invoke(null, name) as? String ?: ""
@@ -8,6 +8,7 @@ object SocNames {
"SM8350" to "Snapdragon 888", "SM8450" to "Snapdragon 8 Gen 1", "SM8475" to "Snapdragon 8+ Gen 1",
"SM8550" to "Snapdragon 8 Gen 2", "QCS8550" to "Snapdragon 8 Gen 2", "SM8650" to "Snapdragon 8 Gen 3",
"SM8750" to "Snapdragon 8 Elite", "SM8850" to "Snapdragon 8 Elite Gen 5",
"SM8975" to "Snapdragon 8 Elite Extreme Gen 6",
"SM7325" to "Snapdragon 778G", "SM7450" to "Snapdragon 7 Gen 1", "SM7550" to "Snapdragon 7 Gen 3",
"SM6375" to "Snapdragon 695",
)
@@ -25,8 +26,10 @@ object SocNames {
.firstOrNull { it.isNotEmpty() && !Regex("(?i)^msm_\\d").containsMatchIn(it) }.orEmpty()
}
internal fun normalizedModel(model: String): String = model.trim().uppercase().removePrefix("QTI ").trim()
fun name(model: String, platform: String = GpuInfo.systemProperty("ro.board.platform")): String? =
MODELS[model.uppercase()] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
MODELS[normalizedModel(model)] ?: PLATFORMS[platform.lowercase()]?.let { "$it family" }
fun label(model: String = model(), platform: String = GpuInfo.systemProperty("ro.board.platform")): String {
val name = name(model, platform)
@@ -11,8 +11,10 @@ object TpUbwcHint {
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
// match the system driver: blob v6xx leaves it off, newer blobs set it. A mismatch corrupts Android UI.
internal fun features(model: Int, systemDriverVersion: String?): String? {
// Samsung's One UI firmware sets it whatever blob version it reports.
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false): String? {
if (model != 740) return null
if (samsung) return "enable_tp_ubwc_flag_hint=1"
val blob = systemDriverVersion?.split('.')?.getOrNull(1)?.toIntOrNull() ?: return null
return "enable_tp_ubwc_flag_hint=" + if (blob >= 700) "1" else "0"
}
@@ -20,11 +22,12 @@ object TpUbwcHint {
@Synchronized
fun forThisDevice(): String? {
if (resolved) return cached
val model = GpuInfo.detect().model
val gpu = GpuInfo.detect()
val model = gpu.model
val version = if (model == 740) VulkanInfo.query()["driver_version"] else null
cached = features(model, version)
cached = features(model, version, gpu.oneUi8Gen2)
resolved = true
Log.i(TAG, "Adreno $model, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
Log.i(TAG, "Adreno $model${if (gpu.oneUi8Gen2) " (One UI)" else ""}, system driver ${version ?: "n/a"}: ${cached ?: "driver default"}")
return cached
}
@@ -307,27 +307,15 @@ public final class TurnipDriver {
Log.i(TAG, "Mali: the compositor uses the system Vulkan driver");
return null;
}
String model = gpuModel();
Log.i(TAG, "gpu model: " + (model == null ? "unknown" : model));
if (model != null) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile("(\\d)\\d\\d").matcher(model);
if (m.find()) {
char generation = m.group(1).charAt(0);
if (generation >= '8') return DRIVER_A8XX;
if (generation == '7') return DRIVER_A7XX;
if (generation == '6') return DRIVER_A6XX;
}
}
return android.os.Build.VERSION.SDK_INT >= 36 ? DRIVER_A8XX : DRIVER_A7XX;
int model = GpuInfo.Companion.detect().getModel();
Log.i(TAG, "gpu model: " + (model == 0 ? "unknown" : model));
return chooseByModel(model, android.os.Build.VERSION.SDK_INT);
}
private static String gpuModel() {
for (String path : new String[]{
"/sys/class/kgsl/kgsl-3d0/gpu_model",
"/sys/class/kgsl/kgsl-3d0/gpu_chipid"}) {
String value = FileUtils.readString(new File(path));
if (value != null && !value.trim().isEmpty()) return value.trim();
}
return null;
static String chooseByModel(int model, int sdk) {
if (model >= 800) return DRIVER_A8XX;
if (model >= 700) return DRIVER_A7XX;
if (model >= 600) return DRIVER_A6XX;
return sdk >= 36 ? DRIVER_A8XX : DRIVER_A7XX;
}
}
@@ -19,6 +19,7 @@ object TurnipReleases {
private const val PREFS = "turnip_releases"
private const val KEY_RELEASE = "latest"
private const val KEY_DOWNLOADS = "downloads"
private const val KEY_PICKS = "autoPicks"
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 pair: String = "", val bundle: Boolean = false,
@@ -76,6 +77,9 @@ object TurnipReleases {
private fun sources(): List<Source> =
if (com.droiddeck.launcher.core.DeviceSupport.adreno()) SOURCES.filter { it.repo != NON_ADRENO_REPO } else SOURCES
fun autoPicks(context: Context): List<AutoPicks.Rule> =
AutoPicks.parse(prefs(context).getString(KEY_PICKS, null)) ?: AutoPicks.BUILT_IN
fun cached(context: Context): Check? {
val raw = prefs(context).getString(KEY_RELEASE, null) ?: return null
return runCatching { parse(JSONObject(raw)) }.getOrNull()
@@ -123,6 +127,10 @@ object TurnipReleases {
}
}
if (failed.size == sources.size) throw lastError ?: IOException("no source answered")
runCatching { get(context, AutoPicks.URL, AutoPicks.URL) }
.onFailure { Log.i(TAG, "auto picks: ${it.message}") }
.getOrNull()?.takeIf { AutoPicks.parse(it) != null }
?.let { prefs(context).edit().putString(KEY_PICKS, it).apply() }
val stored = JSONObject().put("assets", assets).put("latest", latest)
.put("failed", JSONArray(failed)).put("checkedAt", System.currentTimeMillis())
prefs(context).edit().putString(KEY_RELEASE, stored.toString()).apply()
@@ -39,8 +39,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.Executors
import kotlin.math.abs
@@ -188,9 +186,8 @@ internal class HandCamera(
private fun prepare() {
if (stopped) return
runCatching {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
val model = TrackingPack.model(context, TrackingPack.POSE)
val options = PoseLandmarker.PoseLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(RunningMode.VIDEO)
@@ -305,7 +302,6 @@ internal class HandCamera(
companion object {
private const val TAG = "HandCamera"
private const val MODEL = "handtracking/pose_landmarker_lite.task"
private const val ANALYSIS_WIDTH = 960
private const val ANALYSIS_HEIGHT = 720
private const val MIN_CONFIDENCE = 0.5f
@@ -13,8 +13,6 @@ import com.google.mediapipe.tasks.core.BaseOptions
import com.google.mediapipe.tasks.core.Delegate
import com.google.mediapipe.tasks.vision.core.RunningMode
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.hypot
import kotlin.math.max
@@ -134,7 +132,6 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height)
companion object {
private const val MODEL = "handtracking/hand_landmarker.task"
private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)
private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY)
private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX)
@@ -153,10 +150,9 @@ internal class HandModel private constructor(private val landmarkers: List<HandL
private const val MIN_CONFIDENCE = 0.4f
fun open(context: Context): HandModel {
val bytes = context.assets.open(MODEL).use { it.readBytes() }
check(TrackingPack.loadNative(context)) { "hand tracking download is not installed" }
fun landmarker(mode: RunningMode, hands: Int): HandLandmarker {
val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes)
model.rewind()
val model = TrackingPack.model(context, TrackingPack.HAND)
return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder()
.setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build())
.setRunningMode(mode)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.handtracking
import com.droiddeck.launcher.core.AddonPack
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
@@ -16,9 +17,10 @@ internal object HandTracking {
val side: String? = null,
val permitted: Boolean = false,
val sides: Set<String> = emptySet(),
val downloaded: Boolean = false,
) {
val usable: Boolean get() = sides.isNotEmpty()
val ready: Boolean get() = enabled && permitted && usable
val ready: Boolean get() = enabled && permitted && usable && downloaded
}
private val state = MutableStateFlow(Settings())
@@ -38,8 +40,11 @@ internal object HandTracking {
side = prefs.getString(SIDE, null),
permitted = permitted(context),
sides = if (supported) TrackingCameras.sides(found) else emptySet(),
downloaded = TrackingPack.installed(context),
)
state.value = next
if (next.enabled && next.usable && !next.downloaded && TrackingPack.refresh(context) == AddonPack.State.Missing)
TrackingPack.ensure(context)
return next
}
@@ -0,0 +1,56 @@
package com.droiddeck.launcher.handtracking
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.AddonPack
import java.io.File
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.channels.FileChannel
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
internal object TrackingPack {
const val HAND = "hand_landmarker.task"
const val HAND_LITE = "hand_landmarker_lite.task"
const val POSE = "pose_landmarker_lite.task"
private const val OPENCV = "libopencv_java4.so"
private const val MEDIAPIPE = "libmediapipe_tasks_jni.so"
private const val TAG = "TrackingPack"
private val pack = AddonPack.TRACKING
val state get() = pack.state
val size get() = pack.size
private val lock = Any()
@Volatile private var nativeLoaded = false
fun installed(context: Context) = pack.installed(context)
fun refresh(context: Context) = pack.refresh(context)
fun ensure(context: Context) = pack.ensure(context) { HandTracking.load(it) }
fun file(context: Context, name: String): File = pack.file(context, name)
fun model(context: Context, name: String): ByteBuffer =
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
/**
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
*/
fun loadNative(context: Context): Boolean {
if (nativeLoaded) return true
synchronized(lock) {
if (nativeLoaded) return true
if (!pack.installed(context)) return false
return runCatching {
System.load(file(context, OPENCV).absolutePath)
System.load(file(context, MEDIAPIPE).absolutePath)
nativeLoaded = true
true
}.onFailure { Log.e(TAG, "native libraries failed to load", it) }.getOrDefault(false)
}
}
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
pack.await(context, running, onState) && loadNative(context)
}
@@ -0,0 +1,53 @@
package com.droiddeck.launcher.input
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import kotlin.math.abs
class DirectTouchHold(
private val slopPx: Float,
private val listener: PointerGestures.Listener,
private val cancelTouch: () -> Unit,
) {
private val handler = Handler(Looper.getMainLooper())
private var x = 0f
private var y = 0f
private var consumed = false
private var pressed = false
private val release = Runnable {
if (pressed) listener.onButton(PointerGestures.BTN_RIGHT, false, x, y)
pressed = false
}
private val hold = Runnable {
cancelTouch()
consumed = true
listener.onLongPress()
listener.onButton(PointerGestures.BTN_RIGHT, true, x, y)
pressed = true
handler.postDelayed(release, 50L)
}
fun cancel() {
handler.removeCallbacks(hold)
handler.removeCallbacks(release)
release.run()
consumed = false
}
fun onTouch(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
cancel()
x = event.x; y = event.y
handler.postDelayed(hold, 500L)
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount > 1 ||
abs(event.x - x) > slopPx || abs(event.y - y) > slopPx) handler.removeCallbacks(hold)
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
MotionEvent.ACTION_UP -> handler.removeCallbacks(hold)
MotionEvent.ACTION_CANCEL -> cancel()
}
return consumed
}
}
@@ -99,7 +99,8 @@ public class FakeInputWriter {
private static final int TRITON_REPORT_OFFSET = TRITON_OFFSET + 24;
private static final int TRITON_OUTPUT_SEQ_OFFSET = TRITON_OFFSET + 88;
private static final int TRITON_OUTPUT_FEATURE_OFFSET = TRITON_OFFSET + 96;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_LENGTH_OFFSET = TRITON_OFFSET + 98;
private static final int TRITON_OUTPUT_ACK_OFFSET = TRITON_OFFSET + 100;
private static final int TRITON_OUTPUT_OFFSET = TRITON_OFFSET + 104;
private static final int TRITON_GET_REQUEST_OFFSET = TRITON_OFFSET + 168;
private static final int TRITON_GET_REPLY_OFFSET = TRITON_OFFSET + 176;
@@ -229,8 +230,9 @@ public class FakeInputWriter {
data.putInt(TRITON_REPORT_ID_OFFSET, 0);
data.putInt(TRITON_REPORT_LENGTH_OFFSET, 0);
data.putLong(TRITON_OUTPUT_SEQ_OFFSET, 0L);
data.putInt(TRITON_OUTPUT_FEATURE_OFFSET, 0);
data.putInt(TRITON_OUTPUT_LENGTH_OFFSET, 0);
data.putShort(TRITON_OUTPUT_FEATURE_OFFSET, (short) 0);
data.putShort(TRITON_OUTPUT_LENGTH_OFFSET, (short) 0);
data.putInt(TRITON_OUTPUT_ACK_OFFSET, 0);
data.putLong(TRITON_GET_REQUEST_OFFSET, 0L);
data.putLong(TRITON_GET_REPLY_OFFSET, 0L);
}
@@ -587,14 +589,18 @@ public class FakeInputWriter {
nativeStoreFence();
long seq = ring.getLong(TRITON_OUTPUT_SEQ_OFFSET);
if ((seq & 1) != 0 || seq <= lastSequence) return null;
int length = ring.getInt(TRITON_OUTPUT_LENGTH_OFFSET);
int length = ring.getShort(TRITON_OUTPUT_LENGTH_OFFSET) & 0xffff;
if (length <= 0 || length > TRITON_REPORT_MAX) return null;
byte[] output = new byte[9 + length];
ByteBuffer.wrap(output).order(ByteOrder.LITTLE_ENDIAN).putLong(seq);
output[8] = (byte) (ring.getInt(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
output[8] = (byte) (ring.getShort(TRITON_OUTPUT_FEATURE_OFFSET) != 0 ? 1 : 0);
for (int i = 0; i < length; i++) output[9 + i] = ring.get(TRITON_OUTPUT_OFFSET + i);
nativeStoreFence();
return seq == ring.getLong(TRITON_OUTPUT_SEQ_OFFSET) ? output : null;
if (seq != ring.getLong(TRITON_OUTPUT_SEQ_OFFSET)) return null;
// Acknowledge the exact report sequence taken so a concurrent timeout overwrite cannot lose its length.
ring.putInt(TRITON_OUTPUT_ACK_OFFSET, (int) seq);
nativeStoreFence();
return output;
}
}
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0
* Modified for DroidDeck on 2026-10-15.
*/
package com.droiddeck.launcher.input
object GestureKeys {
val codes: Map<String, Int> = linkedMapOf(
"ESC" to 1,
"1" to 2,
"2" to 3,
"3" to 4,
"4" to 5,
"5" to 6,
"6" to 7,
"7" to 8,
"8" to 9,
"9" to 10,
"0" to 11,
"MINUS" to 12,
"EQUAL" to 13,
"BKSP" to 14,
"TAB" to 15,
"Q" to 16,
"W" to 17,
"E" to 18,
"R" to 19,
"T" to 20,
"Y" to 21,
"U" to 22,
"I" to 23,
"O" to 24,
"P" to 25,
"BRACKET_LEFT" to 26,
"BRACKET_RIGHT" to 27,
"ENTER" to 28,
"CTRL_L" to 29,
"A" to 30,
"S" to 31,
"D" to 32,
"F" to 33,
"G" to 34,
"H" to 35,
"J" to 36,
"K" to 37,
"L" to 38,
"SEMICOLON" to 39,
"APOSTROPHE" to 40,
"GRAVE" to 41,
"SHIFT_L" to 42,
"BACKSLASH" to 43,
"Z" to 44,
"X" to 45,
"C" to 46,
"V" to 47,
"B" to 48,
"N" to 49,
"M" to 50,
"COMMA" to 51,
"PERIOD" to 52,
"SLASH" to 53,
"SHIFT_R" to 54,
"KP_MULTIPLY" to 55,
"ALT_L" to 56,
"SPACE" to 57,
"CAPS_LOCK" to 58,
"F1" to 59,
"F2" to 60,
"F3" to 61,
"F4" to 62,
"F5" to 63,
"F6" to 64,
"F7" to 65,
"F8" to 66,
"F9" to 67,
"F10" to 68,
"NUM_LOCK" to 69,
"SCROLL_LOCK" to 70,
"KP_7" to 71,
"KP_8" to 72,
"KP_9" to 73,
"KP_SUBTRACT" to 74,
"KP_4" to 75,
"KP_5" to 76,
"KP_6" to 77,
"KP_ADD" to 78,
"KP_1" to 79,
"KP_2" to 80,
"KP_3" to 81,
"KP_0" to 82,
"KP_DEL" to 83,
"F11" to 87,
"F12" to 88,
"KP_ENTER" to 96,
"CTRL_R" to 97,
"KP_DIVIDE" to 98,
"PRTSCN" to 99,
"ALT_R" to 100,
"HOME" to 102,
"UP" to 103,
"PRIOR" to 104,
"LEFT" to 105,
"RIGHT" to 106,
"END" to 107,
"DOWN" to 108,
"NEXT" to 109,
"INSERT" to 110,
"DEL" to 111,
)
@JvmStatic fun code(name: String): Int? = codes[name]
}
@@ -0,0 +1,24 @@
package com.droiddeck.launcher.input
import android.content.Context
/** Global defaults with optional overrides for the currently focused game. */
object GesturePrefs {
private fun prefs(context: Context) = context.getSharedPreferences("touch_gestures", Context.MODE_PRIVATE)
fun hasOverride(context: Context, game: String): Boolean = prefs(context).contains("game.$game")
fun read(context: Context, game: String? = null): TouchGestureConfig {
val p = prefs(context)
val json = game?.let { p.getString("game.$it", null) } ?: p.getString("default", null)
return if (json == null) TouchGestureConfig() else TouchGestureConfig.fromJson(json)
}
fun save(context: Context, game: String?, config: TouchGestureConfig) {
prefs(context).edit().putString(game?.let { "game.$it" } ?: "default", config.toJson()).apply()
}
fun clear(context: Context, game: String?) {
prefs(context).edit().remove(game?.let { "game.$it" } ?: "default").apply()
}
}
@@ -55,6 +55,7 @@ public final class PadBridge {
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private boolean open;
private volatile boolean selectSteam;
private volatile boolean builtinPad = true;
private TouchRoute touchRoute;
private final PadState routedTouch = new PadState();
private boolean systemGuidePressed;
@@ -110,6 +111,35 @@ public final class PadBridge {
excluded = devices;
}
public synchronized void setBuiltinPad(boolean on) {
boolean changed = builtinPad != on;
builtinPad = on;
if (changed && !on) resetBuiltin();
}
private static volatile int firstSlot;
public static void setFirstSlot(int slot) {
firstSlot = slot;
}
private int firstPlayerSlot() {
return firstSlot;
}
private void resetBuiltin() {
routedTouch.clear();
systemGuidePressed = false;
systemQamPressed = false;
qamTapPressed = false;
qamChordActive = false;
chordButtonPressed = false;
qamChordGeneration++;
chordHandler.removeCallbacksAndMessages(null);
state.clear();
publish();
}
public synchronized boolean start() {
if (!open) {
open = writer.open();
@@ -146,9 +176,15 @@ public final class PadBridge {
}
}
static boolean isSystemVirtualInputName(String name) {
if (name == null) return false;
String lower = name.trim().toLowerCase(java.util.Locale.ROOT);
return lower.startsWith("uinput-") || lower.startsWith("uinput_");
}
public static boolean isFromController(InputDevice device) {
if (device == null) return false;
if (device.isVirtual()) return false;
if (device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
int sources = device.getSources();
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) return true;
if ((sources & InputDevice.SOURCE_JOYSTICK) != InputDevice.SOURCE_JOYSTICK) return false;
@@ -165,7 +201,7 @@ public final class PadBridge {
public static boolean isControllerKeyEvent(KeyEvent event) {
InputDevice device = event.getDevice();
if (device == null || device.isVirtual()) return false;
if (device == null || device.isVirtual() || isSystemVirtualInputName(device.getName())) return false;
if (KeyEvent.isGamepadButton(event.getKeyCode())) {
return device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK);
@@ -190,6 +226,7 @@ public final class PadBridge {
public static boolean isControllerMotionEvent(MotionEvent event) {
InputDevice device = event.getDevice();
return event.isFromSource(InputDevice.SOURCE_JOYSTICK) && device != null && !device.isVirtual()
&& !isSystemVirtualInputName(device.getName())
&& (device.supportsSource(InputDevice.SOURCE_GAMEPAD)
|| device.supportsSource(InputDevice.SOURCE_JOYSTICK));
}
@@ -225,7 +262,9 @@ public final class PadBridge {
public synchronized void syncConnectedControllers() {
for (int deviceId : InputDevice.getDeviceIds()) {
InputDevice device = InputDevice.getDevice(deviceId);
if (isFromController(device) && !excluded.test(deviceId)) publish(slotFor(device));
if (!isFromController(device) || excluded.test(deviceId)) continue;
int slot = slotFor(device);
if (slot >= 0) publish(slot);
}
}
@@ -245,6 +284,7 @@ public final class PadBridge {
public synchronized boolean onKeyEvent(KeyEvent event) {
if (!isControllerKeyEvent(event) || excluded.test(event.getDeviceId())) return false;
int slot = slotFor(event.getDevice());
if (slot < 0) return false;
int side = JoyCon.side(event.getDevice());
if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true;
PadState pad = stateFor(slot);
@@ -293,6 +333,7 @@ public final class PadBridge {
if (!isControllerMotionEvent(event) || excluded.test(event.getDeviceId())) return false;
if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
int slot = slotFor(event.getDevice());
if (slot < 0) return false;
PadState pad = stateFor(slot);
noteDevice(event.getDevice(), slot);
statAxes++;
@@ -319,16 +360,27 @@ public final class PadBridge {
boolean wired = wiredXboxLayout(event);
pad.rightX = rightX(event, wired);
pad.rightY = rightY(event, wired);
float lt = leftTrigger(event, wired);
float rt = rightTrigger(event, wired);
pad.leftTrigger = lt;
pad.rightTrigger = rt;
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.right = hatX > 0.5f;
pad.down = hatY > 0.5f;
pad.left = hatX < -0.5f;
int source = event.getSource();
float lt = 0f;
float rt = 0f;
if (wired || hasAxis(device, source, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)) {
lt = leftTrigger(event, wired);
pad.leftTrigger = lt;
}
if (wired || hasAxis(device, source, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)) {
rt = rightTrigger(event, wired);
pad.rightTrigger = rt;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_X)) {
float hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X);
pad.right = hatX > 0.5f;
pad.left = hatX < -0.5f;
}
if (hasAxis(device, source, MotionEvent.AXIS_HAT_Y)) {
float hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y);
pad.up = hatY < -0.5f;
pad.down = hatY > 0.5f;
}
if (Math.max(Math.max(Math.abs(pad.leftX), Math.abs(pad.leftY)), Math.max(Math.abs(pad.rightX), Math.abs(pad.rightY))) > 0.5f
|| lt > 0.5f || rt > 0.5f || pad.up || pad.right || pad.down || pad.left) {
notePlayerInput();
@@ -337,6 +389,12 @@ public final class PadBridge {
return true;
}
static boolean hasAxis(InputDevice device, int source, int... axes) {
if (device == null) return false;
for (int axis : axes) if (device.getMotionRange(axis, source) != null) return true;
return false;
}
public interface TouchRoute {
boolean active();
@@ -349,6 +407,7 @@ public final class PadBridge {
}
public synchronized void applyTouch(java.util.function.Consumer<PadState> mutation) {
if (!builtinPad) return;
if (touchRoute != null && touchRoute.active()) {
mutation.accept(routedTouch);
touchRoute.accept(routedTouch);
@@ -388,6 +447,7 @@ public final class PadBridge {
}
public synchronized void setSystemButtons(boolean guidePressed, boolean qamPressed) {
if (!builtinPad) return;
if (systemGuidePressed == guidePressed && systemQamPressed == qamPressed) return;
boolean qamStarted = qamPressed && !systemQamPressed;
systemGuidePressed = guidePressed;
@@ -419,6 +479,7 @@ public final class PadBridge {
}
public synchronized void triggerQam() {
if (!builtinPad) return;
if (!nativeQam()) {
startChord(PadState.A);
return;
@@ -437,6 +498,7 @@ public final class PadBridge {
}
public synchronized void triggerSteamKeyboard() {
if (!builtinPad) return;
startChord(PadState.X);
}
@@ -486,25 +548,25 @@ public final class PadBridge {
if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) leave(slot, owners[slot]);
}
int side = JoyCon.side(device);
for (int slot = 0; side >= 0 && slot < SLOTS; slot++) {
for (int slot = firstPlayerSlot(); side >= 0 && slot < SLOTS; slot++) {
if (owners[slot] == NO_CONTROLLER || partners[slot] != NO_CONTROLLER) continue;
InputDevice owner = InputDevice.getDevice(owners[slot]);
if (JoyCon.side(owner) != 1 - side) continue;
partners[slot] = id;
slotJoyCons.set(slot * 2 + side, id);
slotJoyCons.set(slot * 2 + 1 - side, owner.getId());
if (slot == 0) releaseBackPaddles();
if (slot == firstPlayerSlot()) releaseBackPaddles();
stateFor(slot).clear();
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") joins \"" + owner.getName() + "\" as player " + (slot + 1));
return slot;
}
for (int slot = 0; slot < SLOTS; slot++) {
for (int slot = firstPlayerSlot(); slot < SLOTS; slot++) {
if (owners[slot] != NO_CONTROLLER) continue;
owners[slot] = id;
Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") is player " + (slot + 1));
return slot;
}
return 0;
return -1;
}
private PadState stateFor(int slot) {
@@ -513,7 +575,8 @@ public final class PadBridge {
private boolean isPrimary(KeyEvent event) {
int id = event.getDevice().getId();
return owners[0] == id || partners[0] == id;
int first = firstPlayerSlot();
return owners[first] == id || partners[first] == id;
}
private void releaseBackPaddles() {
@@ -532,7 +595,7 @@ public final class PadBridge {
clearJoyCons(slot);
if (slotControllers.get(slot) == id) slotControllers.set(slot, owners[slot]);
if (activeControllerId == id) activeControllerId = owners[slot];
if (slot == 0) releaseBackPaddles();
if (slot == firstPlayerSlot()) releaseBackPaddles();
stateFor(slot).clear();
publish(slot);
}
@@ -548,8 +611,8 @@ public final class PadBridge {
partners[slot] = NO_CONTROLLER;
clearJoyCons(slot);
slotControllers.set(slot, NO_CONTROLLER);
if (slot == firstPlayerSlot()) releaseBackPaddles();
if (slot == 0) {
releaseBackPaddles();
state.clear();
publish();
return;
@@ -608,6 +671,7 @@ public final class PadBridge {
}
private void publish() {
if (!builtinPad && !open && owners[0] == NO_CONTROLLER && partners[0] == NO_CONTROLLER) return;
if (!open && !start()) {
statDropped++;
return;
@@ -13,6 +13,7 @@ import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import android.util.Log
import com.droiddeck.launcher.session.SessionState
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.ArrayDeque
@@ -51,6 +52,10 @@ class SteamControllerBle(private val context: Context) {
private fun connect() {
if (!running || gatt != null) return
if (SessionState.suspended) {
handler.postDelayed(::connect, RETRY_MS)
return
}
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
val bonded = runCatching { adapter?.takeIf(BluetoothAdapter::isEnabled)?.bondedDevices }
.onFailure { Log.w(TAG, "paired devices unreadable", it) }
Loaded 100 of 574 files, more files were not shown because too many files have changed in this diff. Show more