mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Merge latest origin/main and resolve conflicts
This commit is contained in:
commit
8171bf20fd
581 files changed
+14773
-4227
No files matched your search
@@ -3,7 +3,6 @@ tools/mangoapp/** text=auto eol=lf
|
||||
tools/comet/LICENSE whitespace=-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/desktop/** text eol=lf
|
||||
tools/mangoapp/mangoapp text eol=lf
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# GitHub PR openers trusted for automatic release-signed test builds.
|
||||
FX192
|
||||
K11MCH1
|
||||
kingokksa
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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:
|
||||
push:
|
||||
paths:
|
||||
@@ -31,7 +28,6 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build in Arch Linux ARM
|
||||
# Inputs reach the shell as environment variables, never pasted into the script.
|
||||
env:
|
||||
FEX_EXTRA_CXXFLAGS: ${{ inputs.cxxflags }}
|
||||
FEX_THUNKS: ${{ inputs.thunks == 'true' && '1' || '0' }}
|
||||
|
||||
@@ -63,6 +63,8 @@ jobs:
|
||||
echo "base-sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
|
||||
git config user.name "ci"
|
||||
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
|
||||
- name: Resolve trusted manifest base
|
||||
id: trusted
|
||||
@@ -100,6 +102,7 @@ jobs:
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
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
|
||||
- name: Verify pinned source tree
|
||||
env:
|
||||
@@ -158,6 +161,7 @@ jobs:
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
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
|
||||
- name: Verify pinned source tree
|
||||
env:
|
||||
@@ -319,6 +323,7 @@ jobs:
|
||||
!/docs/releases/media/
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
- name: Check human authorship
|
||||
env:
|
||||
@@ -379,6 +384,7 @@ jobs:
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
BASE=$(git rev-parse --short FETCH_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
|
||||
{
|
||||
echo "### Mergeable"
|
||||
@@ -531,6 +537,30 @@ jobs:
|
||||
- name: Build the add-on 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -684,6 +714,7 @@ jobs:
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
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
|
||||
- name: Verify backend and test source match
|
||||
env:
|
||||
@@ -737,6 +768,7 @@ jobs:
|
||||
git fetch --no-tags origin "$BUILD_BASE_SHA"
|
||||
git config user.name "ci"
|
||||
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
|
||||
- name: Verify backend and test source match
|
||||
env:
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
name: Publish add-on packs
|
||||
|
||||
# The add-on packs (tools/packs/build.sh) a Build APK run made, published to DroidDeck-Components under
|
||||
# content-addressed tags (pack-<name>-<first 16 hex of sha256>). The APK from that run pins each pack's
|
||||
# SHA-256 and downloads it from that tag. A tag is created once and never replaced.
|
||||
# Only main and release builds publish here; a PR test build attaches the packs main has not published
|
||||
# to its pr-N release on DroidDeck-CI (pr-release-publish.yml), which is deleted when the PR closes.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
|
||||
@@ -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-outputs
|
||||
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/rust/stores/target/
|
||||
app/src/main/assets/droiddeck-esync/
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
[submodule "external/MaliBridge"]
|
||||
path = external/MaliBridge
|
||||
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
@@ -4,7 +4,6 @@ plugins {
|
||||
apply plugin: 'kotlin-android'
|
||||
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 localBuildOptionsFile = rootProject.layout.projectDirectory.file('local.properties')
|
||||
if (localBuildOptionsFile.asFile.exists()) {
|
||||
@@ -98,8 +97,6 @@ def addonPacks = ['proot', 'directaudio', 'tracking', 'xbox', 'comet', 'butler']
|
||||
def pack = file("build/packs/${name}.tar.zst")
|
||||
if (!pack.isFile()) {
|
||||
if (ciBuild) throw new GradleException("Add-on pack ${pack} is missing: run tools/packs/build.sh app/build/packs")
|
||||
// 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) {
|
||||
return [(name): [sha256: prootPackPin.PROOT_PACK_SHA256, size: (prootPackPin.PROOT_PACK_SIZE ?: '0') as long]]
|
||||
}
|
||||
@@ -208,7 +205,6 @@ android {
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -237,7 +233,6 @@ android {
|
||||
outputs.upToDateWhen { false }
|
||||
outputs.doNotCacheIf('CI executes the test suite on every run') { true }
|
||||
}
|
||||
// Robolectric keeps a sandbox per SDK level the tests ask for; seven no longer fit in Gradle's default 512 MB.
|
||||
maxHeapSize = '2g'
|
||||
maxParallelForks = 2
|
||||
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) {
|
||||
android.sourceSets.main.jniLibs.srcDir(new File(vitureSdkDir, 'android'))
|
||||
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'))
|
||||
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) {
|
||||
def stageLocalPacks = tasks.register('stageLocalPacks', Sync) {
|
||||
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-lifecycle:1.4.2'
|
||||
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("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
|
||||
exclude group: 'com.google.auto.value', module: 'auto-value'
|
||||
}
|
||||
implementation 'com.google.zxing:core:3.5.3'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.robolectric:robolectric:4.16.1'
|
||||
|
||||
@@ -194,6 +194,13 @@
|
||||
android:resizeableActivity="true"
|
||||
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
|
||||
android:name=".files.FileManagerActivity"
|
||||
android:exported="false"
|
||||
@@ -293,6 +300,11 @@
|
||||
android:name=".session.SessionService"
|
||||
android:exported="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
|
||||
android:name=".runtime.RuntimeInstallService"
|
||||
android:exported="false" />
|
||||
@@ -303,6 +315,10 @@
|
||||
android:name=".stores.download.StoreDownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".sync.SyncthingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.logs"
|
||||
|
||||
@@ -8,7 +8,6 @@ set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,max-page-size=16384
|
||||
|
||||
add_subdirectory(adrenotools)
|
||||
|
||||
# DXVK's DXBC->SPIR-V translator is zlib licensed (vendored under thirdparty/dxbc).
|
||||
add_subdirectory(thirdparty/dxbc)
|
||||
|
||||
set(DDFG_UPSTREAM "v0.3.0")
|
||||
@@ -37,13 +36,14 @@ target_link_libraries(sdnative log)
|
||||
add_library(deviceinfo SHARED deviceinfo/vkinfo.c)
|
||||
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(ddvenc SHARED venc/venc_server.cpp)
|
||||
target_compile_features(ddvenc PRIVATE cxx_std_17)
|
||||
target_link_libraries(ddvenc mediandk log)
|
||||
|
||||
# The optional proprietary SDK is supplied locally; its headers/libraries are not vendored.
|
||||
if(DROIDDECK_VITURE_SDK_DIR)
|
||||
add_library(droiddeck_viture SHARED viture/sdk_bridge.cpp)
|
||||
target_compile_features(droiddeck_viture PRIVATE cxx_std_17)
|
||||
|
||||
@@ -78,7 +78,6 @@ static constexpr uint8_t GAMEPAD_AXIS_COUNT = 8;
|
||||
static constexpr uint8_t GAMEPAD_BUTTON_COUNT = 11;
|
||||
static constexpr uint32_t FAKE_INPUT_RING_MAGIC = 0x46494252;
|
||||
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 {
|
||||
int64_t sec;
|
||||
int64_t usec;
|
||||
@@ -391,7 +390,6 @@ send_vibration(int strong, int weak, uint16_t duration_ms, uint16_t slot) {
|
||||
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;
|
||||
|
||||
__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_REPORT_INTERVAL_US = 4000;
|
||||
static constexpr int TRITON_FEATURE_TIMEOUT_MS = 500;
|
||||
// The app clears output_length once it has taken a report; a writer waits this long for that before overwriting.
|
||||
static constexpr int TRITON_OUTPUT_TIMEOUT_MS = 50;
|
||||
static constexpr const char *TRITON_NAME = "Steam Controller";
|
||||
static constexpr const char *TRITON_SERIAL = "DROIDDECKSC01";
|
||||
@@ -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_FW_TIMESTAMP = 0x6AC686B3;
|
||||
|
||||
// Steam compares the controller's firmware with TRITON_FW_TS in its bundled hardwareupdater.cfg (and blocks
|
||||
// with a required update below MUST_UPDATE_TRITON_FW_TS), so the emulated pad reports at least the build
|
||||
// Steam ships.
|
||||
static uint32_t triton_fw_timestamp() {
|
||||
static const uint32_t timestamp = [] {
|
||||
std::vector<std::string> roots;
|
||||
@@ -3679,7 +3673,6 @@ EXPORT int ioctl(int fd, ioctl_request_t op, ...) {
|
||||
Logger::log("Hooking ioctl EVIOCGRAB for event %s\n", event);
|
||||
return 0;
|
||||
} 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);
|
||||
guard.unlock();
|
||||
errno = EINVAL;
|
||||
|
||||
@@ -267,8 +267,6 @@ void FrameGen::record(VkCommandBuffer cmd, VkImageView prevView, VkImageView cur
|
||||
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) {
|
||||
bool gmEngaged = updateGlobalMotion(gmSlot, extent_);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
#include "lsfg_dll.h"
|
||||
#include "lsfg_dxbc.h"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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.
|
||||
// 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.
|
||||
|
||||
#pragma once
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#pragma once
|
||||
// DXVK's dxbc subset is zlib licensed; vendored under cpp/thirdparty/dxbc.
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -76,7 +76,6 @@ public:
|
||||
}
|
||||
lastFrame_ = frame.header.frame_id;
|
||||
if (state == XrGpuFrameState::Stale) {
|
||||
// No GPU read was submitted, so the producer can safely reclaim a dropped frame.
|
||||
dropped_++;
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
return false;
|
||||
@@ -88,7 +87,6 @@ public:
|
||||
glBindImageTexture(0, raw_, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
|
||||
glDispatchCompute((width_ + 7) / 8, (height_ + 7) / 8, 1);
|
||||
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();
|
||||
if (glGetError() != GL_NO_ERROR) { rejected_++; Disconnect(); return false; }
|
||||
if (!xr_gpu_send(peer_, &frame.header.frame_id, sizeof(frame.header.frame_id))) Disconnect();
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
/* SOCK_SEQPACKET between the guest's Remote Play hooks and the app: the emulated /dev/video-enc0 and
|
||||
* MediaCodec encoder, and remote touches. A config carries the buffer memfds (inputs first) as SCM_RIGHTS;
|
||||
* every frame gets one reply, touches get none. */
|
||||
|
||||
#define VENC_MAX_BUFFERS 16
|
||||
|
||||
enum venc_msg { VENC_CONFIG = 1, VENC_FRAME = 2, VENC_DONE = 3, VENC_BITRATE = 4, VENC_TOUCH = 5, VDEC_CONFIG = 6, VDEC_CAPTURE = 7, VDEC_PACKET = 8, VDEC_RESULT = 9 };
|
||||
@@ -45,7 +41,6 @@ struct venc_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 {
|
||||
uint32_t type;
|
||||
uint32_t action;
|
||||
|
||||
@@ -33,8 +33,8 @@ struct Mapping {
|
||||
constexpr int32_t kColorFormatYuv420SemiPlanar = 21;
|
||||
constexpr uint32_t kBufferFlagKeyFrame = 1;
|
||||
constexpr uint32_t kBufferFlagCodecConfig = 2;
|
||||
constexpr int kTouchWidth = 1920, kTouchHeight = 1080; // InputSpace.kt
|
||||
constexpr int kRemoteTouchId = 31; // the compositor's last touch slot (MAX_TOUCH_POINTS 32)
|
||||
constexpr int kTouchWidth = 1920, kTouchHeight = 1080;
|
||||
constexpr int kRemoteTouchId = 31;
|
||||
|
||||
int32_t avc_profile(int32_t v4l2) {
|
||||
switch (v4l2) {
|
||||
@@ -549,7 +549,7 @@ void serve(int listener) {
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_droiddeck_launcher_session_VideoEncodeBridge_nativeStart(JNIEnv *env, jobject, jstring jpath) {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
#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 {
|
||||
constexpr int to_native_mode(int standard) {
|
||||
switch (standard) {
|
||||
case 0x31: return 0x31; // 1080p 60
|
||||
case 0x33: return 0x32; // 1080p 90
|
||||
case 0x34: return 0x33; // 1080p 120
|
||||
case 0x32: return 0x37; // 1080p SBS 60
|
||||
case 0x35: return 0x38; // 1080p SBS 90
|
||||
case 0x41: return 0x34; // 1200p 60
|
||||
case 0x43: return 0x35; // 1200p 90
|
||||
case 0x44: return 0x36; // 1200p 120
|
||||
case 0x42: return 0x3a; // 1200p SBS 60
|
||||
case 0x45: return 0x3b; // 1200p SBS 90
|
||||
case 0x31: return 0x31;
|
||||
case 0x33: return 0x32;
|
||||
case 0x34: return 0x33;
|
||||
case 0x32: return 0x37;
|
||||
case 0x35: return 0x38;
|
||||
case 0x41: return 0x34;
|
||||
case 0x43: return 0x35;
|
||||
case 0x44: return 0x36;
|
||||
case 0x42: return 0x3a;
|
||||
case 0x45: return 0x3b;
|
||||
default: return -4;
|
||||
}
|
||||
}
|
||||
@@ -30,7 +28,7 @@ constexpr int from_native_mode(int native) {
|
||||
case 0x36: return 0x44;
|
||||
case 0x3a: return 0x42;
|
||||
case 0x3b: return 0x45;
|
||||
default: return -4; // Ultrawide is not stereo.
|
||||
default: return -4;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
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;
|
||||
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;
|
||||
return bytes[end-2] == 0xff && bytes[end-1] == 0xd9 ? end : 0;
|
||||
}
|
||||
@@ -30,7 +30,6 @@
|
||||
#include "stereo_frame_gate.h"
|
||||
|
||||
namespace {
|
||||
// SDK callbacks have no user-data parameter. Only one provider may exist at a time.
|
||||
std::mutex api_mutex;
|
||||
std::mutex pose_mutex;
|
||||
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);
|
||||
if (!accepting_pose) return;
|
||||
std::copy_n(data + 3, 4, latest_pose.begin());
|
||||
// SDK device ticks are not Android's elapsed-realtime clock.
|
||||
latest_time = now_ns();
|
||||
}
|
||||
|
||||
@@ -234,8 +232,6 @@ int set_display(Session& s, int standard) {
|
||||
if (mode == 0) {
|
||||
int rc = xr_device_provider_set_display_mode(s.device, standard);
|
||||
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);
|
||||
if (native < 0) return native;
|
||||
|
||||
@@ -18,6 +18,7 @@ add_library(droiddeckwayland SHARED
|
||||
src/wl_color_mgmt.c
|
||||
src/hdr_compose.c
|
||||
src/blend_pass.c
|
||||
src/gpu_busy.c
|
||||
generated/color-management-v1-protocol.c
|
||||
generated/xdg-shell-protocol.c
|
||||
generated/linux-dmabuf-v1-protocol.c
|
||||
@@ -43,6 +44,7 @@ target_include_directories(droiddeckwayland PRIVATE
|
||||
prebuilt/include
|
||||
src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../shaders
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../../../../../tools/gpuload/src
|
||||
)
|
||||
|
||||
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->k1 = clampf(p->k1, -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->lens_inset = clampf(p->lens_inset, -0.25f, 0.25f);
|
||||
p->chroma = clampf(p->chroma, 0.0f, 0.05f);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// 1115.1.0
|
||||
#pragma once
|
||||
const uint32_t cardboard_frag_code[] = {
|
||||
0x07230203,0x00010000,0x0008000b,0x000001ba,0x00000000,0x00020011,0x00000001,0x0006000b,
|
||||
|
||||
@@ -289,7 +289,6 @@ static struct wl_event_source *g_fallback_timer;
|
||||
static int g_fallback_armed;
|
||||
|
||||
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_hide_shell;
|
||||
@@ -446,15 +445,10 @@ static int g_cursor_w, g_cursor_h;
|
||||
static int g_cursor_hidden = 1;
|
||||
static int g_cursor_serial;
|
||||
|
||||
/* The pointer image as a compositor-owned texture, for outputs that cannot show
|
||||
* 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 */
|
||||
#define CURSOR_MAX_DRAWS 2
|
||||
static struct vkp_image *g_cursor_img;
|
||||
static int g_cursor_img_w, g_cursor_img_h, g_cursor_img_serial = -1;
|
||||
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 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; }
|
||||
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;
|
||||
for (int i = 0; i < dl->n; i++) {
|
||||
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
|
||||
static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
static atomic_int g_stream_on;
|
||||
@@ -2051,7 +2037,6 @@ static void stream_forget_all(void) {
|
||||
g_stream_img = NULL;
|
||||
}
|
||||
|
||||
/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
|
||||
static void stream_take(void) {
|
||||
pthread_mutex_lock(&g_stream_lock);
|
||||
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 (slot >= 0) {
|
||||
AHardwareBuffer_release(ahb); /* the slot keeps its own reference */
|
||||
AHardwareBuffer_release(ahb);
|
||||
} else {
|
||||
struct vkp_image *img = vkp_image_from_ahb(ahb);
|
||||
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));
|
||||
slot = oldest;
|
||||
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;
|
||||
vkp_image_destroy(g_stream_slots[slot].img);
|
||||
}
|
||||
@@ -2123,7 +2108,6 @@ void droiddeck_stream_set(int on) {
|
||||
(void)n;
|
||||
}
|
||||
|
||||
/* Any thread: hands over one decoded frame (acquires its own reference). */
|
||||
void droiddeck_stream_frame(AHardwareBuffer *ahb) {
|
||||
if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
|
||||
AHardwareBuffer_acquire(ahb);
|
||||
@@ -2131,7 +2115,7 @@ void droiddeck_stream_frame(AHardwareBuffer *ahb) {
|
||||
AHardwareBuffer *dropped = g_stream_pending;
|
||||
g_stream_pending = ahb;
|
||||
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 };
|
||||
ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
|
||||
(void)n;
|
||||
@@ -2200,11 +2184,6 @@ static void render_scene(void) {
|
||||
hdr_note_precedence(hdr_route == 3 ? hs.who : NULL, fx_on, framegen);
|
||||
}
|
||||
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 *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);
|
||||
@@ -2216,9 +2195,6 @@ static void render_scene(void) {
|
||||
} else if (!layer_only) {
|
||||
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;
|
||||
if (cursor_on && hdr_route == 0 && hs.n_hdr > 0 && !cardboard) {
|
||||
hdr_route = 2;
|
||||
@@ -2257,8 +2233,6 @@ static void render_scene(void) {
|
||||
}
|
||||
if (hdr_route == 0 && vkp_quest_layer_presentation() && !framegen && !cardboard && !cursor_on &&
|
||||
!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;
|
||||
} else if (hdr_route == 0 || hdr_route == 3) {
|
||||
const int pass_on = fx_on || framegen;
|
||||
@@ -2367,8 +2341,6 @@ static int on_fallback_timer(void *data) {
|
||||
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) {
|
||||
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_cardboard_pose_changed()) tick_change = 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;
|
||||
g_tick_held = 0;
|
||||
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]) {
|
||||
out[0] = ptr_x - hx; out[1] = -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;
|
||||
out[1] = (ptr_x < half ? ptr_x + half : ptr_x - half) - hx;
|
||||
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) {
|
||||
int left = x0, right = x0 + w;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Appends the guest pointer as the topmost draw of the scene. */
|
||||
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;
|
||||
|
||||
@@ -2740,9 +2703,6 @@ static int add_cursor(struct draw_list *dl, int scene_w) {
|
||||
if (!d) return 0;
|
||||
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 pointer_eye = ptr_x < scene_w / 2 ? 0 : 1;
|
||||
int appended = 0;
|
||||
@@ -2765,8 +2725,6 @@ static void sync_lock_notify(void) {
|
||||
if (locked == last) return;
|
||||
last = locked;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -2849,8 +2807,6 @@ static void constraint_activate(struct constraint *k) {
|
||||
g_active_constraint = k;
|
||||
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);
|
||||
/* 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))
|
||||
schedule_render();
|
||||
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);
|
||||
}
|
||||
}
|
||||
/* 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))
|
||||
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();
|
||||
|
||||
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);
|
||||
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);
|
||||
@@ -3182,9 +3132,6 @@ static void pointer_button(uint32_t button, int pressed) {
|
||||
pointer_input(0, 0, 1, button, pressed);
|
||||
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();
|
||||
for (int i = 0; i < g_nptrs; 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;
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
const int want = on ? 1 : 0;
|
||||
if (atomic_exchange(&g_cursor_composite_want, want) == want) return;
|
||||
@@ -3871,8 +3816,6 @@ int droiddeck_wayland_run(void) {
|
||||
open_session_log();
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
return 0;
|
||||
}
|
||||
/* The ring and the engine's images are new on this frame. Dispatching into them now
|
||||
* null-dereferenced Turnip (vkp_framegen_run, first Steam frame, 2560x1440 to the panel).
|
||||
* Show this frame as it is; generation starts once the size has settled. */
|
||||
if (size_changed || ring == 2) {
|
||||
FGLOG("frame generation catches up at %dx%d; this frame is shown as it is", w, h);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* The pacer's source rate must be the game's, not this loop's: a loop slowed by its own
|
||||
* generations would otherwise read as a slower game and justify even more generations. */
|
||||
++g_source_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);
|
||||
|
||||
@@ -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);
|
||||
if (commands == MAP_FAILED) return false;
|
||||
pin.commands = commands;
|
||||
// A3xx/A4xx use type-3 CP_NOP; newer cores use type-7 with zero odd-parity bits.
|
||||
uint32_t *words = commands;
|
||||
words[0] = pin.nop;
|
||||
words[1] = 0;
|
||||
@@ -124,7 +123,6 @@ static void *hold_pin(void *unused) {
|
||||
if (errno == EINTR || errno == ETIMEDOUT) continue;
|
||||
break;
|
||||
}
|
||||
// Constraints expire after idle_timer (normally 80 ms); renew with a NOP, keeping devfreq alive.
|
||||
usleep(25000);
|
||||
if (atomic_load(&running) && !submit_pin()) break;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ struct droiddeck_headpose {
|
||||
float eye_aspect;
|
||||
uint32_t eye_w, eye_h;
|
||||
float display_hz;
|
||||
// Uses a formerly reserved word; older publishers leave this zero.
|
||||
uint32_t render_height;
|
||||
uint32_t reserved[10];
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
#include "hudframes.h"
|
||||
|
||||
/* Layout written by gamescope patch 0125 (tools/gamescope/patches). */
|
||||
#define HUD_FRAMES_MAGIC 0x46484444u
|
||||
#define HUD_FRAMES_HEADER 128u
|
||||
#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(×[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 valid = after > cap ? after - cap : 0;
|
||||
if (from < valid) {
|
||||
|
||||
@@ -8,9 +8,7 @@ struct hud_frames;
|
||||
|
||||
struct hud_frames *hud_frames_open(const char *path);
|
||||
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);
|
||||
/* 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);
|
||||
|
||||
#endif
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <string.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) {
|
||||
return !strcasecmp(model, "Quest 2") || !strcasecmp(model, "Quest 3") ||
|
||||
!strcasecmp(model, "Quest 3S") ||
|
||||
|
||||
@@ -291,7 +291,7 @@ int sc_layer_base_black(int width, int height) {
|
||||
ARect src = {0, 0, 16, 16}, dst = {0, 0, width, height};
|
||||
api.setBuffer(tx, g_base_sc, black, -1);
|
||||
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.setVisibility(tx, g_base_sc, ASC_VISIBILITY_SHOW);
|
||||
api.txApply(tx); api.txDelete(tx);
|
||||
@@ -1026,7 +1026,7 @@ void sc_layer_hide_overlay(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;
|
||||
layers_init();
|
||||
for (int i = SC_LAYER_COUNT - 1; i >= 0; i--) retire_sc(&g_layers[i]);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "framegen_bridge.h"
|
||||
#include "hdr_compose.h"
|
||||
#include "blend_pass.h"
|
||||
#include "gpu_busy.h"
|
||||
#include "droiddeck_color.h"
|
||||
#include "quest_device.h"
|
||||
#include <stdio.h>
|
||||
@@ -70,8 +71,6 @@ static int g_first_frame_done;
|
||||
static int g_base_black;
|
||||
|
||||
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;
|
||||
if (enabled < 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);
|
||||
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*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++) {
|
||||
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]);
|
||||
@@ -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);
|
||||
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) &&
|
||||
has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
|
||||
if (g_ahb_import) {
|
||||
@@ -521,6 +518,7 @@ static int dev_init(void) {
|
||||
}
|
||||
vk_loader_load_device(g_dev);
|
||||
g_vk.GetDeviceQueue(g_dev, g_qfam, 0, &g_queue);
|
||||
vkp_gpu_busy_device(g_pd, g_dev, g_qfam);
|
||||
if (want_sem_fd) {
|
||||
g_import_sem_fd = (PFN_vkImportSemaphoreFdKHR)g_vk.GetDeviceProcAddr(g_dev, "vkImportSemaphoreFdKHR");
|
||||
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_base_black = 0;
|
||||
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; "
|
||||
"restart the session", where);
|
||||
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;
|
||||
if (fx) {
|
||||
int mapped_w = (int)(scene_w * g_map.kx + 0.5f), mapped_h = (int)(scene_h * g_map.ky + 0.5f);
|
||||
/* Frame generation samples every output pixel. Upscaling first made it interpolate
|
||||
* the whole panel (3200x1440 on a 1440p phone) and rebuild on Steam's first frame.
|
||||
* Grade at the game's size instead; the screen blit still scales to the panel. */
|
||||
if (fg) { mapped_w = scene_w; mapped_h = scene_h; }
|
||||
vkp_effects_set_formats(scene_fmt, scene_fmt);
|
||||
result = vkp_effects_run(cmd, scene_img, scene_w, scene_h, mapped_w, mapped_h, &rw, &rh);
|
||||
@@ -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) {
|
||||
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();
|
||||
sc_layer_hide_overlay();
|
||||
if (n > 0) {
|
||||
@@ -1914,9 +1907,6 @@ int vkp_render(int scene_w, int scene_h, const struct vkp_draw *draws, int n) {
|
||||
} else {
|
||||
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 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) {
|
||||
if (g_dev_state == -2 || dev_init() != 0 || !g_window || scene_w <= 0 || scene_h <= 0) return -1;
|
||||
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);
|
||||
if (w <= 0 || h <= 0) return -1;
|
||||
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");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -725,7 +724,6 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
|
||||
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
|
||||
Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
|
||||
droiddeck_stream_set(on ? 1 : 0);
|
||||
|
||||
@@ -19,7 +19,6 @@ class App : Application() {
|
||||
com.droiddeck.launcher.ui.Themes.loadGradient(this)
|
||||
com.droiddeck.launcher.ui.BuildWatermark.install(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()
|
||||
else (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager)
|
||||
.runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName
|
||||
@@ -32,12 +31,11 @@ class App : Application() {
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
com.droiddeck.launcher.core.SteamIntegration.syncShortcutsListing(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.itch.ItchBackend)
|
||||
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)
|
||||
}
|
||||
|
||||
/** Fetches the packs an existing setup already relies on, so the first use after an update does not wait. */
|
||||
private fun prefetchAddonPacks() {
|
||||
val app = this
|
||||
Thread({
|
||||
|
||||
@@ -79,6 +79,7 @@ import com.droiddeck.launcher.session.SessionArtifacts
|
||||
import com.droiddeck.launcher.session.SessionPhase
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SteamLibraries
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplay
|
||||
import com.droiddeck.launcher.input.SecondScreenDisplays
|
||||
|
||||
@@ -480,6 +481,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
private var suspendPolicy by mutableStateOf(SessionPrefs.SUSPEND_MANUAL)
|
||||
private var steamDownloadsInBackground by mutableStateOf(false)
|
||||
private var oscMode by mutableStateOf(SessionPrefs.OSC_AUTO)
|
||||
private var backHold by mutableStateOf(true)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var gameStorage by mutableStateOf("")
|
||||
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 ->
|
||||
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 steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
|
||||
private var steamGamesX64 by mutableStateOf(false)
|
||||
@@ -720,7 +736,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
LauncherIcon.set(this, style)
|
||||
launcherIcon = LauncherIcon.current(this)
|
||||
if (launcherIcon == before) return false
|
||||
// Android finishes activities started through the alias that was just disabled.
|
||||
startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
.setComponent(LauncherIcon.activity(this))
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
@@ -1810,7 +1825,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
|
||||
private fun refreshSecondScreenDisplays() {
|
||||
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
|
||||
dualScreenMode = SessionPrefs.dualScreenMode(this)
|
||||
secondScreenDisplays = SecondScreenDisplays.available(displayManager, ownDisplayId)
|
||||
@@ -2124,6 +2138,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
unfoldedControls = if (mode == SessionService.MODE_STEAM) unfoldedControls else null,
|
||||
unfoldedControlsWithController = unfoldedControlsWithController,
|
||||
oscMode = if (mode == SessionService.MODE_STEAM) oscMode else null,
|
||||
backHold = backHold,
|
||||
backActionsInverted = backActionsInverted,
|
||||
directAudio = if (mode == SessionService.MODE_STEAM) directAudio 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,
|
||||
gameStorage = if (mode == SessionService.MODE_STEAM) gameStorage else null,
|
||||
storageOptions = storageOptions,
|
||||
steamLibraries = steamLibraries,
|
||||
storageDiagnostics = mode == SessionService.MODE_STEAM && storageDiagnostics,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
forceSsbs = forceSsbs,
|
||||
@@ -2176,6 +2192,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
SessionPrefs.setUnfoldedControlsWithController(this, on); unfoldedControlsWithController = on
|
||||
},
|
||||
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
|
||||
onBackHold = { on -> SessionPrefs.setBackHold(this, on); backHold = on },
|
||||
onBackActionsInverted = { inverted ->
|
||||
SessionPrefs.setBackActionsInverted(this, inverted)
|
||||
backActionsInverted = inverted
|
||||
@@ -2194,6 +2211,19 @@ class FrontEndActivity : ComponentActivity() {
|
||||
onPickGameStorageFolder = {
|
||||
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 ->
|
||||
SessionPrefs.setStorageDiagnosticsEnabled(this, on)
|
||||
storageDiagnostics = on
|
||||
@@ -2420,6 +2450,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backHold = SessionPrefs.backHold(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
directAudio = SessionPrefs.directAudio(this)
|
||||
picturePosition = SessionPrefs.picturePosition(this)
|
||||
@@ -2427,6 +2458,7 @@ class FrontEndActivity : ComponentActivity() {
|
||||
mic = SessionPrefs.micEnabled(this)
|
||||
refreshWifiDiscovery()
|
||||
gameStorage = SessionPrefs.gameStorage(this)
|
||||
steamLibraries = SteamLibraries.list(this)
|
||||
storageDiagnostics = SessionPrefs.storageDiagnosticsEnabled(this)
|
||||
settingsMode = mode
|
||||
Thread({
|
||||
@@ -2504,7 +2536,6 @@ class FrontEndActivity : ComponentActivity() {
|
||||
!SessionPaths.currentSessionLogsRetained(this)
|
||||
|
||||
private fun refresh() {
|
||||
// Until the scan finishes, the other client's games would launch in this one.
|
||||
val listX64 = SessionPrefs.activeSteamClientArch(this) == "x86_64"
|
||||
if (listX64 != steamGamesX64) {
|
||||
steamGamesX64 = listX64
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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 {
|
||||
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 ""
|
||||
|
||||
@@ -197,7 +197,6 @@ object HomeWidgets {
|
||||
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 {
|
||||
if (pendingId != AppWidgetManager.INVALID_APPWIDGET_ID) return false
|
||||
val info = request.getAppWidgetProviderInfo(activity) ?: return false
|
||||
|
||||
@@ -20,7 +20,6 @@ internal class ProtonMenu(
|
||||
private val ui: Handler,
|
||||
private val idle: () -> Boolean = { true },
|
||||
) {
|
||||
// Install workers and their results outlive the activity that starts them.
|
||||
private object Operation {
|
||||
val rows = mutableStateOf<List<ProtonRow>>(emptyList())
|
||||
val busyId = mutableStateOf<String?>(null)
|
||||
|
||||
@@ -71,7 +71,6 @@ class QuestXrActivity : NativeActivity() {
|
||||
}
|
||||
} finally {
|
||||
synchronized(steamVrLock) {
|
||||
// A retiring activity must not stop a replacement's SteamVR session.
|
||||
if (steamVrOwner === this) {
|
||||
if (sessionAlive()) SteamVr.run(this, "stop")
|
||||
steamVrOwner = null
|
||||
|
||||
@@ -67,7 +67,6 @@ import com.droiddeck.launcher.gpu.ScreenEffectLooks
|
||||
import com.droiddeck.launcher.gpu.ScreenEffects
|
||||
import com.droiddeck.launcher.gpu.Lossless
|
||||
import com.droiddeck.launcher.gpu.TurnipDriver
|
||||
import com.droiddeck.launcher.input.EvdevKeys
|
||||
import com.droiddeck.launcher.input.HomeGuideInput
|
||||
import com.droiddeck.launcher.input.InputSpace
|
||||
import com.droiddeck.launcher.input.KeyboardHost
|
||||
@@ -266,7 +265,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (locked) openEvdevMouse() else closeEvdevMouse()
|
||||
padMouseCheckedAt = 0L
|
||||
if (::padMouse.isInitialized) padMouseMode()
|
||||
// Touchpad deltas are measured from the middle while locked (moveLockedPointer).
|
||||
if (locked && ::touchpad.isInitialized) {
|
||||
drawnRect()?.let { touchpad.place(it.centerX(), it.centerY()) }
|
||||
cursorVisible = false
|
||||
@@ -310,6 +308,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onTap = { performBackAction(double = it) },
|
||||
doubleTapMs = { backDoubleTapMs.toLong() },
|
||||
onCancel = { padBridge?.cancelBackQam() },
|
||||
holds = { backHold },
|
||||
)
|
||||
private var drawerDirectionKey = KeyEvent.KEYCODE_UNKNOWN
|
||||
private var drawerDirectionDownTime = 0L
|
||||
@@ -388,6 +387,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private val drawerGameProfileRefreshes = LatestRequestCoordinator<DrawerGameProfileRefresh>()
|
||||
private var drawerPage by mutableStateOf(SessionDrawerPage.CONTROLLER)
|
||||
private var drawerControllerActive by mutableStateOf(false)
|
||||
private var backHold by mutableStateOf(true)
|
||||
private var backActionsInverted by mutableStateOf(false)
|
||||
private var backDoubleTapMs by mutableStateOf(500)
|
||||
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())) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
applyOutputPacing()
|
||||
@@ -570,7 +568,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
finish()
|
||||
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) {
|
||||
runtimeRemovalBlocked = true
|
||||
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
|
||||
SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty()
|
||||
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.secondScreenProvisional = false
|
||||
}
|
||||
@@ -605,7 +601,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
fpsLimit = 0
|
||||
}
|
||||
applyOutputPacing()
|
||||
if (!SessionState.running) SessionState.followScreen = followChoice()
|
||||
if (!SessionState.running) {
|
||||
SessionState.followScreen = followChoice()
|
||||
SessionState.followScale = followScale()
|
||||
}
|
||||
applyFollowOrientation(resources.configuration)
|
||||
goFullscreen()
|
||||
volumeControlStream = android.media.AudioManager.STREAM_MUSIC
|
||||
@@ -714,8 +713,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
ViewConfiguration.get(this).scaledTouchSlop.toFloat(), gestureOutput,
|
||||
)
|
||||
refreshGestureConfig()
|
||||
// A real touch target lets FrameLayout split fingers between the
|
||||
// game surface and on-screen buttons, in either finger-down order.
|
||||
root.isMotionEventSplittingEnabled = true
|
||||
surfaceView.setOnTouchListener { _, event ->
|
||||
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))
|
||||
},
|
||||
)
|
||||
// Also runs when a second screen appears, so Automatic applies then with the running game known.
|
||||
LaunchedEffect(AgentGuest.focusRevision, secondScreenDisplays.isNotEmpty()) {
|
||||
val nextGame = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
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)
|
||||
}
|
||||
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
|
||||
LaunchedEffect(SessionState.manualPause, SessionState.suspended) {
|
||||
if (!SessionState.manualPause && !SessionState.suspended) pauseAnimated = false
|
||||
@@ -1193,6 +1188,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
fexPreset = fexPreset,
|
||||
secondScreenMode = secondScreenMode,
|
||||
dashboardAvailable = bottomPages().isNotEmpty(),
|
||||
desktopAvailable = com.droiddeck.launcher.session.BottomDesktop.available(),
|
||||
secondScreenDisplays = secondScreenDisplays,
|
||||
selectedSecondScreenDisplay = selectedSecondScreenDisplay,
|
||||
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))
|
||||
padType = id
|
||||
},
|
||||
backHold = backHold,
|
||||
onBackHold = { on ->
|
||||
SessionPrefs.setBackHold(this@SessionActivity, on)
|
||||
backHold = on
|
||||
},
|
||||
backActionsInverted = backActionsInverted,
|
||||
backDoubleTapMs = backDoubleTapMs,
|
||||
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() {
|
||||
val chord = { down: Boolean -> padBridge?.applyTouch { state ->
|
||||
state.press(com.droiddeck.launcher.input.PadState.L3, down)
|
||||
@@ -1873,6 +1873,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
suspendPolicy = SessionPrefs.suspendPolicy(this, SessionState.mode)
|
||||
steamDownloadsInBackground = SessionPrefs.steamDownloadsInBackground(this)
|
||||
oscMode = SessionPrefs.oscMode(this)
|
||||
backHold = SessionPrefs.backHold(this)
|
||||
backActionsInverted = SessionPrefs.backActionsInverted(this)
|
||||
physicalShortcuts.bindings = PhysicalShortcuts.read(this)
|
||||
backDoubleTapMs = SessionPrefs.backDoubleTapMs(this)
|
||||
@@ -2160,7 +2161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
private fun windowOutputSize(width: Int, height: Int): Pair<Int, Int>? {
|
||||
if (isInPictureInPictureMode || width <= 0 || height <= 0) return null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2175,10 +2176,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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 {
|
||||
val mode = displayMode()
|
||||
return SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)) ==
|
||||
com.droiddeck.launcher.session.SessionDisplay.FOLLOW_SCREEN
|
||||
return com.droiddeck.launcher.session.SessionDisplay.isFollow(
|
||||
SessionPrefs.resolutionChoice(this, mode, com.droiddeck.launcher.session.SessionDisplay.panelSize(this)))
|
||||
}
|
||||
|
||||
private fun applyFollowOrientation(config: android.content.res.Configuration) {
|
||||
@@ -2198,8 +2205,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
|
||||
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
|
||||
cancelTouchGestures()
|
||||
surfaceW = 0
|
||||
@@ -2596,27 +2601,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
if (down && event.repeatCount > 0) return true
|
||||
if (down || event.action == KeyEvent.ACTION_UP) {
|
||||
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
|
||||
}
|
||||
}
|
||||
if (com.droiddeck.launcher.input.GuestKeys.send(event, ::sendGuestKey)) return true
|
||||
}
|
||||
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?.onMotionEvent(event) == true) return true
|
||||
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 (super.dispatchGenericMotionEvent(event)) return true
|
||||
if (onMouse(event)) return true
|
||||
@@ -2657,7 +2638,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun dispatchTrackballEvent(event: MotionEvent): Boolean =
|
||||
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 {
|
||||
val at = IntArray(2)
|
||||
fun takes(view: View, hit: (Float, Float) -> Boolean): Boolean {
|
||||
@@ -2835,9 +2815,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
override fun prepareRelativePan() {
|
||||
if (pointerLocked) return
|
||||
val rect = drawnRect() ?: return
|
||||
// Gamescope retains the last absolute position while a game can warp
|
||||
// its own cursor to the center. Its first unlocked motion must start
|
||||
// from that same origin, before the game switches to pointer lock.
|
||||
moveTo(rect.centerX(), rect.centerY())
|
||||
}
|
||||
override fun key(code: Int, down: Boolean) {
|
||||
@@ -3066,7 +3043,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (secondScreenDisplays.none { it.id == target.displayId }) return
|
||||
SessionState.secondScreenAutoOpened = true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3074,7 +3052,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!dualControlsProvisional || pipUi || !started || (::loading.isInitialized && loading.visible)) return
|
||||
dualControlsProvisional = false
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -3152,12 +3131,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) },
|
||||
onClose = { selectSecondScreenMode(SecondScreenMode.NONE) },
|
||||
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(
|
||||
takeScreenshot = ::captureScreenshot,
|
||||
fps = { if (::hud.isInitialized) hud.liveFps else null },
|
||||
@@ -3165,9 +3146,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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) {
|
||||
val presentation = secondScreenPresentation ?: return
|
||||
val options = android.app.ActivityOptions.makeBasic().setLaunchDisplayId(presentation.display.displayId)
|
||||
@@ -3179,7 +3157,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (!opened) return
|
||||
presentation.hide()
|
||||
var checks = 0
|
||||
// Show throws if the display went away while the settings page was open, as in showSecondScreen.
|
||||
fun showAgain() {
|
||||
try {
|
||||
presentation.show()
|
||||
@@ -3200,9 +3177,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}, 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) {
|
||||
// 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 width = source?.width ?: 0
|
||||
val height = source?.height ?: 0
|
||||
@@ -3644,23 +3619,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
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) {
|
||||
if (!::sessionRoot.isInitialized || !::surfaceView.isInitialized) 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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (!SessionState.running || pipUi || isInPictureInPictureMode || glassesOutputActive) return
|
||||
if (SessionState.suspended && pausedFrame != null) return
|
||||
@@ -3926,8 +3888,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
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() {
|
||||
if (pausedFrame != null) return
|
||||
pausedFrameWanted = true
|
||||
@@ -4164,8 +4124,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
Glasses.cancelSteamVrStart()
|
||||
}
|
||||
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
|
||||
runSteamVrAction(action, ::currentRequest) {
|
||||
val ok = SteamVr.run(this, action)
|
||||
@@ -4358,8 +4316,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
if (holder !== outputHolder()) return
|
||||
glassesOutputActive = glassesScreen?.holder === holder
|
||||
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
|
||||
Log.i(TAG, "compositor output attached: " +
|
||||
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 choice = SessionPrefs.resolutionChoice(this, mode, panel)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4438,8 +4394,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}
|
||||
if (target != null) {
|
||||
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
|
||||
applyOutputPacing()
|
||||
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) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||||
synchronized (micLock) {
|
||||
|
||||
@@ -13,12 +13,6 @@ import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
|
||||
/**
|
||||
* A pack built by CI alongside this APK (tools/packs). Main and release builds publish it to
|
||||
* DroidDeck-Components under a content-addressed tag; a PR test build attaches packs that are not there
|
||||
* yet to its pr-N release on DroidDeck-CI. The APK pins the pack's SHA-256, so only the bytes this build
|
||||
* was made with install, whichever host serves them.
|
||||
*/
|
||||
internal class AddonPack(
|
||||
val name: String, private val sha256: String, val size: Long, private val required: List<String>,
|
||||
private val reuseDeviceCopy: Boolean = sha256.isEmpty() && !BuildConfig.CI_BUILD,
|
||||
@@ -43,13 +37,11 @@ internal class AddonPack(
|
||||
)
|
||||
|
||||
private fun root(context: Context) = File(context.filesDir, "addons/$name")
|
||||
/** App-private and written only after the archive matched the pinned digest. */
|
||||
fun dir(context: Context) = File(root(context), version(context))
|
||||
fun file(context: Context, relative: String) = File(dir(context), relative)
|
||||
|
||||
fun installed(context: Context): Boolean = version(context).isNotEmpty() && File(dir(context), COMPLETE).isFile
|
||||
|
||||
// A build made outside CI pins no packs, so it runs the newest one a CI build already verified and installed.
|
||||
private fun version(context: Context): String = if (!reuseDeviceCopy) sha256 else deviceCopy(context).orEmpty()
|
||||
|
||||
private fun deviceCopy(context: Context): String? = root(context).listFiles().orEmpty()
|
||||
@@ -66,7 +58,6 @@ internal class AddonPack(
|
||||
.also { mutableState.value = it }
|
||||
}
|
||||
|
||||
/** Starts the download in the background unless the pack is already present or on its way. */
|
||||
fun ensure(context: Context, onReady: (Context) -> Unit = {}) {
|
||||
val app = context.applicationContext
|
||||
synchronized(lock) {
|
||||
@@ -81,7 +72,6 @@ internal class AddonPack(
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks a worker thread until the pack is installed, starting the download if needed. */
|
||||
@JvmOverloads
|
||||
fun await(context: Context, running: () -> Boolean = { true }, onState: (State) -> Unit = {}): Boolean {
|
||||
if (installed(context)) return true
|
||||
@@ -127,7 +117,6 @@ internal class AddonPack(
|
||||
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 {
|
||||
val copied = runCatching {
|
||||
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) {
|
||||
0 -> "UNKNOWN"
|
||||
1 -> "EXIT_SELF"
|
||||
|
||||
@@ -126,11 +126,9 @@ object DownloadSource {
|
||||
|
||||
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 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)
|
||||
val prefs = prefs() ?: return listOf(target)
|
||||
if (!isGithub(target)) return listOf(target)
|
||||
@@ -184,7 +182,6 @@ object DownloadSource {
|
||||
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, "") ?: "")
|
||||
|
||||
internal fun proxyOf(value: String): Proxy? {
|
||||
|
||||
@@ -91,7 +91,6 @@ public final class Downloader {
|
||||
if (isCancelled(cancelled)) return null;
|
||||
if (i > 0) Log.i(TAG, "retrying " + url + " via " + sources.get(i));
|
||||
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 (fetch.body != null) {
|
||||
DownloadSource.INSTANCE.succeeded(sources.get(i));
|
||||
@@ -103,7 +102,6 @@ public final class Downloader {
|
||||
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) {
|
||||
return fetch(url).body;
|
||||
}
|
||||
@@ -113,13 +111,11 @@ public final class Downloader {
|
||||
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,
|
||||
java.util.function.Consumer<Float> 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,
|
||||
java.util.function.BooleanSupplier cancelled,
|
||||
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);
|
||||
}
|
||||
|
||||
/** A third-party mirror may only serve bytes that can be checked against a digest from a direct source. */
|
||||
static List<String> sources(String url, String expectedSha256) {
|
||||
return expectedSha256 == null ? java.util.Collections.singletonList(url) : DownloadSource.INSTANCE.candidates(url);
|
||||
|
||||
}
|
||||
|
||||
// Package-private for the local multi-source HTTP test.
|
||||
static boolean downloadCandidates(String original, List<String> sources, File destination, boolean resume,
|
||||
String expectedSha256, java.util.function.Consumer<Float> progress) {
|
||||
return downloadCandidates(original, sources, destination, resume, expectedSha256, null, progress);
|
||||
@@ -149,7 +143,6 @@ public final class Downloader {
|
||||
if (isCancelled(cancelled)) return false;
|
||||
if (i > 0) Log.i(TAG, "retrying " + original + " via " + source);
|
||||
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.DONE) {
|
||||
if (expectedSha256 != null) {
|
||||
@@ -192,7 +185,6 @@ public final class Downloader {
|
||||
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) {
|
||||
Identity identity = new Identity();
|
||||
File marker = resumeFile(dest);
|
||||
@@ -213,7 +205,6 @@ public final class Downloader {
|
||||
return identity;
|
||||
}
|
||||
} 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);
|
||||
return identity;
|
||||
@@ -239,7 +230,7 @@ public final class Downloader {
|
||||
try (FileOutputStream out = new FileOutputStream(marker)) {
|
||||
p.store(out, "DroidDeck partial download validator");
|
||||
} catch (IOException e) {
|
||||
marker.delete(); // No valid metadata means no future resume.
|
||||
marker.delete();
|
||||
Log.w(TAG, "cannot persist download validator", e);
|
||||
}
|
||||
}
|
||||
@@ -252,7 +243,6 @@ public final class Downloader {
|
||||
long have = resume && destination.isFile() ? destination.length() : 0;
|
||||
try {
|
||||
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;
|
||||
have = 0;
|
||||
}
|
||||
@@ -286,7 +276,6 @@ public final class Downloader {
|
||||
return attempt(url, destination, false, identity, expectedSha256, cancelled, progress);
|
||||
}
|
||||
if (!appending && have > 0) {
|
||||
// If-Range may legitimately return full 200 when representation changed.
|
||||
if (!discardPartial(destination, identity)) return Outcome.RETRY;
|
||||
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) {
|
||||
if (contentRange == null) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
/** The complete length in a `Content-Range` header, or -1 when the header leaves it unknown. */
|
||||
static long rangeTotal(String contentRange) {
|
||||
if (contentRange == null) return -1;
|
||||
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 {
|
||||
// An explicit Proxy routes the connection through the app setting instead of the system-wide proxy.
|
||||
Proxy proxy = DownloadSource.INSTANCE.httpProxy();
|
||||
HttpURLConnection connection = (HttpURLConnection) (proxy != null
|
||||
? new URL(url).openConnection(proxy) : new URL(url).openConnection());
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
|
||||
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? {
|
||||
val uri = Uri.withAppendedPath(Settings.Global.CONTENT_URI, name)
|
||||
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)
|
||||
|
||||
// Settings.Global.ADB_WIFI_ENABLED is hidden; the key is stable since Android 11.
|
||||
fun wirelessDebuggingOn(context: Context): Boolean = isOn(context, "adb_wifi_enabled")
|
||||
}
|
||||
@@ -11,7 +11,6 @@ object LogRedactor {
|
||||
size > 32
|
||||
}
|
||||
|
||||
// Android matchers own native state, so reuse them instead of creating one for every log line.
|
||||
private val matchers = object : ThreadLocal<MatcherCache>() {
|
||||
override fun initialValue(): MatcherCache = MatcherCache()
|
||||
}
|
||||
@@ -145,7 +144,6 @@ object LogRedactor {
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -228,7 +226,6 @@ object LogRedactor {
|
||||
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
|
||||
|
||||
fun isBinary(file: java.io.File): Boolean = try {
|
||||
@@ -241,8 +238,6 @@ object LogRedactor {
|
||||
true
|
||||
}
|
||||
|
||||
fun isText(file: java.io.File): Boolean = file.isFile && file.length() < IN_MEMORY_SCRUB_MAX && !isBinary(file)
|
||||
|
||||
const val RULES_VERSION = 3
|
||||
|
||||
private val LAN_IPV4 = Regex(
|
||||
|
||||
@@ -52,8 +52,6 @@ object SessionLogCapture {
|
||||
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({
|
||||
while (true) {
|
||||
try { Thread.sleep(2000) } catch (e: InterruptedException) { break }
|
||||
|
||||
@@ -45,8 +45,6 @@ object SteamIntegration {
|
||||
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 {
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return false
|
||||
val mode = runCatching {
|
||||
@@ -90,7 +88,6 @@ object SteamIntegration {
|
||||
fun brightnessSettings(context: Context) = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
|
||||
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 {
|
||||
val service = ComponentName(context, HomeButtonService::class.java).flattenToString()
|
||||
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
|
||||
}
|
||||
if (!copyWithProgress(src, dst, progress, isCancelled)) return false
|
||||
// Once the destination is committed, finish deleting the source.
|
||||
return delete(src)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ object StorageRoots {
|
||||
}
|
||||
return true
|
||||
}
|
||||
// vold-managed locations only hold removable volumes, whatever the fstype is.
|
||||
return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/")
|
||||
}
|
||||
|
||||
@@ -92,8 +91,6 @@ object StorageRoots {
|
||||
.forEachIndexed { index, volume ->
|
||||
val uuid = volume.uuid?.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()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) }
|
||||
?.replace(Regex("[^A-Za-z0-9._-]+"), "-")
|
||||
@@ -108,8 +105,6 @@ object StorageRoots {
|
||||
if (name != null) {
|
||||
entry.candidates += File("/storage/$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) }
|
||||
.forEach { entry.seeds += it }
|
||||
}
|
||||
@@ -122,8 +117,6 @@ object StorageRoots {
|
||||
File("/mnt/media_rw").listFiles().orEmpty().forEach { 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()) {
|
||||
val entry = volumeOf(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 com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamLibraries
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
@@ -69,6 +70,10 @@ object AddedGames {
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
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 ->
|
||||
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
|
||||
val mapped = roots(context).map { it.host to it.guest } + StoreInstallRoot.externalRoots(context) +
|
||||
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")
|
||||
for ((host, root) in mapped.sortedByDescending { it.second.length }) {
|
||||
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()
|
||||
|
||||
/** 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 =
|
||||
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 {
|
||||
val app = context.applicationContext
|
||||
// Binds are mounted at session start, so a new one reaches the session from the next start.
|
||||
if (guestPath(app, dir) == null) bindFolder(app, dir)
|
||||
val removed = SessionPrefs.removedAddedGames(app).toSet()
|
||||
val listed = listedFolders(app, removed)
|
||||
|
||||
@@ -54,21 +54,6 @@ object DependencyDetector {
|
||||
"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> {
|
||||
if (!gameDir.isDirectory) return emptyList()
|
||||
val hits = LinkedHashMap<String, Recommendation>()
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.session.SteamLibraries
|
||||
import com.droiddeck.launcher.session.SecondaryLibrary
|
||||
import java.io.File
|
||||
|
||||
@@ -87,13 +88,14 @@ object Library {
|
||||
|
||||
internal fun steamLibraries(context: Context, arch: String = SessionPrefs.activeSteamClientArch(context)): List<Pair<File, String>> {
|
||||
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)
|
||||
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(
|
||||
root to "internal",
|
||||
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(
|
||||
@@ -106,7 +108,6 @@ object Library {
|
||||
.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> =
|
||||
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> {
|
||||
val config = AddedGames.accountConfig(root) ?: return emptySet()
|
||||
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 }
|
||||
}
|
||||
|
||||
/** Steam's LastPlayTime for each non-Steam shortcut, by its unsigned shortcut app id. */
|
||||
internal fun shortcutsLastPlayed(root: File): Map<Long, Long> = runCatching {
|
||||
val config = AddedGames.accountConfig(root) ?: 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 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
|
||||
fun snapshot(context: Context): Pair<List<Library.SteamGame>, Boolean> = runCatching {
|
||||
val f = file(context)
|
||||
@@ -36,7 +35,6 @@ object LibraryCache {
|
||||
|
||||
fun listedX64(context: Context): Boolean = snapshot(context).second
|
||||
|
||||
/** Without [x64] the list keeps the client already recorded. */
|
||||
@Synchronized
|
||||
fun save(context: Context, games: List<Library.SteamGame>, x64: Boolean? = null) {
|
||||
runCatching {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.frontend
|
||||
|
||||
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 {
|
||||
enum class Pin(val steam: String) {
|
||||
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 MAX_PCT = 100
|
||||
|
||||
/** What Steam draws when a game has no custom position. */
|
||||
val DEFAULT = Value(Pin.BOTTOM_LEFT, 50, 50)
|
||||
|
||||
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) }
|
||||
|
||||
/** `{"nVersion":1,"logoPosition":{...}}`, with whatever else [into] already held kept as it was. */
|
||||
fun toJson(value: Value, into: JSONObject = JSONObject()): JSONObject = into
|
||||
.put("nVersion", VERSION)
|
||||
.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))
|
||||
}
|
||||
|
||||
/** [existing] without the logo position; null when nothing but the version number would be left. */
|
||||
fun without(existing: JSONObject): JSONObject? {
|
||||
existing.remove(KEY)
|
||||
val rest = existing.keys().asSequence().filter { it != "nVersion" }.toList()
|
||||
|
||||
@@ -7,11 +7,9 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
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 {
|
||||
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()
|
||||
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")
|
||||
@@ -36,7 +34,6 @@ object SteamGrid {
|
||||
|
||||
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) =
|
||||
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 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 {
|
||||
val f = File(grid, GRID_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()
|
||||
}.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) }
|
||||
|
||||
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)
|
||||
|
||||
/** 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))
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
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 {
|
||||
val grid = dir(context) ?: return true
|
||||
val file = positionFile(grid, target.id)
|
||||
@@ -160,7 +149,6 @@ object SteamGrid {
|
||||
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 {
|
||||
val json = runCatching { JSONObject(file.readText()) }.getOrNull() ?: return false
|
||||
return json.has("logoPosition") && LogoPosition.parse(json) == null
|
||||
@@ -176,7 +164,6 @@ object SteamGrid {
|
||||
when {
|
||||
before == null && own != null && theirs == null -> placePosition(context, target, own)
|
||||
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 (own == theirs || storePosition(target, theirs)) recordPosition(context, grid, target, now)
|
||||
} 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) }
|
||||
|
||||
private fun reconcile(context: Context, targets: List<Target>): Boolean {
|
||||
|
||||
@@ -15,7 +15,6 @@ object SteamRedists {
|
||||
"228989" to ("vcredist2022_dll" to "VC++ 2022"),
|
||||
"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(
|
||||
72850 to listOf("xact" to "DirectX XACT audio"),
|
||||
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 }
|
||||
}
|
||||
|
||||
/** 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>> =
|
||||
gameDir?.listFiles { f ->
|
||||
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)
|
||||
|
||||
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))
|
||||
|
||||
/** 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>> {
|
||||
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()
|
||||
|
||||
@@ -563,7 +563,6 @@ object Glasses {
|
||||
val device = selected?.first
|
||||
val m = selected?.second
|
||||
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 identity = device?.let {
|
||||
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? {
|
||||
val candidates = displays
|
||||
.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 ->
|
||||
isGlassesDisplayName(display.name) ||
|
||||
(Build.VERSION.SDK_INT >= 31 && display.deviceProductInfo?.let { product ->
|
||||
@@ -728,7 +725,6 @@ object Glasses {
|
||||
if (device.productId == VitureProtocol.PRO2_PRODUCT_ID) {
|
||||
VitureV2Link.open(usb, device, v2Listener(generation))
|
||||
} else if ((model as? VitureProtocol.Model)?.needsSdk == true) {
|
||||
// Android can evict cacheDir while the SDK is running.
|
||||
Log.i(TAG, "opening VITURE SDK pid=0x%04X spatial=%s".format(device.productId, spatial))
|
||||
VitureSdkLink.open(usb, device, File(app.noBackupFilesDir, "viture"), sdkListener(generation),
|
||||
(model as? VitureProtocol.Model)?.family, spatial)
|
||||
@@ -854,7 +850,6 @@ object Glasses {
|
||||
if (generation != linkGeneration) return@post
|
||||
closeLink()
|
||||
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
|
||||
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 (glassesMode == m.mode(hz, false, outputResolution())) return
|
||||
if (!force) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
/** Hardware properties; display refresh is selected from Android's advertised modes. */
|
||||
interface GlassesModel {
|
||||
val name: String
|
||||
val controllable: Boolean
|
||||
@@ -18,9 +17,6 @@ interface GlassesModel {
|
||||
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 {
|
||||
val hasImu: Boolean
|
||||
val badFrames: Long get() = 0L
|
||||
|
||||
@@ -63,7 +63,6 @@ class GlassesMenuSurface(
|
||||
override fun onDetachedFromWindow() {
|
||||
close()
|
||||
super.onDetachedFromWindow()
|
||||
// GLSurfaceView may exit its GL thread before queued cleanup is consumed.
|
||||
releaseInput()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.droiddeck.launcher.glasses
|
||||
|
||||
import com.droiddeck.launcher.gpu.Cardboard
|
||||
|
||||
/** Share the actual picture mode with settings so controls match the rendered view. */
|
||||
internal object GlassesRendering {
|
||||
enum class Picture { FLAT, DUPLICATED, VIRTUAL_SCREEN, STEREO }
|
||||
|
||||
@@ -51,11 +50,7 @@ internal object GlassesRendering {
|
||||
outputActive: Boolean,
|
||||
steamVrRunning: Boolean,
|
||||
): Cardboard {
|
||||
// Mirror output still uses the glasses' picture and sensors during VR.
|
||||
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(
|
||||
enabled = true,
|
||||
source = Cardboard.SOURCE_SBS,
|
||||
|
||||
@@ -36,7 +36,6 @@ class GlassesScreen(context: Context, display: Display, refreshHz: Int, private
|
||||
fun prefer(hz: Int, resolution: GlassesResolution? = null) {
|
||||
wantHz = hz
|
||||
wantResolution = resolution
|
||||
// A 2D/SBS transition can replace the active mode without changing Hz.
|
||||
applyMode()
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ internal class HandImageBuffer {
|
||||
pixels.clear()
|
||||
pixels.put(rgb)
|
||||
pixels.rewind()
|
||||
// MediaPipe applies the model metadata's float normalization to these RGB bytes.
|
||||
return ByteBufferImageBuilder(pixels, width, height, MPImage.IMAGE_FORMAT_RGB).build()
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,6 @@ class LumaRgbCaptureService : Service() {
|
||||
intent?.getIntExtra("durationMs",5000) ?: 5000) }
|
||||
finally {
|
||||
stopSelf()
|
||||
// SDK 2.4.0 can crash while cancelling failed USB transfers; isolate each diagnostic.
|
||||
android.os.Process.killProcess(android.os.Process.myPid())
|
||||
}
|
||||
}, "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) {
|
||||
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 =
|
||||
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 =
|
||||
"rayneo-cal-v1:$vid:$pid:${serial?.takeIf { it.isNotBlank() } ?: "model:$modelName"}"
|
||||
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) {
|
||||
var phase = CalibrationPhase.SETTLING; private set
|
||||
var progress = 0; private set
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
|
||||
/** RayNeo command/response and IMU share one endpoint. Keep reads on a single worker. */
|
||||
internal class RayNeoLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
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) &&
|
||||
command(mode)
|
||||
}
|
||||
// No VITURE brightness, volume, film, rate or raw command is sent to RayNeo.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -159,7 +157,6 @@ internal class RayNeoLink private constructor(
|
||||
}
|
||||
val wxyz = filter.update(packet.sample)
|
||||
if (wantImu && collector == null && lastSensor >= warmupUntil) {
|
||||
// Normalize basis and component order at the hardware boundary.
|
||||
listener.onPose(raw, RayNeoOrientationFilter.glassesPose(wxyz),
|
||||
SystemClock.elapsedRealtimeNanos())
|
||||
}
|
||||
@@ -176,7 +173,6 @@ internal class RayNeoLink private constructor(
|
||||
override fun close() {
|
||||
synchronized(writeLock) {
|
||||
if (!running) return
|
||||
// Best effort: stop the sensor before releasing the claimed interface.
|
||||
if (ready) runCatching { command(RayNeoProtocol.CMD_CLOSE_IMU) }
|
||||
running = false
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ object RayNeoModel {
|
||||
|
||||
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
|
||||
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)
|
||||
if (vid == RayNeoProtocol.GT_VENDOR_ID && !gt) return null
|
||||
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) ||
|
||||
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 =
|
||||
name?.trim().equals("SmartGlasses", ignoreCase = true) &&
|
||||
manufacturer.equals("TCL", ignoreCase = true) && productId == "980"
|
||||
@@ -33,7 +29,7 @@ object RayNeoModel {
|
||||
override val controllable = true
|
||||
override val brightnessMax = 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 fun mode(hz: Int, sbs: Boolean) =
|
||||
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).
|
||||
// See docs/third-party/rayneo/. Returns scaffold quaternions in (w, x, y, z) order.
|
||||
// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0); see docs/third-party/rayneo/ for license and attribution.
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.cos
|
||||
@@ -123,10 +122,6 @@ internal class RayNeoOrientationFilter(
|
||||
private var lastRealtimeNs: Long = 0L
|
||||
|
||||
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 =
|
||||
floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0])
|
||||
}
|
||||
@@ -168,8 +163,6 @@ internal class RayNeoOrientationFilter(
|
||||
if (accelNorm > 1e-3f) {
|
||||
val measuredUp = accel.normalized()
|
||||
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)
|
||||
q = if (measuredUp.y < -0.9999f) {
|
||||
Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat())
|
||||
@@ -179,7 +172,6 @@ internal class RayNeoOrientationFilter(
|
||||
gravityInitialized = true
|
||||
}
|
||||
val predictedUp = q.conjugate().rotate(worldUp).normalized()
|
||||
// q maps body to world: measured x predicted reduces gravity error.
|
||||
val correction = measuredUp.cross(predictedUp)
|
||||
gyro += correction * accelGain
|
||||
}
|
||||
|
||||
@@ -40,14 +40,13 @@ data class RayNeoEndpointSelection(
|
||||
object RayNeoProtocol {
|
||||
const val VENDOR_ID = 0x1BBB
|
||||
const val PRODUCT_ID = 0xAF50
|
||||
// verncat/RayNeo-Air-3S-Pro-OpenVR PR #3 (MIT), tested on GT hardware.
|
||||
const val GT_VENDOR_ID = 0x3941
|
||||
private const val GT_CONTROL_INTERFACE = 5
|
||||
|
||||
fun isGt(vid: Int, pid: Int) = vid == GT_VENDOR_ID && pid == PRODUCT_ID
|
||||
|
||||
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_SENSOR = 0x65
|
||||
@@ -131,7 +130,6 @@ object RayNeoProtocol {
|
||||
endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
// Exclude small button reports from GT's protocol transport.
|
||||
if (gt && endpoint.maxPacketSize < 64) continue
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.hardware.usb.UsbManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
|
||||
/** Controls are vendor control transfers; the HID interrupt endpoint streams IMU reports unprompted. */
|
||||
internal class RokidLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val usbInterface: UsbInterface,
|
||||
@@ -44,7 +43,7 @@ internal class RokidLink private constructor(
|
||||
?.let { listener.onValue(VitureProtocol.MSG_BRIGHTNESS, RokidModel.brightnessStep(it)) } != null
|
||||
VitureProtocol.MSG_BRIGHTNESS -> value in 0..RokidModel.BRIGHTNESS_STEPS &&
|
||||
control(RokidModel.REQUEST_SET_BRIGHTNESS, RokidModel.brightnessRaw(value), RokidModel.INDEX_BRIGHTNESS)
|
||||
else -> false // Volume is the glasses' USB audio device; there is no film or IMU-rate control.
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +53,6 @@ internal class RokidLink private constructor(
|
||||
if (!running) return null
|
||||
val buffer = ByteArray(64)
|
||||
val count = connection.controlTransfer(VENDOR_IN, request, 0, index, buffer, buffer.size, TIMEOUT_MS)
|
||||
// Display mode is reported in the second byte, brightness in the first.
|
||||
val offset = if (request == RokidModel.REQUEST_GET_DISPLAY) 1 else 0
|
||||
if (count > offset) buffer[offset].toInt() and 0xff else null
|
||||
}
|
||||
@@ -90,7 +88,6 @@ internal class RokidLink private constructor(
|
||||
if (enabled && !wasTracking) fusion = ImuFusion()
|
||||
wasTracking = enabled
|
||||
if (!enabled) continue
|
||||
// Max captures: already right/up/back; level gravity +Y, turn left +gyroY, look up +gyroX, roll right -gyroZ.
|
||||
fusion.update(sample.timeNs, sample.accel, sample.gyro)?.let {
|
||||
if (running && tracking) listener.onPose(buffer.copyOf(count), it, SystemClock.elapsedRealtimeNanos())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
|
||||
import java.nio.ByteBuffer
|
||||
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 {
|
||||
override val controllable = true
|
||||
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 BRIGHTNESS_STEPS = 10
|
||||
|
||||
// Vendor control requests, device recipient.
|
||||
const val REQUEST_SET_DISPLAY = 0x01
|
||||
const val REQUEST_GET_DISPLAY = 0x81
|
||||
const val REQUEST_SET_BRIGHTNESS = 0x02
|
||||
@@ -51,7 +49,6 @@ internal class RokidModel private constructor(override val name: String, private
|
||||
const val MODE_SBS_1200_60 = 5
|
||||
val MODES = setOf(MODE_2D_60, MODE_SBS_1080_60, MODE_2D_120, MODE_SBS_1200_90, MODE_SBS_1200_60)
|
||||
|
||||
// 0x04d2 is a shared vendor ID, so the Rokid product string is required too.
|
||||
fun model(vid: Int, pid: Int, name: String?): GlassesModel? {
|
||||
if (vid != VENDOR_ID || (pid != PRODUCT_ID && pid != MAX2_PRODUCT_ID)) 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)
|
||||
|
||||
/** Device brightness is 0..100; the shared slider uses ten steps. */
|
||||
fun brightnessStep(raw: Int): Int = ((raw.coerceIn(0, 100) + 5) / 10).coerceIn(0, BRIGHTNESS_STEPS)
|
||||
fun brightnessRaw(step: Int): Int = step.coerceIn(0, BRIGHTNESS_STEPS) * 10
|
||||
}
|
||||
@@ -74,7 +70,6 @@ internal object RokidProtocol {
|
||||
private const val SENSOR_ACCEL = 1
|
||||
private const val SENSOR_GYRO = 2
|
||||
|
||||
/** Max sends one combined report; Air sends accel and gyro reports that share a timestamp. */
|
||||
class Parser {
|
||||
private var accel: Vec3? = null
|
||||
private var accelTime = -1L
|
||||
@@ -87,7 +82,7 @@ internal object RokidProtocol {
|
||||
return when (packet[0].toInt() and 0xff) {
|
||||
COMBINED -> Sample(b.getLong(1), vec(b, 9), vec(b, 21), packet[59].toInt() and 0xff)
|
||||
SEPARATE -> {
|
||||
val time = b.getLong(9) * 1000 // Air reports microseconds; Max reports nanoseconds.
|
||||
val time = b.getLong(9) * 1000
|
||||
when (packet[1].toInt() and 0xff) {
|
||||
SENSOR_ACCEL -> { accel = vec(b, 21); accelTime = 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"
|
||||
private val p1 = 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 {
|
||||
val a = Mat.eye(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)
|
||||
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>? {
|
||||
val points = triangulateLandmarksRaw(left,right) ?: return null
|
||||
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())
|
||||
} 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?>? {
|
||||
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()
|
||||
@@ -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())
|
||||
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 {
|
||||
fun load(file: File, identity: String, width: Int, height: Int): StereoCalibration? =
|
||||
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) }
|
||||
}
|
||||
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,
|
||||
size,r,t,e,f,Calib3d.CALIB_FIX_INTRINSIC)
|
||||
} 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)
|
||||
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)
|
||||
var captureHead=observation.headPose(SpatialTracking.session,metadata[6]>0)
|
||||
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
|
||||
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
|
||||
// VIO callbacks can arrive during inference; verify this exact camera stamp before publishing.
|
||||
captureHead=observation.headPose(SpatialTracking.session,
|
||||
metadata[6]>0 || VitureSdk.nativeCameraSynchronized(token,metadata[2]))
|
||||
val hands=Array(2) { SpatialHand() }
|
||||
@@ -631,7 +629,7 @@ internal class StereoHandEngine(private val context: Context, private val token:
|
||||
SpatialTracking.publish(System.nanoTime())
|
||||
}
|
||||
}
|
||||
} catch(e: InterruptedException) { /* Shutdown interrupts the idle wait. */ }
|
||||
} catch(e: InterruptedException) { }
|
||||
catch(e: Throwable) {
|
||||
message=context.getString(R.string.spatial_hand_error, e.message.orEmpty())
|
||||
val unavailable=HandObservationDiagnostic(HandObservationOutcome.INFERENCE_UNAVAILABLE)
|
||||
|
||||
@@ -31,7 +31,6 @@ internal class StereoInferenceRunner : AutoCloseable {
|
||||
catch (e: ExecutionException) { throw e.cause ?: e }
|
||||
}
|
||||
} finally {
|
||||
// Native inference must finish before either camera's pixels or detector can be reused.
|
||||
if (interrupted) Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,6 @@ internal object UvcRgbDiagnostic {
|
||||
internal data class UvcStreamHealth(val observationMs: Double, val observedFps: Double,
|
||||
val maximumGapMs: Double, val healthy: Boolean) {
|
||||
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 {
|
||||
require(metadata.size == 14 && requestedFps > 0 && durationMs > 0)
|
||||
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? {
|
||||
val family = when (marketName) {
|
||||
"One", "Lite" -> Family.ONE
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.droiddeck.launcher.glasses
|
||||
import android.util.Log
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** JNI adapter to VITURE's Android C SDK. The vendor binary is a local build input. */
|
||||
internal object VitureSdk {
|
||||
val available: Boolean by lazy {
|
||||
runCatching {
|
||||
@@ -27,7 +26,6 @@ internal object VitureSdk {
|
||||
external fun nativeSpatialDiagnostics(token: Long): DoubleArray?
|
||||
external fun nativeTracking(token: Long, enabled: Boolean): 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 nativeNextState(token: Long, event: IntArray): Int
|
||||
external fun nativeClose(token: Long)
|
||||
@@ -48,12 +46,9 @@ internal object VitureSpatialIdentity {
|
||||
}
|
||||
|
||||
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?) =
|
||||
carina || family == VitureProtocol.Family.PRO2
|
||||
|
||||
/** SDK quaternions are wxyz; normalize their coordinate basis at the boundary. */
|
||||
fun toGlasses(wxyz: FloatArray, openGlAxes: Boolean): FloatArray? {
|
||||
if (wxyz.size != 4 || wxyz.any { !it.isFinite() }) return null
|
||||
val length = sqrt(wxyz.sumOf { it.toDouble() * it }.toFloat())
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/** SDK owns interface selection (including Ultra bulk endpoints) and all USB I/O. */
|
||||
internal class VitureSdkLink private constructor(
|
||||
private val connection: UsbDeviceConnection,
|
||||
private val token: Long,
|
||||
@@ -47,7 +46,6 @@ internal class VitureSdkLink private constructor(
|
||||
if (rc == 0 || !wanted) tracking = wanted
|
||||
return result("tracking", rc)
|
||||
}
|
||||
// Frequency is negotiated against SDK capabilities, including Pro 2's pose limit.
|
||||
VitureProtocol.MSG_IMU_RATE -> return tracking
|
||||
VitureProtocol.MSG_GET_IMU -> return true
|
||||
}
|
||||
@@ -89,7 +87,6 @@ internal class VitureSdkLink private constructor(
|
||||
if (VitureSdk.nativeNextState(token, event) == 1) when (event[0]) {
|
||||
0 -> listener.onValue(VitureProtocol.MSG_GET_BRIGHTNESS, 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)
|
||||
3 -> listener.onValue(VitureProtocol.MSG_FILM, event[1])
|
||||
}
|
||||
@@ -122,7 +119,6 @@ internal class VitureSdkLink private constructor(
|
||||
if (!running) return
|
||||
running = false
|
||||
VitureSdk.nativeClose(token)
|
||||
// Keep Java's descriptor alive until the SDK and its worker threads have stopped.
|
||||
connection.close()
|
||||
}
|
||||
if (Thread.currentThread() !== worker) worker.join(1000)
|
||||
|
||||
@@ -69,7 +69,6 @@ internal class VitureV2Link internal constructor(
|
||||
return true
|
||||
}
|
||||
if (msgId == VitureProtocol.MSG_GET_IMU) return true
|
||||
// Keep the measured 120 Hz mode; the generic controller requests 240 Hz.
|
||||
if (msgId == VitureProtocol.MSG_IMU_RATE) return false
|
||||
val query = when (msgId) {
|
||||
VitureProtocol.MSG_GET_BRIGHTNESS -> VitureV2Protocol.MSG_GET_BRIGHTNESS
|
||||
@@ -146,9 +145,7 @@ internal class VitureV2Link internal constructor(
|
||||
tracking(true)
|
||||
if (becameReady) listener.onReady()
|
||||
val q = pose.quaternion
|
||||
// Upstream right/up/back pose converted to the shared VITURE legacy basis.
|
||||
val shared = floatArrayOf(-q[2], -q[0], q[1], q[3])
|
||||
// Upstream does not establish device tick units; use host monotonic receipt time.
|
||||
if (running && wanted && streamOn) listener.onPose(received.bytes, shared, received.timeNs)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick.
|
||||
// Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
|
||||
// Adapted from elasticjava/viture-v2, MIT, Copyright (c) 2026 Holger Bartnick. Source: ece8c30485f7d357fefa362ae74f3eabac9b3899; see docs/third-party/viture-v2/.
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.sqrt
|
||||
@@ -37,7 +36,6 @@ object VitureV2Protocol {
|
||||
val success: Boolean get() = status == 0
|
||||
}
|
||||
|
||||
// Device ticks have no established time unit; quaternion is xyzw with no axis remapping.
|
||||
class Pose internal constructor(
|
||||
val deviceTick: Long,
|
||||
val quaternion: FloatArray,
|
||||
@@ -64,7 +62,6 @@ object VitureV2Protocol {
|
||||
}
|
||||
|
||||
fun imuControl(streams: Int, rate: Rate = Rate.HZ_120): ByteArray {
|
||||
// Upstream measured that stream mask 3 ACKs but produces no events on Pro 2.
|
||||
require(streams == STREAM_OFF || streams == STREAM_POSE || streams == STREAM_RAW)
|
||||
require(streams != STREAM_POSE || rate.hz <= 240)
|
||||
val wireRate = if (streams == STREAM_OFF) 0 else rate.wireValue
|
||||
|
||||
@@ -24,7 +24,6 @@ internal interface XrealConnector : Closeable {
|
||||
|
||||
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 {
|
||||
private val manager = context.getSystemService(ConnectivityManager::class.java)
|
||||
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.atomic.AtomicReference
|
||||
|
||||
/** One connection owns two TCP channels. All control requests are serialized off the UI thread. */
|
||||
internal class XrealLink internal constructor(
|
||||
private val connector: XrealConnector,
|
||||
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_BRIGHTNESS -> value in 0..9
|
||||
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))
|
||||
}
|
||||
@@ -88,7 +87,7 @@ internal class XrealLink internal constructor(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failure.set(e)
|
||||
current.close() // Wake a control read immediately.
|
||||
current.close()
|
||||
}
|
||||
}, "xreal-reports").apply { isDaemon = true; start() }
|
||||
ready = true
|
||||
@@ -145,12 +144,11 @@ internal class XrealLink internal constructor(
|
||||
tracking = false
|
||||
commands.clear()
|
||||
connection.getAndSet(null)?.close()
|
||||
connector.close() // Includes sockets still inside connect().
|
||||
connector.close()
|
||||
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) {
|
||||
private var transaction = 0
|
||||
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.flush()
|
||||
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")
|
||||
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 {
|
||||
val stereo = mode == 5
|
||||
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 }
|
||||
if (current == mode) return current
|
||||
set(XrealProtocol.SET_DISPLAY, mode)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.droiddeck.launcher.glasses
|
||||
|
||||
/** First One-family target. Recovery and Air USB products must not use this transport. */
|
||||
object XrealModel : GlassesModel {
|
||||
const val VENDOR_ID = 0x3318
|
||||
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 controllable = true
|
||||
override val brightnessMax = 9
|
||||
@@ -16,7 +15,6 @@ object XrealModel : GlassesModel {
|
||||
fun matches(vid: Int, pid: Int) = vid == VENDOR_ID && pid == PRODUCT_1S
|
||||
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 {
|
||||
sbs -> 5
|
||||
hz >= 120 -> 4
|
||||
|
||||
@@ -2,19 +2,14 @@ package com.droiddeck.launcher.glasses
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */
|
||||
internal class XrealOrientationFilter {
|
||||
private val fusion = ImuFusion()
|
||||
|
||||
// 1S reports right/down/forward. Rotate 180 degrees about X into the
|
||||
// shared right/up/back basis before either gravity alignment or fusion.
|
||||
// Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ.
|
||||
fun update(sample: XrealProtocol.Sample): FloatArray? = fusion.update(sample.timeNs,
|
||||
Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z),
|
||||
Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z))
|
||||
}
|
||||
|
||||
/** Gyro integration with gravity correction in the shared right/up/back basis, rad/s and device ns. */
|
||||
internal class ImuFusion {
|
||||
private var previous = 0L
|
||||
private var rotation = Quaternion.IDENTITY
|
||||
@@ -27,7 +22,7 @@ internal class ImuFusion {
|
||||
previous = timeNs
|
||||
val norm = accel.length()
|
||||
val gravityValid = abs(norm - 9.81f) < 1.5f
|
||||
if (dt > 0.25f) initialized = false // Never integrate a reconnect/stall as motion.
|
||||
if (dt > 0.25f) initialized = false
|
||||
if (!initialized) {
|
||||
if (!gravityValid) return null
|
||||
val up = accel.normalized()
|
||||
@@ -47,7 +42,6 @@ internal class ImuFusion {
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/** Independent implementation of the One-family wire format; see xreal-investigation.md. */
|
||||
internal object XrealProtocol {
|
||||
const val HOST = "169.254.2.1"
|
||||
const val REPORT_PORT = 52998
|
||||
@@ -46,7 +45,7 @@ internal object XrealProtocol {
|
||||
fun sample(frame: Frame): Sample? {
|
||||
if (frame.command != REPORT || frame.body.size != 128) return null
|
||||
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 gyro = Vec3(b.getFloat(28), b.getFloat(32), b.getFloat(36))
|
||||
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()
|
||||
}
|
||||
|
||||
/** No acknowledgement is inferred from a write; match both command and transaction. */
|
||||
fun response(frame: Frame, command: Int, transaction: Int): ByteArray? {
|
||||
if (frame.command != command || frame.body.size < 4 ||
|
||||
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()
|
||||
|
||||
/**
|
||||
* Counted before the compositor loads the trial display driver, so a driver that kills the process during
|
||||
* Vulkan init still counts towards a rollback; sessionStarting runs too late for that.
|
||||
*/
|
||||
fun displayStarting(context: Context, display: String) {
|
||||
val p = prefs(context)
|
||||
if (p.getString("release", null) == null || p.getString("display", "") != display) return
|
||||
|
||||
@@ -29,9 +29,10 @@ object AutoPicks {
|
||||
val BUILT_IN = listOf(
|
||||
Rule(setOf(Family.A8XX), models = setOf(840), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A8XX), prefer = listOf(DriverPairs.WN_BALANCED)),
|
||||
Rule(setOf(Family.A7XX_LOW), prefer = listOf(DriverPairs.BANNER_710)),
|
||||
Rule(setOf(Family.A7XX_LOW), prefer = listOf(NEWEST_DD_TURNIP, DriverPairs.BANNER_710)),
|
||||
Rule(setOf(Family.A7XX), oneUi8Gen2 = true, prefer = listOf(DriverPairs.BANNER_ONEUI)),
|
||||
Rule(setOf(Family.A7XX, Family.A6XX), prefer = listOf(DriverPairs.BANNER)),
|
||||
Rule(setOf(Family.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), prefer = listOf(DriverPairs.BANNER)),
|
||||
)
|
||||
|
||||
@@ -52,7 +52,6 @@ object FrameGen {
|
||||
|
||||
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? {
|
||||
when (mode.engine) {
|
||||
ENGINE_LSFG -> {
|
||||
|
||||
@@ -25,14 +25,6 @@ public final class LinuxVulkanDriver {
|
||||
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) {
|
||||
if (value == null || value.isEmpty()) return null;
|
||||
LinuxVulkanDriverManager m = new LinuxVulkanDriverManager(context);
|
||||
|
||||
@@ -6,7 +6,6 @@ import java.io.RandomAccessFile
|
||||
import java.nio.file.Files
|
||||
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) {
|
||||
private var identity: Any? = identity()
|
||||
private var position = file.length()
|
||||
@@ -34,7 +33,6 @@ internal class SteamVrDeviceLossLog(private val file: File) {
|
||||
}
|
||||
identity = current
|
||||
prefix = head
|
||||
// Fault loops can produce megabytes per second. Bound work and keep the newest output.
|
||||
if (input.length() - position > MAX_READ) {
|
||||
position = input.length() - MAX_READ
|
||||
partial = ""
|
||||
|
||||
@@ -11,7 +11,6 @@ import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionEvents
|
||||
import java.io.File
|
||||
|
||||
/** Process-wide cursor survives Activity recreation; a new session ignores older log errors. */
|
||||
internal object SteamVrHealth {
|
||||
private var session: String? = null
|
||||
private var log: SteamVrDeviceLossLog? = null
|
||||
@@ -49,7 +48,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
|
||||
.create()
|
||||
prompt.setOnDismissListener { countdown?.let(handler::removeCallbacks); countdown = null; dialog = null }
|
||||
dialog = prompt
|
||||
// A touch meant for the game must not silently dismiss the recovery controls.
|
||||
prompt.setCanceledOnTouchOutside(false)
|
||||
prompt.show()
|
||||
if (automatic) {
|
||||
@@ -66,7 +64,6 @@ internal class SteamVrRecovery(private val activity: Activity, private val foreg
|
||||
|
||||
private fun restart() {
|
||||
if (!foreground()) return
|
||||
// Commit before crossing processes so a repeated fault cannot create a restart loop.
|
||||
if (!activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||
.putLong(LAST_ATTEMPT, System.currentTimeMillis()).commit()) return
|
||||
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
Reference in new issue
Block a user