Merge latest origin/main and resolve conflicts

This commit is contained in:
DroidDeck Merge committed 2026-11-02 10:44:24 +08:00
commit 8171bf20fd
581 files changed
+14773 -4227

No files matched your search

-1
View File
@@ -3,7 +3,6 @@ tools/mangoapp/** text=auto eol=lf
tools/comet/LICENSE whitespace=-blank-at-eof tools/comet/LICENSE whitespace=-blank-at-eof
tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof tools/comet/droiddeck.patch whitespace=-blank-at-eol,-blank-at-eof
# These helpers are packaged verbatim and executed inside the Linux runtime.
tools/linuxfs/overlay/** text eol=lf tools/linuxfs/overlay/** text eol=lf
tools/linuxfs/desktop/** text eol=lf tools/linuxfs/desktop/** text eol=lf
tools/mangoapp/mangoapp text eol=lf tools/mangoapp/mangoapp text eol=lf
-1
View File
@@ -1,4 +1,3 @@
# GitHub PR openers trusted for automatic release-signed test builds.
FX192 FX192
K11MCH1 K11MCH1
kingokksa kingokksa
-4
View File
@@ -1,8 +1,5 @@
name: Build FEX (x86-64 Steam client) name: Build FEX (x86-64 Steam client)
# Checks tools/fex: the same build DroidDeck-Components' build-fex.yml runs from this repository to publish FEX
# (its single `fex` release and fex.json, which the app follows). Nothing is published from here.
on: on:
push: push:
paths: paths:
@@ -31,7 +28,6 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Build in Arch Linux ARM - name: Build in Arch Linux ARM
# Inputs reach the shell as environment variables, never pasted into the script.
env: env:
FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }} FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }}
FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }} FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }}
+32
View File
@@ -63,6 +63,8 @@ jobs:
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT" echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
git config user.name "ci" git config user.name "ci"
git config user.email "ci@users.noreply.github.com" git config user.email "ci@users.noreply.github.com"
# Partial clone: a merged blob that only exists in a fork's history can't be lazily fetched (GitHub: "not our ref"); merge-tree writes it locally first.
git merge-tree --write-tree HEAD origin/main >/dev/null || true
git merge --no-ff --no-edit origin/main git merge --no-ff --no-edit origin/main
- name: Resolve trusted manifest base - name: Resolve trusted manifest base
id: trusted id: trusted
@@ -100,6 +102,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA" git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci" git config user.name "ci"
git config user.email "ci@users.noreply.github.com" git config user.email "ci@users.noreply.github.com"
git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree - name: Verify pinned source tree
env: env:
@@ -158,6 +161,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA" git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci" git config user.name "ci"
git config user.email "ci@users.noreply.github.com" git config user.email "ci@users.noreply.github.com"
git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD git merge --no-ff --no-edit FETCH_HEAD
- name: Verify pinned source tree - name: Verify pinned source tree
env: env:
@@ -319,6 +323,7 @@ jobs:
!/docs/releases/media/ !/docs/releases/media/
sparse-checkout-cone-mode: false sparse-checkout-cone-mode: false
persist-credentials: false persist-credentials: false
submodules: recursive
- name: Check human authorship - name: Check human authorship
env: env:
@@ -379,6 +384,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA" git fetch --no-tags origin "$BUILD_BASE_SHA"
BASE=$(git rev-parse --short FETCH_HEAD) BASE=$(git rev-parse --short FETCH_HEAD)
HEAD=$(git rev-parse --short HEAD) HEAD=$(git rev-parse --short HEAD)
git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then if git merge --no-ff --no-edit FETCH_HEAD >/tmp/merge.out 2>&1; then
{ {
echo "### Mergeable" echo "### Mergeable"
@@ -531,6 +537,30 @@ jobs:
- name: Build the add-on packs - name: Build the add-on packs
run: bash tools/packs/build.sh app/build/packs run: bash tools/packs/build.sh app/build/packs
- name: Pin the Syncthing source
id: syncthing-src
run: |
set -euo pipefail
git submodule update --init --recursive tools/syncthing/syncthing-android
echo "commit=$(git -C tools/syncthing/syncthing-android/syncthing/src/github.com/syncthing/syncthing rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Cache Syncthing
id: syncthing
uses: actions/cache@v4
with:
path: app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so
key: syncthing-${{ env.NDK_VERSION }}-${{ hashFiles('tools/syncthing/build.sh', 'tools/syncthing/syncthing-android/gradle/libs.versions.toml') }}-${{ steps.syncthing-src.outputs.commit }}
- uses: actions/setup-go@v5
if: steps.syncthing.outputs.cache-hit != 'true'
with:
go-version: stable
cache: false
- name: Build Syncthing
if: steps.syncthing.outputs.cache-hit != 'true'
run: |
set -euo pipefail
export NDK="$ANDROID_HOME/ndk/$NDK_VERSION"
tools/syncthing/build.sh app/src/main/jniLibs/arm64-v8a
- name: Note when the proot pack pin is behind this build - name: Note when the proot pack pin is behind this build
run: | run: |
set -euo pipefail set -euo pipefail
@@ -684,6 +714,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA" git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci" git config user.name "ci"
git config user.email "ci@users.noreply.github.com" git config user.email "ci@users.noreply.github.com"
git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match - name: Verify backend and test source match
env: env:
@@ -737,6 +768,7 @@ jobs:
git fetch --no-tags origin "$BUILD_BASE_SHA" git fetch --no-tags origin "$BUILD_BASE_SHA"
git config user.name "ci" git config user.name "ci"
git config user.email "ci@users.noreply.github.com" git config user.email "ci@users.noreply.github.com"
git merge-tree --write-tree HEAD FETCH_HEAD >/dev/null || true
git merge --no-ff --no-edit FETCH_HEAD git merge --no-ff --no-edit FETCH_HEAD
- name: Verify backend and test source match - name: Verify backend and test source match
env: env:
-6
View File
@@ -1,11 +1,5 @@
name: Publish add-on packs 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: on:
workflow_call: workflow_call:
inputs: inputs:
+2
View File
@@ -13,6 +13,8 @@ app/src/main/jniLibs/arm64-v8a/libproot.so
app/src/main/jniLibs/arm64-v8a/.proot-inputs app/src/main/jniLibs/arm64-v8a/.proot-inputs
app/src/main/jniLibs/arm64-v8a/.proot-outputs app/src/main/jniLibs/arm64-v8a/.proot-outputs
app/src/main/jniLibs/arm64-v8a/libproot-loader.so app/src/main/jniLibs/arm64-v8a/libproot-loader.so
app/src/main/jniLibs/arm64-v8a/libsyncthingnative.so
app/src/main/jniLibs/arm64-v8a/.syncthing-inputs
app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so app/src/main/jniLibs/arm64-v8a/libdirectaudiorelay.so
app/src/main/rust/stores/target/ app/src/main/rust/stores/target/
app/src/main/assets/droiddeck-esync/ app/src/main/assets/droiddeck-esync/
+3
View File
@@ -1,3 +1,6 @@
[submodule "external/MaliBridge"] [submodule "external/MaliBridge"]
path = external/MaliBridge path = external/MaliBridge
url = https://github.com/ASEbrahim/MaliBridge.git url = https://github.com/ASEbrahim/MaliBridge.git
[submodule "tools/syncthing/syncthing-android"]
path = tools/syncthing/syncthing-android
url = https://github.com/researchxxl/syncthing-android
+1 -8
View File
@@ -4,7 +4,6 @@ plugins {
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'org.jetbrains.kotlin.plugin.compose' apply plugin: 'org.jetbrains.kotlin.plugin.compose'
// Machine-specific tooling and optional CI-prepared inputs stay out of version control.
def localBuildOptions = new Properties() def localBuildOptions = new Properties()
def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties') def localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties')
if (localBuildOptionsFile.asFile.exists()) { if (localBuildOptionsFile.asFile.exists()) {
@@ -98,8 +97,6 @@ def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler']
def pack = file("build/packs/${name}.tar.zst") def pack = file("build/packs/${name}.tar.zst")
if (!pack.isFile()) { if (!pack.isFile()) {
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs") if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
// A source build that did not compile proot still pins a published pack, so the app
// downloads the patched binary instead of falling back to the rootfs copy.
if (name == 'proot' && prootPackPin.PROOT_PACK_SHA256) { if (name == 'proot' && prootPackPin.PROOT_PACK_SHA256) {
return [(name): [sha256: prootPackPin.PROOT_PACK_SHA256, size: (prootPackPin.PROOT_PACK_SIZE ?: '0') as long]] return [(name): [sha256: prootPackPin.PROOT_PACK_SHA256, size: (prootPackPin.PROOT_PACK_SIZE ?: '0') as long]]
} }
@@ -208,7 +205,6 @@ android {
} }
androidResources { 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' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~:<dir>droiddeck-xbox:<dir>droiddeck-comet:<dir>directaudio'
} }
@@ -237,7 +233,6 @@ android {
outputs.upToDateWhen { false } outputs.upToDateWhen { false }
outputs.doNotCacheIf('CI executes the test suite on every run') { true } outputs.doNotCacheIf('CI executes the test suite on every run') { true }
} }
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
maxHeapSize = '2g' maxHeapSize = '2g'
maxParallelForks = 2 maxParallelForks = 2
def logTarget = providers.systemProperty('robolectric.logging').orNull def logTarget = providers.systemProperty('robolectric.logging').orNull
@@ -254,7 +249,6 @@ android {
} }
} }
// Vendor binaries remain in the user's SDK directory; never vendor them into Git.
if (vitureSdkDir) { if (vitureSdkDir) {
android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android')) android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android'))
def stageVitureNotices = tasks.register('stageVitureNotices', Copy) { def stageVitureNotices = tasks.register('stageVitureNotices', Copy) {
@@ -299,7 +293,6 @@ def stageGogNotices = tasks.register('stageGogNotices', Copy) {
android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices')) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('gogNotices'))
tasks.named('preBuild').configure { dependsOn(stageGogNotices) } tasks.named('preBuild').configure { dependsOn(stageGogNotices) }
// CI publishes its packs; a local build pins packs nobody hosts (see addonPacks), so the APK carries them.
if (!ciBuild) { if (!ciBuild) {
def stageLocalPacks = tasks.register('stageLocalPacks', Sync) { def stageLocalPacks = tasks.register('stageLocalPacks', Sync) {
from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' } from(layout.buildDirectory.dir('packs')) { include '*.tar.zst' }
@@ -388,11 +381,11 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2' implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2' implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1' implementation 'androidx.webkit:webkit:1.12.1'
// Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity).
implementation 'io.github.webrtc-sdk:android:125.6422.07' implementation 'io.github.webrtc-sdk:android:125.6422.07'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") { implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value' exclude group: 'com.google.auto.value', module: 'auto-value'
} }
implementation 'com.google.zxing:core:3.5.3'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1' testImplementation 'org.robolectric:robolectric:4.16.1'
+16
View File
@@ -194,6 +194,13 @@
android:resizeableActivity="true" android:resizeableActivity="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" /> android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density" />
<activity
android:name=".sync.SyncthingWebActivity"
android:exported="false"
android:screenOrientation="sensorLandscape"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity <activity
android:name=".files.FileManagerActivity" android:name=".files.FileManagerActivity"
android:exported="false" android:exported="false"
@@ -293,6 +300,11 @@
android:name=".session.SessionService" android:name=".session.SessionService"
android:exported="false" android:exported="false"
android:stopWithTask="false" /> android:stopWithTask="false" />
<!-- The compositor's native state is one per process, so the bottom-screen desktop's compositor runs in its own. -->
<service
android:name=".wayland.BottomDesktopCompositorService"
android:process=":bottom_desktop"
android:exported="false" />
<service <service
android:name=".runtime.RuntimeInstallService" android:name=".runtime.RuntimeInstallService"
android:exported="false" /> android:exported="false" />
@@ -303,6 +315,10 @@
android:name=".stores.download.StoreDownloadService" android:name=".stores.download.StoreDownloadService"
android:exported="false" android:exported="false"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync" />
<service
android:name=".sync.SyncthingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.logs" android:authorities="${applicationId}.logs"
+2 -2
View File
@@ -8,7 +8,6 @@ set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,max-page-size=16384
add_subdirectory(adrenotools) add_subdirectory(adrenotools)
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
add_subdirectory(thirdparty/dxbc) add_subdirectory(thirdparty/dxbc)
set(DDFG_UPSTREAM "v0.3.0") set(DDFG_UPSTREAM "v0.3.0")
@@ -37,13 +36,14 @@ target_link_libraries(sdnative log)
add_library(deviceinfo SHARED deviceinfo/vkinfo.c) add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
target_link_libraries(deviceinfo dl) target_link_libraries(deviceinfo dl)
add_library(gpuclock SHARED gpuclock/kgsl_clock.c gpuclock/kgsl_busy.c)
add_library(termux SHARED termux/termux.c) add_library(termux SHARED termux/termux.c)
add_library(ddvenc SHARED venc/venc_server.cpp) add_library(ddvenc SHARED venc/venc_server.cpp)
target_compile_features(ddvenc PRIVATE cxx_std_17) target_compile_features(ddvenc PRIVATE cxx_std_17)
target_link_libraries(ddvenc mediandk log) target_link_libraries(ddvenc mediandk log)
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
if(DROIDDECK_VITURE_SDK_DIR) if(DROIDDECK_VITURE_SDK_DIR)
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp) add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
target_compile_features(droiddeck_viture PRIVATE cxx_std_17) target_compile_features(droiddeck_viture PRIVATE cxx_std_17)
-7
View File
@@ -78,7 +78,6 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8;
static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11; static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11;
static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252; static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252;
static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2; static constexpr uint32_t FAKE_INPUT_RING_VERSION = 2;
// Rings hold the 64-bit input_event layout that the arm64 writers use; 32-bit guests convert on read.
struct RingEvent { struct RingEvent {
int64_t sec; int64_t sec;
int64_t usec; int64_t usec;
@@ -391,7 +390,6 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
syscall(SYS_close, sock); syscall(SYS_close, sock);
} }
// Must match RumbleComponent.HOLD_MS: play until the slot's next effect or stop.
static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff; static constexpr uint16_t RUMBLE_HOLD_MS = 0xffff;
__attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) { __attribute__((visibility("hidden"))) static void send_ff_effect(const struct ff_effect &effect, uint16_t slot) {
@@ -1243,7 +1241,6 @@ static constexpr int TRITON_REPORT_ID_BYTES = 1;
static constexpr int TRITON_STATE_BYTES = 45; static constexpr int TRITON_STATE_BYTES = 45;
static constexpr int TRITON_REPORT_INTERVAL_US = 4000; static constexpr int TRITON_REPORT_INTERVAL_US = 4000;
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500; 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 int TRITON_OUTPUT_TIMEOUT_MS = 50;
static constexpr const char *TRITON_NAME = "Steam Controller"; static constexpr const char *TRITON_NAME = "Steam Controller";
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01"; static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
@@ -2673,9 +2670,6 @@ __attribute__((visibility("hidden"))) static int triton_get_feature(DeckHidraw &
static constexpr uint32_t TRITON_HWID = 72; static constexpr uint32_t TRITON_HWID = 72;
static constexpr uint32_t TRITON_FW_TIMESTAMP = 0x6AC686B3; 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 uint32_t triton_fw_timestamp() {
static const uint32_t timestamp = [] { static const uint32_t timestamp = [] {
std::vector<std::string> roots; std::vector<std::string> roots;
@@ -3679,7 +3673,6 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event); Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event);
return 0; return 0;
} else if (type == 0x6A && !strncmp(event, "event", 5)) { } else if (type == 0x6A && !strncmp(event, "event", 5)) {
// Older SDL2 builds treat any JSIOCGNAME answer as a joydev node and skip EVIOCGID, then drop the pad as 0000:0000.
Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event); Logger::log("Rejecting joydev ioctl %d for event %s\n", number, event);
guard.unlock(); guard.unlock();
errno = EINVAL; errno = EINVAL;
@@ -267,8 +267,6 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_); pFlowReg_, flowRegA_, flowRegB_, gmSsbo_, kLevels, cfg_);
} }
// 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 && !g_scratch.flowReady) return;
if (!reuseFlow) { if (!reuseFlow) {
bool gmEngaged = updateGlobalMotion(gmSlot, extent_); bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
+1 -2
View File
@@ -1,5 +1,4 @@
// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). // LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
#include "lsfg_dll.h" #include "lsfg_dll.h"
#include "lsfg_dxbc.h" #include "lsfg_dxbc.h"
+1 -2
View File
@@ -1,5 +1,4 @@
// LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). // LSFG from Camille LaVey / the Eden Emulator Project, following upstream lsfg-vk (PancakeTAS). Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
// Ported to WinNative and DroidDeck by @maxjivi05. GPL-3.0-or-later.
#pragma once #pragma once
@@ -1,5 +1,4 @@
#pragma once #pragma once
// DXVK's dxbc subset is zlib licensed; vendored under cpp/thirdparty/dxbc.
#include <cstdint> #include <cstdint>
#include <vector> #include <vector>
+121
View File
@@ -0,0 +1,121 @@
// Whole-GPU busy share without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps: a thread
// samples the memstore ringbuffer timestamps (kgsl_busy.h) once a millisecond while running.
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <time.h>
#include <unistd.h>
#include "../adrenotools/src/hook/kgsl.h"
#include "kgsl_busy.h"
#define SAMPLE_NS 1000000L
struct reader {
int fd;
void *map;
size_t bytes;
int first;
uint32_t last[KGSL_BUSY_SLOTS];
pthread_mutex_t lock;
pthread_t thread;
int thread_on;
atomic_int run;
atomic_uint_fast64_t busy, samples, moves;
};
static void *sample_loop(void *arg) {
struct reader *r = arg;
const struct timespec gap = {0, SAMPLE_NS};
while (atomic_load(&r->run)) {
int moved = 0;
int busy = kgsl_busy_sample(r->map, r->first, r->last, &moved);
if (busy) atomic_fetch_add(&r->busy, 1);
if (moved) atomic_fetch_add(&r->moves, 1);
atomic_fetch_add(&r->samples, 1);
nanosleep(&gap, NULL);
}
return NULL;
}
static void reader_run(struct reader *r, int on) {
pthread_mutex_lock(&r->lock);
if (on && !r->thread_on) {
atomic_store(&r->run, 1);
if (pthread_create(&r->thread, NULL, sample_loop, r) == 0) r->thread_on = 1;
else atomic_store(&r->run, 0);
} else if (!on && r->thread_on) {
atomic_store(&r->run, 0);
pthread_join(r->thread, NULL);
r->thread_on = 0;
}
pthread_mutex_unlock(&r->lock);
}
static void reader_close(struct reader *r) {
if (!r) return;
reader_run(r, 0);
if (r->map && r->map != MAP_FAILED) munmap(r->map, r->bytes);
if (r->fd >= 0) close(r->fd);
pthread_mutex_destroy(&r->lock);
free(r);
}
// Returns 0 or a negative errno; *out is set on success.
static int reader_open(struct reader **out) {
struct reader *r = calloc(1, sizeof(*r));
if (!r) return -ENOMEM;
pthread_mutex_init(&r->lock, NULL);
r->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
if (r->fd < 0) { int e = -errno; reader_close(r); return e; }
struct kgsl_shadowprop shadow;
memset(&shadow, 0, sizeof(shadow));
struct kgsl_device_getproperty prop = {.type = KGSL_PROP_DEVICE_SHADOW, .value = &shadow, .sizebytes = sizeof(shadow)};
if (ioctl(r->fd, IOCTL_KGSL_DEVICE_GETPROPERTY, &prop)) { int e = -errno; reader_close(r); return e; }
r->bytes = shadow.size;
r->first = kgsl_busy_first_slot(shadow.size);
if (r->first < 0) { reader_close(r); return -ERANGE; }
r->map = mmap(NULL, r->bytes, PROT_READ, MAP_SHARED, r->fd, (off_t)shadow.gpuaddr);
if (r->map == MAP_FAILED) { int e = -errno; reader_close(r); return e; }
int moved = 0, started = 0;
kgsl_busy_sample(r->map, r->first, r->last, &moved);
for (int i = 0; i < KGSL_BUSY_SLOTS; i++) started |= r->last[i] != 0;
if (!started) { reader_close(r); return -ENODATA; }
*out = r;
return 0;
}
#ifndef KGSL_BUSY_NO_JNI
#include <jni.h>
JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeOpen(JNIEnv *env, jclass cls, jintArray error) {
struct reader *r = NULL;
int e = reader_open(&r);
if (e && error && (*env)->GetArrayLength(env, error) > 0) {
jint v = e;
(*env)->SetIntArrayRegion(env, error, 0, 1, &v);
}
return e ? 0 : (jlong)(intptr_t)r;
}
JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRun(JNIEnv *env, jclass cls, jlong handle, jboolean on) {
if (handle) reader_run((struct reader *)(intptr_t)handle, on ? 1 : 0);
}
JNIEXPORT jboolean JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeRead(JNIEnv *env, jclass cls, jlong handle, jlongArray out) {
if (!handle || !out || (*env)->GetArrayLength(env, out) < 3) return JNI_FALSE;
struct reader *r = (struct reader *)(intptr_t)handle;
jlong v[3] = {(jlong)atomic_load(&r->busy), (jlong)atomic_load(&r->samples), (jlong)atomic_load(&r->moves)};
(*env)->SetLongArrayRegion(env, out, 0, 3, v);
return JNI_TRUE;
}
JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslBusy_nativeClose(JNIEnv *env, jclass cls, jlong handle) {
reader_close((struct reader *)(intptr_t)handle);
}
#endif
+37
View File
@@ -0,0 +1,37 @@
#ifndef DROIDDECK_KGSL_BUSY_H
#define DROIDDECK_KGSL_BUSY_H
// kgsl lets any client map its memstore read-only (KGSL_PROP_DEVICE_SHADOW). The CP writes a ringbuffer's
// start-of-pipe timestamp there before each submission's command buffers and its end-of-pipe timestamp after them
// (adreno_gen7_ringbuffer.c gen7_ringbuffer_addcmds), so start != end means that ringbuffer has work on the GPU.
// Ringbuffer entries start at KGSL_MEMSTORE_MAX: entries - 2 - 4 levels, or entries - 1 - 4 on kernels without the
// LPAC slot. Entries - 6 .. entries - 2 cover both; a context entry in that range follows the same start/end rule.
#include <stddef.h>
#include <stdint.h>
#define KGSL_BUSY_ENTRY_WORDS 10
#define KGSL_BUSY_SOP 0
#define KGSL_BUSY_EOP 2
#define KGSL_BUSY_SLOTS 5
static inline int kgsl_busy_first_slot(size_t bytes) {
size_t entries = bytes / (KGSL_BUSY_ENTRY_WORDS * sizeof(uint32_t));
return entries < 8 ? -1 : (int)(entries - 6);
}
// 1 when a watched slot has a submission between its start and end timestamps. *moved is set when a start
// timestamp differs from last[], which then holds the new ones.
static inline int kgsl_busy_sample(const volatile uint32_t *memstore, int first, uint32_t last[KGSL_BUSY_SLOTS], int *moved) {
int busy = 0;
for (int i = 0; i < KGSL_BUSY_SLOTS; i++) {
const volatile uint32_t *entry = memstore + (size_t)(first + i) * KGSL_BUSY_ENTRY_WORDS;
uint32_t sop = entry[KGSL_BUSY_SOP], eop = entry[KGSL_BUSY_EOP];
if (sop != eop) busy = 1;
if (sop != last[i]) {
*moved = 1;
last[i] = sop;
}
}
return busy;
}
#endif
+156
View File
@@ -0,0 +1,156 @@
// GPU clock without root on Adreno (KGSL) devices whose kgsl sysfs is blocked for apps.
//
// kgsl lets any app reserve a performance counter (IOCTL_KGSL_PERFCOUNTER_GET) but refuses to read it back while
// /sys/class/kgsl/kgsl-3d0/perfcounter is 0, and clears the counters whenever the GPU switches to another process.
// So the counter is read by the GPU itself: a small command buffer copies CP_ALWAYS_ON_COUNTER (19.2 MHz) and the
// RBBM ALWAYS_COUNT counter (GPU core cycles) to memory, runs a block of NOP packets, and copies both again.
// clock = cycles / always-on ticks * 19.2 MHz. Both reads sit in one command buffer, so a counter clear between
// them only happens if the buffer is preempted; such samples fail the range checks and are dropped.
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <unistd.h>
#include "../adrenotools/src/hook/kgsl.h"
#define CP_TYPE7_PKT 0x70000000u
#define CP_NOP 0x10u
#define CP_REG_TO_MEM 0x3eu
#define CP_ALWAYS_ON_COUNTER 0x980u
#define RBBM_ALWAYS_COUNT 0u
// gen7 CP protection marks registers 0x000-0x4ff readable by command buffers (adreno-gpulist.h); a counter
// kgsl places anywhere else is not read.
#define READABLE_LAST 0x4ffu
#define XO_HZ 19200000.0
#define NOP_DWORDS 0x3fffu
#define NOP_BLOCKS 4
struct sampler {
int fd;
unsigned context;
unsigned counter_reg;
struct kgsl_gpumem_alloc_id ib, out;
uint32_t *ib_map;
volatile uint64_t *out_map;
size_t ib_bytes;
};
static unsigned odd_parity(unsigned v) {
v ^= v >> 16;
v ^= v >> 8;
v ^= v >> 4;
v &= 0xf;
return (~0x6996u >> v) & 1u;
}
static uint32_t pkt7(unsigned op, unsigned count) {
return CP_TYPE7_PKT | count | odd_parity(count) << 15 | (op & 0x7f) << 16 | odd_parity(op) << 23;
}
static void sampler_close(struct sampler *s) {
if (!s) return;
if (s->fd >= 0) {
if (s->ib_map && s->ib_map != MAP_FAILED) munmap(s->ib_map, s->ib.mmapsize);
if (s->out_map && s->out_map != MAP_FAILED) munmap((void *)s->out_map, s->out.mmapsize);
if (s->ib.id) { struct kgsl_gpumem_free_id f = {.id = s->ib.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); }
if (s->out.id) { struct kgsl_gpumem_free_id f = {.id = s->out.id}; ioctl(s->fd, IOCTL_KGSL_GPUMEM_FREE_ID, &f); }
if (s->context) { struct kgsl_drawctxt_destroy d = {.drawctxt_id = s->context}; ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_DESTROY, &d); }
if (s->counter_reg) {
struct kgsl_perfcounter_put p = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT};
ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_PUT, &p);
}
close(s->fd);
}
free(s);
}
static void emit_reads(struct sampler *s, size_t *n, unsigned pass) {
const unsigned regs[2] = {CP_ALWAYS_ON_COUNTER, s->counter_reg};
for (unsigned k = 0; k < 2; k++) {
uint64_t dst = s->out.gpuaddr + (uint64_t)(pass * 2 + k) * sizeof(uint64_t);
s->ib_map[(*n)++] = pkt7(CP_REG_TO_MEM, 3);
s->ib_map[(*n)++] = regs[k] | (2u << 18) | (1u << 30); // two dwords, 64-bit read
s->ib_map[(*n)++] = (uint32_t)dst;
s->ib_map[(*n)++] = (uint32_t)(dst >> 32);
}
}
// Returns 0 or a negative errno; *out is set on success.
static int sampler_open(struct sampler **out) {
struct sampler *s = calloc(1, sizeof(*s));
if (!s) return -ENOMEM;
s->fd = open("/dev/kgsl-3d0", O_RDWR | O_CLOEXEC);
if (s->fd < 0) { int e = -errno; s->fd = -1; sampler_close(s); return e; }
struct kgsl_perfcounter_get get = {.groupid = KGSL_PERFCOUNTER_GROUP_RBBM, .countable = RBBM_ALWAYS_COUNT};
if (ioctl(s->fd, IOCTL_KGSL_PERFCOUNTER_GET, &get)) { int e = -errno; sampler_close(s); return e; }
s->counter_reg = get.offset;
if (!get.offset || get.offset > READABLE_LAST) { sampler_close(s); return -ERANGE; }
struct kgsl_drawctxt_create ctx = {.flags = KGSL_CONTEXT_NO_GMEM_ALLOC | KGSL_CONTEXT_PREAMBLE};
if (ioctl(s->fd, IOCTL_KGSL_DRAWCTXT_CREATE, &ctx)) { int e = -errno; sampler_close(s); return e; }
s->context = ctx.drawctxt_id;
s->ib_bytes = (16 + NOP_BLOCKS * (NOP_DWORDS + 1)) * sizeof(uint32_t);
s->ib.size = (s->ib_bytes + 4095) & ~(size_t)4095;
s->out.size = 4096;
if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->ib)) { int e = -errno; s->ib.id = 0; sampler_close(s); return e; }
if (ioctl(s->fd, IOCTL_KGSL_GPUMEM_ALLOC_ID, &s->out)) { int e = -errno; s->out.id = 0; sampler_close(s); return e; }
s->ib_map = mmap(NULL, s->ib.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->ib.id << 12);
s->out_map = mmap(NULL, s->out.mmapsize, PROT_READ | PROT_WRITE, MAP_SHARED, s->fd, (off_t)s->out.id << 12);
if (s->ib_map == MAP_FAILED || s->out_map == MAP_FAILED) { int e = -errno; sampler_close(s); return e; }
size_t n = 0;
emit_reads(s, &n, 0);
for (unsigned b = 0; b < NOP_BLOCKS; b++) {
s->ib_map[n++] = pkt7(CP_NOP, NOP_DWORDS);
memset(&s->ib_map[n], 0, NOP_DWORDS * sizeof(uint32_t));
n += NOP_DWORDS;
}
emit_reads(s, &n, 1);
s->ib_bytes = n * sizeof(uint32_t);
*out = s;
return 0;
}
// MHz, or a negative value: -errno from kgsl, or -1000 for a sample that failed the range checks.
static int sampler_mhz(struct sampler *s) {
for (int i = 0; i < 4; i++) s->out_map[i] = 0;
struct kgsl_command_object cmd = {.gpuaddr = s->ib.gpuaddr, .size = s->ib_bytes, .flags = KGSL_CMDLIST_IB};
struct kgsl_gpu_command submit = {
.cmdlist = (uint64_t)(uintptr_t)&cmd, .cmdsize = sizeof(cmd), .numcmds = 1, .context_id = s->context,
};
if (ioctl(s->fd, IOCTL_KGSL_GPU_COMMAND, &submit)) return -errno;
struct kgsl_device_waittimestamp_ctxtid wait = {.context_id = s->context, .timestamp = submit.timestamp, .timeout = 250};
if (ioctl(s->fd, IOCTL_KGSL_DEVICE_WAITTIMESTAMP_CTXTID, &wait)) return -errno;
uint64_t xo0 = s->out_map[0], cyc0 = s->out_map[1], xo1 = s->out_map[2], cyc1 = s->out_map[3];
if (xo1 <= xo0 || cyc1 <= cyc0) return -1000;
uint64_t xo = xo1 - xo0, cycles = cyc1 - cyc0;
// 5 us .. 50 ms of always-on time, and a clock between 50 MHz and 2 GHz.
if (xo < 96 || xo > 960000) return -1000;
double mhz = (double)cycles / (double)xo * XO_HZ / 1e6;
if (mhz < 50 || mhz > 2000) return -1000;
return (int)(mhz + 0.5);
}
#ifndef KGSL_CLOCK_NO_JNI
#include <jni.h>
JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeOpen(JNIEnv *env, jclass cls, jintArray error) {
struct sampler *s = NULL;
int r = sampler_open(&s);
if (r && error && (*env)->GetArrayLength(env, error) > 0) {
jint e = r;
(*env)->SetIntArrayRegion(env, error, 0, 1, &e);
}
return r ? 0 : (jlong)(intptr_t)s;
}
JNIEXPORT jint JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeSample(JNIEnv *env, jclass cls, jlong handle) {
return handle ? sampler_mhz((struct sampler *)(intptr_t)handle) : -EBADF;
}
JNIEXPORT void JNICALL Java_com_droiddeck_launcher_session_KgslClock_nativeClose(JNIEnv *env, jclass cls, jlong handle) {
sampler_close((struct sampler *)(intptr_t)handle);
}
#endif
-2
View File
@@ -76,7 +76,6 @@ public:
} }
lastFrame_ = frame.header.frame_id; lastFrame_ = frame.header.frame_id;
if (state == XrGpuFrameState::Stale) { if (state == XrGpuFrameState::Stale) {
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
dropped_++; dropped_++;
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect(); if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
return false; return false;
@@ -88,7 +87,6 @@ public:
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8); glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1); glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); glMemoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);
// The producer may overwrite this slot only after the native GPU has finished reading it.
glFinish(); glFinish();
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; } if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect(); if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
-5
View File
@@ -3,10 +3,6 @@
#include <stdint.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 #define VENC_MAX_BUFFERS 16
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5, VDEC_CONFIG = 6, VDEC_CAPTURE = 7, VDEC_PACKET = 8, VDEC_RESULT = 9 }; enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5, VDEC_CONFIG = 6, VDEC_CAPTURE = 7, VDEC_PACKET = 8, VDEC_RESULT = 9 };
@@ -45,7 +41,6 @@ struct venc_bitrate {
uint32_t bitrate; 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 { struct venc_touch {
uint32_t type; uint32_t type;
uint32_t action; uint32_t action;
+3 -3
View File
@@ -33,8 +33,8 @@ struct Mapping {
constexpr int32_t kColorFormatYuv420SemiPlanar = 21; constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
constexpr uint32_t kBufferFlagKeyFrame = 1; constexpr uint32_t kBufferFlagKeyFrame = 1;
constexpr uint32_t kBufferFlagCodecConfig = 2; constexpr uint32_t kBufferFlagCodecConfig = 2;
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt constexpr int kTouchWidth = 1920, kTouchHeight = 1080;
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32) constexpr int kRemoteTouchId = 31;
int32_t avc_profile(int32_t v4l2) { int32_t avc_profile(int32_t v4l2) {
switch (v4l2) { switch (v4l2) {
@@ -549,7 +549,7 @@ void serve(int listener) {
} }
} }
} // namespace }
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) { Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
+11 -13
View File
@@ -1,20 +1,18 @@
#pragma once #pragma once
// Our controller uses the standard display-mode vocabulary, even for Beast.
// Native mode reuses some numbers with different meanings; never pass them through.
namespace droiddeck::viture { namespace droiddeck::viture {
constexpr int to_native_mode(int standard) { constexpr int to_native_mode(int standard) {
switch (standard) { switch (standard) {
case 0x31: return 0x31; // 1080p 60 case 0x31: return 0x31;
case 0x33: return 0x32; // 1080p 90 case 0x33: return 0x32;
case 0x34: return 0x33; // 1080p 120 case 0x34: return 0x33;
case 0x32: return 0x37; // 1080p SBS 60 case 0x32: return 0x37;
case 0x35: return 0x38; // 1080p SBS 90 case 0x35: return 0x38;
case 0x41: return 0x34; // 1200p 60 case 0x41: return 0x34;
case 0x43: return 0x35; // 1200p 90 case 0x43: return 0x35;
case 0x44: return 0x36; // 1200p 120 case 0x44: return 0x36;
case 0x42: return 0x3a; // 1200p SBS 60 case 0x42: return 0x3a;
case 0x45: return 0x3b; // 1200p SBS 90 case 0x45: return 0x3b;
default: return -4; default: return -4;
} }
} }
@@ -30,7 +28,7 @@ constexpr int from_native_mode(int native) {
case 0x36: return 0x44; case 0x36: return 0x44;
case 0x3a: return 0x42; case 0x3a: return 0x42;
case 0x3b: return 0x45; case 0x3b: return 0x45;
default: return -4; // Ultrawide is not stereo. default: return -4;
} }
} }
} }
-1
View File
@@ -5,7 +5,6 @@
inline std::size_t mjpeg_payload_size(const uint8_t* bytes, std::size_t size) { inline std::size_t mjpeg_payload_size(const uint8_t* bytes, std::size_t size) {
if (!bytes || size < 4 || size > 4*1024*1024 || bytes[0] != 0xff || bytes[1] != 0xd8) return 0; if (!bytes || size < 4 || size > 4*1024*1024 || bytes[0] != 0xff || bytes[1] != 0xd8) return 0;
std::size_t end = size; std::size_t end = size;
// Luma Ultra UVC frames can pad the JPEG to an eight-byte boundary with zeros.
while (end > 4 && size-end < 7 && bytes[end-1] == 0) --end; while (end > 4 && size-end < 7 && bytes[end-1] == 0) --end;
return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0; return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0;
} }
-4
View File
@@ -30,7 +30,6 @@
#include "stereo_frame_gate.h" #include "stereo_frame_gate.h"
namespace { namespace {
// SDK callbacks have no user-data parameter. Only one provider may exist at a time.
std::mutex api_mutex; std::mutex api_mutex;
std::mutex pose_mutex; std::mutex pose_mutex;
std::array<float, 4> latest_pose{}; std::array<float, 4> latest_pose{};
@@ -64,7 +63,6 @@ void on_pose(float* data, uint64_t) {
std::lock_guard<std::mutex> lock(pose_mutex); std::lock_guard<std::mutex> lock(pose_mutex);
if (!accepting_pose) return; if (!accepting_pose) return;
std::copy_n(data + 3, 4, latest_pose.begin()); std::copy_n(data + 3, 4, latest_pose.begin());
// SDK device ticks are not Android's elapsed-realtime clock.
latest_time = now_ns(); latest_time = now_ns();
} }
@@ -234,8 +232,6 @@ int set_display(Session& s, int standard) {
if (mode == 0) { if (mode == 0) {
int rc = xr_device_provider_set_display_mode(s.device, standard); int rc = xr_device_provider_set_display_mode(s.device, standard);
if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc; if (rc != VITURE_GLASSES_ERROR_NOT_SUPPORTED) return rc;
// Older Beast firmware exposes fewer bypass modes. Fall back to its
// separate native table only when the SDK explicitly rejects the mode.
} }
const int native = droiddeck::viture::to_native_mode(standard); const int native = droiddeck::viture::to_native_mode(standard);
if (native < 0) return native; if (native < 0) return native;
@@ -18,6 +18,7 @@ add_library(droiddeckwayland SHARED
src/wl_color_mgmt.c src/wl_color_mgmt.c
src/hdr_compose.c src/hdr_compose.c
src/blend_pass.c src/blend_pass.c
src/gpu_busy.c
generated/color-management-v1-protocol.c generated/color-management-v1-protocol.c
generated/xdg-shell-protocol.c generated/xdg-shell-protocol.c
generated/linux-dmabuf-v1-protocol.c generated/linux-dmabuf-v1-protocol.c
@@ -43,6 +44,7 @@ target_include_directories(droiddeckwayland PRIVATE
prebuilt/include prebuilt/include
src src
${CMAKE_CURRENT_SOURCE_DIR}/../shaders ${CMAKE_CURRENT_SOURCE_DIR}/../shaders
${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/gpuload/src
) )
set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg) set(DROIDDECK_FRAMEGEN_LSFG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../framegen/lsfg)
@@ -34,7 +34,6 @@ void cardboard_clamp(struct cardboard_params *p) {
p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f); p->screen_width_m = clampf(p->screen_width_m, 0.2f, 60.0f);
p->k1 = clampf(p->k1, -1.0f, 2.0f); p->k1 = clampf(p->k1, -1.0f, 2.0f);
p->k2 = clampf(p->k2, -1.0f, 2.0f); p->k2 = clampf(p->k2, -1.0f, 2.0f);
// Optical vertical FOV on supported glasses can be as low as ~22 degrees.
p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f); p->fov_deg = clampf(p->fov_deg, 20.0f, 140.0f);
p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f); p->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
p->chroma = clampf(p->chroma, 0.0f, 0.05f); p->chroma = clampf(p->chroma, 0.0f, 0.05f);
@@ -1,4 +1,3 @@
// 1115.1.0
#pragma once #pragma once
const uint32_t cardboard_frag_code[] = { const uint32_t cardboard_frag_code[] = {
0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b, 0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b,
+4 -61
View File
@@ -289,7 +289,6 @@ static struct wl_event_source *g_fallback_timer;
static int g_fallback_armed; static int g_fallback_armed;
volatile int g_fps_limit; volatile int g_fps_limit;
/* fps limit -1 (phone output, not mirrored): replaced buffers go back to the client at once instead of once per refresh. */
volatile int g_release_unpaced; volatile int g_release_unpaced;
volatile int g_hide_shell; volatile int g_hide_shell;
@@ -446,15 +445,10 @@ static int g_cursor_w, g_cursor_h;
static int g_cursor_hidden = 1; static int g_cursor_hidden = 1;
static int g_cursor_serial; static int g_cursor_serial;
/* The pointer image as a compositor-owned texture, for outputs that cannot show #define CURSOR_MAX_DRAWS 2
* the phone's Compose overlay (the glasses surface owns the picture there). */
#define CURSOR_MAX_DRAWS 2 /* one per eye when the picture is side-by-side */
static struct vkp_image *g_cursor_img; static struct vkp_image *g_cursor_img;
static int g_cursor_img_w, g_cursor_img_h, g_cursor_img_serial = -1; static int g_cursor_img_w, g_cursor_img_h, g_cursor_img_serial = -1;
static atomic_int g_cursor_composite; static atomic_int g_cursor_composite;
/* Last request from the app, used only to skip repeated calls. The applied flag below is
* written by the caller and read by the render path; message 22 only wakes the event loop, so
* a queued message can never put back an older value. */
static atomic_int g_cursor_composite_want; static atomic_int g_cursor_composite_want;
static void cursor_publish_shown(void) { static void cursor_publish_shown(void) {
@@ -1922,8 +1916,6 @@ static int hdr_plan(const struct draw_list *dl, int w, int h, int fx_on, int fra
} }
if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; } if (un && g_zero_copy) { hs->color = droiddeck_color_of(un->resource); hs->who = un; return 3; }
hs->hf.tonemap = !droiddeck_color_output(); hs->hf.tonemap = !droiddeck_color_output();
/* The pointer draws are appended after this plan: reserve their slots (zeroed,
* so they count as SDR content above the game) instead of reading past the end. */
if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n + CURSOR_MAX_DRAWS, 1))) return 0; if (dl->n <= 0 || !(hs->is_hdr = calloc((size_t)dl->n + CURSOR_MAX_DRAWS, 1))) return 0;
for (int i = 0; i < dl->n; i++) { for (int i = 0; i < dl->n; i++) {
struct surface *s = surface_for_image(dl->d[i].img); struct surface *s = surface_for_image(dl->d[i].img);
@@ -2026,12 +2018,6 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
} }
} }
/*
* Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one
* picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once
* and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects
* and frame generation apply to it as to a game.
*/
#define STREAM_SLOTS 6 #define STREAM_SLOTS 6
static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
static atomic_int g_stream_on; static atomic_int g_stream_on;
@@ -2051,7 +2037,6 @@ static void stream_forget_all(void) {
g_stream_img = NULL; g_stream_img = NULL;
} }
/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
static void stream_take(void) { static void stream_take(void) {
pthread_mutex_lock(&g_stream_lock); pthread_mutex_lock(&g_stream_lock);
AHardwareBuffer *ahb = g_stream_pending; AHardwareBuffer *ahb = g_stream_pending;
@@ -2065,7 +2050,7 @@ static void stream_take(void) {
if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i; if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i;
} }
if (slot >= 0) { if (slot >= 0) {
AHardwareBuffer_release(ahb); /* the slot keeps its own reference */ AHardwareBuffer_release(ahb);
} else { } else {
struct vkp_image *img = vkp_image_from_ahb(ahb); struct vkp_image *img = vkp_image_from_ahb(ahb);
if (!img) { if (!img) {
@@ -2077,7 +2062,7 @@ static void stream_take(void) {
droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img)); droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img));
slot = oldest; slot = oldest;
if (g_stream_slots[slot].img) { if (g_stream_slots[slot].img) {
vkp_wait_idle(); /* the GPU may still read the picture being replaced */ vkp_wait_idle();
if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL; if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL;
vkp_image_destroy(g_stream_slots[slot].img); vkp_image_destroy(g_stream_slots[slot].img);
} }
@@ -2123,7 +2108,6 @@ void droiddeck_stream_set(int on) {
(void)n; (void)n;
} }
/* Any thread: hands over one decoded frame (acquires its own reference). */
void droiddeck_stream_frame(AHardwareBuffer *ahb) { void droiddeck_stream_frame(AHardwareBuffer *ahb) {
if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return; if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
AHardwareBuffer_acquire(ahb); AHardwareBuffer_acquire(ahb);
@@ -2131,7 +2115,7 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) {
AHardwareBuffer *dropped = g_stream_pending; AHardwareBuffer *dropped = g_stream_pending;
g_stream_pending = ahb; g_stream_pending = ahb;
pthread_mutex_unlock(&g_stream_lock); pthread_mutex_unlock(&g_stream_lock);
if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */ if (dropped) { AHardwareBuffer_release(dropped); return; }
struct input_msg m = { 20, 0, 0, 0, 0 }; struct input_msg m = { 20, 0, 0, 0, 0 };
ssize_t n = write(g_input_pipe[1], &m, sizeof(m)); ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
(void)n; (void)n;
@@ -2200,11 +2184,6 @@ static void render_scene(void) {
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen); hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
} }
if (hdr_route == 3) { fx_on = 0; framegen = 0; } if (hdr_route == 3) { fx_on = 0; framegen = 0; }
/* A game the compositor cannot import (an AHardwareBuffer-only surface, or HDR frames it
* has no texture for) is shown on its own display layer and is not part of the scene, so
* composing would leave the game out of the picture: the pointer stays out instead, the
* same trade-off as HDR route 3. Both are decided before the pointer is appended, because
* the appended draws change the draw list they are matched against. */
struct surface *ahb_only = ahb_layer_only_candidate(w, h); struct surface *ahb_only = ahb_layer_only_candidate(w, h);
struct surface *layer_only = ahb_only ? ahb_only : hdr_unimported_fullscreen(&dl, w, h); struct surface *layer_only = ahb_only ? ahb_only : hdr_unimported_fullscreen(&dl, w, h);
const int cursor_on = hdr_route == 3 || layer_only ? 0 : add_cursor(&dl, w); const int cursor_on = hdr_route == 3 || layer_only ? 0 : add_cursor(&dl, w);
@@ -2216,9 +2195,6 @@ static void render_scene(void) {
} else if (!layer_only) { } else if (!layer_only) {
cursor_layer_said = 0; cursor_layer_said = 0;
} }
/* With cardboard the scene is composed anyway (the pass needs the whole picture), so the
* pointer reaches the output without switching routes: the HDR composition path would drop
* the cardboard pass, which is 8-bit only. */
static int cursor_hdr_said; static int cursor_hdr_said;
if (cursor_on && hdr_route == 0 && hs.n_hdr > 0 && !cardboard) { if (cursor_on && hdr_route == 0 && hs.n_hdr > 0 && !cardboard) {
hdr_route = 2; hdr_route = 2;
@@ -2257,8 +2233,6 @@ static void render_scene(void) {
} }
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && !cursor_on && if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && !cursor_on &&
!ahb_only) { !ahb_only) {
/* The complete SDR scene stays on one display layer. Do not hide/re-show GAME
* on each desktop frame just because it is not a single fullscreen window. */
rendered = vkp_render(w, h, dl.d, dl.n) == 0; rendered = vkp_render(w, h, dl.d, dl.n) == 0;
} else if (hdr_route == 0 || hdr_route == 3) { } else if (hdr_route == 0 || hdr_route == 3) {
const int pass_on = fx_on || framegen; const int pass_on = fx_on || framegen;
@@ -2367,8 +2341,6 @@ static int on_fallback_timer(void *data) {
return 0; return 0;
} }
/* A new frame is drawn when it arrives instead of at the next screen tick, at most once per refresh,
while frames go straight back to the game (no compositor limit), without streaming or frame generation. */
static int commit_renders_on(void) { static int commit_renders_on(void) {
return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active(); return g_render_on_commit && g_release_unpaced && !atomic_load(&g_stream_on) && !vkp_framegen_active();
} }
@@ -2427,10 +2399,6 @@ static void on_vsync(int64_t frame_time_ns) {
if (vkp_effects_sync()) tick_change = 1; if (vkp_effects_sync()) tick_change = 1;
if (vkp_cardboard_pose_changed()) tick_change = 1; if (vkp_cardboard_pose_changed()) tick_change = 1;
if (tick_change) g_dirty = 1; if (tick_change) g_dirty = 1;
/* A frame that arrived after this refresh was already drawn is left for the next arrival to draw;
drawing it here would block that arrival too and keep every frame waiting for the tick. A client
that draws again only after its frame callback sends no such arrival: once a held frame is still
waiting at the next tick, the tick draws every frame for a second before holding one again. */
if (g_tick_held && g_dirty) g_hold_missed_ns = now; if (g_tick_held && g_dirty) g_hold_missed_ns = now;
g_tick_held = 0; g_tick_held = 0;
if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns && if (!tick_change && commit_renders_on() && g_dirty_since_ns && now - g_dirty_since_ns < g_refresh_ns &&
@@ -2665,15 +2633,11 @@ static int pointer_locked_for_cursor(void) {
static int cursor_scene_positions(int scene_w, int source, int ptr_x, int hx, int out[2]) { static int cursor_scene_positions(int scene_w, int source, int ptr_x, int hx, int out[2]) {
out[0] = ptr_x - hx; out[1] = -1; out[0] = ptr_x - hx; out[1] = -1;
if (source != CARDBOARD_SOURCE_SBS || scene_w <= 1) return 1; if (source != CARDBOARD_SOURCE_SBS || scene_w <= 1) return 1;
/* The pointer decides which eye it is in; the hotspot only shifts the image drawn there,
* so a hotspot that crosses the seam cannot put the second copy in the wrong eye. */
const int half = scene_w / 2; const int half = scene_w / 2;
out[1] = (ptr_x < half ? ptr_x + half : ptr_x - half) - hx; out[1] = (ptr_x < half ? ptr_x + half : ptr_x - half) - hx;
return 2; return 2;
} }
/* Crops one pointer copy to the eye it belongs to (eye < 0 crops nothing). Returns 0 when no
* part of the copy falls inside that eye, so the caller skips it. */
static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int *sx, int *dw) { static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int *sx, int *dw) {
int left = x0, right = x0 + w; int left = x0, right = x0 + w;
if (eye >= 0 && scene_w > 1) { if (eye >= 0 && scene_w > 1) {
@@ -2689,7 +2653,6 @@ static int cursor_clip_to_eye(int scene_w, int eye, int x0, int w, int *dx, int
return 1; return 1;
} }
/* Appends the guest pointer as the topmost draw of the scene. */
static int add_cursor(struct draw_list *dl, int scene_w) { static int add_cursor(struct draw_list *dl, int scene_w) {
if (!atomic_load(&g_cursor_composite) || !g_ptr_known || pointer_locked_for_cursor()) return 0; if (!atomic_load(&g_cursor_composite) || !g_ptr_known || pointer_locked_for_cursor()) return 0;
@@ -2740,9 +2703,6 @@ static int add_cursor(struct draw_list *dl, int scene_w) {
if (!d) return 0; if (!d) return 0;
dl->d = d; dl->cap = cap; dl->d = d; dl->cap = cap;
} }
/* The cardboard pass samples each half of the scene as one eye, so a copy that crosses the
* seam would show up as a fragment at the opposite edge of the other eye: crop each copy to
* the eye it belongs to and drop it when nothing of it lands there. */
const int sbs = source == CARDBOARD_SOURCE_SBS && scene_w > 1; const int sbs = source == CARDBOARD_SOURCE_SBS && scene_w > 1;
const int pointer_eye = ptr_x < scene_w / 2 ? 0 : 1; const int pointer_eye = ptr_x < scene_w / 2 ? 0 : 1;
int appended = 0; int appended = 0;
@@ -2765,8 +2725,6 @@ static void sync_lock_notify(void) {
if (locked == last) return; if (locked == last) return;
last = locked; last = locked;
droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y); droiddeck_on_pointer_lock(locked, (int)g_ptr_x, (int)g_ptr_y);
/* The drawn pointer follows the lock state, so a game that stops presenting after it grabs
* the pointer would keep the last composited cursor on screen without a redraw here. */
if (atomic_load(&g_cursor_composite)) schedule_render(); if (atomic_load(&g_cursor_composite)) schedule_render();
} }
@@ -2849,8 +2807,6 @@ static void constraint_activate(struct constraint *k) {
g_active_constraint = k; g_active_constraint = k;
const double was_x = g_ptr_x, was_y = g_ptr_y; const double was_x = g_ptr_x, was_y = g_ptr_y;
if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y); if (!k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
/* Confining can move the pointer while the lock state stays the same, so sync_lock_notify()
* below stays quiet; the drawn pointer follows the position, so repaint here. */
if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite)) if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite))
schedule_render(); schedule_render();
constraint_origin(k, &ox, &oy); constraint_origin(k, &ox, &oy);
@@ -2935,8 +2891,6 @@ static void constraints_surface_commit(struct surface *s) {
if (k->active && !k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y); if (k->active && !k->is_lock) confine_clamp(k, &g_ptr_x, &g_ptr_y);
} }
} }
/* A position hint or a newly committed confine region can move the pointer while the lock
* state stays the same, so nothing else would repaint the composited cursor. */
if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite)) if ((g_ptr_x != was_x || g_ptr_y != was_y) && atomic_load(&g_cursor_composite))
schedule_render(); schedule_render();
} }
@@ -3147,10 +3101,6 @@ static void pointer_input(double x, double y, int relative, uint32_t button, int
uint32_t t = now_ms(); uint32_t t = now_ms();
pointer_focus(target->resource, fx, fy); pointer_focus(target->resource, fx, fy);
// 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.
int rel_consumed = send_relative_motion(client, dx, dy); int rel_consumed = send_relative_motion(client, dx, dy);
if (!(k && k->is_lock) && !(relative && rel_consumed) && (!relative || dx != 0 || dy != 0)) if (!(k && k->is_lock) && !(relative && rel_consumed) && (!relative || dx != 0 || dy != 0))
for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy); for_each_pointer_of(client, sp) wl_pointer_send_motion(sp->ptr, t, fx, fy);
@@ -3182,9 +3132,6 @@ static void pointer_button(uint32_t button, int pressed) {
pointer_input(0, 0, 1, button, pressed); pointer_input(0, 0, 1, button, pressed);
return; return;
} }
// Releases also clean up interrupted gestures. Deliver them to the existing
// pointer focus without motion or hit-testing: native touch has independent
// coordinates, so replaying g_ptr_x/y here can warp the game cursor.
uint32_t t = now_ms(); uint32_t t = now_ms();
for (int i = 0; i < g_nptrs; i++) { for (int i = 0; i < g_nptrs; i++) {
struct seat_pointer *sp = &g_ptrs[i]; struct seat_pointer *sp = &g_ptrs[i];
@@ -3679,8 +3626,6 @@ void droiddeck_wayland_send_scene_input(int type, int a, int b) {
(void)n; (void)n;
} }
/* Any thread: draw the pointer into the compositor's own output (the glasses
* surface owns the picture, so the phone's Compose overlay never reaches it). */
void droiddeck_wayland_set_cursor_composite(int on) { void droiddeck_wayland_set_cursor_composite(int on) {
const int want = on ? 1 : 0; const int want = on ? 1 : 0;
if (atomic_exchange(&g_cursor_composite_want, want) == want) return; if (atomic_exchange(&g_cursor_composite_want, want) == want) return;
@@ -3871,8 +3816,6 @@ int droiddeck_wayland_run(void) {
open_session_log(); open_session_log();
if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn"); if (vkp_ready() != 0) WLOGE("renderer unavailable; clients will connect but nothing is drawn");
/* Presentation compatibility is not HDR capability. Set the initial layer mode before
* clients bind, including when Desktop is the first session in this app process. */
if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1; if (vkp_quest_layer_presentation() && sc_layer_available()) g_zero_copy = 1;
struct wl_display *display = wl_display_create(); struct wl_display *display = wl_display_create();
@@ -374,16 +374,11 @@ int vkp_framegen_run(VkCommandBuffer cmd, VkImage scene, VkImageView scene_view,
mark_failed(kind); mark_failed(kind);
return 0; return 0;
} }
/* The ring and the engine's images are new on this frame. Dispatching into them now
* null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
* Show this frame as it is; generation starts once the size has settled. */
if (size_changed || ring == 2) { if (size_changed || ring == 2) {
FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h); FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
return 0; return 0;
} }
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
* generations would otherwise read as a slower game and justify even more generations. */
++g_source_frames; ++g_source_frames;
const uint64_t game_frames = droiddeck_game_frames(); const uint64_t game_frames = droiddeck_game_frames();
uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate); uint32_t n_gen = fge_plan(g_ring_n, game_frames ? game_frames : g_source_frames, g_presented_rate);
@@ -0,0 +1,89 @@
#include "gpu_busy.h"
#include <jni.h>
#include <pthread.h>
#include <stdatomic.h>
#include "gpuload_ring.h"
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
static struct gpuload_vk g_v;
static struct gpuload_ring g_ring;
static uint32_t g_family, g_bits;
static PFN_vkQueueSubmit g_submit;
static int g_was_on;
static _Atomic int g_on, g_lost, g_timing;
static _Atomic uint64_t g_busy_ns;
static VkResult VKAPI_CALL timed_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *infos, VkFence fence) {
if (!count || !atomic_load(&g_on) || atomic_load(&g_lost)) return g_submit(queue, count, infos, fence);
pthread_mutex_lock(&g_lock);
if (!atomic_load(&g_on) || atomic_load(&g_lost)) {
pthread_mutex_unlock(&g_lock);
return g_submit(queue, count, infos, fence);
}
if (g_ring.state == 0 && gpuload_ring_setup(&g_ring, &g_v, g_family, g_bits)) atomic_store(&g_timing, 1);
VkSubmitInfo *mine = NULL;
uint32_t used = 0;
if (g_ring.state == 1) {
uint64_t ns = gpuload_ring_harvest(&g_ring, &g_v);
if (g_was_on) atomic_fetch_add(&g_busy_ns, ns); // the first harvest after a pause holds work timed before it
g_was_on = 1;
mine = gpuload_ring_wrap(&g_ring, count, infos, &used);
}
VkResult r = g_submit(queue, count, mine ? mine : infos, fence);
if (mine && r != VK_SUCCESS) gpuload_ring_unwind(&g_ring, used);
pthread_mutex_unlock(&g_lock);
free(mine);
return r;
}
void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family) {
pthread_mutex_lock(&g_lock);
memset(&g_ring, 0, sizeof(g_ring));
g_was_on = 0;
atomic_store(&g_timing, 0);
atomic_store(&g_lost, 0);
PFN_vkGetDeviceProcAddr gdpa = g_vk.GetDeviceProcAddr;
g_v = (struct gpuload_vk){
.device = dev,
.create_pool = g_vk.CreateCommandPool,
.destroy_pool = (PFN_vkDestroyCommandPool)gdpa(dev, "vkDestroyCommandPool"),
.alloc_cmds = g_vk.AllocateCommandBuffers,
.begin = g_vk.BeginCommandBuffer,
.end = g_vk.EndCommandBuffer,
.cmd_reset = (PFN_vkCmdResetQueryPool)gdpa(dev, "vkCmdResetQueryPool"),
.cmd_timestamp = (PFN_vkCmdWriteTimestamp)gdpa(dev, "vkCmdWriteTimestamp"),
.create_queries = (PFN_vkCreateQueryPool)gdpa(dev, "vkCreateQueryPool"),
.destroy_queries = (PFN_vkDestroyQueryPool)gdpa(dev, "vkDestroyQueryPool"),
.results = (PFN_vkGetQueryPoolResults)gdpa(dev, "vkGetQueryPoolResults"),
};
VkPhysicalDeviceProperties props;
g_vk.GetPhysicalDeviceProperties(pd, &props);
gpuload_vk_period(&g_v, props.limits.timestampPeriod);
VkQueueFamilyProperties families[16];
uint32_t n = 16;
g_vk.GetPhysicalDeviceQueueFamilyProperties(pd, &n, families);
g_family = family;
g_bits = family < n ? families[family].timestampValidBits : 0;
// vk_loader_load_device has just put the driver's function back.
if (g_vk.QueueSubmit != timed_submit) g_submit = g_vk.QueueSubmit;
if (g_submit) g_vk.QueueSubmit = timed_submit;
pthread_mutex_unlock(&g_lock);
}
void vkp_gpu_busy_device_lost(void) {
atomic_store(&g_lost, 1);
atomic_store(&g_timing, 0);
}
JNIEXPORT jlong JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeGpuBusyNs(JNIEnv *env, jclass cls) {
return atomic_load(&g_timing) ? (jlong)atomic_load(&g_busy_ns) : -1;
}
JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTiming(JNIEnv *env, jclass cls, jboolean on) {
pthread_mutex_lock(&g_lock);
if (!on) g_was_on = 0;
atomic_store(&g_on, on ? 1 : 0);
pthread_mutex_unlock(&g_lock);
}
@@ -0,0 +1,9 @@
#ifndef GPU_BUSY_H
#define GPU_BUSY_H
#include "vk_loader.h"
// Times the compositor's own GPU work for the HUD (tools/gpuload/src/gpuload_ring.h); call after the device and its
// functions are loaded. Off until WaylandCompositor.nativeSetGpuTiming(true).
void vkp_gpu_busy_device(VkPhysicalDevice pd, VkDevice dev, uint32_t family);
void vkp_gpu_busy_device_lost(void);
#endif
@@ -103,7 +103,6 @@ static bool prepare_pin(void) {
(off_t)pin.memory << 12); (off_t)pin.memory << 12);
if (commands == MAP_FAILED) return false; if (commands == MAP_FAILED) return false;
pin.commands = commands; pin.commands = commands;
// A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
uint32_t *words = commands; uint32_t *words = commands;
words[0] = pin.nop; words[0] = pin.nop;
words[1] = 0; words[1] = 0;
@@ -124,7 +123,6 @@ static void *hold_pin(void *unused) {
if (errno == EINTR || errno == ETIMEDOUT) continue; if (errno == EINTR || errno == ETIMEDOUT) continue;
break; break;
} }
// Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
usleep(25000); usleep(25000);
if (atomic_load(&running) && !submit_pin()) break; if (atomic_load(&running) && !submit_pin()) break;
} }
@@ -24,7 +24,6 @@ struct droiddeck_headpose {
float eye_aspect; float eye_aspect;
uint32_t eye_w, eye_h; uint32_t eye_w, eye_h;
float display_hz; float display_hz;
// Uses a formerly reserved word; older publishers leave this zero.
uint32_t render_height; uint32_t render_height;
uint32_t reserved[10]; uint32_t reserved[10];
}; };
@@ -8,7 +8,6 @@
#include "hudframes.h" #include "hudframes.h"
/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */
#define HUD_FRAMES_MAGIC 0x46484444u #define HUD_FRAMES_MAGIC 0x46484444u
#define HUD_FRAMES_HEADER 128u #define HUD_FRAMES_HEADER 128u
#define HUD_FRAMES_ENTRY 16u #define HUD_FRAMES_ENTRY 16u
@@ -69,7 +68,6 @@ int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, i
memcpy(&frametimes[n], e, 8); memcpy(&frametimes[n], e, 8);
memcpy(&times[n], e + 8, 8); memcpy(&times[n], e + 8, 8);
} }
/* The writer may have lapped the ring while we copied: drop what it overwrote. */
uint64_t after = load64(h->map + 16); uint64_t after = load64(h->map + 16);
uint64_t valid = after > cap ? after - cap : 0; uint64_t valid = after > cap ? after - cap : 0;
if (from < valid) { if (from < valid) {
@@ -8,9 +8,7 @@ struct hud_frames;
struct hud_frames *hud_frames_open(const char *path); struct hud_frames *hud_frames_open(const char *path);
void hud_frames_close(struct hud_frames *h); void hud_frames_close(struct hud_frames *h);
/* New frames since the last poll, oldest first; -1 while the file holds no valid ring. */
int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max); int hud_frames_poll(struct hud_frames *h, int64_t *frametimes, int64_t *times, int max);
/* info: pid, output width, output height, refresh mHz, Steam focused. */
int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len); int hud_frames_info(const struct hud_frames *h, int32_t *info, char *engine, size_t engine_len);
#endif #endif
@@ -3,7 +3,6 @@
#include <string.h> #include <string.h>
#include <strings.h> #include <strings.h>
/* Presentation only; this does not select a GPU driver or expand driver support. */
static inline int quest_device_matches(const char *model, const char *device, const char *maker) { static inline int quest_device_matches(const char *model, const char *device, const char *maker) {
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") || return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
!strcasecmp(model, "Quest 3S") || !strcasecmp(model, "Quest 3S") ||
+2 -2
View File
@@ -291,7 +291,7 @@ int sc_layer_base_black(int width, int height) {
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height}; ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
api.setBuffer(tx, g_base_sc, black, -1); api.setBuffer(tx, g_base_sc, black, -1);
api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE); api.setBufferTransparency(tx, g_base_sc, ASC_TRANSPARENCY_OPAQUE);
api.setZOrder(tx, g_base_sc, 0); /* GAME = 1, OVERLAY = 2 */ api.setZOrder(tx, g_base_sc, 0);
api.setGeometry(tx, g_base_sc, &src, &dst, 0); api.setGeometry(tx, g_base_sc, &src, &dst, 0);
api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW); api.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
api.txApply(tx); api.txDelete(tx); api.txApply(tx); api.txDelete(tx);
@@ -1026,7 +1026,7 @@ void sc_layer_hide_overlay(void) {
} }
void sc_layer_window_gone(void) { void sc_layer_window_gone(void) {
base_hide(1); /* the backdrop belongs to the old window, just like GAME and OVERLAY */ base_hide(1);
if (api.state != 1) return; if (api.state != 1) return;
layers_init(); layers_init();
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]); for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
+3 -15
View File
@@ -7,6 +7,7 @@
#include "framegen_bridge.h" #include "framegen_bridge.h"
#include "hdr_compose.h" #include "hdr_compose.h"
#include "blend_pass.h" #include "blend_pass.h"
#include "gpu_busy.h"
#include "droiddeck_color.h" #include "droiddeck_color.h"
#include "quest_device.h" #include "quest_device.h"
#include <stdio.h> #include <stdio.h>
@@ -70,8 +71,6 @@ static int g_first_frame_done;
static int g_base_black; static int g_base_black;
int vkp_quest_layer_presentation(void) { int vkp_quest_layer_presentation(void) {
/* Compositor thread only; this is a process-wide device property, not a session setting.
* Quest 2 still needs its separate A6xx driver: this gate only chooses presentation. */
static int enabled = -1; static int enabled = -1;
if (enabled < 0) { if (enabled < 0) {
char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0}; char model[PROP_VALUE_MAX] = {0}, device[PROP_VALUE_MAX] = {0}, maker[PROP_VALUE_MAX] = {0};
@@ -480,7 +479,6 @@ static int dev_init(void) {
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL); g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts)); VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts); g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
/* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */
for (unsigned i = 0; i < 4; i++) { for (unsigned i = 0; i < 4; i++) {
if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i]; if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i];
else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]); else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]);
@@ -492,7 +490,6 @@ static int dev_init(void) {
} }
const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME); const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME; if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
/* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */
g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) && g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) &&
has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME); has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
if (g_ahb_import) { if (g_ahb_import) {
@@ -521,6 +518,7 @@ static int dev_init(void) {
} }
vk_loader_load_device(g_dev); vk_loader_load_device(g_dev);
g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue); g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue);
vkp_gpu_busy_device(g_pd, g_dev, g_qfam);
if (want_sem_fd) { if (want_sem_fd) {
g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR"); g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR");
VkSemaphoreCreateInfo wsci = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO}; VkSemaphoreCreateInfo wsci = {.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
@@ -1411,6 +1409,7 @@ static void device_lost(const char *where) {
g_dev_state = -2; g_dev_state = -2;
g_base_black = 0; g_base_black = 0;
vkp_framegen_device_lost(); vkp_framegen_device_lost();
vkp_gpu_busy_device_lost();
droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; " droiddeck_log("error", "GPU device lost (VK_ERROR_DEVICE_LOST in %s): the compositor has stopped presenting; "
"restart the session", where); "restart the session", where);
for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]); for (uint32_t i = 0; i < g_nimg; i++) blendp_forget_image(g_images[i]);
@@ -1798,9 +1797,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; VkImageLayout result_layout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
if (fx) { if (fx) {
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f); int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
/* Frame generation samples every output pixel. Upscaling first made it interpolate
* the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
* Grade at the game's size instead; the screen blit still scales to the panel. */
if (fg) { mapped_w = scene_w; mapped_h = scene_h; } if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
vkp_effects_set_formats(scene_fmt, scene_fmt); vkp_effects_set_formats(scene_fmt, scene_fmt);
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh); result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
@@ -1900,9 +1896,6 @@ static int render_impl(int scene_w, int scene_h, const struct vkp_draw *draws, i
int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) { int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) { if (vkp_quest_layer_presentation() && !vkp_framegen_active() && !vkp_cardboard_active()) {
/* The desktop (multiple windows), overlays and an empty scene must also avoid WSI.
* Merely enabling zero-copy handles only a single fullscreen window and leaves both
* the letterbox and every fallback frame going through the corrupted swapchain. */
vkp_apply_window_request(); vkp_apply_window_request();
sc_layer_hide_overlay(); sc_layer_hide_overlay();
if (n > 0) { if (n > 0) {
@@ -1914,9 +1907,6 @@ int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
} else { } else {
sc_layer_hide(); sc_layer_hide();
} }
/* sc_layer_present_pass signals only after submitting client content.
* A backdrop alone, or a successful no-op while a layer slot is unavailable,
* must not dismiss the app's loading UI or mark Steam's window as shown. */
return vkp_base_black(scene_w, scene_h); return vkp_base_black(scene_w, scene_h);
} }
return render_impl(scene_w, scene_h, draws, n, NULL, NULL); return render_impl(scene_w, scene_h, draws, n, NULL, NULL);
@@ -1953,8 +1943,6 @@ void vkp_signal_first_frame(void) {
int vkp_update_map(int scene_w, int scene_h) { int vkp_update_map(int scene_w, int scene_h) {
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1; if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
if (vkp_quest_layer_presentation()) { if (vkp_quest_layer_presentation()) {
/* Layer geometry needs the window size, not a Vulkan swapchain. In particular, do
* not create/present a swapchain just to paint black under an otherwise clean layer. */
int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window); int w = ANativeWindow_getWidth(g_window), h = ANativeWindow_getHeight(g_window);
if (w <= 0 || h <= 0) return -1; if (w <= 0 || h <= 0) return -1;
g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h}; g_extent = (VkExtent2D){(uint32_t)w, (uint32_t)h};
@@ -698,7 +698,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetGpuTurbo(JNIEnv *
jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException"); jclass error = (*env)->FindClass(env, "java/lang/IllegalStateException");
if (error) (*env)->ThrowNew(env, error, strerror(saved_errno)); if (error) (*env)->ThrowNew(env, error, strerror(saved_errno));
} }
// Clear a legacy global pin when recovering its marker after an app upgrade or crash.
if (on != JNI_TRUE) adrenotools_set_turbo(false); if (on != JNI_TRUE) adrenotools_set_turbo(false);
} }
@@ -725,7 +724,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0); vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
} }
/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) { Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
droiddeck_stream_set(on ? 1 : 0); droiddeck_stream_set(on ? 1 : 0);
@@ -19,7 +19,6 @@ class App : Application() {
com.droiddeck.launcher.ui.Themes.loadGradient(this) com.droiddeck.launcher.ui.Themes.loadGradient(this)
com.droiddeck.launcher.ui.BuildWatermark.install(this) com.droiddeck.launcher.ui.BuildWatermark.install(this)
HomeApp.trackRecents(this) HomeApp.trackRecents(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() val process = if (android.os.Build.VERSION.SDK_INT >= 28) Application.getProcessName()
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName .runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
@@ -32,12 +31,11 @@ class App : Application() {
return return
} }
if (process == "$packageName:xcloudnative") { if (process == "$packageName:xcloudnative") {
// The native cloud player: the stores log and nothing that touches the Linux session.
com.droiddeck.launcher.stores.StoresState.init(this) com.droiddeck.launcher.stores.StoresState.init(this)
return return
} }
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" || if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic" ||
process == "$packageName:shortcuts_sync") return process == "$packageName:shortcuts_sync" || process == "$packageName:bottom_desktop") return
CrashHandler.install(this) CrashHandler.install(this)
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this) com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this)
@@ -58,10 +56,10 @@ class App : Application() {
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.amazon.AmazonBackend) 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.itch.ItchBackend)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.xbox.XboxBackend)
com.droiddeck.launcher.sync.Syncthing.init(this)
com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend) com.droiddeck.launcher.stores.StoresState.register(com.droiddeck.launcher.stores.battlenet.BattleNetBackend)
} }
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
private fun prefetchAddonPacks() { private fun prefetchAddonPacks() {
val app = this val app = this
Thread({ Thread({
@@ -79,6 +79,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
import com.droiddeck.launcher.session.SessionPhase import com.droiddeck.launcher.session.SessionPhase
import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.SessionState
import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SteamLibraries
import com.droiddeck.launcher.input.SecondScreenDisplay import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenDisplays import com.droiddeck.launcher.input.SecondScreenDisplays
@@ -480,6 +481,7 @@ class FrontEndActivity : ComponentActivity() {
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL) private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
private var steamDownloadsInBackground by mutableStateOf(false) private var steamDownloadsInBackground by mutableStateOf(false)
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO) private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
private var backHold by mutableStateOf(true)
private var backActionsInverted by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false)
private var gameStorage by mutableStateOf("") private var gameStorage by mutableStateOf("")
private var slowUsb by mutableStateOf<com.droiddeck.launcher.session.UsbLink.SlowDrive?>(null) private var slowUsb by mutableStateOf<com.droiddeck.launcher.session.UsbLink.SlowDrive?>(null)
@@ -497,6 +499,20 @@ class FrontEndActivity : ComponentActivity() {
private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r -> private val pickGameStorage = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) } if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path -> setGameStorage(path, GameStorage.labelFor(this, path)) }
} }
private var steamLibraries by mutableStateOf<List<SteamLibraries.Entry>>(emptyList())
private val pickSteamLibrary = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val path = if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data) else null
if (path != null) Thread({
val result = SteamLibraries.add(this, path)
ui.post {
steamLibraries = SteamLibraries.list(this)
refresh()
if (result is SteamLibraries.AddResult.Refused) {
android.widget.Toast.makeText(this, getString(R.string.main_storage_not_usable, result.reason), android.widget.Toast.LENGTH_LONG).show()
}
}
}, "steam-library-add").start()
}
private var romsDir by mutableStateOf<String?>(null) private var romsDir by mutableStateOf<String?>(null)
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList()) private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
private var steamGamesX64 by mutableStateOf(false) private var steamGamesX64 by mutableStateOf(false)
@@ -720,7 +736,6 @@ class FrontEndActivity : ComponentActivity() {
LauncherIcon.set(this, style) LauncherIcon.set(this, style)
launcherIcon = LauncherIcon.current(this) launcherIcon = LauncherIcon.current(this)
if (launcherIcon == before) return false 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) startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.setComponent(LauncherIcon.activity(this)) .setComponent(LauncherIcon.activity(this))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
@@ -1810,7 +1825,6 @@ class FrontEndActivity : ComponentActivity() {
private fun refreshSecondScreenDisplays() { private fun refreshSecondScreenDisplays() {
if (!::displayManager.isInitialized) return if (!::displayManager.isInitialized) return
// The launcher can itself be shown on a non-default display (DeX, an external monitor); that one is not a "second screen".
val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId val ownDisplayId = if (android.os.Build.VERSION.SDK_INT >= 30) display?.displayId ?: android.view.Display.DEFAULT_DISPLAY else @Suppress("DEPRECATION") windowManager.defaultDisplay.displayId
dualScreenMode = SessionPrefs.dualScreenMode(this) dualScreenMode = SessionPrefs.dualScreenMode(this)
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId) secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
@@ -2124,6 +2138,7 @@ class FrontEndActivity : ComponentActivity() {
unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null, unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null,
unfoldedControlsWithController = unfoldedControlsWithController, unfoldedControlsWithController = unfoldedControlsWithController,
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null, oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
backHold = backHold,
backActionsInverted = backActionsInverted, backActionsInverted = backActionsInverted,
directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null, directAudio = if (mode == SessionService.MODE_STEAM) directAudio else null,
picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null, picturePosition = if (mode == SessionService.MODE_STEAM) picturePosition else null,
@@ -2131,6 +2146,7 @@ class FrontEndActivity : ComponentActivity() {
mic = if (mode == SessionService.MODE_STEAM) mic else null, mic = if (mode == SessionService.MODE_STEAM) mic else null,
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null, gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
storageOptions = storageOptions, storageOptions = storageOptions,
steamLibraries = steamLibraries,
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics, storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null, fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
forceSsbs = forceSsbs, forceSsbs = forceSsbs,
@@ -2176,6 +2192,7 @@ class FrontEndActivity : ComponentActivity() {
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
}, },
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o }, onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
onBackActionsInverted = { inverted -> onBackActionsInverted = { inverted ->
SessionPrefs.setBackActionsInverted(this, inverted) SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted backActionsInverted = inverted
@@ -2194,6 +2211,19 @@ class FrontEndActivity : ComponentActivity() {
onPickGameStorageFolder = { onPickGameStorageFolder = {
pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null })) pickGameStorage.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.main_pick_game_storage), gameStorage.ifEmpty { null }))
}, },
onAddSteamLibrary = {
pickSteamLibrary.launch(InAppFilePicker.buildDirIntent(this, getString(R.string.libraries_pick), null))
},
onRenameSteamLibrary = { id, label ->
SteamLibraries.rename(this, id, label)
steamLibraries = SteamLibraries.list(this)
refresh()
},
onRemoveSteamLibrary = { id ->
SteamLibraries.remove(this, id)
steamLibraries = SteamLibraries.list(this)
refresh()
},
onStorageDiagnostics = { on -> onStorageDiagnostics = { on ->
SessionPrefs.setStorageDiagnosticsEnabled(this, on) SessionPrefs.setStorageDiagnosticsEnabled(this, on)
storageDiagnostics = on storageDiagnostics = on
@@ -2420,6 +2450,7 @@ class FrontEndActivity : ComponentActivity() {
suspendPolicy = SessionPrefs.suspendPolicy(this, mode) suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this) oscMode = SessionPrefs.oscMode(this)
backHold = SessionPrefs.backHold(this)
backActionsInverted = SessionPrefs.backActionsInverted(this) backActionsInverted = SessionPrefs.backActionsInverted(this)
directAudio = SessionPrefs.directAudio(this) directAudio = SessionPrefs.directAudio(this)
picturePosition = SessionPrefs.picturePosition(this) picturePosition = SessionPrefs.picturePosition(this)
@@ -2427,6 +2458,7 @@ class FrontEndActivity : ComponentActivity() {
mic = SessionPrefs.micEnabled(this) mic = SessionPrefs.micEnabled(this)
refreshWifiDiscovery() refreshWifiDiscovery()
gameStorage = SessionPrefs.gameStorage(this) gameStorage = SessionPrefs.gameStorage(this)
steamLibraries = SteamLibraries.list(this)
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this) storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
settingsMode = mode settingsMode = mode
Thread({ Thread({
@@ -2504,7 +2536,6 @@ class FrontEndActivity : ComponentActivity() {
!SessionPaths.currentSessionLogsRetained(this) !SessionPaths.currentSessionLogsRetained(this)
private fun refresh() { private fun refresh() {
// Until the scan finishes, the other client's games would launch in this one.
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64" val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
if (listX64 != steamGamesX64) { if (listX64 != steamGamesX64) {
steamGamesX64 = listX64 steamGamesX64 = listX64
@@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
/** Shortcuts other apps asked to put on the home screen. The system keeps the pinned set per launcher. */
object HomePins { object HomePins {
class Pinned(val info: ShortcutInfo, val icon: Bitmap?, val appLabel: String, val userSerial: Long) { class Pinned(val info: ShortcutInfo, val icon: Bitmap?, val appLabel: String, val userSerial: Long) {
val key get() = "pin:${info.`package`}/${info.id}" + if (userSerial != 0L) "#$userSerial" else "" val key get() = "pin:${info.`package`}/${info.id}" + if (userSerial != 0L) "#$userSerial" else ""
@@ -197,7 +197,6 @@ object HomeWidgets {
if (pendingId == AppWidgetManager.INVALID_APPWIDGET_ID) add(activity, provider.info, null) if (pendingId == AppWidgetManager.INVALID_APPWIDGET_ID) add(activity, provider.info, null)
} }
/** Places a widget another app asked for; its own app has already configured it. */
fun addPinned(activity: Activity, request: LauncherApps.PinItemRequest): Boolean { fun addPinned(activity: Activity, request: LauncherApps.PinItemRequest): Boolean {
if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false
val info = request.getAppWidgetProviderInfo(activity) ?: return false val info = request.getAppWidgetProviderInfo(activity) ?: return false
@@ -20,7 +20,6 @@ internal class ProtonMenu(
private val ui: Handler, private val ui: Handler,
private val idle: () -> Boolean = { true }, private val idle: () -> Boolean = { true },
) { ) {
// Install workers and their results outlive the activity that starts them.
private object Operation { private object Operation {
val rows = mutableStateOf<List<ProtonRow>>(emptyList()) val rows = mutableStateOf<List<ProtonRow>>(emptyList())
val busyId = mutableStateOf<String?>(null) val busyId = mutableStateOf<String?>(null)
@@ -71,7 +71,6 @@ class QuestXrActivity : NativeActivity() {
} }
} finally { } finally {
synchronized(steamVrLock) { synchronized(steamVrLock) {
// A retiring activity must not stop a replacement's SteamVR session.
if (steamVrOwner === this) { if (steamVrOwner === this) {
if (sessionAlive()) SteamVr.run(this, "stop") if (sessionAlive()) SteamVr.run(this, "stop")
steamVrOwner = null steamVrOwner = null
@@ -67,7 +67,6 @@ import com.droiddeck.launcher.gpu.ScreenEffectLooks
import com.droiddeck.launcher.gpu.ScreenEffects import com.droiddeck.launcher.gpu.ScreenEffects
import com.droiddeck.launcher.gpu.Lossless import com.droiddeck.launcher.gpu.Lossless
import com.droiddeck.launcher.gpu.TurnipDriver import com.droiddeck.launcher.gpu.TurnipDriver
import com.droiddeck.launcher.input.EvdevKeys
import com.droiddeck.launcher.input.HomeGuideInput import com.droiddeck.launcher.input.HomeGuideInput
import com.droiddeck.launcher.input.InputSpace import com.droiddeck.launcher.input.InputSpace
import com.droiddeck.launcher.input.KeyboardHost import com.droiddeck.launcher.input.KeyboardHost
@@ -266,7 +265,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (locked) openEvdevMouse() else closeEvdevMouse() if (locked) openEvdevMouse() else closeEvdevMouse()
padMouseCheckedAt = 0L padMouseCheckedAt = 0L
if (::padMouse.isInitialized) padMouseMode() if (::padMouse.isInitialized) padMouseMode()
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
if (locked && ::touchpad.isInitialized) { if (locked && ::touchpad.isInitialized) {
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) } drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
cursorVisible = false cursorVisible = false
@@ -310,6 +308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onTap = { performBackAction(double = it) }, onTap = { performBackAction(double = it) },
doubleTapMs = { backDoubleTapMs.toLong() }, doubleTapMs = { backDoubleTapMs.toLong() },
onCancel = { padBridge?.cancelBackQam() }, onCancel = { padBridge?.cancelBackQam() },
holds = { backHold },
) )
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
private var drawerDirectionDownTime = 0L private var drawerDirectionDownTime = 0L
@@ -388,6 +387,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>() private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER) private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
private var drawerControllerActive by mutableStateOf(false) private var drawerControllerActive by mutableStateOf(false)
private var backHold by mutableStateOf(true)
private var backActionsInverted by mutableStateOf(false) private var backActionsInverted by mutableStateOf(false)
private var backDoubleTapMs by mutableStateOf(500) private var backDoubleTapMs by mutableStateOf(500)
private var lowLatency by mutableStateOf(true) private var lowLatency by mutableStateOf(true)
@@ -512,8 +512,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
} }
} }
// A higher refresh rate allowed in Android's settings does not change the display while this window
// still asks for a lower mode, so the mode is chosen again when the setting changes.
private val refreshSettingObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { private val refreshSettingObserver = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean) { override fun onChange(selfChange: Boolean) {
applyOutputPacing() applyOutputPacing()
@@ -570,7 +568,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
finish() finish()
return return
} }
// A cancelled install keeps its thread until the download stops; a new launch would reuse its stop flag and close itself.
if (savedInstanceState == null && SessionState.installing != null && !SessionState.running) { if (savedInstanceState == null && SessionState.installing != null && !SessionState.running) {
runtimeRemovalBlocked = true runtimeRemovalBlocked = true
android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show() android.widget.Toast.makeText(this, R.string.main_wait_desktop_install, android.widget.Toast.LENGTH_SHORT).show()
@@ -581,7 +578,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM val mode = intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM))) SessionEvents.begin(this, mode, SessionPaths.label(mode, intent.getStringExtra(SessionService.EXTRA_PROGRAM)))
// begin() moves the phase on, so the block below never runs for a new session; reset per-session flags here.
SessionState.secondScreenAutoOpened = false SessionState.secondScreenAutoOpened = false
SessionState.secondScreenProvisional = false SessionState.secondScreenProvisional = false
} }
@@ -605,7 +601,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fpsLimit = 0 fpsLimit = 0
} }
applyOutputPacing() applyOutputPacing()
if (!SessionState.running) SessionState.followScreen = followChoice() if (!SessionState.running) {
SessionState.followScreen = followChoice()
SessionState.followScale = followScale()
}
applyFollowOrientation(resources.configuration) applyFollowOrientation(resources.configuration)
goFullscreen() goFullscreen()
volumeControlStream = android.media.AudioManager.STREAM_MUSIC volumeControlStream = android.media.AudioManager.STREAM_MUSIC
@@ -714,8 +713,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput, ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
) )
refreshGestureConfig() 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 root.isMotionEventSplittingEnabled = true
surfaceView.setOnTouchListener { _, event -> surfaceView.setOnTouchListener { _, event ->
if (!event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) { if (!event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
@@ -921,7 +918,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK)) window.setBackgroundDrawable(android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK))
}, },
) )
// Also runs when a second screen appears, so Automatic applies then with the running game known.
LaunchedEffect(AgentGuest.focusRevision, secondScreenDisplays.isNotEmpty()) { LaunchedEffect(AgentGuest.focusRevision, secondScreenDisplays.isNotEmpty()) {
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" } if (SessionState.mode == SessionService.MODE_RUN) SessionState.program?.let { "program:$it" }
@@ -1115,7 +1111,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements) SessionAchievements(inFront.game, inFront.title, onClose = ::closeAchievements)
} }
com.droiddeck.launcher.ui.AchievementNotificationOverlay() com.droiddeck.launcher.ui.AchievementNotificationOverlay()
// Auto and Native resume on their own as the session comes back; only a failed resume needs the button.
val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE val autoResume = suspendPolicy == SessionPrefs.SUSPEND_AUTO || suspendPolicy == SessionPrefs.SUSPEND_NATIVE
LaunchedEffect(SessionState.manualPause, SessionState.suspended) { LaunchedEffect(SessionState.manualPause, SessionState.suspended) {
if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false
@@ -1193,6 +1188,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
fexPreset = fexPreset, fexPreset = fexPreset,
secondScreenMode = secondScreenMode, secondScreenMode = secondScreenMode,
dashboardAvailable = bottomPages().isNotEmpty(), dashboardAvailable = bottomPages().isNotEmpty(),
desktopAvailable = com.droiddeck.launcher.session.BottomDesktop.available(),
secondScreenDisplays = secondScreenDisplays, secondScreenDisplays = secondScreenDisplays,
selectedSecondScreenDisplay = selectedSecondScreenDisplay, selectedSecondScreenDisplay = selectedSecondScreenDisplay,
onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() }, onHud = { on -> SessionPrefs.setHudEnabled(this@SessionActivity, on); hudOn = on; hud.refresh() },
@@ -1264,6 +1260,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id)) com.droiddeck.launcher.session.InputSysfsComponent.padTypeChanged(SessionPrefs.padType(id))
padType = id padType = id
}, },
backHold = backHold,
onBackHold = { on ->
SessionPrefs.setBackHold(this@SessionActivity, on)
backHold = on
},
backActionsInverted = backActionsInverted, backActionsInverted = backActionsInverted,
backDoubleTapMs = backDoubleTapMs, backDoubleTapMs = backDoubleTapMs,
onBackDoubleTapMs = { ms -> onBackDoubleTapMs = { ms ->
@@ -1470,7 +1471,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)) ))
} }
/** The SteamVR driver switches pad sticks between hands and game on both stick clicks together. */
private fun switchVrSticks() { private fun switchVrSticks() {
val chord = { down: Boolean -> padBridge?.applyTouch { state -> val chord = { down: Boolean -> padBridge?.applyTouch { state ->
state.press(com.droiddeck.launcher.input.PadState.L3, down) state.press(com.droiddeck.launcher.input.PadState.L3, down)
@@ -1873,6 +1873,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode) suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this) steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
oscMode = SessionPrefs.oscMode(this) oscMode = SessionPrefs.oscMode(this)
backHold = SessionPrefs.backHold(this)
backActionsInverted = SessionPrefs.backActionsInverted(this) backActionsInverted = SessionPrefs.backActionsInverted(this)
physicalShortcuts.bindings = PhysicalShortcuts.read(this) physicalShortcuts.bindings = PhysicalShortcuts.read(this)
backDoubleTapMs = SessionPrefs.backDoubleTapMs(this) backDoubleTapMs = SessionPrefs.backDoubleTapMs(this)
@@ -2160,7 +2161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? { private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? {
if (isInPictureInPictureMode || width <= 0 || height <= 0) return null if (isInPictureInPictureMode || width <= 0 || height <= 0) return null
return if (SessionState.followScreen) return if (SessionState.followScreen)
com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted()) com.droiddeck.launcher.session.SessionDisplay.followSize(width, height, controlsBelowWanted(), SessionState.followScale)
else if (glassesScreen == null) outputSize() else null else if (glassesScreen == null) outputSize() else null
} }
@@ -2175,10 +2176,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
WaylandCompositor.nativeResizeOutput(size.first, size.second) WaylandCompositor.nativeResizeOutput(size.first, size.second)
} }
private fun followScale(): Float {
val mode = displayMode()
val choice = SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this))
return com.droiddeck.launcher.session.SessionDisplay.followScale(choice) ?: 1f
}
private fun followChoice(): Boolean { private fun followChoice(): Boolean {
val mode = displayMode() val mode = displayMode()
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) == return com.droiddeck.launcher.session.SessionDisplay.isFollow(
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)))
} }
private fun applyFollowOrientation(config: android.content.res.Configuration) { private fun applyFollowOrientation(config: android.content.res.Configuration) {
@@ -2198,8 +2205,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
// 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 if (holder !== outputHolder()) return
cancelTouchGestures() cancelTouchGestures()
surfaceW = 0 surfaceW = 0
@@ -2596,27 +2601,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) { if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
val down = event.action == KeyEvent.ACTION_DOWN val down = event.action == KeyEvent.ACTION_DOWN
if (down && event.repeatCount > 0) return true if (down && event.repeatCount > 0) return true
if (down || event.action == KeyEvent.ACTION_UP) { if (com.droiddeck.launcher.input.GuestKeys.send(event, ::sendGuestKey)) return true
var evdev = EvdevKeys.fromKeyCode(event.keyCode)
val ch = event.unicodeChar
val plain = if (ch > 0) EvdevKeys.unshiftedChar(ch) else 0
if (evdev <= 0 && ch > 0) {
val code = EvdevKeys.fromKeyCode(EvdevKeys.keycodeForChar(if (plain != 0) plain else ch))
if (code > 0) evdev = code
}
val shiftEvdev = if (evdev > 0 && plain != 0 && event.deviceId <= 0) 42 else 0
if (evdev <= 0 && event.scanCode > 0) evdev = event.scanCode
if (evdev > 0) {
if (down) {
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, true)
sendGuestKey(evdev, true)
} else {
sendGuestKey(evdev, false)
if (shiftEvdev != 0) sendGuestKey(shiftEvdev, false)
}
return true
}
}
} }
return super.dispatchKeyEvent(event) return super.dispatchKeyEvent(event)
} }
@@ -2640,10 +2625,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true if (PadBridge.isControllerMotionEvent(event) && padMouseMode() && padMouse.onMotion(event)) return true
if (padBridge?.onMotionEvent(event) == true) return true if (padBridge?.onMotionEvent(event) == true) return true
if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen) { if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen) {
/* The app's own controls get the pointer first. With the picture on the glasses the
* phone shows its panel, and hovering or clicking a control there must reach it
* instead of moving and clicking the guest pointer on the glasses. Whatever the UI
* leaves alone — the rest of the screen — still drives the guest pointer. */
if (appUiOwnsPointer()) { if (appUiOwnsPointer()) {
if (super.dispatchGenericMotionEvent(event)) return true if (super.dispatchGenericMotionEvent(event)) return true
if (onMouse(event)) return true if (onMouse(event)) return true
@@ -2657,7 +2638,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchTrackballEvent(event: MotionEvent): Boolean = override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
onCapturedPointer(event) || super.dispatchTrackballEvent(event) onCapturedPointer(event) || super.dispatchTrackballEvent(event)
/** Whether one of the on-screen control layers takes a finger going down at this screen point. */
private fun controlTakesTouchAt(rawX: Float, rawY: Float): Boolean { private fun controlTakesTouchAt(rawX: Float, rawY: Float): Boolean {
val at = IntArray(2) val at = IntArray(2)
fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean { fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean {
@@ -2835,9 +2815,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun prepareRelativePan() { override fun prepareRelativePan() {
if (pointerLocked) return if (pointerLocked) return
val rect = drawnRect() ?: 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()) moveTo(rect.centerX(), rect.centerY())
} }
override fun key(code: Int, down: Boolean) { override fun key(code: Int, down: Boolean) {
@@ -3066,7 +3043,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (secondScreenDisplays.none { it.id == target.displayId }) return if (secondScreenDisplays.none { it.id == target.displayId }) return
SessionState.secondScreenAutoOpened = true SessionState.secondScreenAutoOpened = true
selectedSecondScreenDisplay = target.displayId selectedSecondScreenDisplay = target.displayId
selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty())) selectSecondScreenMode(SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(),
com.droiddeck.launcher.session.BottomDesktop.available()))
dualControlsProvisional = ::loading.isInitialized && loading.visible dualControlsProvisional = ::loading.isInitialized && loading.visible
} }
@@ -3074,7 +3052,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return
dualControlsProvisional = false dualControlsProvisional = false
if (secondScreenMode == SecondScreenMode.NONE) return if (secondScreenMode == SecondScreenMode.NONE) return
val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty()) val wanted = SecondScreenMode.opening(SessionPrefs.secondScreenPanel(this), SessionState.deckPad, bottomPages().isNotEmpty(),
com.droiddeck.launcher.session.BottomDesktop.available())
if (wanted != secondScreenMode) selectSecondScreenMode(wanted) else secondScreenPresentation?.retryTerminal() if (wanted != secondScreenMode) selectSecondScreenMode(wanted) else secondScreenPresentation?.retryTerminal()
} }
@@ -3152,12 +3131,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) }, onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) },
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null, onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null,
strip = { pageId ->
com.droiddeck.launcher.ui.SessionBottomStrip(pageId) { if (::hud.isInitialized) hud.liveFps else null }
},
) )
} }
}), }),
) )
/** Pages for the bottom-screen dashboard; the dashboard is offered only while at least one exists. */
private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions( private fun bottomPages(): List<BottomPage> = sessionBottomPages(BottomPageActions(
takeScreenshot = ::captureScreenshot, takeScreenshot = ::captureScreenshot,
fps = { if (::hud.isInitialized) hud.liveFps else null }, fps = { if (::hud.isInitialized) hud.liveFps else null },
@@ -3165,9 +3146,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
runningGame = { gestureGame }, runningGame = { gestureGame },
)) ))
// A settings page opened on the bottom screen sits under the dashboard window, so the dashboard hides
// until the setting is on, or for at most SETTINGS_WATCH_CHECKS seconds when it is left off (another
// app's page closing cannot be seen); choosing Dashboard in the menu again shows it sooner.
private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) { private fun openSettingsBelow(intent: android.content.Intent, done: () -> Boolean) {
val presentation = secondScreenPresentation ?: return val presentation = secondScreenPresentation ?: return
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId) val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
@@ -3179,7 +3157,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (!opened) return if (!opened) return
presentation.hide() presentation.hide()
var checks = 0 var checks = 0
// Show throws if the display went away while the settings page was open, as in showSecondScreen.
fun showAgain() { fun showAgain() {
try { try {
presentation.show() presentation.show()
@@ -3200,9 +3177,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}, SETTINGS_WATCH_MS) }, SETTINGS_WATCH_MS)
} }
/** Copies the game frame (no overlay) at the size it is shown and saves it to Pictures/DroidDeck. */
private fun captureScreenshot(onDone: (Boolean) -> Unit) { private fun captureScreenshot(onDone: (Boolean) -> Unit) {
// With the glasses as the output the game is drawn on their surface; the phone's surface is hidden then.
val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (::surfaceView.isInitialized) surfaceView else null val source = if (glassesOutputActive) glassesScreen?.surfaceView else if (::surfaceView.isInitialized) surfaceView else null
val width = source?.width ?: 0 val width = source?.width ?: 0
val height = source?.height ?: 0 val height = source?.height ?: 0
@@ -3644,23 +3619,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (want) view.requestPointerCapture() else view.releasePointerCapture() if (want) view.requestPointerCapture() else view.releasePointerCapture()
} }
/** Null until the first application, so the initial state is applied rather than skipped. */
private var platformPointerShown: Boolean? = null private var platformPointerShown: Boolean? = null
/**
* The app's own UI is what the mouse is over, so it needs a pointer of its own: the drawer,
* keyboard, achievements and PiP controls, and — once the glasses own the picture — the whole
* phone, which then shows [GlassesPhonePanel] instead of the game.
*/
private fun appUiOwnsPointer(): Boolean = private fun appUiOwnsPointer(): Boolean =
drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive drawerOpen || pcKeyboardOpen || achievementsOpen || pipUi || glassesOutputActive
/**
* Over the game neither this app nor Android draws a pointer: the compositor's cursor is the
* only one (Compose's on the phone, inside the picture on the glasses). Over the app's own UI
* the game gets no pointer events at all, so without this nothing visible follows the mouse
* while buttons still highlight and click.
*/
private fun applyPlatformPointer(shown: Boolean) { private fun applyPlatformPointer(shown: Boolean) {
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) return
if (shown == platformPointerShown) return if (shown == platformPointerShown) return
@@ -3881,7 +3844,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0) if (down || code !in gestureKeysDown) WaylandCompositor.nativeSendKey(code, if (down) 1 else 0)
} }
// The frozen game cannot draw again, so the paused overlay shows a copy taken while the surface still holds its frame.
private fun capturePausedFrame(manual: Boolean = false) { private fun capturePausedFrame(manual: Boolean = false) {
if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
if (SessionState.suspended && pausedFrame != null) return if (SessionState.suspended && pausedFrame != null) return
@@ -3926,8 +3888,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
SessionService.pause(this) SessionService.pause(this)
} }
// Sessions that froze from picture-in-picture, or before onPause could copy a frame, have none; the
// compositor shows the frozen frame again once its output is back, so copy it from there.
private fun requestPausedFrame() { private fun requestPausedFrame() {
if (pausedFrame != null) return if (pausedFrame != null) return
pausedFrameWanted = true pausedFrameWanted = true
@@ -4164,8 +4124,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
Glasses.cancelSteamVrStart() Glasses.cancelSteamVrStart()
} }
if (glasses.present) { if (glasses.present) {
// Optional stereo starts only after RUNNING is observed. READY can
// arrive repeatedly while the launch worker is still starting VR.
if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING
runSteamVrAction(action, ::currentRequest) { runSteamVrAction(action, ::currentRequest) {
val ok = SteamVr.run(this, action) val ok = SteamVr.run(this, action)
@@ -4358,8 +4316,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
if (holder !== outputHolder()) return if (holder !== outputHolder()) return
glassesOutputActive = glassesScreen?.holder === holder glassesOutputActive = glassesScreen?.holder === holder
if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(glassesOutputActive) if (CompositorHost.isStarted) WaylandCompositor.nativeSetCursorComposite(glassesOutputActive)
// Keep the phone's layout for touch/controller coordinates, but remove
// its SurfaceView so it cannot retain the last game frame under the hint.
surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE surfaceView.visibility = if (glassesOutputActive) View.INVISIBLE else View.VISIBLE
Log.i(TAG, "compositor output attached: " + Log.i(TAG, "compositor output attached: " +
if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone") if (glassesOutputActive) "glasses display ${glassesScreen?.display?.displayId}" else "phone")
@@ -4387,7 +4343,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val display = com.droiddeck.launcher.session.SessionDisplay val display = com.droiddeck.launcher.session.SessionDisplay
val choice = SessionPrefs.resolutionChoice(this, mode, panel) val choice = SessionPrefs.resolutionChoice(this, mode, panel)
val size = display.resolveChoice(panel, choice) val size = display.resolveChoice(panel, choice)
val fixed = choice != display.MATCH_SCREEN && choice != display.FOLLOW_SCREEN && display.presetHeight(choice) == null val fixed = choice != display.MATCH_SCREEN && !display.isFollow(choice) && display.presetHeight(choice) == null
return if (fixed) display.resolve(panel, minOf(size.first, size.second), SessionPrefs.SHAPE_AUTO) else size return if (fixed) display.resolve(panel, minOf(size.first, size.second), SessionPrefs.SHAPE_AUTO) else size
} }
@@ -4438,8 +4394,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
} }
if (target != null) { if (target != null) {
val screen = GlassesScreen(this, target, Glasses.outputHz(), this, Glasses.outputResolution()) val screen = GlassesScreen(this, target, Glasses.outputHz(), this, Glasses.outputResolution())
// show() can create the surface synchronously. Select its holder
// first, otherwise surfaceCreated() rejects the glasses callback.
glassesScreen = screen glassesScreen = screen
applyOutputPacing() applyOutputPacing()
surfaceW = 0 surfaceW = 0
@@ -118,7 +118,6 @@ public class DirectAudioRelayComponent extends SessionPart {
} }
} }
// Android keeps the input stream's wake lock while a frozen helper still has it started.
public boolean awaitMicReleased(long timeoutMs) { public boolean awaitMicReleased(long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs; long deadline = System.currentTimeMillis() + timeoutMs;
synchronized (micLock) { synchronized (micLock) {
@@ -13,12 +13,6 @@ import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.io.FileInputStream 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( internal class AddonPack(
val name: String, private val sha256: String, val size: Long, private val required: List<String>, val name: String, private val sha256: String, val size: Long, private val required: List<String>,
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD, private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
@@ -43,13 +37,11 @@ internal class AddonPack(
) )
private fun root(context: Context) = File(context.filesDir, "addons/$name") 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 dir(context: Context) = File(root(context), version(context))
fun file(context: Context, relative: String) = File(dir(context), relative) fun file(context: Context, relative: String) = File(dir(context), relative)
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile 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 version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty() private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
@@ -66,7 +58,6 @@ internal class AddonPack(
.also { mutableState.value = it } .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 = {}) { fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
val app = context.applicationContext val app = context.applicationContext
synchronized(lock) { synchronized(lock) {
@@ -81,7 +72,6 @@ internal class AddonPack(
} }
} }
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
@JvmOverloads @JvmOverloads
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean { fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
if (installed(context)) return true if (installed(context)) return true
@@ -127,7 +117,6 @@ internal class AddonPack(
return true return true
} }
// A local build pins the packs it compiled, which no release hosts, so it ships them as assets.
internal fun bundled(context: Context, archive: File): Boolean { internal fun bundled(context: Context, archive: File): Boolean {
val copied = runCatching { val copied = runCatching {
bundle(context).use { input -> archive.outputStream().use { input.copyTo(it) } } bundle(context).use { input -> archive.outputStream().use { input.copyTo(it) } }
@@ -42,7 +42,6 @@ object DeviceReport {
} }
} }
/** Name of an ApplicationExitInfo reason code, so a report says "LOW_MEMORY" instead of "3". */
internal fun exitReasonName(reason: Int): String = when (reason) { internal fun exitReasonName(reason: Int): String = when (reason) {
0 -> "UNKNOWN" 0 -> "UNKNOWN"
1 -> "EXIT_SELF" 1 -> "EXIT_SELF"
@@ -126,11 +126,9 @@ object DownloadSource {
fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") } fun isGithub(url: String): Boolean = GITHUB_HOSTS.any { url.startsWith("https://$it/") }
/** Catalogs carry the digests everything else is checked against, so a mirror must never supply one. */
fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) } fun isCatalog(url: String): Boolean = CATALOGS.any { url.startsWith(it) }
fun candidates(target: String): List<String> { fun candidates(target: String): List<String> {
// GitHub API metadata carries executable URLs and digests; never proxy it.
if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target) if (isCatalog(target) || target.startsWith("https://api.github.com/")) return listOf(target)
val prefs = prefs() ?: return listOf(target) val prefs = prefs() ?: return listOf(target)
if (!isGithub(target)) return listOf(target) if (!isGithub(target)) return listOf(target)
@@ -184,7 +182,6 @@ object DownloadSource {
prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply() prefs(context)?.edit()?.putString(KEY_PROXY, normalizeProxy(value))?.apply()
} }
/** An HTTP proxy every Downloader connection uses, or null when none is configured. */
fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "") fun httpProxy(): Proxy? = proxyOf(prefs()?.getString(KEY_PROXY, "") ?: "")
internal fun proxyOf(value: String): Proxy? { internal fun proxyOf(value: String): Proxy? {
@@ -91,7 +91,6 @@ public final class Downloader {
if (isCancelled(cancelled)) return null; if (isCancelled(cancelled)) return null;
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i)); if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
Fetch fetch = fetch(sources.get(i), cancelled); Fetch fetch = fetch(sources.get(i), cancelled);
// A cancelled fetch says nothing about the source, so it must not count as a failure.
if (isCancelled(cancelled)) return null; if (isCancelled(cancelled)) return null;
if (fetch.body != null) { if (fetch.body != null) {
DownloadSource.INSTANCE.succeeded(sources.get(i)); DownloadSource.INSTANCE.succeeded(sources.get(i));
@@ -103,7 +102,6 @@ public final class Downloader {
return null; return null;
} }
/** Fetches without the mirror list, for a URL whose body is a digest the payload is checked against. */
public static String downloadDirect(String url) { public static String downloadDirect(String url) {
return fetch(url).body; return fetch(url).body;
} }
@@ -113,13 +111,11 @@ public final class Downloader {
return downloadFile(url, destination, resume, null, progress); return downloadFile(url, destination, resume, null, progress);
} }
/** Verify each candidate before accepting it, so a bad mirror can fall back. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.Consumer<Float> progress) { java.util.function.Consumer<Float> progress) {
return downloadFile(url, destination, resume, expectedSha256, null, progress); return downloadFile(url, destination, resume, expectedSha256, null, progress);
} }
/** {@code cancelled} is polled while bytes arrive; a cancel stops at once and leaves the partial file for the caller. */
public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256, public static boolean downloadFile(String url, File destination, boolean resume, String expectedSha256,
java.util.function.BooleanSupplier cancelled, java.util.function.BooleanSupplier cancelled,
java.util.function.Consumer<Float> progress) { java.util.function.Consumer<Float> progress) {
@@ -128,13 +124,11 @@ public final class Downloader {
return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, progress); return downloadCandidates(url, sources(url, expectedSha256), destination, resume, expectedSha256, cancelled, 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) { static List<String> sources(String url, String expectedSha256) {
return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url); return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url);
} }
// Package-private for the local multi-source HTTP test.
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume, static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
String expectedSha256, java.util.function.Consumer<Float> progress) { String expectedSha256, java.util.function.Consumer<Float> progress) {
return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress); return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress);
@@ -149,7 +143,6 @@ public final class Downloader {
if (isCancelled(cancelled)) return false; if (isCancelled(cancelled)) return false;
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source); if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress); Outcome outcome = attempt(source, destination, resume, identity, expectedSha256, cancelled, progress);
// A cancel is not a source failure: no other mirror, and no DownloadSource.failed().
if (outcome == Outcome.CANCELLED) return false; if (outcome == Outcome.CANCELLED) return false;
if (outcome == Outcome.DONE) { if (outcome == Outcome.DONE) {
if (expectedSha256 != null) { if (expectedSha256 != null) {
@@ -192,7 +185,6 @@ public final class Downloader {
return value != null && !value.isEmpty() && !value.startsWith("W/"); return value != null && !value.isEmpty() && !value.startsWith("W/");
} }
/** Only reuse saved bytes when we persisted a strong validator and their source. */
private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) { private static Identity loadIdentity(File dest, boolean resume, String expectedSha256) {
Identity identity = new Identity(); Identity identity = new Identity();
File marker = resumeFile(dest); File marker = resumeFile(dest);
@@ -213,7 +205,6 @@ public final class Downloader {
return identity; return identity;
} }
} catch (IOException e) { } catch (IOException e) {
// Legacy partials or interrupted metadata writes have no trustworthy identity.
} }
if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest); if (!discardPartial(dest, identity)) Log.w(TAG, "could not discard unidentified partial " + dest);
return identity; return identity;
@@ -239,7 +230,7 @@ public final class Downloader {
try (FileOutputStream out = new FileOutputStream(marker)) { try (FileOutputStream out = new FileOutputStream(marker)) {
p.store(out, "DroidDeck partial download validator"); p.store(out, "DroidDeck partial download validator");
} catch (IOException e) { } catch (IOException e) {
marker.delete(); // No valid metadata means no future resume. marker.delete();
Log.w(TAG, "cannot persist download validator", e); Log.w(TAG, "cannot persist download validator", e);
} }
} }
@@ -252,7 +243,6 @@ public final class Downloader {
long have = resume && destination.isFile() ? destination.length() : 0; long have = resume && destination.isFile() ? destination.length() : 0;
try { try {
if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) { if (have > 0 && (!url.equals(identity.source) || !strongETag(identity.etag))) {
// A new source or unvalidated bytes must restart from byte zero.
if (!discardPartial(destination, identity)) return Outcome.RETRY; if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0; have = 0;
} }
@@ -286,7 +276,6 @@ public final class Downloader {
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress); return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
} }
if (!appending && have > 0) { if (!appending && have > 0) {
// If-Range may legitimately return full 200 when representation changed.
if (!discardPartial(destination, identity)) return Outcome.RETRY; if (!discardPartial(destination, identity)) return Outcome.RETRY;
have = 0; have = 0;
} }
@@ -333,14 +322,12 @@ public final class Downloader {
} }
} }
/** True when a `Content-Range: bytes START-END/TOTAL` header starts exactly at {@code start}. */
static boolean rangeStartsAt(String contentRange, long start) { static boolean rangeStartsAt(String contentRange, long start) {
if (contentRange == null) return false; if (contentRange == null) return false;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange); java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+(\\d+)-\\d+/(?:\\d+|\\*)\\s*$").matcher(contentRange);
return m.matches() && Long.parseLong(m.group(1)) == start; return m.matches() && Long.parseLong(m.group(1)) == start;
} }
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
static long rangeTotal(String contentRange) { static long rangeTotal(String contentRange) {
if (contentRange == null) return -1; if (contentRange == null) return -1;
java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange); java.util.regex.Matcher m = java.util.regex.Pattern.compile("^\\s*bytes\\s+\\d+-\\d+/(\\d+)\\s*$").matcher(contentRange);
@@ -390,7 +377,6 @@ public final class Downloader {
} }
private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException { private static HttpURLConnection open(String url, int readTimeoutMs) throws java.io.IOException {
// An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
Proxy proxy = DownloadSource.INSTANCE.httpProxy(); Proxy proxy = DownloadSource.INSTANCE.httpProxy();
HttpURLConnection connection = (HttpURLConnection) (proxy != null HttpURLConnection connection = (HttpURLConnection) (proxy != null
? new URL(url).openConnection(proxy) : new URL(url).openConnection()); ? new URL(url).openConnection(proxy) : new URL(url).openConnection());
@@ -5,7 +5,6 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
object GlobalSettings { object GlobalSettings {
// Settings.Global caches values in-process and misses deletions made over adb; ask the provider directly.
fun read(context: Context, name: String): String? { fun read(context: Context, name: String): String? {
val uri = Uri.withAppendedPath(Settings.Global.CONTENT_URI, name) val uri = Uri.withAppendedPath(Settings.Global.CONTENT_URI, name)
context.contentResolver.query(uri, arrayOf(Settings.NameValueTable.VALUE), null, null, null)?.use { cursor -> context.contentResolver.query(uri, arrayOf(Settings.NameValueTable.VALUE), null, null, null)?.use { cursor ->
@@ -19,6 +18,5 @@ object GlobalSettings {
fun developerOptionsOn(context: Context): Boolean = isOn(context, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED) fun developerOptionsOn(context: Context): Boolean = isOn(context, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED)
// Settings.Global.ADB_WIFI_ENABLED is hidden; the key is stable since Android 11.
fun wirelessDebuggingOn(context: Context): Boolean = isOn(context, "adb_wifi_enabled") fun wirelessDebuggingOn(context: Context): Boolean = isOn(context, "adb_wifi_enabled")
} }
@@ -11,7 +11,6 @@ object LogRedactor {
size > 32 size > 32
} }
// Android matchers own native state, so reuse them instead of creating one for every log line.
private val matchers = object : ThreadLocal<MatcherCache>() { private val matchers = object : ThreadLocal<MatcherCache>() {
override fun initialValue(): MatcherCache = MatcherCache() override fun initialValue(): MatcherCache = MatcherCache()
} }
@@ -145,7 +144,6 @@ object LogRedactor {
val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } } val needs = name.lowercase().takeIf { name.all { it.code < 0x80 } }
Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs) Learned(Regex("(?i)(?<![A-Za-z0-9_])" + Regex.escape(name) + "(?![A-Za-z0-9_])"), needs)
} }
// A file that could not be read keeps the names learned from it before.
accounts = if (unread) (accounts + learned).distinctBy { it.regex.pattern } else learned accounts = if (unread) (accounts + learned).distinctBy { it.regex.pattern } else learned
} }
@@ -228,7 +226,6 @@ object LogRedactor {
return out return out
} }
/** 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 const val IN_MEMORY_SCRUB_MAX = 64L * 1024 * 1024
fun isBinary(file: java.io.File): Boolean = try { fun isBinary(file: java.io.File): Boolean = try {
@@ -241,8 +238,6 @@ object LogRedactor {
true true
} }
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
const val RULES_VERSION = 3 const val RULES_VERSION = 3
private val LAN_IPV4 = Regex( private val LAN_IPV4 = Regex(
@@ -52,8 +52,6 @@ object SessionLogCapture {
if (t.parentFile?.absolutePath == dir.absolutePath) stop() if (t.parentFile?.absolutePath == dir.absolutePath) stop()
} }
/** flush() only reaches the page cache, and a hard reset on f2fs lost the last minutes of every
* log. Every 2 s the session folder's files (ours and the native parts') are synced to storage. */
private fun startSyncing(dir: File): Thread = Thread({ private fun startSyncing(dir: File): Thread = Thread({
while (true) { while (true) {
try { Thread.sleep(2000) } catch (e: InterruptedException) { break } try { Thread.sleep(2000) } catch (e: InterruptedException) { break }
@@ -45,8 +45,6 @@ object SteamIntegration {
suggested = !SessionPrefs.sessionPrefs(context).getBoolean("steam_integration_seen", false), suggested = !SessionPrefs.sessionPrefs(context).getBoolean("steam_integration_seen", false),
) )
// Android 13+ blocks accessibility services for sideloaded APKs until the user allows restricted settings in App info.
// MODE_DEFAULT is inconclusive on Android 15, so only an explicit denial counts here.
private fun restrictedSettings(context: Context): Boolean { private fun restrictedSettings(context: Context): Boolean {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return false if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return false
val mode = runCatching { val mode = runCatching {
@@ -90,7 +88,6 @@ object SteamIntegration {
fun brightnessSettings(context: Context) = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS, fun brightnessSettings(context: Context) = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
Uri.parse("package:${context.packageName}")) Uri.parse("package:${context.packageName}"))
// ACCESSIBILITY_DETAILS_SETTINGS is restricted to system apps; the fragment args key highlights our entry in the list.
fun shortcutSettings(context: Context): Intent { fun shortcutSettings(context: Context): Intent {
val service = ComponentName(context, HomeButtonService::class.java).flattenToString() val service = ComponentName(context, HomeButtonService::class.java).flattenToString()
return Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) return Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
@@ -311,87 +311,3 @@ fun CollapsibleRail(
} }
} }
@Composable
fun RailTopTabs(
items: List<RailItem>,
modifier: Modifier = Modifier,
links: List<RailLink> = emptyList(),
) {
Column(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
items.forEach { item ->
TopTabCell(
icon = item.icon,
label = item.label,
selected = item.selected,
onClick = item.onClick,
modifier = Modifier.weight(1f),
)
}
if (links.isNotEmpty()) {
Box(
modifier = Modifier
.padding(horizontal = 2.dp)
.width(1.dp)
.height(30.dp)
.background(MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)),
)
links.forEach { link ->
TopTabCell(link.icon, link.label, selected = false, onClick = link.onClick, isTab = false, modifier = Modifier.weight(1f))
}
}
}
HorizontalDivider()
}
}
@Composable
private fun TopTabCell(
icon: ImageVector,
label: String,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
isTab: Boolean = true,
) {
val tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.clip(RoundedCornerShape(9.dp))
.background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) else Color.Transparent)
.focusGlide(RoundedCornerShape(9.dp))
.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))
Spacer(Modifier.height(3.dp))
Text(
label,
color = tint,
fontSize = 8.5.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.2.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
Box(
modifier = Modifier
.height(2.dp)
.width(if (selected) 16.dp else 0.dp)
.clip(RoundedCornerShape(3.dp))
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent),
)
}
}
@@ -57,7 +57,6 @@ object FileOps {
return true return true
} }
if (!copyWithProgress(src, dst, progress, isCancelled)) return false if (!copyWithProgress(src, dst, progress, isCancelled)) return false
// Once the destination is committed, finish deleting the source.
return delete(src) return delete(src)
} }
@@ -63,7 +63,6 @@ object StorageRoots {
} }
return true return true
} }
// vold-managed locations only hold removable volumes, whatever the fstype is.
return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/") return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/")
} }
@@ -92,8 +91,6 @@ object StorageRoots {
.forEachIndexed { index, volume -> .forEachIndexed { index, volume ->
val uuid = volume.uuid?.takeIf { it.isNotBlank() } val uuid = volume.uuid?.takeIf { it.isNotBlank() }
val dirName = volume.directory?.name?.takeIf { it.isNotBlank() } val dirName = volume.directory?.name?.takeIf { it.isNotBlank() }
// Unmounted volumes often report neither uuid nor directory; never drop the
// volume or a plugged-in drive stays invisible with no explanation.
val descSlug = volume.getDescription(context)?.trim() val descSlug = volume.getDescription(context)?.trim()
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) } ?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
?.replace(Regex("[^A-Za-z0-9._-]+"), "-") ?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
@@ -108,8 +105,6 @@ object StorageRoots {
if (name != null) { if (name != null) {
entry.candidates += File("/storage/$name") entry.candidates += File("/storage/$name")
entry.candidates += File("/mnt/media_rw/$name") entry.candidates += File("/mnt/media_rw/$name")
// Only strict uuid matches own an app directory; a loose match would
// attach another volume's folder to this entry.
if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) } if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) }
.forEach { entry.seeds += it } .forEach { entry.seeds += it }
} }
@@ -122,8 +117,6 @@ object StorageRoots {
File("/mnt/media_rw").listFiles().orEmpty().forEach { child -> File("/mnt/media_rw").listFiles().orEmpty().forEach { child ->
if (child.isDirectory) volumeOf(child.name).candidates += child if (child.isDirectory) volumeOf(child.name).candidates += child
} }
// USB OTG drives do not always appear as storage volumes or under /storage for
// third-party apps, but their kernel mounts are still visible in /proc/mounts.
for (mount in externalMounts()) { for (mount in externalMounts()) {
val entry = volumeOf(mount.name) val entry = volumeOf(mount.name)
if (entry.label == null) entry.label = mount.name if (entry.label == null) entry.label = mount.name
@@ -6,6 +6,7 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import org.json.JSONObject import org.json.JSONObject
import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamLibraries
import com.droiddeck.launcher.stores.Store import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar import com.droiddeck.launcher.stores.StoreGameSidecar
import com.droiddeck.launcher.stores.StoreInstallRoot import com.droiddeck.launcher.stores.StoreInstallRoot
@@ -69,6 +70,10 @@ object AddedGames {
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/") if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
} }
StoreInstallRoot.externalGuestPath(context, host)?.let { return it } StoreInstallRoot.externalGuestPath(context, host)?.let { return it }
for (lib in SteamLibraries.list(context)) {
if (path == lib.path) return lib.guest
if (path.startsWith("${lib.path}/")) return lib.guest + "/" + path.removePrefix("${lib.path}/")
}
GameStorage.effective(context)?.let { lib -> GameStorage.effective(context)?.let { lib ->
if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/") if (path.startsWith("${lib.path}/")) return "$LIBRARY/" + path.removePrefix("${lib.path}/")
} }
@@ -83,6 +88,7 @@ object AddedGames {
if (!guest.startsWith("/") || guest.split('/').any { it == ".." }) return null if (!guest.startsWith("/") || guest.split('/').any { it == ".." }) return null
val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) + val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) +
listOfNotNull(GameStorage.effective(context)?.let { File(it.path) to LIBRARY }) + listOfNotNull(GameStorage.effective(context)?.let { File(it.path) to LIBRARY }) +
SteamLibraries.list(context).map { it.host to it.guest } +
(android.os.Environment.getExternalStorageDirectory() to "/root/Storage") (android.os.Environment.getExternalStorageDirectory() to "/root/Storage")
for ((host, root) in mapped.sortedByDescending { it.second.length }) { for ((host, root) in mapped.sortedByDescending { it.second.length }) {
if (guest == root) return host if (guest == root) return host
@@ -180,7 +186,6 @@ object AddedGames {
private fun entryFolders(context: Context): Set<String> = AddedExes.list(context).map { it.folder }.toSet() private fun entryFolders(context: Context): Set<String> = AddedExes.list(context).map { it.folder }.toSet()
/** The card's Steam libraries: the arm64 client's steamapps and the x86-64 client's own folder beside it. */
internal fun isSteamLibraryFolder(name: String): Boolean = internal fun isSteamLibraryFolder(name: String): Boolean =
name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR name.equals("steamapps", ignoreCase = true) || name == com.droiddeck.launcher.session.GameStorage.X64_CARD_DIR
@@ -315,7 +320,6 @@ object AddedGames {
fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult { fun addFolder(context: Context, dir: File, onGame: (Game) -> Unit = {}, onChecking: (String?) -> Unit = {}, showHidden: Boolean = false): FolderResult {
val app = context.applicationContext 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) if (guestPath(app, dir) == null) bindFolder(app, dir)
val removed = SessionPrefs.removedAddedGames(app).toSet() val removed = SessionPrefs.removedAddedGames(app).toSet()
val listed = listedFolders(app, removed) val listed = listedFolders(app, removed)
@@ -54,21 +54,6 @@ object DependencyDetector {
"d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist", "d3dx9", "d3dx11", "xact", "xact_x64", "xna31", "xna40", "physx", "oalinst", "XLiveRedist",
) )
fun detectForExe(exeFile: File): List<Recommendation> {
val start = exeFile.absoluteFile.parentFile ?: return emptyList()
var d: File? = start
var depth = 0
while (d != null && depth < 4) {
val hasRedistContainer = try {
d.listFiles()?.any { it.isDirectory && REDIST_DIR_RE.matches(it.name.lowercase(Locale.US)) } == true
} catch (_: Throwable) { false }
if (hasRedistContainer) return detect(d)
d = d.parentFile
depth++
}
return detect(start)
}
fun detect(gameDir: File): List<Recommendation> { fun detect(gameDir: File): List<Recommendation> {
if (!gameDir.isDirectory) return emptyList() if (!gameDir.isDirectory) return emptyList()
val hits = LinkedHashMap<String, Recommendation>() val hits = LinkedHashMap<String, Recommendation>()
@@ -5,6 +5,7 @@ import com.droiddeck.launcher.R
import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.GameStorage import com.droiddeck.launcher.session.GameStorage
import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionPrefs
import com.droiddeck.launcher.session.SteamLibraries
import com.droiddeck.launcher.session.SecondaryLibrary import com.droiddeck.launcher.session.SecondaryLibrary
import java.io.File import java.io.File
@@ -87,13 +88,14 @@ object Library {
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> { internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
val root = SessionPrefs.steamRoot(context, arch) val root = SessionPrefs.steamRoot(context, arch)
// The session gives the x86-64 client only its card folder; the games folders stay the arm64 client's.
val card = GameStorage.effective(context) val card = GameStorage.effective(context)
if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label }) val added = SteamLibraries.list(context).filter { it.host.isDirectory }
if (arch == "x86_64") return listOfNotNull(root to "internal", card?.let { File(it.path, GameStorage.X64_CARD_DIR) to it.label }) +
added.map { it.x64Host to it.label }
return listOfNotNull( return listOfNotNull(
root to "internal", root to "internal",
card?.let { File(it.path) to it.label }, card?.let { File(it.path) to it.label },
) + GameStorage.gamesFolderLibraries(context).map { it.host to it.label } ) + added.map { it.host to it.label } + GameStorage.gamesFolderLibraries(context).map { it.host to it.label }
} }
fun protonPrefix( fun protonPrefix(
@@ -106,7 +108,6 @@ object Library {
.firstOrNull { it.isDirectory } .firstOrNull { it.isDirectory }
} }
/** Every steamapps/compatdata folder, in the libraries [protonPrefix] searches. */
fun compatdataDirs(context: Context, libraries: List<File> = steamLibraries(context).map { it.first }): List<File> = fun compatdataDirs(context: Context, libraries: List<File> = steamLibraries(context).map { it.first }): List<File> =
prefixRoots(context, libraries).map { File(it, "steamapps/compatdata") }.filter { it.isDirectory } prefixRoots(context, libraries).map { File(it, "steamapps/compatdata") }.filter { it.isDirectory }
@@ -179,7 +180,6 @@ object Library {
} }
} }
/** Apps the user hid (the "hidden" collection) or marked private in Steam. */
internal fun concealedSteamApps(root: File): Set<Long> { internal fun concealedSteamApps(root: File): Set<Long> {
val config = AddedGames.accountConfig(root) ?: return emptySet() val config = AddedGames.accountConfig(root) ?: return emptySet()
val hidden = runCatching { val hidden = runCatching {
@@ -220,7 +220,6 @@ object Library {
return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId } return (steam + own.map { g -> addedGame(context, g, roots, played[g.appId] ?: 0L) }).distinctBy { it.gameId }
} }
/** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching { internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
val config = AddedGames.accountConfig(root) ?: return emptyMap() val config = AddedGames.accountConfig(root) ?: return emptyMap()
val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap() val bytes = File(config, "shortcuts.vdf").takeIf { it.isFile }?.readBytes() ?: return emptyMap()
@@ -10,7 +10,6 @@ object LibraryCache {
private const val TAG = "LibraryCache" private const val TAG = "LibraryCache"
private fun file(context: Context) = File(context.filesDir, "library-cache.json") private fun file(context: Context) = File(context.filesDir, "library-cache.json")
/** The cached games and whether they are the x86-64 client's; a cache written as a bare list is arm64's. */
@Synchronized @Synchronized
fun snapshot(context: Context): Pair<List<Library.SteamGame>, Boolean> = runCatching { fun snapshot(context: Context): Pair<List<Library.SteamGame>, Boolean> = runCatching {
val f = file(context) val f = file(context)
@@ -36,7 +35,6 @@ object LibraryCache {
fun listedX64(context: Context): Boolean = snapshot(context).second fun listedX64(context: Context): Boolean = snapshot(context).second
/** Without [x64] the list keeps the client already recorded. */
@Synchronized @Synchronized
fun save(context: Context, games: List<Library.SteamGame>, x64: Boolean? = null) { fun save(context: Context, games: List<Library.SteamGame>, x64: Boolean? = null) {
runCatching { runCatching {
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.frontend
import org.json.JSONObject import org.json.JSONObject
/** Where Steam pins a game's logo on its hero image and how big it draws it; the same JSON Steam keeps in `<appid>.json`. */
object LogoPosition { object LogoPosition {
enum class Pin(val steam: String) { enum class Pin(val steam: String) {
UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter"); UPPER_LEFT("UpperLeft"), UPPER_CENTER("UpperCenter"), CENTER("CenterCenter"), BOTTOM_LEFT("BottomLeft"), BOTTOM_CENTER("BottomCenter");
@@ -17,7 +16,6 @@ object LogoPosition {
const val MIN_PCT = 10 const val MIN_PCT = 10
const val MAX_PCT = 100 const val MAX_PCT = 100
/** What Steam draws when a game has no custom position. */
val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50) val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50)
private const val VERSION = 1 private const val VERSION = 1
@@ -25,7 +23,6 @@ object LogoPosition {
fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) } fun sized(pin: Pin, size: Int): Value = size.coerceIn(MIN_PCT, MAX_PCT).let { Value(pin, it, it) }
/** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */
fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into
.put("nVersion", VERSION) .put("nVersion", VERSION)
.put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct)) .put(KEY, JSONObject().put("pinnedPosition", value.pin.steam).put("nWidthPct", value.widthPct).put("nHeightPct", value.heightPct))
@@ -39,7 +36,6 @@ object LogoPosition {
return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT)) return Value(pin, inner.optInt("nWidthPct").coerceIn(MIN_PCT, MAX_PCT), inner.optInt("nHeightPct").coerceIn(MIN_PCT, MAX_PCT))
} }
/** [existing] without the logo position; null when nothing but the version number would be left. */
fun without(existing: JSONObject): JSONObject? { fun without(existing: JSONObject): JSONObject? {
existing.remove(KEY) existing.remove(KEY)
val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList() val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList()
@@ -7,11 +7,9 @@ import org.json.JSONObject
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
/** Steam keeps a game's custom art in `userdata/<account>/config/grid`; this is where DroidDeck's choices and Steam's meet. */
object SteamGrid { object SteamGrid {
private const val TAG = "SteamGrid" private const val TAG = "SteamGrid"
/** Every change to a game's art, here or in a scan, takes this first so two of them never interleave. */
internal val lock = Any() internal val lock = Any()
private const val MANIFEST = ".droiddeck-art.json" private const val MANIFEST = ".droiddeck-art.json"
private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo") private val SUFFIX = mapOf(AddedGameArt.Slot.COVER to "p", AddedGameArt.Slot.WIDE to "", AddedGameArt.Slot.BACKGROUND to "_hero", AddedGameArt.Slot.LOGO to "_logo")
@@ -36,7 +34,6 @@ object SteamGrid {
private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}" private fun signature(file: File?) = if (file == null) "" else "${file.length()}:${file.lastModified()}"
/** Each Steam client has its own grid folder, so what was last seen there is kept per client. */
private fun sigName(context: Context, key: String) = private fun sigName(context: Context, key: String) =
if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key" if (SessionPrefs.activeSteamClientArch(context) == "x86_64") "grid-x86_64-$key" else "grid-$key"
@@ -45,10 +42,6 @@ object SteamGrid {
private const val GRID_ID = ".droiddeck-grid" private const val GRID_ID = ".droiddeck-grid"
private const val UNKNOWN_GRID = "?" private const val UNKNOWN_GRID = "?"
/**
* A random id kept in the grid folder, so a record made against another account's folder, or one wiped with the
* runtime, reads as "not seen yet" instead of "removed in Steam". "?" never matches, so nothing is dropped.
*/
private fun gridId(grid: File): String = runCatching { private fun gridId(grid: File): String = runCatching {
val f = File(grid, GRID_ID) val f = File(grid, GRID_ID)
f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id -> f.takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString().also { id ->
@@ -83,7 +76,6 @@ object SteamGrid {
manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet() manifest.keys().asSequence().filter { name -> File(grid, name).let { it.isFile && it.length() == manifest.optLong(name, -1L) } }.toSet()
}.getOrDefault(emptySet()) }.getOrDefault(emptySet())
/** Puts a choice (or, with null, the lack of one) where Steam reads it, whether or not Steam is running. */
fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) } fun write(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean = synchronized(lock) { place(context, target, slot, file) }
private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean { private fun place(context: Context, target: Target, slot: AddedGameArt.Slot, file: File?): Boolean {
@@ -116,7 +108,6 @@ object SteamGrid {
private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse) private fun positionIn(file: File): LogoPosition.Value? = file.takeIf { it.isFile }?.let { runCatching { it.readText() }.getOrNull() }?.let(LogoPosition::parse)
/** The logo position chosen here (or taken over from Steam) for a game; null when Steam's own default applies. */
fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION)) fun position(target: Target): LogoPosition.Value? = positionIn(File(target.artDir, POSITION))
private fun positionRecorded(context: Context, grid: File, target: Target): String? = private fun positionRecorded(context: Context, grid: File, target: Target): String? =
@@ -132,12 +123,10 @@ object SteamGrid {
return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess return runCatching { file.writeText(LogoPosition.toJson(value).toString()) }.isSuccess
} }
/** Sets (or with null, clears) a game's logo position here and in the client's grid folder, whether or not Steam is running. */
fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) { fun writePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean = synchronized(lock) {
placePosition(context, target, value) && storePosition(target, value) placePosition(context, target, value) && storePosition(target, value)
} }
/** Steam keeps the position in `<id>.json`; anything else that file holds is left as it was. */
private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean { private fun placePosition(context: Context, target: Target, value: LogoPosition.Value?): Boolean {
val grid = dir(context) ?: return true val grid = dir(context) ?: return true
val file = positionFile(grid, target.id) val file = positionFile(grid, target.id)
@@ -160,7 +149,6 @@ object SteamGrid {
return true return true
} }
/** A `logoPosition` this version cannot read (a pin it does not know, say) is left alone rather than read as "removed". */
private fun hasUnknownPosition(file: File): Boolean { private fun hasUnknownPosition(file: File): Boolean {
val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false
return json.has("logoPosition") && LogoPosition.parse(json) == null return json.has("logoPosition") && LogoPosition.parse(json) == null
@@ -176,7 +164,6 @@ object SteamGrid {
when { when {
before == null && own != null && theirs == null -> placePosition(context, target, own) before == null && own != null && theirs == null -> placePosition(context, target, own)
before == null || now != before -> { before == null || now != before -> {
// Recorded only once the local side agrees, so a failed take-over is tried again on the next scan.
if (theirs != null) { if (theirs != null) {
if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now) if (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now)
} else { } else {
@@ -187,7 +174,6 @@ object SteamGrid {
} }
} }
/** Takes over what changed in Steam since the last look; returns true when a choice was added, replaced or dropped. */
fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) } fun sync(context: Context, targets: List<Target>): Boolean = synchronized(lock) { reconcile(context, targets) }
private fun reconcile(context: Context, targets: List<Target>): Boolean { private fun reconcile(context: Context, targets: List<Target>): Boolean {
@@ -15,7 +15,6 @@ object SteamRedists {
"228989" to ("vcredist2022_dll" to "VC++ 2022"), "228989" to ("vcredist2022_dll" to "VC++ 2022"),
"228990" to ("d3dx9" to "DirectX (June 2010)"), "228990" to ("d3dx9" to "DirectX (June 2010)"),
) )
/** Redists a game's own install script runs on Windows that its shared depots don't name. */
val SCRIPTED = mapOf( val SCRIPTED = mapOf(
72850 to listOf("xact" to "DirectX XACT audio"), 72850 to listOf("xact" to "DirectX XACT audio"),
489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"), 489830 to listOf("xact_x64" to "DirectX XACT audio (64-bit)"),
@@ -28,7 +27,6 @@ object SteamRedists {
return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile } return File(steamapps, "appmanifest_$appId.acf").takeIf { it.isFile }
} }
/** Redists the game's own install script has Steam run on Windows, from its "Run Process" entries. */
fun installScript(gameDir: File?): List<Pair<String, String>> = fun installScript(gameDir: File?): List<Pair<String, String>> =
gameDir?.listFiles { f -> gameDir?.listFiles { f ->
f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") } f.isFile && f.length() < 1L shl 20 && f.name.lowercase().let { it.startsWith("installscript") && it.endsWith(".vdf") }
@@ -51,10 +51,8 @@ object WinCompSources {
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path) fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
private val XBOX_KNOWN = Regex("""<KnownDependency\s+Name="([A-Za-z0-9_.-]{1,40})"""") private val XBOX_KNOWN = Regex("""<KnownDependency\s+Name="([A-Za-z0-9_.-]{1,40})"""")
// VC14 also registers the ARM64 runtime: launchers running as ARM64EC look for it and stop when it is missing.
private val XBOX_COMPONENTS = mapOf("VC14" to listOf("vcredist2022", WinComponents.VCREDIST_ARM64)) private val XBOX_COMPONENTS = mapOf("VC14" to listOf("vcredist2022", WinComponents.VCREDIST_ARM64))
/** Microsoft installs a PC Game Pass game's declared runtimes with it; VC14 is wanted by nearly all and is always added. */
fun xbox(folder: File?): List<Pair<String, String>> { fun xbox(folder: File?): List<Pair<String, String>> {
val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 } val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 }
val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty() val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty()
@@ -563,7 +563,6 @@ object Glasses {
val device = selected?.first val device = selected?.first
val m = selected?.second val m = selected?.second
val controllable = m?.let(::canOpenModel) == true val controllable = m?.let(::canOpenModel) == true
// XREAL uses Android's Ethernet sockets, without opening or claiming USB interfaces.
val permitted = controllable && device != null && (m === XrealModel || usb.hasPermission(device)) val permitted = controllable && device != null && (m === XrealModel || usb.hasPermission(device))
val identity = device?.let { val identity = device?.let {
GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null) GlassesModeState.Device(it.productId, if (permitted) runCatching { it.serialNumber }.getOrNull() else null)
@@ -677,8 +676,6 @@ object Glasses {
internal fun selectGlassesDisplay(displays: List<Display>, usbPresent: Boolean): Display? { internal fun selectGlassesDisplay(displays: List<Display>, usbPresent: Boolean): Display? {
val candidates = displays val candidates = displays
.filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 } .filter { it.displayId != Display.DEFAULT_DISPLAY && it.flags and Display.FLAG_PRESENTATION != 0 }
// Android can expose the glasses name on either the logical display or
// its product metadata. A generic label on one must not hide the other.
return candidates.firstOrNull { display -> return candidates.firstOrNull { display ->
isGlassesDisplayName(display.name) || isGlassesDisplayName(display.name) ||
(Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product -> (Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product ->
@@ -728,7 +725,6 @@ object Glasses {
if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) { if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) {
VitureV2Link.open(usb, device, v2Listener(generation)) VitureV2Link.open(usb, device, v2Listener(generation))
} else if ((model as? VitureProtocol.Model)?.needsSdk == true) { } 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)) 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), VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation),
(model as? VitureProtocol.Model)?.family, spatial) (model as? VitureProtocol.Model)?.family, spatial)
@@ -854,7 +850,6 @@ object Glasses {
if (generation != linkGeneration) return@post if (generation != linkGeneration) return@post
closeLink() closeLink()
publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) } publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) }
// Let a reconnect or explicit Allow retry restart the SDK, not an endless failure loop.
} }
} }
@@ -974,7 +969,6 @@ object Glasses {
} }
if (!s.usable || s.sbs || modeState.pendingStereo) return if (!s.usable || s.sbs || modeState.pendingStereo) return
val hz = _settings.value.refreshHz val hz = _settings.value.refreshHz
// Native 30 Hz is selected by the Android presentation, without a vendor mode command.
if (hz == 30 && GlassesRendering.native30HzAvailable(_settings.value, s)) return if (hz == 30 && GlassesRendering.native30HzAvailable(_settings.value, s)) return
if (glassesMode == m.mode(hz, false, outputResolution())) return if (glassesMode == m.mode(hz, false, outputResolution())) return
if (!force) { if (!force) {
@@ -1,6 +1,5 @@
package com.droiddeck.launcher.glasses package com.droiddeck.launcher.glasses
/** Hardware properties; display refresh is selected from Android's advertised modes. */
interface GlassesModel { interface GlassesModel {
val name: String val name: String
val controllable: Boolean val controllable: Boolean
@@ -18,9 +17,6 @@ interface GlassesModel {
fun modeForStep(step: Int): Int? fun modeForStep(step: Int): Int?
} }
/** The command IDs here are the existing glasses controller's internal vocabulary.
* Each transport translates them to its own wire protocol; unsupported controls return false.
*/
internal interface GlassesLink { internal interface GlassesLink {
val hasImu: Boolean val hasImu: Boolean
val badFrames: Long get() = 0L val badFrames: Long get() = 0L
@@ -63,7 +63,6 @@ class GlassesMenuSurface(
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
close() close()
super.onDetachedFromWindow() super.onDetachedFromWindow()
// GLSurfaceView may exit its GL thread before queued cleanup is consumed.
releaseInput() releaseInput()
} }
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.glasses
import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.Cardboard
/** Share the actual picture mode with settings so controls match the rendered view. */
internal object GlassesRendering { internal object GlassesRendering {
enum class Picture { FLAT, DUPLICATED, VIRTUAL_SCREEN, STEREO } enum class Picture { FLAT, DUPLICATED, VIRTUAL_SCREEN, STEREO }
@@ -51,11 +50,7 @@ internal object GlassesRendering {
outputActive: Boolean, outputActive: Boolean,
steamVrRunning: Boolean, steamVrRunning: Boolean,
): Cardboard { ): Cardboard {
// Mirror output still uses the glasses' picture and sensors during VR.
if (!usesGlasses(state, outputActive, steamVrRunning)) { if (!usesGlasses(state, outputActive, steamVrRunning)) {
// SteamVR uses the renderer's enabled flag as headset proximity.
// A flat phone preview still needs an active HMD and one SBS eye;
// otherwise SteamVR enters standby and leaves a frozen frame.
return if (steamVrRunning && !base.enabled) base.copy( return if (steamVrRunning && !base.enabled) base.copy(
enabled = true, enabled = true,
source = Cardboard.SOURCE_SBS, source = Cardboard.SOURCE_SBS,
@@ -36,7 +36,6 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private
fun prefer(hz: Int, resolution: GlassesResolution? = null) { fun prefer(hz: Int, resolution: GlassesResolution? = null) {
wantHz = hz wantHz = hz
wantResolution = resolution wantResolution = resolution
// A 2D/SBS transition can replace the active mode without changing Hz.
applyMode() applyMode()
} }
@@ -51,7 +51,6 @@ internal class HandImageBuffer {
pixels.clear() pixels.clear()
pixels.put(rgb) pixels.put(rgb)
pixels.rewind() pixels.rewind()
// MediaPipe applies the model metadata's float normalization to these RGB bytes.
return ByteBufferImageBuilder(pixels, width, height, MPImage.IMAGE_FORMAT_RGB).build() return ByteBufferImageBuilder(pixels, width, height, MPImage.IMAGE_FORMAT_RGB).build()
} }
} }
@@ -150,7 +150,6 @@ class LumaRgbCaptureService : Service() {
intent?.getIntExtra("durationMs",5000) ?: 5000) } intent?.getIntExtra("durationMs",5000) ?: 5000) }
finally { finally {
stopSelf() stopSelf()
// SDK 2.4.0 can crash while cancelling failed USB transfers; isolate each diagnostic.
android.os.Process.killProcess(android.os.Process.myPid()) android.os.Process.killProcess(android.os.Process.myPid())
} }
}, "luma-rgb-diagnostic").start() }, "luma-rgb-diagnostic").start()
@@ -40,12 +40,9 @@ internal data class RayNeoCalibration(val gyroBiasDps: Vec3, val level: Quaterni
internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) { internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) {
companion object { companion object {
// GT and GT Max can share USB identity fields. Keep the non-Max mounting
// correction separate from existing Max profiles and their legacy keys.
fun profileKey(base: String, gtUsb: Boolean, boardId: Int): String = fun profileKey(base: String, gtUsb: Boolean, boardId: Int): String =
if (gtUsb && boardId == RayNeoProtocol.BOARD_GT) "$base:gt-standard-v1" else base if (gtUsb && boardId == RayNeoProtocol.BOARD_GT) "$base:gt-standard-v1" else base
} }
// A serial isolates physical units. Devices without one share their model profile.
fun key(vid: Int, pid: Int, serial: String?, modelName: String = ""): String = fun key(vid: Int, pid: Int, serial: String?, modelName: String = ""): String =
"rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}" "rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}"
fun load(key: String): RayNeoCalibration? = RayNeoCalibration.decode(prefs.getString(key, null)) fun load(key: String): RayNeoCalibration? = RayNeoCalibration.decode(prefs.getString(key, null))
@@ -55,7 +52,6 @@ internal class RayNeoCalibrationStore(private val prefs: SharedPreferences) {
} }
} }
/** One upside-down stationary pose estimates gyro bias and tilt, never yaw or accelerometer scale. */
internal class RayNeoCalibrationCollector(private val axes: RayNeoSensorAxes, private val beganMs: Long) { internal class RayNeoCalibrationCollector(private val axes: RayNeoSensorAxes, private val beganMs: Long) {
var phase = CalibrationPhase.SETTLING; private set var phase = CalibrationPhase.SETTLING; private set
var progress = 0; private set var progress = 0; private set
@@ -6,7 +6,6 @@ import android.hardware.usb.UsbManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
/** RayNeo command/response and IMU share one endpoint. Keep reads on a single worker. */
internal class RayNeoLink private constructor( internal class RayNeoLink private constructor(
private val connection: UsbDeviceConnection, private val connection: UsbDeviceConnection,
private val endpoints: RayNeoEndpointSelection, private val endpoints: RayNeoEndpointSelection,
@@ -52,7 +51,6 @@ internal class RayNeoLink private constructor(
ready && (mode == RayNeoProtocol.CMD_SWITCH_TO_3D || mode == RayNeoProtocol.CMD_SWITCH_TO_2D) && ready && (mode == RayNeoProtocol.CMD_SWITCH_TO_3D || mode == RayNeoProtocol.CMD_SWITCH_TO_2D) &&
command(mode) command(mode)
} }
// No VITURE brightness, volume, film, rate or raw command is sent to RayNeo.
else -> false else -> false
} }
} }
@@ -159,7 +157,6 @@ internal class RayNeoLink private constructor(
} }
val wxyz = filter.update(packet.sample) val wxyz = filter.update(packet.sample)
if (wantImu && collector == null && lastSensor >= warmupUntil) { if (wantImu && collector == null && lastSensor >= warmupUntil) {
// Normalize basis and component order at the hardware boundary.
listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz), listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz),
SystemClock.elapsedRealtimeNanos()) SystemClock.elapsedRealtimeNanos())
} }
@@ -176,7 +173,6 @@ internal class RayNeoLink private constructor(
override fun close() { override fun close() {
synchronized(writeLock) { synchronized(writeLock) {
if (!running) return if (!running) return
// Best effort: stop the sensor before releasing the claimed interface.
if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) } if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) }
running = false running = false
} }
@@ -8,8 +8,6 @@ object RayNeoModel {
fun model(vid: Int, pid: Int, name: String?): GlassesModel? { fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (!isVendor(vid)) return null if (!isVendor(vid)) return null
// GT's identity is confirmed by verncat's SDK; do not claim other products
// from that vendor just because their name resembles a supported model.
val gt = RayNeoProtocol.isGt(vid, pid) val gt = RayNeoProtocol.isGt(vid, pid)
if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null
if (!(vid == RayNeoProtocol.VENDOR_ID && pid == RayNeoProtocol.PRODUCT_ID) && if (!(vid == RayNeoProtocol.VENDOR_ID && pid == RayNeoProtocol.PRODUCT_ID) &&
@@ -23,8 +21,6 @@ object RayNeoModel {
name.contains("rayneo", ignoreCase = true) || name.contains("ray neo", ignoreCase = true) || name.contains("rayneo", ignoreCase = true) || name.contains("ray neo", ignoreCase = true) ||
modelName.containsMatchIn(name) modelName.containsMatchIn(name)
// Observed on GT Max: Android exposes an unbranded EDID monitor name.
// Match its product metadata, not every display called SmartGlasses/TCL.
fun isDisplayProduct(name: String?, manufacturer: String?, productId: String?): Boolean = fun isDisplayProduct(name: String?, manufacturer: String?, productId: String?): Boolean =
name?.trim().equals("SmartGlasses", ignoreCase = true) && name?.trim().equals("SmartGlasses", ignoreCase = true) &&
manufacturer.equals("TCL", ignoreCase = true) && productId == "980" manufacturer.equals("TCL", ignoreCase = true) && productId == "980"
@@ -33,7 +29,7 @@ object RayNeoModel {
override val controllable = true override val controllable = true
override val brightnessMax = 0 override val brightnessMax = 0
override val volumeMax = 0 override val volumeMax = 0
override val diagonalFovDeg = 46f // Conservative existing default, not a calibrated model profile. override val diagonalFovDeg = 46f
override val sbs90 = false override val sbs90 = false
override fun mode(hz: Int, sbs: Boolean) = override fun mode(hz: Int, sbs: Boolean) =
if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D
@@ -1,5 +1,4 @@
// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0). // Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0); see docs/third-party/rayneo/ for license and attribution.
// See docs/third-party/rayneo/. Returns scaffold quaternions in (w, x, y, z) order.
package com.droiddeck.launcher.glasses package com.droiddeck.launcher.glasses
import kotlin.math.cos import kotlin.math.cos
@@ -123,10 +122,6 @@ internal class RayNeoOrientationFilter(
private var lastRealtimeNs: Long = 0L private var lastRealtimeNs: Long = 0L
companion object { companion object {
/** Scaffold: right +X, up +Y, back +Z. VITURE: right -Y, up +Z, back -X.
* Conjugate by HeadPose.GLASSES_AXES before entering the unchanged VITURE pose path.
* This is a basis conversion, not a projection or seated-origin adjustment.
*/
fun glassesPose(wxyz: FloatArray): FloatArray = fun glassesPose(wxyz: FloatArray): FloatArray =
floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0]) floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0])
} }
@@ -168,8 +163,6 @@ internal class RayNeoOrientationFilter(
if (accelNorm > 1e-3f) { if (accelNorm > 1e-3f) {
val measuredUp = accel.normalized() val measuredUp = accel.normalized()
if (!gravityInitialized) { if (!gravityInitialized) {
// Align gravity before publishing the first pose. Starting at
// identity and settling after recenter can rotate the horizon.
val axis = measuredUp.cross(worldUp) val axis = measuredUp.cross(worldUp)
q = if (measuredUp.y < -0.9999f) { q = if (measuredUp.y < -0.9999f) {
Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat()) Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
@@ -179,7 +172,6 @@ internal class RayNeoOrientationFilter(
gravityInitialized = true gravityInitialized = true
} }
val predictedUp = q.conjugate().rotate(worldUp).normalized() val predictedUp = q.conjugate().rotate(worldUp).normalized()
// q maps body to world: measured x predicted reduces gravity error.
val correction = measuredUp.cross(predictedUp) val correction = measuredUp.cross(predictedUp)
gyro += correction * accelGain gyro += correction * accelGain
} }
@@ -40,14 +40,13 @@ data class RayNeoEndpointSelection(
object RayNeoProtocol { object RayNeoProtocol {
const val VENDOR_ID = 0x1BBB const val VENDOR_ID = 0x1BBB
const val PRODUCT_ID = 0xAF50 const val PRODUCT_ID = 0xAF50
// verncat/RayNeo-Air-3S-Pro-OpenVR PR #3 (MIT), tested on GT hardware.
const val GT_VENDOR_ID = 0x3941 const val GT_VENDOR_ID = 0x3941
private const val GT_CONTROL_INTERFACE = 5 private const val GT_CONTROL_INTERFACE = 5
fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID
const val BOARD_AIR_4_PRO = 0x3A const val BOARD_AIR_4_PRO = 0x3A
const val BOARD_GT = 0x40 // Observed on the user's non-Max GT. const val BOARD_GT = 0x40
const val PKT_MAGIC = 0x99 const val PKT_MAGIC = 0x99
const val PKT_SENSOR = 0x65 const val PKT_SENSOR = 0x65
@@ -131,7 +130,6 @@ object RayNeoProtocol {
endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) { endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
continue continue
} }
// Exclude small button reports from GT's protocol transport.
if (gt && endpoint.maxPacketSize < 64) continue if (gt && endpoint.maxPacketSize < 64) continue
if (endpoint.direction == UsbConstants.USB_DIR_IN && inEndpoint == null) { if (endpoint.direction == UsbConstants.USB_DIR_IN && inEndpoint == null) {
@@ -159,8 +157,6 @@ object RayNeoProtocol {
} }
} }
// Interface numbers can change with firmware. Use a unique protocol-sized
// pair when interface 5 is absent, never an arbitrary composite interface.
return if (gt) gtCandidates.singleOrNull() else fallback return if (gt) gtCandidates.singleOrNull() else fallback
} }
@@ -9,7 +9,6 @@ import android.hardware.usb.UsbManager
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
internal class RokidLink private constructor( internal class RokidLink private constructor(
private val connection: UsbDeviceConnection, private val connection: UsbDeviceConnection,
private val usbInterface: UsbInterface, private val usbInterface: UsbInterface,
@@ -44,7 +43,7 @@ internal class RokidLink private constructor(
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null ?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS && VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS) 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. else -> false
} }
} }
@@ -54,7 +53,6 @@ internal class RokidLink private constructor(
if (!running) return null if (!running) return null
val buffer = ByteArray(64) val buffer = ByteArray(64)
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS) 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 val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
if (count > offset) buffer[offset].toInt() and 0xff else null if (count > offset) buffer[offset].toInt() and 0xff else null
} }
@@ -90,7 +88,6 @@ internal class RokidLink private constructor(
if (enabled && !wasTracking) fusion = ImuFusion() if (enabled && !wasTracking) fusion = ImuFusion()
wasTracking = enabled wasTracking = enabled
if (!enabled) continue 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 { fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos()) if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
} }
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
/** Rokid Air/Max share one USB identity; Max 2 has a separate product ID. */
internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel { internal class RokidModel private constructor(override val name: String, private val max: Boolean) : GlassesModel {
override val controllable = true override val controllable = true
override val brightnessMax = BRIGHTNESS_STEPS override val brightnessMax = BRIGHTNESS_STEPS
@@ -36,7 +35,6 @@ internal class RokidModel private constructor(override val name: String, private
const val MAX2_PRODUCT_ID = 0x2002 const val MAX2_PRODUCT_ID = 0x2002
const val BRIGHTNESS_STEPS = 10 const val BRIGHTNESS_STEPS = 10
// Vendor control requests, device recipient.
const val REQUEST_SET_DISPLAY = 0x01 const val REQUEST_SET_DISPLAY = 0x01
const val REQUEST_GET_DISPLAY = 0x81 const val REQUEST_GET_DISPLAY = 0x81
const val REQUEST_SET_BRIGHTNESS = 0x02 const val REQUEST_SET_BRIGHTNESS = 0x02
@@ -51,7 +49,6 @@ internal class RokidModel private constructor(override val name: String, private
const val MODE_SBS_1200_60 = 5 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) 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? { fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) return null
val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null val product = name?.trim()?.takeIf { it.startsWith("Rokid", ignoreCase = true) } ?: return null
@@ -60,7 +57,6 @@ internal class RokidModel private constructor(override val name: String, private
fun isDisplay(name: String) = name.contains("Rokid", 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 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 fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
} }
@@ -74,7 +70,6 @@ internal object RokidProtocol {
private const val SENSOR_ACCEL = 1 private const val SENSOR_ACCEL = 1
private const val SENSOR_GYRO = 2 private const val SENSOR_GYRO = 2
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
class Parser { class Parser {
private var accel: Vec3? = null private var accel: Vec3? = null
private var accelTime = -1L private var accelTime = -1L
@@ -87,7 +82,7 @@ internal object RokidProtocol {
return when (packet[0].toInt() and 0xff) { return when (packet[0].toInt() and 0xff) {
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff) COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
SEPARATE -> { SEPARATE -> {
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds. val time = b.getLong(9) * 1000
when (packet[1].toInt() and 0xff) { when (packet[1].toInt() and 0xff) {
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time } SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = time }
SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time } SENSOR_GYRO -> { gyro = vec(b, 21); gyroTime = time }
@@ -26,7 +26,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
val source get() = if (bundledBase && !verified) "luma-ultra-base" else "device" val source get() = if (bundledBase && !verified) "luma-ultra-base" else "device"
private val p1 = Mat.zeros(3, 4, CvType.CV_64F) private val p1 = Mat.zeros(3, 4, CvType.CV_64F)
private val p2 = Mat.zeros(3, 4, CvType.CV_64F) private val p2 = Mat.zeros(3, 4, CvType.CV_64F)
private val undistortionMaps = Array(2) { arrayOf(Mat(),Mat()) }
init { init {
val a = Mat.eye(3, 4, CvType.CV_64F) val a = Mat.eye(3, 4, CvType.CV_64F)
val b = Mat.zeros(3, 4, CvType.CV_64F) val b = Mat.zeros(3, 4, CvType.CV_64F)
@@ -35,18 +34,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
Core.gemm(k1, a, 1.0, empty, 0.0, p1); Core.gemm(k2, b, 1.0, empty, 0.0, p2) Core.gemm(k1, a, 1.0, empty, 0.0, p1); Core.gemm(k2, b, 1.0, empty, 0.0, p2)
a.release(); b.release(); empty.release() a.release(); b.release(); empty.release()
} }
fun undistort(src: Mat, dst: Mat, right: Boolean) {
val k = if (right) k2 else k1; val d = if (right) d2 else d1
val maps=undistortionMaps[if(right) 1 else 0]
if(maps[0].empty()) {
val identity=Mat.eye(3,3,CvType.CV_64F)
try {
if(fisheye) Calib3d.fisheye_initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1])
else Calib3d.initUndistortRectifyMap(k,d,identity,k,src.size(),CvType.CV_16SC2,maps[0],maps[1])
} finally { identity.release() }
}
Imgproc.remap(src,dst,maps[0],maps[1],Imgproc.INTER_LINEAR)
}
fun triangulateRaw(left: List<Point>, right: List<Point>): List<SpatialVector>? { fun triangulateRaw(left: List<Point>, right: List<Point>): List<SpatialVector>? {
val points = triangulateLandmarksRaw(left,right) ?: return null val points = triangulateLandmarksRaw(left,right) ?: return null
return if(points.any { it == null }) null else points.filterNotNull() return if(points.any { it == null }) null else points.filterNotNull()
@@ -74,10 +61,6 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
return triangulatePoints(l.toList(),r.toList()) return triangulatePoints(l.toList(),r.toList())
} finally { l.release(); r.release(); identity.release() } } finally { l.release(); r.release(); identity.release() }
} }
fun triangulate(left: List<Point>, right: List<Point>): List<SpatialVector>? {
val points = triangulatePoints(left,right) ?: return null
return if(points.any { it == null }) null else points.filterNotNull()
}
private fun triangulatePoints(left: List<Point>, right: List<Point>): List<SpatialVector?>? { private fun triangulatePoints(left: List<Point>, right: List<Point>): List<SpatialVector?>? {
if (left.size != right.size || left.isEmpty()) return null if (left.size != right.size || left.isEmpty()) return null
val a = Mat(2, left.size, CvType.CV_64F); val b = Mat(2, right.size, CvType.CV_64F); val out = Mat() val a = Mat(2, left.size, CvType.CV_64F); val b = Mat(2, right.size, CvType.CV_64F); val out = Mat()
@@ -105,7 +88,7 @@ internal class StereoCalibration(val identity: String, val width: Int, val heigh
val tmp = File(file.parentFile,file.name+".tmp"); tmp.writeText(j.toString()) val tmp = File(file.parentFile,file.name+".tmp"); tmp.writeText(j.toString())
java.nio.file.Files.move(tmp.toPath(),file.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING) java.nio.file.Files.move(tmp.toPath(),file.toPath(),java.nio.file.StandardCopyOption.REPLACE_EXISTING)
} }
override fun close() { (listOf(k1,d1,k2,d2,r,t,p1,p2)+undistortionMaps.flatMap { it.toList() }).forEach { it.release() } } override fun close() { listOf(k1,d1,k2,d2,r,t,p1,p2).forEach { it.release() } }
companion object { companion object {
fun load(file: File, identity: String, width: Int, height: Int): StereoCalibration? = fun load(file: File, identity: String, width: Int, height: Int): StereoCalibration? =
runCatching { StereoCalibrationProfile.parse(file.readText(), identity, width, height)?.let(::fromProfile) }.getOrNull() runCatching { StereoCalibrationProfile.parse(file.readText(), identity, width, height)?.let(::fromProfile) }.getOrNull()
@@ -286,7 +269,6 @@ internal class StereoCalibrator(private val context: Context) : AutoCloseable {
Mat().also { output -> corrected.add(output); Calib3d.fisheye_undistortPoints(pixels,output,k,d,identity,k) } Mat().also { output -> corrected.add(output); Calib3d.fisheye_undistortPoints(pixels,output,k,d,identity,k) }
} }
try { try {
// OpenCV 4.13 fisheye stereo fitting assumes equal corner counts in every view.
Calib3d.stereoCalibrate(objects,images(left,k1,d1),images(right,k2,d2),k1,if(fish)zero else d1,k2,if(fish)zero else d2, Calib3d.stereoCalibrate(objects,images(left,k1,d1),images(right,k2,d2),k1,if(fish)zero else d1,k2,if(fish)zero else d2,
size,r,t,e,f,Calib3d.CALIB_FIX_INTRINSIC) size,r,t,e,f,Calib3d.CALIB_FIX_INTRINSIC)
} finally { (corrected+listOf(e,f,identity,zero)).forEach(Mat::release) } } finally { (corrected+listOf(e,f,identity,zero)).forEach(Mat::release) }
@@ -383,7 +383,6 @@ internal class StereoHandEngine(private val context: Context, private val token:
message=context.getString(R.string.spatial_frame_changed) message=context.getString(R.string.spatial_frame_changed)
calibrationProgress = calibrationProgress.copy(collecting = false, capturing = false, captureMessage = message) calibrationProgress = calibrationProgress.copy(collecting = false, capturing = false, captureMessage = message)
} }
// The callback pose's basis is undocumented; use canonical GL pose history.
val observation=HandObservation(time,generation,trackingFrame) val observation=HandObservation(time,generation,trackingFrame)
var captureHead=observation.headPose(SpatialTracking.session,metadata[6]>0) var captureHead=observation.headPose(SpatialTracking.session,metadata[6]>0)
val w=metadata[0].toInt();val h=metadata[1].toInt() val w=metadata[0].toInt();val h=metadata[1].toInt()
@@ -499,7 +498,6 @@ internal class StereoHandEngine(private val context: Context, private val token:
dl=pair.first.hands;dr=pair.second.hands dl=pair.first.hands;dr=pair.second.hands
if(scheduled) inferenceSchedule.observe(stamp,dl.map { it.side },dr.map { it.side },pair.first.rawCount,pair.second.rawCount) if(scheduled) inferenceSchedule.observe(stamp,dl.map { it.side },dr.map { it.side },pair.first.rawCount,pair.second.rawCount)
val inferenceMs=(System.nanoTime()-inferenceStart)/1e6f val inferenceMs=(System.nanoTime()-inferenceStart)/1e6f
// VIO callbacks can arrive during inference; verify this exact camera stamp before publishing.
captureHead=observation.headPose(SpatialTracking.session, captureHead=observation.headPose(SpatialTracking.session,
metadata[6]>0 || VitureSdk.nativeCameraSynchronized(token,metadata[2])) metadata[6]>0 || VitureSdk.nativeCameraSynchronized(token,metadata[2]))
val hands=Array(2) { SpatialHand() } val hands=Array(2) { SpatialHand() }
@@ -631,7 +629,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
SpatialTracking.publish(System.nanoTime()) SpatialTracking.publish(System.nanoTime())
} }
} }
} catch(e: InterruptedException) { /* Shutdown interrupts the idle wait. */ } } catch(e: InterruptedException) { }
catch(e: Throwable) { catch(e: Throwable) {
message=context.getString(R.string.spatial_hand_error, e.message.orEmpty()) message=context.getString(R.string.spatial_hand_error, e.message.orEmpty())
val unavailable=HandObservationDiagnostic(HandObservationOutcome.INFERENCE_UNAVAILABLE) val unavailable=HandObservationDiagnostic(HandObservationOutcome.INFERENCE_UNAVAILABLE)
@@ -31,7 +31,6 @@ internal class StereoInferenceRunner : AutoCloseable {
catch (e: ExecutionException) { throw e.cause ?: e } catch (e: ExecutionException) { throw e.cause ?: e }
} }
} finally { } finally {
// Native inference must finish before either camera's pixels or detector can be reused.
if (interrupted) Thread.currentThread().interrupt() if (interrupted) Thread.currentThread().interrupt()
} }
} }
@@ -12,8 +12,6 @@ internal object UvcRgbDiagnostic {
internal data class UvcStreamHealth(val observationMs: Double, val observedFps: Double, internal data class UvcStreamHealth(val observationMs: Double, val observedFps: Double,
val maximumGapMs: Double, val healthy: Boolean) { val maximumGapMs: Double, val healthy: Boolean) {
companion object { companion object {
// Health requires the full requested window, 80% of requested FPS, and no gap
// (including the final silent interval) above max(250 ms, three frame periods).
fun assess(metadata: LongArray, requestedFps: Int, durationMs: Int): UvcStreamHealth { fun assess(metadata: LongArray, requestedFps: Int, durationMs: Int): UvcStreamHealth {
require(metadata.size == 14 && requestedFps > 0 && durationMs > 0) require(metadata.size == 14 && requestedFps > 0 && durationMs > 0)
val first = metadata[7] val first = metadata[7]
@@ -81,7 +81,6 @@ object VitureProtocol {
} }
} }
/** SDK validates the PID before returning this market name. Never guess a new PID. */
fun sdkModel(marketName: String): Model? { fun sdkModel(marketName: String): Model? {
val family = when (marketName) { val family = when (marketName) {
"One", "Lite" -> Family.ONE "One", "Lite" -> Family.ONE
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
import android.util.Log import android.util.Log
import kotlin.math.sqrt import kotlin.math.sqrt
/** JNI adapter to VITURE's Android C SDK. The vendor binary is a local build input. */
internal object VitureSdk { internal object VitureSdk {
val available: Boolean by lazy { val available: Boolean by lazy {
runCatching { runCatching {
@@ -27,7 +26,6 @@ internal object VitureSdk {
external fun nativeSpatialDiagnostics(token: Long): DoubleArray? external fun nativeSpatialDiagnostics(token: Long): DoubleArray?
external fun nativeTracking(token: Long, enabled: Boolean): Int external fun nativeTracking(token: Long, enabled: Boolean): Int
external fun nativeCommand(token: Long, command: Int, value: Int): Int external fun nativeCommand(token: Long, command: Int, value: Int): Int
/** Returns CLOCK_BOOTTIME receipt time, zero for no new pose, or a negative SDK error. */
external fun nativePose(token: Long, wxyz: FloatArray): Long external fun nativePose(token: Long, wxyz: FloatArray): Long
external fun nativeNextState(token: Long, event: IntArray): Int external fun nativeNextState(token: Long, event: IntArray): Int
external fun nativeClose(token: Long) external fun nativeClose(token: Long)
@@ -48,12 +46,9 @@ internal object VitureSpatialIdentity {
} }
internal object VitureSdkPose { internal object VitureSdkPose {
// Pro 2 hardware captures use OpenGL axes despite the generic Gen2 callback's
// NWU documentation. Keep that model-specific exception out of other Gen2 devices.
fun usesOpenGlAxes(carina: Boolean, family: VitureProtocol.Family?) = fun usesOpenGlAxes(carina: Boolean, family: VitureProtocol.Family?) =
carina || family == VitureProtocol.Family.PRO2 carina || family == VitureProtocol.Family.PRO2
/** SDK quaternions are wxyz; normalize their coordinate basis at the boundary. */
fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? { fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? {
if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null
val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat()) val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat())
@@ -7,7 +7,6 @@ import android.os.SystemClock
import android.util.Log import android.util.Log
import java.io.File import java.io.File
/** SDK owns interface selection (including Ultra bulk endpoints) and all USB I/O. */
internal class VitureSdkLink private constructor( internal class VitureSdkLink private constructor(
private val connection: UsbDeviceConnection, private val connection: UsbDeviceConnection,
private val token: Long, private val token: Long,
@@ -47,7 +46,6 @@ internal class VitureSdkLink private constructor(
if (rc == 0 || !wanted) tracking = wanted if (rc == 0 || !wanted) tracking = wanted
return result("tracking", rc) return result("tracking", rc)
} }
// Frequency is negotiated against SDK capabilities, including Pro 2's pose limit.
VitureProtocol.MSG_IMU_RATE -> return tracking VitureProtocol.MSG_IMU_RATE -> return tracking
VitureProtocol.MSG_GET_IMU -> return true VitureProtocol.MSG_GET_IMU -> return true
} }
@@ -89,7 +87,6 @@ internal class VitureSdkLink private constructor(
if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) { if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) {
0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, event[1]) 0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, event[1])
1 -> listener.onValue(VitureProtocol.MSG_GET_VOLUME, event[1]) 1 -> listener.onValue(VitureProtocol.MSG_GET_VOLUME, event[1])
// A device event can use a different display-mode namespace.
2 -> send(VitureProtocol.MSG_GET_DISPLAY) 2 -> send(VitureProtocol.MSG_GET_DISPLAY)
3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1]) 3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1])
} }
@@ -122,7 +119,6 @@ internal class VitureSdkLink private constructor(
if (!running) return if (!running) return
running = false running = false
VitureSdk.nativeClose(token) VitureSdk.nativeClose(token)
// Keep Java's descriptor alive until the SDK and its worker threads have stopped.
connection.close() connection.close()
} }
if (Thread.currentThread() !== worker) worker.join(1000) if (Thread.currentThread() !== worker) worker.join(1000)
@@ -69,7 +69,6 @@ internal class VitureV2Link internal constructor(
return true return true
} }
if (msgId == VitureProtocol.MSG_GET_IMU) 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 if (msgId == VitureProtocol.MSG_IMU_RATE) return false
val query = when (msgId) { val query = when (msgId) {
VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS
@@ -146,9 +145,7 @@ internal class VitureV2Link internal constructor(
tracking(true) tracking(true)
if (becameReady) listener.onReady() if (becameReady) listener.onReady()
val q = pose.quaternion 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]) 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) if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs)
return return
} }
@@ -1,5 +1,4 @@
// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. // Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
package com.droiddeck.launcher.glasses package com.droiddeck.launcher.glasses
import kotlin.math.sqrt import kotlin.math.sqrt
@@ -37,7 +36,6 @@ object VitureV2Protocol {
val success: Boolean get() = status == 0 val success: Boolean get() = status == 0
} }
// Device ticks have no established time unit; quaternion is xyzw with no axis remapping.
class Pose internal constructor( class Pose internal constructor(
val deviceTick: Long, val deviceTick: Long,
val quaternion: FloatArray, val quaternion: FloatArray,
@@ -64,7 +62,6 @@ object VitureV2Protocol {
} }
fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray { 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_OFF || streams == STREAM_POSE || streams == STREAM_RAW)
require(streams != STREAM_POSE || rate.hz <= 240) require(streams != STREAM_POSE || rate.hz <= 240)
val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue
@@ -24,7 +24,6 @@ internal interface XrealConnector : Closeable {
internal class XrealNetworkMissing : IOException("XREAL USB Ethernet network is not available") internal class XrealNetworkMissing : IOException("XREAL USB Ethernet network is not available")
/** Bind individual sockets, never the process: Steam must retain its Internet network. */
internal class AndroidXrealConnector(context: Context) : XrealConnector { internal class AndroidXrealConnector(context: Context) : XrealConnector {
private val manager = context.getSystemService(ConnectivityManager::class.java) private val manager = context.getSystemService(ConnectivityManager::class.java)
private val address = InetAddress.getByName(XrealProtocol.HOST) private val address = InetAddress.getByName(XrealProtocol.HOST)
@@ -7,7 +7,6 @@ import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
/** One connection owns two TCP channels. All control requests are serialized off the UI thread. */
internal class XrealLink internal constructor( internal class XrealLink internal constructor(
private val connector: XrealConnector, private val connector: XrealConnector,
private val listener: Listener, private val listener: Listener,
@@ -47,7 +46,7 @@ internal class XrealLink internal constructor(
VitureProtocol.MSG_DISPLAY -> value in 2..5 || value == XrealModel.NATIVE_60HZ VitureProtocol.MSG_DISPLAY -> value in 2..5 || value == XrealModel.NATIVE_60HZ
VitureProtocol.MSG_BRIGHTNESS -> value in 0..9 VitureProtocol.MSG_BRIGHTNESS -> value in 0..9
VitureProtocol.MSG_FILM -> value in 0..1 VitureProtocol.MSG_FILM -> value in 0..1
else -> false // No guessed volume, sensor-rate, or VITURE wire commands. else -> false
} }
return valid && commands.offer(Command(msgId, value)) return valid && commands.offer(Command(msgId, value))
} }
@@ -88,7 +87,7 @@ internal class XrealLink internal constructor(
} }
} catch (e: Exception) { } catch (e: Exception) {
failure.set(e) failure.set(e)
current.close() // Wake a control read immediately. current.close()
} }
}, "xreal-reports").apply { isDaemon = true; start() } }, "xreal-reports").apply { isDaemon = true; start() }
ready = true ready = true
@@ -145,12 +144,11 @@ internal class XrealLink internal constructor(
tracking = false tracking = false
commands.clear() commands.clear()
connection.getAndSet(null)?.close() connection.getAndSet(null)?.close()
connector.close() // Includes sockets still inside connect(). connector.close()
worker.interrupt() worker.interrupt()
} }
} }
/** Kept independent of Android so the real framing/acknowledgement path is testable. */
internal class XrealControl(private val channels: XrealChannels, private val settleDelayMs: Long = 150) { internal class XrealControl(private val channels: XrealChannels, private val settleDelayMs: Long = 150) {
private var transaction = 0 private var transaction = 0
private fun exchange(command: Int, value: Int? = null): ByteArray { private fun exchange(command: Int, value: Int? = null): ByteArray {
@@ -158,7 +156,7 @@ internal class XrealControl(private val channels: XrealChannels, private val set
channels.controlOutput.write(XrealProtocol.request(command, transaction, value)) channels.controlOutput.write(XrealProtocol.request(command, transaction, value))
channels.controlOutput.flush() channels.controlOutput.flush()
val deadline = System.nanoTime() + 3_000_000_000L val deadline = System.nanoTime() + 3_000_000_000L
repeat(32) { // Bound unsolicited button/status packets and stale responses. repeat(32) {
if (System.nanoTime() > deadline) throw IOException("XREAL control timed out") if (System.nanoTime() > deadline) throw IOException("XREAL control timed out")
XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it } XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it }
} }
@@ -172,9 +170,6 @@ internal class XrealControl(private val channels: XrealChannels, private val set
fun changeDisplay(mode: Int): Int { fun changeDisplay(mode: Int): Int {
val stereo = mode == 5 val stereo = mode == 5
set(XrealProtocol.SET_INPUT, if (stereo) 1 else 0) set(XrealProtocol.SET_INPUT, if (stereo) 1 else 0)
// On 1S, SET_INPUT itself changes EDID. Sending SET_DISPLAY immediately
// receives error 5001 while that change is pending. GET_DISPLAY returns
// an empty property during the transition; wait for a real mode report.
val current = awaitDisplay { (it == 5) == stereo } val current = awaitDisplay { (it == 5) == stereo }
if (current == mode) return current if (current == mode) return current
set(XrealProtocol.SET_DISPLAY, mode) set(XrealProtocol.SET_DISPLAY, mode)
@@ -1,10 +1,9 @@
package com.droiddeck.launcher.glasses package com.droiddeck.launcher.glasses
/** First One-family target. Recovery and Air USB products must not use this transport. */
object XrealModel : GlassesModel { object XrealModel : GlassesModel {
const val VENDOR_ID = 0x3318 const val VENDOR_ID = 0x3318
const val PRODUCT_1S = 0x043e const val PRODUCT_1S = 0x043e
const val NATIVE_60HZ = 29 // Captured on 1S: 1920 x 1200 at 60 Hz. const val NATIVE_60HZ = 29
override val name = "XREAL 1S" override val name = "XREAL 1S"
override val controllable = true override val controllable = true
override val brightnessMax = 9 override val brightnessMax = 9
@@ -16,7 +15,6 @@ object XrealModel : GlassesModel {
fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S
fun isDisplay(name: String) = name.contains("XREAL", ignoreCase = true) fun isDisplay(name: String) = name.contains("XREAL", ignoreCase = true)
// One-family EDID configuration values, not VITURE's display mode IDs.
override fun mode(hz: Int, sbs: Boolean) = when { override fun mode(hz: Int, sbs: Boolean) = when {
sbs -> 5 sbs -> 5
hz >= 120 -> 4 hz >= 120 -> 4
@@ -2,19 +2,14 @@ package com.droiddeck.launcher.glasses
import kotlin.math.abs import kotlin.math.abs
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
internal class XrealOrientationFilter { internal class XrealOrientationFilter {
private val fusion = ImuFusion() 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, fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z), Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.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 { internal class ImuFusion {
private var previous = 0L private var previous = 0L
private var rotation = Quaternion.IDENTITY private var rotation = Quaternion.IDENTITY
@@ -27,7 +22,7 @@ internal class ImuFusion {
previous = timeNs previous = timeNs
val norm = accel.length() val norm = accel.length()
val gravityValid = abs(norm - 9.81f) < 1.5f val gravityValid = abs(norm - 9.81f) < 1.5f
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion. if (dt > 0.25f) initialized = false
if (!initialized) { if (!initialized) {
if (!gravityValid) return null if (!gravityValid) return null
val up = accel.normalized() val up = accel.normalized()
@@ -47,7 +42,6 @@ internal class ImuFusion {
} }
rotation = rotation.integrateBodyRate(rate, dt).normalized() rotation = rotation.integrateBodyRate(rate, dt).normalized()
} }
// Same right/up/back -> shared glasses basis used by the RayNeo adapter.
return RayNeoOrientationFilter.glassesPose(floatArrayOf(rotation.w, rotation.x, rotation.y, rotation.z)) return RayNeoOrientationFilter.glassesPose(floatArrayOf(rotation.w, rotation.x, rotation.y, rotation.z))
} }
} }
@@ -6,7 +6,6 @@ import java.io.InputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
/** Independent implementation of the One-family wire format; see xreal-investigation.md. */
internal object XrealProtocol { internal object XrealProtocol {
const val HOST = "169.254.2.1" const val HOST = "169.254.2.1"
const val REPORT_PORT = 52998 const val REPORT_PORT = 52998
@@ -46,7 +45,7 @@ internal object XrealProtocol {
fun sample(frame: Frame): Sample? { fun sample(frame: Frame): Sample? {
if (frame.command != REPORT || frame.body.size != 128) return null if (frame.command != REPORT || frame.body.size != 128) return null
val b = ByteBuffer.wrap(frame.body).order(ByteOrder.LITTLE_ENDIAN) val b = ByteBuffer.wrap(frame.body).order(ByteOrder.LITTLE_ENDIAN)
if (b.getInt(24) != 11) return null // Magnetometer reports are a different type. if (b.getInt(24) != 11) return null
val time = b.getLong(8) val time = b.getLong(8)
val gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36)) val gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36))
val accel = Vec3(b.getFloat(40), b.getFloat(44), b.getFloat(48)) val accel = Vec3(b.getFloat(40), b.getFloat(44), b.getFloat(48))
@@ -64,7 +63,6 @@ internal object XrealProtocol {
.putInt(4 + payload.size).putInt(transaction or Int.MIN_VALUE).put(payload).array() .putInt(4 + payload.size).putInt(transaction or Int.MIN_VALUE).put(payload).array()
} }
/** No acknowledgement is inferred from a write; match both command and transaction. */
fun response(frame: Frame, command: Int, transaction: Int): ByteArray? { fun response(frame: Frame, command: Int, transaction: Int): ByteArray? {
if (frame.command != command || frame.body.size < 4 || if (frame.command != command || frame.body.size < 4 ||
ByteBuffer.wrap(frame.body).int != transaction) return null ByteBuffer.wrap(frame.body).int != transaction) return null
@@ -48,10 +48,6 @@ object AutoDriverTrial {
fun bad(context: Context): Set<String> = prefs(context).getStringSet("bad", emptySet()).orEmpty() 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) { fun displayStarting(context: Context, display: String) {
val p = prefs(context) val p = prefs(context)
if (p.getString("release", null) == null || p.getString("display", "") != display) return if (p.getString("release", null) == null || p.getString("display", "") != display) return
@@ -29,9 +29,10 @@ object AutoPicks {
val BUILT_IN = listOf( val BUILT_IN = listOf(
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)), 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.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)), Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)), Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)), Rule(setOf(Family.A7XX), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER)),
Rule(setOf(Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)), Rule(setOf(Family.ADRENO_UNKNOWN), minSdk = 36, prefer = listOf(DriverPairs.WN_BALANCED)),
Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)), Rule(setOf(Family.ADRENO_UNKNOWN), prefer = listOf(DriverPairs.BANNER)),
) )
@@ -52,7 +52,6 @@ object FrameGen {
fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz) fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz)
/** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */
fun apply(context: Context, mode: Mode, refreshHz: Float): String? { fun apply(context: Context, mode: Mode, refreshHz: Float): String? {
when (mode.engine) { when (mode.engine) {
ENGINE_LSFG -> { ENGINE_LSFG -> {
@@ -25,14 +25,6 @@ public final class LinuxVulkanDriver {
return values; return values;
} }
public static String optionLabel(Context context, String value) {
if (value == null || value.isEmpty()) return "Runtime default (built into the runtime)";
LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context);
if (!m.isInstalled(value)) return value + " (imported, missing - uses the runtime default)";
String ver = m.getDriverVersion(value);
return m.getDriverName(value) + (ver.isEmpty() ? "" : " " + ver) + " (imported)";
}
public static String resolveIcdPath(Context context, String value) { public static String resolveIcdPath(Context context, String value) {
if (value == null || value.isEmpty()) return null; if (value == null || value.isEmpty()) return null;
LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context); LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context);
@@ -6,7 +6,6 @@ import java.io.RandomAccessFile
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.BasicFileAttributes
/** Only new compositor output is evidence of a fault in the current session. */
internal class SteamVrDeviceLossLog(private val file: File) { internal class SteamVrDeviceLossLog(private val file: File) {
private var identity: Any? = identity() private var identity: Any? = identity()
private var position = file.length() private var position = file.length()
@@ -34,7 +33,6 @@ internal class SteamVrDeviceLossLog(private val file: File) {
} }
identity = current identity = current
prefix = head prefix = head
// Fault loops can produce megabytes per second. Bound work and keep the newest output.
if (input.length() - position > MAX_READ) { if (input.length() - position > MAX_READ) {
position = input.length() - MAX_READ position = input.length() - MAX_READ
partial = "" partial = ""
@@ -11,7 +11,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
import com.droiddeck.launcher.session.SessionEvents import com.droiddeck.launcher.session.SessionEvents
import java.io.File import java.io.File
/** Process-wide cursor survives Activity recreation; a new session ignores older log errors. */
internal object SteamVrHealth { internal object SteamVrHealth {
private var session: String? = null private var session: String? = null
private var log: SteamVrDeviceLossLog? = null private var log: SteamVrDeviceLossLog? = null
@@ -49,7 +48,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
.create() .create()
prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null } prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null }
dialog = prompt dialog = prompt
// A touch meant for the game must not silently dismiss the recovery controls.
prompt.setCanceledOnTouchOutside(false) prompt.setCanceledOnTouchOutside(false)
prompt.show() prompt.show()
if (automatic) { if (automatic) {
@@ -66,7 +64,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
private fun restart() { private fun restart() {
if (!foreground()) return if (!foreground()) return
// Commit before crossing processes so a repeated fault cannot create a restart loop.
if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return .putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return
SessionEvents.record("steamvr.recovery_requested") SessionEvents.record("steamvr.recovery_requested")
Loaded 100 of 581 files, more files were not shown because too many files have changed in this diff. Show more