mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-07 02:36:02 -10:00
chore: remove source comments and unused code, translate every missing string (#1965)
* chore(i18n): translate every missing string in all locales Adds the 87 strings the bottom-screen Dashboard and in-game stats pages introduced to all 32 locales, plus the 51 older settings strings Japanese and the 50 Chinese locales were still missing. Every locale now has all 3456 translatable strings with the same placeholders as English. Also translates 36 entries that were still English where the language does not use the English word, and makes es-419 touch_app_id use the same %1$s placeholder as English. * chore(patches): drop comments from the lines downstream patches add Removes comment lines and trailing comments from lines our patches add, with hunk headers recounted. Lines that a later patch uses as context and the upstream backport 0123 are left as they are. The gamescope, FEX, proot and xodus series still apply to their pinned sources, and the patched trees differ only in those comments. test_hud_frames now finds the HUD writer by its first declaration. * docs: credit Greenlight, Better xCloud, viture-v2, GameNative and DiRTTelemetryErrorFix These were credited only in source headers and NOTICE files. * chore: remove source comments and unused functions Removes explanatory comments from first-party Kotlin, Java, C, C++, Rust, TypeScript, Python, shell, Gradle, CMake and workflow files. License and attribution headers stay, and vendored third-party trees, generated files and license files are untouched. A shellcheck directive is kept, and udevmon's fallthrough comments became fallthrough attributes. Syntax trees and token streams match the previous sources apart from the comments and Python docstrings. Module docstrings that argparse prints are kept, and device-smoke.sh prints the same --help text from a heredoc. Also removes 37 functions and 4 fields that nothing referenced. * chore: remove comments from the Syncthing integration and the latest main sources * docs: credit Syncthing and the syncthing-android fork * chore(i18n): translate the Syncthing strings in all locales The 107 strings the Syncthing integration added are now in all 32 locales. sync_web_path_locked names the Syncthing tab by its label. * docs: credit @xXJSONDeruloXx for the HUD metric readers The CPU, GPU load, thermal and battery readers come from their PerformanceHudView pull requests #777, #877, #991, #1406 and #1573. The FusionHUD credit line is unchanged. * docs: credit the HUD metric readers to @xXJSONDeruloXx's work
This commit is contained in:
1 parent
d730f0fa8b
commit
c5577478ca
511 files changed
+6813
-4068
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' }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -78,6 +78,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- libblsession runs Wine's arm64 `SIGUSR2` context handler (`dlls/ntdll/unix/signal_arm64.c`, LGPL-2.1-or-later) in place on a frame laid out like [Linux](https://github.com/torvalds/linux)'s `arch/arm64/kernel/signal.c`, and serves `fill_vm_counters` in `dlls/ntdll/unix/process.c` the kernel's own `/proc/<pid>/status`.
|
||||
- droiddeck-ntsync follows the [Linux](https://github.com/torvalds/linux) ntsync semantics in `Documentation/userspace-api/ntsync.rst` (GPL-2.0).
|
||||
- The proot fast path orders bindings like [PRoot](https://github.com/proot-me/proot)'s `path/binding.c` (GPL-2.0-or-later).
|
||||
- DiRT 3 and DiRT Showdown keep their telemetry socket the way RoccoC's DiRTTelemetryErrorFix (MIT) does, based on the fix by Carsten Gottschling.
|
||||
- Games use [Mesa](https://gitlab.freedesktop.org/mesa/mesa) Turnip's per render pass memory choice. The Steam client keeps `TU_DEBUG=sysmem`.
|
||||
- Black Ops III and Dark Souls Remastered start without [umu-protonfixes](https://github.com/Open-Wine-Components/umu-protonfixes) (BSD-2-Clause), whose `311210.py` and `570940.py` fail under ARM64 Proton.
|
||||
- [ReSkate](https://github.com/Dingo-Shenanigans/ReSkate) (GPL-3.0) by Dingo Shenanigans uses the launch option from its README and D3D12 feature level 12_1.
|
||||
@@ -93,8 +94,10 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
- Battle.net is by [@kayakyakr](https://github.com/kayakyakr). It runs Blizzard's own client in Proton (downloaded from Blizzard, not included) and shows key art from the [Battle.net Shop](https://shop.battle.net). Without Steam, its games see the pad as an Xbox 360 controller via `PROTON_SPOOF_STEAMINPUT_VIDPID` in [Proton](https://github.com/ValveSoftware/wine)'s `dlls/winebus.sys`.
|
||||
- Battle.net installs drive Blizzard's Agent the way [@barncastle](https://github.com/barncastle)'s [Battle.Net-Installer](https://github.com/barncastle/Battle.Net-Installer) does, with requests from [wowdev.wiki](https://wowdev.wiki/Agent). Read without taking code: [@mubaraknumann](https://github.com/mubaraknumann)'s [unifideck](https://github.com/mubaraknumann/unifideck), [@djmittens](https://github.com/djmittens)'s [neo-mittens](https://github.com/djmittens/neo-mittens) `bnetswitch`, [@BCD1210](https://github.com/BCD1210)'s [soju](https://github.com/BCD1210/soju) and [Lutris](https://github.com/lutris/lutris)'s `product.db` reader.
|
||||
- Xbox games load [@domsvoboda](https://github.com/domsvoboda)'s [xodus-nexus](https://github.com/domsvoboda/xodus-nexus) modules (LGPL-2.1-or-later), including `wintypes.dll`. Minecraft's GameInput setup was checked against [@Wyze3306](https://github.com/Wyze3306)'s [BedrockOnLinux](https://github.com/Wyze3306/BedrockOnLinux) (MIT).
|
||||
- The Xbox Cloud Gaming player uses the WebRTC client of [@unknownskl](https://github.com/unknownskl)'s [Greenlight](https://github.com/unknownskl/greenlight) (MIT), and its offer tweaks and sharpening follow [@redphx](https://github.com/redphx)'s [Better xCloud](https://github.com/redphx/better-xcloud) (MIT). See `tools/xcloud-player/NOTICE.md`.
|
||||
- The itch.io butler pack ships butler's `7z.so`, itch.io's build of [7-Zip](https://www.7-zip.org) by Igor Pavlov (LGPL-2.1, unRAR restriction on RAR code), unmodified. Other archives use Apache Commons Compress and the runtime's bsdtar (libarchive).
|
||||
- The Store uses [Flatpak](https://github.com/flatpak/flatpak) (LGPL-2.1) through DroidDeck's own bubblewrap shim. Chromium and Electron apps start directly, since [zypak](https://github.com/refi64/zypak) by [@refi64](https://github.com/refi64) (BSD-3-Clause) always uses Flatpak's spawn portal.
|
||||
- Folder sync runs [Syncthing](https://github.com/syncthing/syncthing) (MPL-2.0), built from [@researchxxl](https://github.com/researchxxl)'s [syncthing-android](https://github.com/researchxxl/syncthing-android) fork (MPL-2.0).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -103,6 +106,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
|
||||
- Steam audio goes through [@The412Banner](https://github.com/The412Banner)'s [DirectAudio](https://github.com/The412Banner/directaudio) PulseAudio sink (LGPL-2.1-or-later), built from `tools/directaudio/sink`.
|
||||
- The DroidDeck HUD is [@The412Banner](https://github.com/The412Banner)'s [FusionHUD](https://github.com/The412Banner/FusionHUD) (GPL-3.0 with an attribution term). Its readings match Valve's [MangoHud](https://github.com/flightlessmango/MangoHud) build (MIT), with frame times from gamescope patch 0125.
|
||||
- The HUD's CPU, GPU load, thermal and battery readers come from [@xXJSONDeruloXx](https://github.com/xXJSONDeruloXx)'s work.
|
||||
- LSFG frame generation comes from Camille LaVey and the [Eden](https://eden-emu.dev) project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported by [@maxjivi05](https://github.com/maxjivi05). It needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders.
|
||||
- Remote Play decodes through Android's MediaCodec or the [FFmpeg](https://ffmpeg.org) (LGPL-2.1-or-later) in Steam's runtime. Qualcomm low latency settings follow [Moonlight](https://github.com/moonlight-stream/moonlight-android) (GPL-3.0).
|
||||
- [PipeWire](https://gitlab.freedesktop.org/pipewire/pipewire) (MIT) sockets use the settings in its `module-access.c`, and [WirePlumber](https://gitlab.freedesktop.org/pipewire/wireplumber) (MIT) keeps those clients at full permissions.
|
||||
@@ -125,6 +129,7 @@ DroidDeck is [GPL-3.0](LICENSE). Steam and Proton belong to Valve Corporation. V
|
||||
<summary><b>Smart glasses</b></summary>
|
||||
|
||||
- Builds on WinNative's VITURE work. Protocol checked against [@wheaney](https://github.com/wheaney)'s [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver), [@badicsalex](https://github.com/badicsalex)'s [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) and [go-macos/iokit](https://pkg.go.dev/github.com/go-macos/iokit/viture). The optional VITURE SDK is not included.
|
||||
- The VITURE protocol V2 frames adapt [@elasticjava](https://github.com/elasticjava)'s [viture-v2](https://github.com/elasticjava/viture-v2) (MIT) by Holger Bartnick. See `docs/third-party/viture-v2`.
|
||||
- RayNeo adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0). See [docs/third-party/rayneo](docs/third-party/rayneo/README.md).
|
||||
- XREAL 1S references XRLinuxDriver and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [@rohitsangwan01](https://github.com/rohitsangwan01)'s [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) (MIT) and [@0xcaff](https://github.com/0xcaff)'s [xr-tools](https://github.com/0xcaff/xr-tools).
|
||||
- Rokid follows ar-drivers-rs, @badicsalex's [Void Computing write-ups](https://voidcomputing.hu/blog/worse-better-prettier/) and Monado's Rokid driver.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -219,7 +215,6 @@ android {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
pickFirsts += ['**/libtermux.so']
|
||||
// Shipped in the proot and tracking packs (tools/packs) and run or loaded from there.
|
||||
excludes += ['**/libproot.so', '**/libproot-loader.so', '**/libopencv_java4.so', '**/libmediapipe_tasks_jni.so']
|
||||
}
|
||||
}
|
||||
@@ -238,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
|
||||
@@ -255,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) {
|
||||
@@ -300,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' }
|
||||
@@ -374,7 +366,6 @@ 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'
|
||||
|
||||
@@ -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")
|
||||
@@ -45,7 +44,6 @@ 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -973,7 +973,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]);
|
||||
|
||||
@@ -71,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};
|
||||
@@ -477,7 +475,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]);
|
||||
@@ -489,7 +486,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) {
|
||||
@@ -1701,9 +1697,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);
|
||||
@@ -1803,9 +1796,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) {
|
||||
@@ -1817,9 +1807,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);
|
||||
@@ -1856,8 +1843,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,7 +31,6 @@ 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
|
||||
}
|
||||
@@ -62,7 +60,6 @@ class App : Application() {
|
||||
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({
|
||||
|
||||
@@ -717,7 +717,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)
|
||||
@@ -1797,7 +1796,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)
|
||||
@@ -2491,7 +2489,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
|
||||
|
||||
@@ -265,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
|
||||
@@ -513,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()
|
||||
@@ -571,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()
|
||||
@@ -582,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
|
||||
}
|
||||
@@ -718,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)) {
|
||||
@@ -925,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" }
|
||||
@@ -1119,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
|
||||
@@ -1480,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)
|
||||
@@ -2215,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
|
||||
@@ -2637,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
|
||||
@@ -2654,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 {
|
||||
@@ -2832,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) {
|
||||
@@ -3156,7 +3136,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
|
||||
}),
|
||||
)
|
||||
|
||||
/** 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 },
|
||||
@@ -3164,9 +3143,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)
|
||||
@@ -3178,7 +3154,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()
|
||||
@@ -3199,9 +3174,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
|
||||
@@ -3643,23 +3616,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
|
||||
@@ -3880,7 +3841,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
|
||||
@@ -3925,8 +3885,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
|
||||
@@ -4163,8 +4121,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)
|
||||
@@ -4357,8 +4313,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")
|
||||
@@ -4437,8 +4391,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
|
||||
|
||||
@@ -180,7 +180,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 +314,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>()
|
||||
|
||||
@@ -87,7 +87,6 @@ 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 })
|
||||
return listOfNotNull(
|
||||
@@ -106,7 +105,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 +177,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 +217,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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -5,13 +5,11 @@ import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
internal object SteamVrRecoverySettings {
|
||||
/** After a graphics crash SteamVR can quarantine our HMD, leaving a server with no VR output. */
|
||||
fun unblockHeadset(file: File): Boolean {
|
||||
if (!file.isFile) return false
|
||||
val settings = JSONObject(file.readText())
|
||||
val driver = settings.optJSONObject("driver_droiddeck") ?: return false
|
||||
if (!driver.optBoolean("blocked_by_safe_mode")) return false
|
||||
// Preserve every other setting, including safe mode and blocks on other drivers.
|
||||
driver.put("blocked_by_safe_mode", false)
|
||||
val atomic = AtomicFile(file)
|
||||
val output = atomic.startWrite()
|
||||
|
||||
@@ -14,7 +14,6 @@ import com.droiddeck.launcher.SessionActivity
|
||||
import com.droiddeck.launcher.session.SessionService
|
||||
import org.json.JSONObject
|
||||
|
||||
/** A visible, private process survives destruction of the app's native graphics context. */
|
||||
class SteamVrRestartActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -41,7 +40,6 @@ class SteamVrRestartActivity : Activity() {
|
||||
}, SystemClock::elapsedRealtime, SystemClock::sleep)
|
||||
if (shutdown != SteamVrRecoveryPolicy.Shutdown.COMPLETE)
|
||||
android.util.Log.w("SteamVrRecovery", "Restarting before the session finished stopping: $shutdown")
|
||||
// Only the verified main process, never a caller-supplied PID or an external app.
|
||||
Process.killProcess(main.pid)
|
||||
val exited = SystemClock.elapsedRealtime() + 5000
|
||||
while (java.io.File("/proc/${main.pid}").exists() && SystemClock.elapsedRealtime() < exited) SystemClock.sleep(50)
|
||||
|
||||
@@ -13,9 +13,6 @@ object TpUbwcHint {
|
||||
@Volatile var activeMode: String? = null
|
||||
private set
|
||||
|
||||
// On Adreno 740, TPL1_DBG_ECO_CNTL1.TP_UBWC_FLAG_HINT is shared by every driver on the GPU and must
|
||||
// match the system driver. A mismatch corrupts Android UI.
|
||||
// Samsung's One UI firmware sets it whatever blob version it reports.
|
||||
internal fun features(model: Int, systemDriverVersion: String?, samsung: Boolean = false,
|
||||
mode: String = SessionPrefs.TP_UBWC_AUTO): String? {
|
||||
if (model != 740) return null
|
||||
|
||||
@@ -8,7 +8,6 @@ import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.FileChannel
|
||||
|
||||
/** Hand-tracking models and the OpenCV/MediaPipe native libraries, downloaded on first use. */
|
||||
internal object TrackingPack {
|
||||
const val HAND = "hand_landmarker.task"
|
||||
const val HAND_LITE = "hand_landmarker_lite.task"
|
||||
@@ -32,10 +31,6 @@ internal object TrackingPack {
|
||||
fun model(context: Context, name: String): ByteBuffer =
|
||||
FileInputStream(file(context, name)).use { it.channel.map(FileChannel.MapMode.READ_ONLY, 0, it.channel.size()) }
|
||||
|
||||
/**
|
||||
* Loads OpenCV and MediaPipe from the pack. MediaPipe's own System.loadLibrary then resolves to the
|
||||
* already-loaded library by soname, so this must run before any MediaPipe task class initializes.
|
||||
*/
|
||||
fun loadNative(context: Context): Boolean {
|
||||
if (nativeLoaded) return true
|
||||
synchronized(lock) {
|
||||
@@ -50,7 +45,6 @@ internal object TrackingPack {
|
||||
}
|
||||
}
|
||||
|
||||
/** Blocks the calling worker thread until the pack is usable, starting the download if needed. */
|
||||
fun awaitNative(context: Context, running: () -> Boolean, onState: (AddonPack.State) -> Unit): Boolean =
|
||||
pack.await(context, running, onState) && loadNative(context)
|
||||
}
|
||||
@@ -3,12 +3,7 @@ package com.droiddeck.launcher.hud
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
|
||||
/** DroidDeck HUD's settings as one "key=value,…" string (DroidDeckHudView.applyConfig), stored in SessionPrefs. */
|
||||
internal object DroidDeckHudConfig {
|
||||
/**
|
||||
* Bannerlator's defaults (DeckOverlaySettings), with the FPS graph from the old DroidDeck HUD. The HUD starts
|
||||
* locked, so like the old HUD it lets touches through until it is unlocked to move it.
|
||||
*/
|
||||
val DEFAULTS: Map<String, String> = linkedMapOf(
|
||||
"hudSize" to "minimal", "hudLocked" to "1", "hudSnap" to "1", "hudScale" to "100", "hudDock" to "none", "hudOpacity" to "80", "hudColor" to "mid",
|
||||
"hudOutline" to "40", "hudOutlineAccent" to "1",
|
||||
@@ -20,7 +15,6 @@ internal object DroidDeckHudConfig {
|
||||
"tempUnit" to "c", "tempBands" to "1", "tempAuto" to "1", "tempRedCpu" to "90", "tempRedGpu" to "90", "tempRedBat" to "48",
|
||||
)
|
||||
|
||||
/** Switches Bannerlator sets together (DeckOverlaySettings twins). */
|
||||
private val TWINS = mapOf("showCPUUsage" to "showCPULoad", "showTemp" to "showBatteryTemp", "showEngine" to "showRenderer")
|
||||
|
||||
fun load(context: Context): MutableMap<String, String> {
|
||||
|
||||
@@ -19,10 +19,6 @@ import com.droiddeck.launcher.wayland.WaylandCompositor
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* DroidDeck HUD on the session screen, in place of the old text HUD: the view, the frame feed from gamescope
|
||||
* (MangoHud's frame times) and MangoHud's readings, started with the session's first frame.
|
||||
*/
|
||||
class DroidDeckHudHost(context: Context) {
|
||||
private val context = context.applicationContext
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@@ -36,8 +32,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
private var polling = false
|
||||
private var wantX = Float.NaN
|
||||
private var wantY = Float.NaN
|
||||
// With snapping on, the side the HUD keeps to (0 = left, 1 = right), so it stays against that side when its
|
||||
// size changes. NaN = not snapped.
|
||||
private var anchorX = Float.NaN
|
||||
private var snaps = true
|
||||
private var snapping = false
|
||||
@@ -51,13 +45,10 @@ class DroidDeckHudHost(context: Context) {
|
||||
|
||||
var onPresentingWindowChanged: (() -> Unit)? = null
|
||||
|
||||
/** The frame rate the HUD shows; null while the HUD is off and not counting frames. */
|
||||
val liveFps: Float? get() = if (polling) feed.stats.fps else null
|
||||
|
||||
/** The HUD itself changed a setting: a tap cycles the size, a long press locks it. */
|
||||
var onConfigChanged: (() -> Unit)? = null
|
||||
|
||||
/** Screen point → whether an on-screen control takes a touch there; a locked HUD lets those through. */
|
||||
var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
|
||||
|
||||
private val listener = object : WaylandCompositor.GameListener {
|
||||
@@ -86,7 +77,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
get() = SessionPrefs.hudEnabled(context) &&
|
||||
!File(Environment.getExternalStorageDirectory(), "Download/droiddeck-no-hud").exists()
|
||||
|
||||
/** Adds the HUD to [root] at [index] (above the game surface, under the session overlay). */
|
||||
fun attach(root: FrameLayout, index: Int) {
|
||||
if (view != null) return
|
||||
val hud = DroidDeckHudView(root.context)
|
||||
@@ -171,13 +161,11 @@ class DroidDeckHudHost(context: Context) {
|
||||
view?.visibility = View.GONE
|
||||
}
|
||||
|
||||
/** Picture-in-picture and similar: keep running, draw nothing. */
|
||||
fun setHidden(hide: Boolean) {
|
||||
hidden = hide
|
||||
applyShown()
|
||||
}
|
||||
|
||||
/** The session screen is stopped (another app in front): draw nothing and stop reading sensors. */
|
||||
fun setBackgrounded(stopped: Boolean) {
|
||||
backgrounded = stopped
|
||||
applyShown()
|
||||
@@ -188,7 +176,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
val show = !hidden && !backgrounded
|
||||
view?.visibility = if (show) View.VISIBLE else View.GONE
|
||||
showing = show
|
||||
// Frames are read once per display frame only while the HUD is drawn.
|
||||
if (show != polling) {
|
||||
polling = show
|
||||
if (show) Choreographer.getInstance().postFrameCallback(frameCallback)
|
||||
@@ -196,14 +183,11 @@ class DroidDeckHudHost(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** A sensor report (DroidDeck HUD's diagnostics) in Downloads/DroidDeck; null when it can't be written. */
|
||||
fun exportDiagnostics(): File? = runCatching {
|
||||
val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "DroidDeck")
|
||||
dir.mkdirs()
|
||||
val stamp = java.text.SimpleDateFormat("yyyyMMdd-HHmmss", java.util.Locale.US).format(java.util.Date())
|
||||
val file = File(dir, "HUD-diagnostics-$stamp.txt")
|
||||
// Its own reader: the HUD's CPU load is the change since its previous sample, which an export must
|
||||
// not move. Two samples half a second apart, MangoHud's sampling period.
|
||||
val metrics = HudMetrics(context)
|
||||
metrics.setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context)))
|
||||
metrics.snapshot()
|
||||
@@ -243,17 +227,14 @@ class DroidDeckHudHost(context: Context) {
|
||||
return if (stats.size > 3 && stats[3] > 1f) stats[3] else 0f
|
||||
}
|
||||
|
||||
/** The edge the HUD was dropped over, once a good part of it hangs past that edge; null when it stays. */
|
||||
private fun dockEdgeFor(v: View): DroidDeckHudDock? {
|
||||
val over = overhang(v) ?: return null
|
||||
return over.key.takeIf { over.value >= DOCK_OVERHANG }
|
||||
}
|
||||
|
||||
/** The edge the HUD hangs furthest past, with how much of it does, as a share of its own size. */
|
||||
private fun overhang(v: View): Map.Entry<DroidDeckHudDock, Float>? {
|
||||
val p = v.parent as? View ?: return null
|
||||
if (p.width <= 0 || p.height <= 0 || v.width <= 0 || v.height <= 0) return null
|
||||
// The Strip spans the screen and is short: measure its pull in a set distance rather than its own size.
|
||||
val strip = (v as? DroidDeckHudView)?.isStrip == true
|
||||
val density = v.resources.displayMetrics.density
|
||||
val w = if (strip) minOf(v.width.toFloat(), STRIP_PULL_ACROSS_DP * density) else v.width.toFloat()
|
||||
@@ -266,7 +247,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
).maxByOrNull { it.value }
|
||||
}
|
||||
|
||||
/** The tab follows the finger along its edge and away from it, staying on screen. */
|
||||
private fun dragTab(hud: DroidDeckHudView, x: Float, y: Float) {
|
||||
val p = hud.parent as? View ?: return
|
||||
stopMotion(hud)
|
||||
@@ -275,7 +255,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
hud.y = y.coerceIn(0f, (p.height - hud.height).toFloat().coerceAtLeast(0f))
|
||||
}
|
||||
|
||||
/** A tab pulled out by more than its own size comes out as the HUD there; a shorter pull slides it back onto its edge. */
|
||||
private fun dropTab(hud: DroidDeckHudView) {
|
||||
val p = hud.parent as? View ?: return
|
||||
val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
|
||||
@@ -298,13 +277,11 @@ class DroidDeckHudHost(context: Context) {
|
||||
.withEndAction { snapping = false }.start()
|
||||
}
|
||||
|
||||
/** Stops the HUD's running move, and the per-frame listener a dock animation hangs on it, which outlives a cancel. */
|
||||
private fun stopMotion(hud: DroidDeckHudView) {
|
||||
hud.animate().setUpdateListener(null)
|
||||
hud.animate().cancel()
|
||||
}
|
||||
|
||||
/** Glides the HUD from (fromX, fromY) to where it now is, back at full size. */
|
||||
private fun glideFrom(hud: DroidDeckHudView, fromX: Float, fromY: Float) {
|
||||
if (Motion.scale == 0f || (fromX == hud.x && fromY == hud.y && hud.scaleX == 1f && hud.scaleY == 1f)) {
|
||||
hud.scaleX = 1f; hud.scaleY = 1f
|
||||
@@ -317,7 +294,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
.withEndAction { snapping = false; keepOnScreen(hud) }.start()
|
||||
}
|
||||
|
||||
/** A drop short of docking fades the tab preview back out. */
|
||||
private fun fadeDockPreview(hud: DroidDeckHudView) {
|
||||
val from = hud.dockPreviewBlend
|
||||
if (from <= 0f) return
|
||||
@@ -331,10 +307,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropped, the HUD glides to the nearer side of the screen at the height it was let go, as a picture-in-picture
|
||||
* window does, carried to the side a fling throws it toward.
|
||||
*/
|
||||
private fun snapToAnchor(hud: DroidDeckHudView, vx: Float) {
|
||||
val p = hud.parent as? View ?: return
|
||||
val maxX = (p.width - hud.width).toFloat().coerceAtLeast(0f)
|
||||
@@ -354,10 +326,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
.withEndAction { snapping = false; keepOnScreen(hud) }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dragged past an edge, the HUD shrinks around the finger toward the tab it docks as, reaching the tab's
|
||||
* size where a drop would dock it, so the drop finishes a contraction the drag already started.
|
||||
*/
|
||||
private fun shrinkPastEdge(hud: DroidDeckHudView, rawX: Float, rawY: Float) {
|
||||
if (hud.dock != DroidDeckHudDock.NONE) return
|
||||
val p = hud.parent as? View ?: return
|
||||
@@ -372,7 +340,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
hud.pivotX = rawX - at[0] - hud.x
|
||||
hud.pivotY = rawY - at[1] - hud.y
|
||||
if (hud.isStrip) {
|
||||
// The bar draws in sideways toward the tab's width; scaled evenly it would thin to a line.
|
||||
hud.scaleX = 1f + (tabW / hud.width - 1f) * e
|
||||
hud.scaleY = 1f
|
||||
return
|
||||
@@ -382,7 +349,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
hud.scaleX = s; hud.scaleY = s
|
||||
}
|
||||
|
||||
/** Tucks the HUD against [edge] as its FPS tab, or with [DroidDeckHudDock.NONE] brings it back out from where it was docked. */
|
||||
private fun setDock(hud: DroidDeckHudView, edge: DroidDeckHudDock) {
|
||||
if (edge == hud.dock) return
|
||||
animateDock(hud, edge) { commitDock(hud, edge) }
|
||||
@@ -405,10 +371,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
onConfigChanged?.invoke()
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the HUD comes out of its tab, decided before it expands so it grows straight there: where the tab is
|
||||
* (against its edge, or wherever it was pulled to), and with snapping on, against the nearer side.
|
||||
*/
|
||||
private fun settleOutOfTab(hud: DroidDeckHudView, tabX: Float, tabY: Float) {
|
||||
val p = hud.parent as? View
|
||||
val maxX = if (p == null) Float.MAX_VALUE else (p.width - hud.contentWidth).coerceAtLeast(0f)
|
||||
@@ -419,11 +381,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
SessionPrefs.setDroidDeckHudPosition(context, wantX, wantY)
|
||||
}
|
||||
|
||||
/**
|
||||
* One shape contracts into the tab, and the tab expands back out to the HUD: the view is scaled onto the
|
||||
* tab's exact rectangle, and the layouts swap at that same size and place. Timing and curve are the
|
||||
* launcher's own ([Motion]), and off when animations are off.
|
||||
*/
|
||||
private fun animateDock(hud: DroidDeckHudView, edge: DroidDeckHudDock, commit: () -> Unit) {
|
||||
stopMotion(hud)
|
||||
previewFade?.cancel(); previewFade = null
|
||||
@@ -436,7 +393,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
commit(); return
|
||||
}
|
||||
if (edge != DroidDeckHudDock.NONE) {
|
||||
// Continue from what the drag left on screen: the shrunk HUD, and the tab where it is along the edge.
|
||||
val left = hud.x + hud.pivotX * (1f - hud.scaleX); val top = hud.y + hud.pivotY * (1f - hud.scaleY)
|
||||
val shownW = hud.width * hud.scaleX; val shownH = hud.height * hud.scaleY
|
||||
hud.pivotX = 0f; hud.pivotY = 0f
|
||||
@@ -480,11 +436,9 @@ class DroidDeckHudHost(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The pivot, in parent coordinates, that scaling by [scale] takes a span starting at [from] onto one starting at [to]. */
|
||||
private fun pivotFor(from: Float, to: Float, scale: Float): Float =
|
||||
if (scale > 0.999f) from else (to - from * scale) / (1f - scale)
|
||||
|
||||
/** Where a [w] x [h] HUD or tab sits once [keepOnScreen] has settled it against [dock]. */
|
||||
private fun settled(p: View, dock: DroidDeckHudDock, w: Float, h: Float): Pair<Float, Float> {
|
||||
val maxX = (p.width - w).coerceAtLeast(0f); val maxY = (p.height - h).coerceAtLeast(0f)
|
||||
var x = wantX.coerceIn(0f, maxX); var y = wantY.coerceIn(0f, maxY)
|
||||
@@ -498,9 +452,6 @@ class DroidDeckHudHost(context: Context) {
|
||||
return Pair(x, y)
|
||||
}
|
||||
|
||||
// The HUD is clamped from where the user left it (wantX/wantY, set on a drop or restore), not from where
|
||||
// it is drawn: a window that shrinks and grows back (picture-in-picture, a fold) must not leave it in the
|
||||
// corner the small window pushed it to.
|
||||
private fun keepOnScreen(v: View) {
|
||||
if (snapping) return
|
||||
val p = v.parent as? View ?: return
|
||||
@@ -528,27 +479,19 @@ class DroidDeckHudHost(context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** How much of the HUD, as a share of its own size, must hang past a screen edge for it to dock there. */
|
||||
private const val DOCK_OVERHANG = 0.4f
|
||||
/** How far ahead a fling carries the HUD when picking the spot it snaps to, as seconds of its speed. */
|
||||
private const val FLING_REACH_S = 0.18f
|
||||
/** How far toward docking a drag goes, as a share of the way, before the tab starts to fade in over the HUD. */
|
||||
private const val PREVIEW_FROM = 0.55f
|
||||
/** How far the Strip is pulled sideways, or off its edge, for its overhang to count as its whole size. */
|
||||
private const val STRIP_PULL_ACROSS_DP = 600f
|
||||
private const val STRIP_PULL_OFF_DP = 150f
|
||||
/** The share of the expand animation over which the tab fades out to the HUD. */
|
||||
private const val EXPAND_FADE = 0.5f
|
||||
private val EASE = PathInterpolator(0.2f, 0.8f, 0.2f, 1f)
|
||||
|
||||
/** True while the HUD is on screen; DroidDeck keeps sampling VRAM for it (GpuMemComponent). */
|
||||
@Volatile @JvmStatic var showing = false
|
||||
private set
|
||||
|
||||
/** "Adreno (TM) 750" as the driver names it, "Adreno 750" as DroidDeck does elsewhere. */
|
||||
internal fun cleanGpuName(name: String): String = name.replace("(TM)", "").replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
/** The Proton build of a game process: the first STEAM_COMPAT_TOOL_PATHS entry's folder. */
|
||||
internal fun protonName(pid: Int): String? = runCatching {
|
||||
val environ = File("/proc/$pid/environ").readBytes().toString(Charsets.UTF_8).split('\u0000')
|
||||
val paths = environ.firstOrNull { it.startsWith("STEAM_COMPAT_TOOL_PATHS=") }?.substringAfter('=') ?: return null
|
||||
|
||||
@@ -8,16 +8,11 @@
|
||||
* link to https://github.com/The412Banner/FusionHUD in the project documentation
|
||||
* AND in the in-app credits/about screen.
|
||||
*
|
||||
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
|
||||
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
|
||||
*/
|
||||
|
||||
package com.droiddeck.launcher.hud
|
||||
|
||||
/**
|
||||
* Size modes for the DroidDeck HUD (the 4th selectable in-game overlay style). One shared color-coded
|
||||
* visual language, four amounts of detail — see the approved mockup. The config value is the lower-case
|
||||
* token stored under the `hudSize` key; the label is the user-facing string.
|
||||
*/
|
||||
enum class DroidDeckHudSize(val token: String, val label: String) {
|
||||
FULL("full", "Full"),
|
||||
TILES("tiles", "Tiles"),
|
||||
@@ -26,18 +21,11 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
|
||||
MEGA("mega", "Mega"),
|
||||
STRIP("strip", "Strip");
|
||||
|
||||
/** Next size in the tap-cycle: Full → Tiles → Pill → Minimal → Mega → Strip → Full. */
|
||||
fun next(): DroidDeckHudSize {
|
||||
val all = values()
|
||||
return all[(ordinal + 1) % all.size]
|
||||
}
|
||||
|
||||
/**
|
||||
* The metric toggle chips this size actually renders — the SINGLE source of truth queried by BOTH
|
||||
* config surfaces ({@code FpsCounterConfigDialog} + in-game {@code XServerDrawer} HudContent) so the
|
||||
* chip list can't drift from what the view draws. Chip ids are the chip LABELS used in the UI.
|
||||
* (Hiding a chip is purely UI; buildConfig() still emits every key — strip-invariant.)
|
||||
*/
|
||||
fun supportedChips(): Set<String> = when (this) {
|
||||
MINIMAL -> MINIMAL_CHIPS
|
||||
PILL -> PILL_CHIPS
|
||||
@@ -49,11 +37,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
|
||||
fun from(token: String?): DroidDeckHudSize =
|
||||
values().firstOrNull { it.token.equals(token, ignoreCase = true) } ?: FULL
|
||||
|
||||
// Chip-label ids (must match the labels the config UIs build). "Clock" is a small subtle
|
||||
// corner readout available in EVERY size, so it's in all sets.
|
||||
// The API/engine label ("Engine" chip) shows on the FPS row in EVERY size, so it's in all sets.
|
||||
// "Wrapper" and "DX ver" are Mega-only: the wrapper appears solely below Mega's frametime graph,
|
||||
// and "DX ver" gates the DX-version suffix on Mega's FPS-row engine label.
|
||||
val MINIMAL_CHIPS = setOf("FPS", "FPS graph", "0.01% low", "FPS .1", "Engine", "Clock")
|
||||
val PILL_CHIPS = setOf("FPS", "FPS graph", "GPU", "CPU", "VRAM", "RAM", "Battery", "GPU model", "FPS .1", "Engine", "Clock")
|
||||
val STANDARD_CHIPS = setOf(
|
||||
@@ -67,7 +50,6 @@ enum class DroidDeckHudSize(val token: String, val label: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen edge the HUD is tucked against, where only its FPS tab shows ([NONE] = floating free). */
|
||||
enum class DroidDeckHudDock(val token: String) {
|
||||
NONE("none"), LEFT("left"), RIGHT("right"), TOP("top"), BOTTOM("bottom");
|
||||
|
||||
@@ -76,21 +58,12 @@ enum class DroidDeckHudDock(val token: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wayland HDR state the DroidDeck HUD shows on its own line, directly under the latency · display-server
|
||||
* line (the host sets it with [DroidDeckHudView.setHdrState]). [NONE] = no line at all: every session whose
|
||||
* HDR gate is closed (X11, SDR screens, HDR off) keeps the HUD exactly as it was.
|
||||
*/
|
||||
object DroidDeckHudHdr {
|
||||
const val NONE = 0
|
||||
const val ON = 1 // HDR frames on screen with HDR headroom
|
||||
const val NO_HEADROOM = 2 // HDR frames on screen, but the display gives them no headroom (5 s+)
|
||||
const val OFF = 3 // the drawer's HDR output switch is off (tone-mapped to SDR)
|
||||
const val READY = 4 // HDR open for the session, no HDR frames on screen right now
|
||||
const val TONEMAPPED = 5 // HDR output on, but the frames are shown tone-mapped (frame generation
|
||||
// on a screen that offers no HDR10 swapchain)
|
||||
const val NOT_ON_THIS_SCREEN = 6 // HDR open for the session, but the screen the game is on NOW
|
||||
// reports no HDR10 (the TV was unplugged mid-game). The offer to the
|
||||
// game cannot be withdrawn once made, so the gate stays open for the
|
||||
// session — but "ready" would be a lie on a panel that cannot show it.
|
||||
const val ON = 1
|
||||
const val NO_HEADROOM = 2
|
||||
const val OFF = 3
|
||||
const val READY = 4
|
||||
const val TONEMAPPED = 5
|
||||
const val NOT_ON_THIS_SCREEN = 6
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* link to https://github.com/The412Banner/FusionHUD in the project documentation
|
||||
* AND in the in-app credits/about screen.
|
||||
*
|
||||
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD); DroidDeck changes are marked in the code.
|
||||
* Ported to DroidDeck from FusionHUD (Bannerlator's HUD) and modified for DroidDeck.
|
||||
*/
|
||||
|
||||
package com.droiddeck.launcher.hud
|
||||
@@ -53,28 +53,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* DroidDeck HUD — the 4th selectable in-game overlay. One color-coded visual language rendered at four
|
||||
* SIZE modes (Full / Tiles / Pill / Minimal / Mega), all driven by [FrameStats] (gamescope's frame times
|
||||
* read by MangoHud's rules) + a single cached [HudMetrics.Snapshot]. Pure Canvas drawing.
|
||||
*
|
||||
* DroidDeck: the readouts refresh every 500 ms (MangoHud's sampling period) on a Main-dispatcher
|
||||
* coroutine, collecting the snapshot on Dispatchers.IO; the frame graph is redrawn whenever new frames
|
||||
* arrive ([onFrames]), as MangoHud draws it.
|
||||
*
|
||||
* Gestures go through [HudLockController]: long-press toggles the position lock (with a lock/unlock
|
||||
* badge fade), a tap cycles the size (Full→Tiles→Pill→Minimal→Full), and a drag repositions — tap +
|
||||
* drag are frozen while locked. A locked HUD lets a touch through where an on-screen control under it
|
||||
* takes it ([passesTouchAt]); everywhere else it keeps the touch, so a long press there unlocks it.
|
||||
*/
|
||||
class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private var frames: FrameStats? = null
|
||||
internal fun setFrameStats(stats: FrameStats?) { frames = stats }
|
||||
internal fun setMangoReadings(readings: MangoReadings?) { metrics.setMangoReadings(readings) }
|
||||
|
||||
// Frames per second actually reaching the panel while DroidDeck's frame generation adds frames the
|
||||
// game never made; 0 = nothing is adding frames, and the readout is the game's rate alone.
|
||||
private var presentedFps = 0f
|
||||
fun setPresentedFps(fps: Float) { presentedFps = fps }
|
||||
|
||||
@@ -85,7 +69,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private val density = resources.displayMetrics.density
|
||||
|
||||
// ---- Config -----------------------------------------------------------
|
||||
private var size = DroidDeckHudSize.FULL
|
||||
private var scale = 1f
|
||||
private var bgOpacity = 0.8f
|
||||
@@ -109,8 +92,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var showGraph = false
|
||||
private var showLow001 = true
|
||||
private var fpsDecimal = true
|
||||
private var showClockTime = true // subtle corner/footer clock, available in EVERY size
|
||||
// Mega-only rows
|
||||
private var showClockTime = true
|
||||
private var showPerCore = true
|
||||
private var showSwap = true
|
||||
private var showNet = true
|
||||
@@ -121,21 +103,15 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var showSession = true
|
||||
|
||||
private var engineLabel = ""
|
||||
private var displayServer = "" // "X11" / "Wayland": which display server the game runs on
|
||||
private var hdrState = DroidDeckHudHdr.NONE // Wayland HDR, on its own line under latency · display server
|
||||
private var displayServer = ""
|
||||
private var hdrState = DroidDeckHudHdr.NONE
|
||||
private var gpuModel = ""
|
||||
// The GPU name a Wayland session reports to games instead of the real adapter ("" = none). It has
|
||||
// to travel separately from gpuModel: Wayland hands the spoof to DXVK and leaves the Vulkan device
|
||||
// alone, so everything the host reads back is still the real chip. On X11 the driver wrapper renames
|
||||
// the device itself, so there gpuModel already IS the spoofed name and this stays empty.
|
||||
private var gpuSpoofName = ""
|
||||
// Stack-layer version strings (Mega bottom band + DX version on the engine row); fed by the host.
|
||||
private var wineVersion = "" // "Proton 10.0-4"
|
||||
private var graphicsWrapper = "" // graphics-driver wrapper package, e.g. "GameNative", "bcn_layer 20260719"
|
||||
private var dxvkVersion = "" // DXVK version, appended to a DXVK engine row
|
||||
private var vkd3dVersion = "" // VKD3D version, appended to a VKD3D engine row
|
||||
private var wineVersion = ""
|
||||
private var graphicsWrapper = ""
|
||||
private var dxvkVersion = ""
|
||||
private var vkd3dVersion = ""
|
||||
|
||||
// ---- Live data (updated on the refresh coroutine, read on draw) --------
|
||||
private var snap: HudMetrics.Snapshot? = null
|
||||
private var fpsNow = 0f
|
||||
private var fpsAvg = 0f
|
||||
@@ -146,25 +122,17 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private val graphPath = Path()
|
||||
private var resolution = ""
|
||||
|
||||
// DroidDeck look: the theme's main colour for the FPS label, graph and accent outline, the theme's
|
||||
// surface behind the readouts, a DroidDeck header (wordmark) on Full/Tiles/Minimal/Mega and the logo
|
||||
// chip on the Pill.
|
||||
private var accent = 0xFF1A9FFF.toInt()
|
||||
private var surfaceRgb = 0x121417
|
||||
private var headerMark: RectF? = null
|
||||
private var chipRect: RectF? = null
|
||||
|
||||
// Pushed past a screen edge, the HUD shrinks to a tab with the FPS that stays at that edge.
|
||||
var dock = DroidDeckHudDock.NONE
|
||||
private set
|
||||
private var tabRect: RectF? = null
|
||||
// The Strip is a bar across the whole screen, kept against the top or bottom edge.
|
||||
private var stripRect: RectF? = null
|
||||
private val tabPath = Path()
|
||||
|
||||
// Every size sits in the DroidDeck mark's D: a straight left side, round on the
|
||||
// right. Its fixed layers (fill, header band, watermark, glowing outline) go into a bitmap, because
|
||||
// Android before 9 skips glow on shapes drawn with hardware acceleration.
|
||||
private var dPanel: RectF? = null
|
||||
private var dRadius = 0f
|
||||
private var dDividerTop = -1f
|
||||
@@ -172,7 +140,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var dFrameKey = ""
|
||||
private val glowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// ---- Colors (mockup) --------------------------------------------------
|
||||
private val colGpu = 0xFF5EE08A.toInt()
|
||||
private val colCpu = 0xFF58A6FF.toInt()
|
||||
private val colVram = 0xFFC98BFF.toInt()
|
||||
@@ -183,10 +150,9 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private val colValue = 0xFFF2F5F9.toInt()
|
||||
private val colDim = 0xFF9AA4B2.toInt()
|
||||
private val colLo = 0xFFE4E8EE.toInt()
|
||||
private val colDisp = 0xFF4DD0E1.toInt() // display server (X11 / Wayland)
|
||||
private val colHdr = 0xFFFFD54F.toInt() // HDR on screen
|
||||
private val colDisp = 0xFF4DD0E1.toInt()
|
||||
private val colHdr = 0xFFFFD54F.toInt()
|
||||
|
||||
// ---- Paints -----------------------------------------------------------
|
||||
private val measurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
|
||||
}
|
||||
@@ -205,16 +171,10 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
|
||||
}
|
||||
|
||||
// ---- Built geometry ---------------------------------------------------
|
||||
private class Glyph(val x: Float, val baseline: Float, val text: String, val color: Int, val sizePx: Float, val sans: Boolean = false, val glow: Boolean = false) {
|
||||
fun moved(dx: Float, dy: Float) = Glyph(x + dx, baseline + dy, text, color, sizePx, sans, glow)
|
||||
}
|
||||
private class Span(val text: String, val color: Int, val sizePx: Float, val glow: Boolean = false)
|
||||
/**
|
||||
* A "label + value-run" row for the aligned grid sizes (Full / Mega). When [inline] is true the row
|
||||
* opts OUT of the shared label column — its value run is placed directly after the label — so a long
|
||||
* label (the graphics-wrapper on the FPS row) can't widen the column and shove every value right.
|
||||
*/
|
||||
private class HudRow(val label: Span, val vals: List<Span>, val inline: Boolean = false)
|
||||
private val glyphs = ArrayList<Glyph>()
|
||||
private val tileRects = ArrayList<RectF>()
|
||||
@@ -222,7 +182,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var contentW = 0f
|
||||
private var contentH = 0f
|
||||
|
||||
// ---- Listeners --------------------------------------------------------
|
||||
private var onMovedListener: BiConsumer<Float, Float>? = null
|
||||
private var onSizeCycledListener: Consumer<String>? = null
|
||||
private var onLockChangedListener: Consumer<Boolean>? = null
|
||||
@@ -232,13 +191,10 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var onDockChangedListener: Consumer<String>? = null
|
||||
private var onScaledListener: IntConsumer? = null
|
||||
private var onDraggedListener: BiConsumer<Float, Float>? = null
|
||||
/** The HUD moved under the finger, at that screen point. */
|
||||
fun setOnDraggedListener(l: BiConsumer<Float, Float>?) { onDraggedListener = l }
|
||||
private var onFlungListener: BiConsumer<Float, Float>? = null
|
||||
/** A drag ended in a fling, at this velocity (pixels per second), just before the move is reported. */
|
||||
fun setOnFlungListener(l: BiConsumer<Float, Float>?) { onFlungListener = l }
|
||||
fun setOnDockChangedListener(l: Consumer<String>?) { onDockChangedListener = l }
|
||||
/** A pinch settled on this size, in percent. */
|
||||
fun setOnScaledListener(l: IntConsumer?) { onScaledListener = l }
|
||||
|
||||
private val lockController = HudLockController(context, this, object : HudLockController.Callbacks {
|
||||
@@ -256,7 +212,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
override fun onPinchEnd() { onScaledListener?.accept((scale * 100f).roundToInt()) }
|
||||
})
|
||||
|
||||
// A docked tab always takes touches, locked or not: a tap brings the HUD back, a drag moves the tab.
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
||||
private var tabDownRawX = 0f
|
||||
private var tabDownRawY = 0f
|
||||
@@ -266,10 +221,8 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private var onTabDraggedListener: BiConsumer<Float, Float>? = null
|
||||
private var onTabDroppedListener: Runnable? = null
|
||||
private var onTabDragCancelledListener: Runnable? = null
|
||||
/** The docked tab is dragged: where the finger would put it (x, y in the parent). */
|
||||
fun setOnTabDraggedListener(l: BiConsumer<Float, Float>?) { onTabDraggedListener = l }
|
||||
fun setOnTabDroppedListener(l: Runnable?) { onTabDroppedListener = l }
|
||||
/** The system took the tab's drag away: the tab goes back to where it was docked. */
|
||||
fun setOnTabDragCancelledListener(l: Runnable?) { onTabDragCancelledListener = l }
|
||||
|
||||
private fun onTabTouch(event: MotionEvent): Boolean {
|
||||
@@ -296,7 +249,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// On the way into the tab or out of it, the tab fades in over the HUD at the tab's true size on screen.
|
||||
private var previewEdge = DroidDeckHudDock.NONE
|
||||
private var previewBlend = 0f
|
||||
internal fun setDockPreview(edge: DroidDeckHudDock, blend: Float) {
|
||||
@@ -307,32 +259,15 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
internal val dockPreviewBlend: Float get() = previewBlend
|
||||
|
||||
// ---- Public surface (symmetric with the other overlays) ---------------
|
||||
fun setEngineLabel(s: String?) { if ((s ?: "") != engineLabel) { engineLabel = s ?: ""; post { rebuildAndInvalidate() } } }
|
||||
fun setGpuModel(s: String?) { if ((s ?: "") != gpuModel) { gpuModel = s ?: ""; post { rebuildAndInvalidate() } } }
|
||||
/** This session's Wayland GPU-name spoof (null / empty = none, the default): the GPU row names it
|
||||
* in place of the real model, marked so it can't be read as the chip that is really rendering. */
|
||||
fun setGpuSpoofName(s: String?) { gpuSpoofName = s ?: ""; post { rebuildAndInvalidate() } }
|
||||
fun setDisplayServer(s: String?) { displayServer = s ?: ""; post { rebuildAndInvalidate() } }
|
||||
/** [DroidDeckHudHdr] code; [DroidDeckHudHdr.NONE] (the default) draws no HDR line anywhere. Any thread. */
|
||||
fun setHdrState(state: Int) {
|
||||
val v = if (state in DroidDeckHudHdr.ON..DroidDeckHudHdr.NOT_ON_THIS_SCREEN) state else DroidDeckHudHdr.NONE
|
||||
post { if (v != hdrState) { hdrState = v; rebuildAndInvalidate() } }
|
||||
}
|
||||
fun setWineVersion(s: String?) { if ((s ?: "") != wineVersion) { wineVersion = s ?: ""; post { rebuildAndInvalidate() } } }
|
||||
fun setGraphicsWrapper(s: String?) { graphicsWrapper = s ?: ""; post { rebuildAndInvalidate() } }
|
||||
fun setDxWrapper(dxvk: String?, vkd3d: String?) {
|
||||
dxvkVersion = dxvk ?: ""; vkd3dVersion = vkd3d ?: ""; post { rebuildAndInvalidate() }
|
||||
}
|
||||
|
||||
/** The game's output from gamescope ("1280x720 @ 144Hz"), as MangoHud shows it; "" = this display. */
|
||||
fun setResolution(s: String?) { resolution = s ?: "" }
|
||||
|
||||
private val periodDone = Channel<Unit>(Channel.CONFLATED)
|
||||
private var seenFpsUpdates = 0L
|
||||
|
||||
/** New frames arrived: redraw the graph (MangoHud redraws it every frame), and the readouts when a
|
||||
* sampling period completed, at the moment MangoHud redraws its own. */
|
||||
fun onFrames() {
|
||||
val updates = frames?.fpsUpdates ?: 0L
|
||||
if (updates != seenFpsUpdates) {
|
||||
@@ -342,10 +277,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (graphRect != null) invalidate()
|
||||
}
|
||||
|
||||
/** The HUD has no orientation (tap cycles size instead); kept for a symmetric host surface. */
|
||||
fun setVertical(vertical: Boolean) { /* no-op */ }
|
||||
fun isVertical(): Boolean = false
|
||||
|
||||
private fun cycleSize() {
|
||||
size = size.next()
|
||||
onSizeCycledListener?.accept(size.token)
|
||||
@@ -372,7 +303,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
showGraph = cfg.get("showFPSGraph", "0") == "1"
|
||||
showLow001 = cfg.get("showLow001", "1") == "1"
|
||||
fpsDecimal = cfg.get("fpsDecimal", "1") == "1"
|
||||
showClockTime = cfg.get("showClock", "1") == "1" // the clock defaults ON
|
||||
showClockTime = cfg.get("showClock", "1") == "1"
|
||||
showPerCore = cfg.get("showPerCore", "1") == "1"
|
||||
showSwap = cfg.get("showSwap", "1") == "1"
|
||||
showNet = cfg.get("showNet", "1") == "1"
|
||||
@@ -397,7 +328,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
rebuildAndInvalidate()
|
||||
}
|
||||
|
||||
// ---- Lifecycle: self-refresh ------------------------------------------
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
if (isShown) startUpdates()
|
||||
@@ -408,8 +338,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
// The host hides the HUD with GONE (HUD off, picture-in-picture) and keeps it attached: read the
|
||||
// sensors only while it shows.
|
||||
override fun onVisibilityChanged(changedView: View, visibility: Int) {
|
||||
super.onVisibilityChanged(changedView, visibility)
|
||||
if (isAttachedToWindow && isShown) startUpdates() else stopUpdates()
|
||||
@@ -438,13 +366,11 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
graphMaxMs = stats?.graphMaxMs() ?: 0f
|
||||
onRefresh?.run()
|
||||
rebuildAndInvalidate()
|
||||
// Without frames (a still screen) MangoHud redraws nothing; the HUD keeps refreshing.
|
||||
withTimeoutOrNull(REFRESH_MS) { periodDone.receive() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs on the main thread just before each readout refresh, for the host's labels. */
|
||||
var onRefresh: Runnable? = null
|
||||
|
||||
private fun applyTheme() {
|
||||
@@ -459,10 +385,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
colGraph = lighten(accent, 0.3f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner Gamer turns the colour wheel inside the app's Compose UI, which is not running while the drawer is
|
||||
* closed, so the HUD turns it here and shares the hue so the drawer picks up where the HUD is.
|
||||
*/
|
||||
private fun startHueCycle() {
|
||||
if (hueJob?.isActive == true) return
|
||||
if (SessionPrefs.theme(context) != Themes.INNER_GAMER) return
|
||||
@@ -504,7 +426,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
invalidate()
|
||||
}
|
||||
|
||||
// ---- Text helpers -----------------------------------------------------
|
||||
private fun blend(color: Int): Int {
|
||||
val i = colorIntensity.coerceIn(0f, 1f)
|
||||
if (i >= 0.999f) return color
|
||||
@@ -537,27 +458,19 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private fun one(v: Float): String =
|
||||
if (fpsDecimal) String.format(Locale.US, "%.1f", v) else v.roundToInt().toString()
|
||||
|
||||
// Shows "30->118" while something downstream of the game is adding frames.
|
||||
// The game's own rate stays visible next to the panel rate, because the gap
|
||||
// between the two is precisely what frame generation is doing.
|
||||
private fun fpsText(v: Float): String =
|
||||
if (differs(v)) "${one(v)}\u2192${one(presentedFps)}" else one(v)
|
||||
|
||||
// Either direction: up = frames added (LSFG Native), down = frames lost
|
||||
// between the guest and the panel, which is the failure win-fg can hit
|
||||
// and which a single number hides completely.
|
||||
private fun differs(v: Float): Boolean =
|
||||
presentedFps > 0f && kotlin.math.abs(presentedFps - v) > maxOf(1f, v * 0.10f)
|
||||
|
||||
private fun fmt1(v: Float): String = String.format(Locale.US, "%.1f", v)
|
||||
private fun lowText(v: Float): String? = if (v <= 0f) null else fmt1(v)
|
||||
|
||||
// number (white, or "—" muted when null) + unit (muted, smaller)
|
||||
private fun numUnit(num: String?, unit: String, numPx: Float, unitPx: Float): List<Span> =
|
||||
if (num == null) listOf(Span("—", colDim, numPx), Span(unit, colDim, unitPx))
|
||||
else listOf(Span(num, colValue, numPx), Span(unit, colDim, unitPx))
|
||||
|
||||
// "0.2GiB"/"75%"/"1938MHz" → white number + muted suffix
|
||||
private fun valueUnit(text: String, numPx: Float, unitPx: Float): List<Span> {
|
||||
val i = text.indexOfFirst { !(it.isDigit() || it == '.' || it == '-') }
|
||||
return if (i <= 0) listOf(Span(text, colValue, numPx))
|
||||
@@ -566,7 +479,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private fun tempSpans(c: Int?, sensor: HudMetrics.TempSensor, numPx: Float, unitPx: Float): List<Span> {
|
||||
if (c == null) return emptyList()
|
||||
val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false) // "81°C"
|
||||
val txt = HudMetrics.formatTemp(c.toFloat(), tempDisplay, false)
|
||||
val col = HudMetrics.tempColor(
|
||||
c.toFloat(), metrics.resolveThresholds(sensor, tempDisplay), tempDisplay, colValue)
|
||||
val i = txt.indexOf('°')
|
||||
@@ -576,13 +489,9 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private fun gap(unitPx: Float) = Span(" ", colDim, unitPx)
|
||||
|
||||
/** The frame-rate number in neon, as on Minimal. */
|
||||
private fun glowing(spans: List<Span>): List<Span> =
|
||||
spans.mapIndexed { i, s -> if (i == 0) Span(s.text, s.color, s.sizePx, glow = true) else s }
|
||||
|
||||
// ---- Wayland HDR state (one line of its own; nothing at all for DroidDeckHudHdr.NONE) ----
|
||||
/** The state as a full line: "HDR", "HDR (no headroom)", "HDR off", "HDR ready", "HDR tone-mapped",
|
||||
* "HDR not on this screen". */
|
||||
private fun hdrLine(px: Float): List<Span> = when (hdrState) {
|
||||
DroidDeckHudHdr.ON -> listOf(Span("HDR", colHdr, px))
|
||||
DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("HDR", colHdr, px), Span(" (no headroom)", colBat, px))
|
||||
@@ -592,8 +501,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
DroidDeckHudHdr.NOT_ON_THIS_SCREEN -> listOf(Span("HDR", colDim, px), Span(" not on this screen", colBat, px))
|
||||
else -> emptyList()
|
||||
}
|
||||
/** The value after an "HDR" label (Full / Mega): "on", "no headroom", "off", "ready", "tone-mapped",
|
||||
* "not on this screen". */
|
||||
private fun hdrValue(px: Float): List<Span> = when (hdrState) {
|
||||
DroidDeckHudHdr.ON -> listOf(Span("on", colHdr, px))
|
||||
DroidDeckHudHdr.NO_HEADROOM -> listOf(Span("no headroom", colBat, px))
|
||||
@@ -605,12 +512,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
private fun hdrText(): String = hdrLine(1f).joinToString("") { it.text }
|
||||
|
||||
// ---- GPU model (with the Wayland GPU-name spoof) ----
|
||||
/** What the GPU row names: the spoofed GPU when this session reports one, else the real model. */
|
||||
private fun gpuNameText(): String = gpuSpoofName.ifBlank { gpuModel }
|
||||
/** The GPU row's value run. A spoofed name carries a marker in the same warning colour the HDR line
|
||||
* qualifies its state with, so the row can't be read as the chip that is really rendering:
|
||||
* "GeForce GTX 1080 (spoof)", and where the line is tightest (the pill) "GeForce GTX 1080 spoof". */
|
||||
private fun gpuModelSpans(px: Float, color: Int, compact: Boolean = false): List<Span> {
|
||||
val name = Span(gpuNameText(), color, px)
|
||||
if (gpuSpoofName.isBlank()) return listOf(name)
|
||||
@@ -619,7 +521,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private val inkRect = android.graphics.Rect()
|
||||
|
||||
// ---- Layout builders --------------------------------------------------
|
||||
private fun rebuild() {
|
||||
glyphs.clear(); tileRects.clear(); graphRect = null
|
||||
headerMark = null; chipRect = null; dPanel = null; dDividerTop = -1f; tabRect = null; stripRect = null
|
||||
@@ -636,7 +537,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The docked tab: the FPS alone, in a box square on the edge it sits against. */
|
||||
private fun buildDockTab() {
|
||||
val numPx = sp(20f)
|
||||
val spans = tabSpans()
|
||||
@@ -649,7 +549,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
private fun tabSpans() = listOf(Span(one(fpsNow), colValue, sp(20f)), Span("fps", colDim, sp(8.5f)))
|
||||
|
||||
/** The size the docked tab has, so the HUD can contract to exactly that shape before the tab replaces it. */
|
||||
internal fun dockTabSize(): Pair<Float, Float> =
|
||||
Pair(runWidth(tabSpans()) + 2f * sp(9f), capHeight(sp(20f)) + 2f * sp(7f))
|
||||
|
||||
@@ -675,18 +574,14 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawPath(tabPath, strokePaint)
|
||||
}
|
||||
|
||||
// ---- DroidDeck panel, header and chip -----------------------------------
|
||||
/** The clock sits in the header on every size but the Pill, which keeps it under the FPS. */
|
||||
private fun footerClock(): Boolean = showClockTime && size == DroidDeckHudSize.PILL
|
||||
|
||||
private fun measureSans(text: String, sizePx: Float): Float { wordmarkPaint.textSize = sizePx; return wordmarkPaint.measureText(text) }
|
||||
|
||||
private fun panelInset() = sp(8f) // room for the glow around the panel
|
||||
private fun panelLeft() = sp(13f) // the outline's glow sticks out of the left side
|
||||
private fun panelInset() = sp(8f)
|
||||
private fun panelLeft() = sp(13f)
|
||||
private fun contentLeft() = panelLeft() + sp(16f)
|
||||
|
||||
/** The DroidDeck mark, "DroidDeck" and the clock across the top, starting on [left], with the same
|
||||
* space above and below the mark. Returns the header's bottom edge and its right end. */
|
||||
private fun addPanelHeader(left: Float, inner: Float): Pair<Float, Float> {
|
||||
val wordPx = sp(10f); val clockPx = sp(9.5f); val gapH = sp(7f)
|
||||
wordmarkPaint.textSize = wordPx
|
||||
@@ -714,11 +609,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
return Pair(dDividerTop + max(1f, density), x)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a size's content, laid out from [pad] at the origin, into the panel: under the header, or after
|
||||
* the logo chip on the Pill. Lines that start on the content's left edge are lined up by their ink, so
|
||||
* the logo, labels and numbers share one edge.
|
||||
*/
|
||||
private fun intoPanel(pad: Float, header: Boolean) {
|
||||
if (contentW <= 0f || contentH <= 0f) return
|
||||
for (i in glyphs.indices) {
|
||||
@@ -748,8 +638,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
finishPanel(panel, halfRound = !header)
|
||||
}
|
||||
|
||||
/** Closes the panel around everything placed so far, widened until no line, tile or graph reaches the
|
||||
* round side: a half circle on the short sizes (Pill, Minimal), large round corners on the tall ones. */
|
||||
private fun finishPanel(panel: RectF, halfRound: Boolean) {
|
||||
val r = if (halfRound) panel.height() / 2f else min(panel.height() / 2f, sp(56f))
|
||||
val margin = outlineWidth() + sp(4f)
|
||||
@@ -773,7 +661,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
contentH = panel.bottom + panelInset()
|
||||
}
|
||||
|
||||
// ---- Shared small-info helpers ----------------------------------------
|
||||
private fun clockTimeString(): String = DateFormat.getTimeFormat(context).format(Date())
|
||||
|
||||
private fun elapsedString(): String {
|
||||
@@ -783,31 +670,21 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
else String.format(Locale.US, "%02d:%02d", m, s)
|
||||
}
|
||||
|
||||
/**
|
||||
* The FPS row's API/engine label — the DX→Vulkan translator ([engineLabel]: "DXVK"/"VKD3D"/"Zink"),
|
||||
* gated by the "Engine" chip. This is short and shows in EVERY size. Falls back to a bare "FPS" when
|
||||
* the toggle is off or no engine label is set. (The graphics WRAPPER is separate — Mega-only, drawn
|
||||
* below the frametime graph — not on the FPS row.)
|
||||
*/
|
||||
private fun apiLabel(): String =
|
||||
if (showEngine && engineLabel.isNotBlank()) engineLabel else "FPS"
|
||||
|
||||
/** The DX-wrapper version WITH its engine name (e.g. "DXVK 2.4.1", "VKD3D 2.14"), or "" if none.
|
||||
* Used for the Mega stack line below the graph. */
|
||||
private fun dxVersionLabeled(): String = when {
|
||||
engineLabel.contains("VKD3D", true) && vkd3dVersion.isNotBlank() -> "VKD3D $vkd3dVersion"
|
||||
engineLabel.contains("DXVK", true) && dxvkVersion.isNotBlank() -> "DXVK $dxvkVersion"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
/** The DX-wrapper version for the current engine, or "" (used for the Tiles "API" tile sub-line). */
|
||||
private fun dxVersion(): String = when {
|
||||
engineLabel.contains("VKD3D", true) -> vkd3dVersion
|
||||
engineLabel.contains("DXVK", true) -> dxvkVersion
|
||||
else -> ""
|
||||
}
|
||||
|
||||
/** Resolution + refresh, e.g. "1280x720 @ 120Hz". Read from the current display (Main thread). */
|
||||
private fun resolutionString(): String {
|
||||
if (resolution.isNotBlank()) return resolution
|
||||
val dm = resources.displayMetrics
|
||||
@@ -816,15 +693,13 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
return if (hz >= 1f) "$base @ ${hz.roundToInt()}Hz" else base
|
||||
}
|
||||
|
||||
/** A subtle, low-emphasis time-of-day tucked into the bottom-right of any size. Grows contentH slightly. */
|
||||
private fun addSubtleClock(pad: Float, leftAligned: Boolean = false) {
|
||||
if (!footerClock()) return
|
||||
val px = sp(9.5f)
|
||||
val t = clockTimeString()
|
||||
val w = measure(t, px)
|
||||
val footerTop = contentH - pad // reuse the bottom padding band as the footer line
|
||||
val footerTop = contentH - pad
|
||||
val baseline = footerTop - ascent(px)
|
||||
// Right-aligned footer; on the Pill it sits on the left edge, under the FPS.
|
||||
val x = if (leftAligned) pad else (contentW - pad - w).coerceAtLeast(pad)
|
||||
glyphs.add(Glyph(x, baseline, t, blend(colDim), px))
|
||||
contentH = footerTop + lineH(px) + pad * 0.5f
|
||||
@@ -878,7 +753,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val v = ArrayList<Span>()
|
||||
v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx))
|
||||
v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx)
|
||||
// Short API/engine label ("DXVK"/"VKD3D"/"Zink", else "FPS") — a normal aligned row.
|
||||
rows.add(HudRow(Span(apiLabel(), colFps, rowPx), v))
|
||||
rows.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx)))
|
||||
rows.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx)))
|
||||
@@ -902,9 +776,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
|
||||
if (showFPS && showGraph) {
|
||||
// Frametime min/max line + green graph. "Frametime" is far wider than the short-label grid
|
||||
// column, so place the stat AFTER the ACTUAL label width (not labelCol) — otherwise the two
|
||||
// overlap and render as "Frametimemin:.. max:..".
|
||||
val ftPx = unitPx * 1.15f
|
||||
val baseline = y - asc
|
||||
placeRun(pad, baseline, listOf(Span("Frametime", colFps, ftPx)))
|
||||
@@ -945,8 +816,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (showVram && s.vramText() != null) tiles.add(Tile("VRAM", colVram, valueUnit(s.vramText()!!, valPx, unitPx), null, false))
|
||||
if (showRAM) tiles.add(Tile("RAM", colRam, numUnit(s.ramPercent.roundToInt().toString(), "%", valPx, unitPx),
|
||||
"${s.ramUsedText()} / ${s.ramTotalText()}", false))
|
||||
// API/engine tile — short translator name (DXVK/VKD3D/Zink) with the DX version on the sub-line;
|
||||
// a normal half-width tile that naturally fills the empty slot beside RAM. Gated by "Engine".
|
||||
if (showEngine && engineLabel.isNotBlank())
|
||||
tiles.add(Tile("API", colFps, listOf(Span(engineLabel, colValue, valPx)),
|
||||
dxVersion().ifBlank { null }, false))
|
||||
@@ -970,7 +839,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
var normalW = 0f
|
||||
for (t in tiles) if (!t.wide) normalW = max(normalW, tileW(t))
|
||||
if (normalW == 0f) for (t in tiles) normalW = max(normalW, tileW(t) / 2f)
|
||||
val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH // uniform (room for a sub line)
|
||||
val tileH = innerPad * 2 + keyH + lineGap + valH + lineGap + subH
|
||||
val fullW = normalW * 2 + tileGap
|
||||
|
||||
var x = pad; var y = pad; var col = 0
|
||||
@@ -1011,20 +880,12 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val pad = sp(10f); val midGap = sp(12f); val stkLineGap = sp(3f)
|
||||
|
||||
val left = ArrayList<Span>()
|
||||
// "30->118" is roughly three times the width of "30", and the pill's
|
||||
// number is set at 30sp precisely because it is the one big thing on a
|
||||
// small overlay. Shrink it while it carries both figures so the pill
|
||||
// does not balloon across the screen; it is still the largest element.
|
||||
val generating = differs(fpsNow)
|
||||
val fpsPx = if (generating) bigPx * 0.55f else bigPx
|
||||
left += Span(fpsText(fpsNow), colValue, fpsPx, glow = true)
|
||||
left += Span("fps", colDim, if (generating) bigUnitPx * 0.8f else bigUnitPx)
|
||||
|
||||
val stack = ArrayList<List<Span>>()
|
||||
// The GPU name normally heads the stack. A name wider than every stat under it (a Wayland spoof
|
||||
// such as "Radeon RX 6800/6800 XT / 6900 XT spoof") would set the stack's width and stretch the
|
||||
// whole capsule around empty space, so that name gets its own line across the top of the pill
|
||||
// instead, starting over the API caption (see nameOnTop below).
|
||||
val gpuName = if (showGpuModel && gpuNameText().isNotBlank()) gpuModelSpans(stkPx, colDim, compact = true) else null
|
||||
run {
|
||||
val l = ArrayList<Span>()
|
||||
@@ -1032,11 +893,7 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (showCPU) { if (l.isNotEmpty()) l += Span(" · ", colDim, stkPx); l += Span("CPU ${s.cpuPercent ?: "—"}%", colCpu, stkPx) }
|
||||
if (l.isNotEmpty()) stack.add(l)
|
||||
}
|
||||
// RAM % (system-wide) takes the slot the latency row used to occupy, matching the GPU/CPU idiom
|
||||
// on the row above it. colRam keeps RAM's identity colour consistent with the Full/Tiles sizes.
|
||||
if (showRAM) stack.add(listOf(Span("RAM ${s.ramPercent.roundToInt()}%", colRam, stkPx)))
|
||||
// One battery row, as on the other sizes: each part shows on its own switch, so the battery
|
||||
// temperature no longer goes with the RAM switch and watts show without the battery level.
|
||||
if (showBattery || showPower || showBatteryTemp) {
|
||||
val l = ArrayList<Span>()
|
||||
fun part(spans: List<Span>) {
|
||||
@@ -1049,9 +906,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (showPower && s.battery.watts > 0f) part(listOf(Span("${fmt1(s.battery.watts)}W", colDim, stkPx)))
|
||||
if (l.isNotEmpty()) stack.add(l)
|
||||
}
|
||||
// Latency (+ the display server, + optional VRAM) drops to the bottom of the stack, under the
|
||||
// battery row. The display server sits here, not on the GPU-model line: that line is the
|
||||
// pill's widest and ran into the capsule's rounded edge.
|
||||
run {
|
||||
val l = ArrayList<Span>()
|
||||
l += Span("${fmt1(1000f / max(fpsNow, 1f))}ms", colDim, stkPx)
|
||||
@@ -1059,8 +913,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (showVram && s.vramText() != null) { l += Span(" · ", colDim, stkPx); l += Span("${s.vramText()} vram", colDim, stkPx) }
|
||||
stack.add(l)
|
||||
}
|
||||
// Wayland HDR state on its OWN line directly under the latency · display-server line (it ran off
|
||||
// the capsule's right edge as "40.3ms · Wayland · HDR (no headroom)"). Only in HDR sessions.
|
||||
if (hdrState != DroidDeckHudHdr.NONE) stack.add(hdrLine(stkPx))
|
||||
|
||||
var statsW = 0f
|
||||
@@ -1068,8 +920,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val nameOnTop = gpuName != null && runWidth(gpuName) > statsW
|
||||
if (gpuName != null && !nameOnTop) stack.add(0, gpuName)
|
||||
|
||||
// Left column: a small API/engine caption (DXVK/VKD3D/Zink) above the big FPS and the clock below
|
||||
// it, all on the left edge, so the left reads API · FPS · clock top-to-bottom.
|
||||
val apiStr = apiLabel()
|
||||
val hasApi = apiStr != "FPS"
|
||||
val apiH = if (hasApi) lineH(stkPx) else 0f
|
||||
@@ -1088,14 +938,12 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
contentW = pad + leftBlockW + midGap + stackW + pad
|
||||
contentH = pad + topLineH + innerH + pad
|
||||
|
||||
// left column (API caption + big FPS), vertically centered as a block
|
||||
var ly = pad + topLineH + (innerH - leftColH) / 2f
|
||||
if (hasApi) {
|
||||
placeRun(pad, ly - ascent(stkPx), listOf(Span(apiStr, colFps, stkPx)))
|
||||
ly += apiH + apiGap
|
||||
}
|
||||
placeRun(pad, ly - ascent(bigPx), left)
|
||||
// stack, vertically centered
|
||||
var sy = pad + topLineH + (innerH - stackTotalH) / 2f
|
||||
for (l in stack) {
|
||||
placeRun(pad + leftBlockW + midGap, sy - ascent(stkPx), l)
|
||||
@@ -1103,22 +951,17 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
addSubtleClock(pad, leftAligned = true)
|
||||
if (nameOnTop && gpuName != null) {
|
||||
// Left-aligned over the API caption; the pill only widens if the name is wider than the
|
||||
// API/FPS column + stats.
|
||||
val end = placeRun(pad, pad - ascent(stkPx), gpuName)
|
||||
contentW = max(contentW, end + pad)
|
||||
}
|
||||
}
|
||||
|
||||
// One column as wide as the widest row: the logo, FPS, lows, graph and engine line start on its left
|
||||
// edge (ink, not advance), and the header keeps the same space above and below the logo.
|
||||
private fun buildMinimal(s: HudMetrics.Snapshot) {
|
||||
val bigPx = sp(34f); val bigUnitPx = bigPx * 0.32f; val subPx = sp(11.5f); val footPx = sp(9.5f)
|
||||
val stroke = outlineWidth()
|
||||
val left = contentLeft()
|
||||
val inner = panelInset() + stroke
|
||||
|
||||
// Same reasoning as the pill: 34sp is sized for one number, not two.
|
||||
val minGenerating = differs(fpsNow)
|
||||
val big = listOf(
|
||||
Span(fpsText(fpsNow), colValue, if (minGenerating) bigPx * 0.55f else bigPx),
|
||||
@@ -1164,11 +1007,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
finishPanel(panel, halfRound = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full's readouts on one line across the screen: the mark, the FPS, the graph, the lows, then GPU, CPU,
|
||||
* memory, battery, display server and GPU model, with the clock on the right end. Groups that no longer
|
||||
* fit before the clock are left off, from the end.
|
||||
*/
|
||||
private fun buildStrip(s: HudMetrics.Snapshot) {
|
||||
val rowPx = sp(11.5f); val unitPx = rowPx * 0.8f; val fpsPx = sp(15f)
|
||||
val padX = sp(10f); val groupGap = sp(13f)
|
||||
@@ -1265,8 +1103,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
return inkRect.left.toFloat()
|
||||
}
|
||||
|
||||
/** Places a column of "label + value" rows; returns (bottomY, rightX). Appends glyphs. Rows flagged
|
||||
* [HudRow.inline] opt out of the shared label column (value follows the label directly). */
|
||||
private fun layoutColumn(rows: List<HudRow>, x: Float, y0: Float,
|
||||
rowPx: Float, lineGap: Float, lvGap: Float): Pair<Float, Float> {
|
||||
if (rows.isEmpty()) return Pair(y0, x)
|
||||
@@ -1286,7 +1122,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
return Pair(y - lineGap, right)
|
||||
}
|
||||
|
||||
/** Places wrapping " · "-separated info fragments within maxWidth; returns (bottomY, rightX). */
|
||||
private fun layoutWrap(fragments: List<List<Span>>, x: Float, y0: Float, maxWidth: Float,
|
||||
px: Float, lineGap: Float): Pair<Float, Float> {
|
||||
if (fragments.isEmpty()) return Pair(y0, x)
|
||||
@@ -1307,7 +1142,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val rowPx = sp(11.5f); val unitPx = rowPx * 0.62f; val bandPx = sp(9.5f)
|
||||
val pad = sp(10f); val lineGap = sp(3.5f); val lvGap = sp(7f); val gutter = sp(16f)
|
||||
|
||||
// ---- LEFT column: GPU, aggregate CPU, then per-core rows ----
|
||||
val left = ArrayList<HudRow>()
|
||||
if (showGpuModel && gpuNameText().isNotBlank())
|
||||
left.add(HudRow(Span("GPU", colGpu, rowPx), gpuModelSpans(rowPx, colValue)))
|
||||
@@ -1339,7 +1173,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RIGHT column: VRAM, RAM, SWP, NET, BAT, engine, lows ----
|
||||
val right = ArrayList<HudRow>()
|
||||
if (showVram && s.vramText() != null)
|
||||
right.add(HudRow(Span("VRAM", colVram, rowPx), valueUnit(s.vramText()!!, rowPx, unitPx)))
|
||||
@@ -1377,8 +1210,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val v = ArrayList<Span>()
|
||||
v += glowing(numUnit(fpsText(fpsNow), "FPS", rowPx, unitPx))
|
||||
v += gap(unitPx); v += numUnit(fmt1(1000f / max(fpsNow, 1f)), "ms", rowPx, unitPx)
|
||||
// FPS/engine row: just the short API label. The DX version moved to the stack lines
|
||||
// below the frametime graph (see below), so this row stays compact.
|
||||
right.add(HudRow(Span(apiLabel(), colFps, rowPx), v))
|
||||
right.add(HudRow(Span("AVG", colLo, rowPx), numUnit(fmt1(fpsAvg), "FPS", rowPx, unitPx)))
|
||||
right.add(HudRow(Span("1%", colLo, rowPx), numUnit(lowText(lows.low1Fps), "FPS", rowPx, unitPx)))
|
||||
@@ -1387,15 +1218,12 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
right.add(HudRow(Span("0.01%", colLo, rowPx), numUnit(lowText(lows.low001Fps), "FPS", rowPx, unitPx)))
|
||||
}
|
||||
|
||||
// ---- Two columns side by side (each reflows independently) ----
|
||||
val (leftBottom, leftRight) = layoutColumn(left, pad, pad, rowPx, lineGap, lvGap)
|
||||
val rightX = (if (left.isEmpty()) pad else leftRight + gutter)
|
||||
val (rightBottom, rightRight) = layoutColumn(right, rightX, pad, rowPx, lineGap, lvGap)
|
||||
var y = max(leftBottom, rightBottom)
|
||||
var maxRight = max(leftRight, rightRight)
|
||||
|
||||
// ---- Bottom band spanning both columns: res · Proton · elapsed ----
|
||||
// (The graphics wrapper moved to the FPS-row label above, so it's no longer in this band.)
|
||||
val band = ArrayList<List<Span>>()
|
||||
if (showResolution) band.add(listOf(Span("RES ", colDim, bandPx), Span(resolutionString(), colValue, bandPx)))
|
||||
if (displayServer.isNotBlank()) band.add(listOf(Span("DISP ", colDim, bandPx), Span(displayServer, colDisp, bandPx)))
|
||||
@@ -1408,7 +1236,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
y = bBottom; maxRight = max(maxRight, bRight)
|
||||
}
|
||||
|
||||
// ---- Full-width frametime graph ----
|
||||
if (showGraph) {
|
||||
y += sp(3f)
|
||||
val gh = sp(22f)
|
||||
@@ -1418,9 +1245,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
y += gh
|
||||
}
|
||||
|
||||
// ---- Stack-layer lines BELOW the frametime graph, subtle (same size/style as the bottom
|
||||
// band): the DX-wrapper version first (e.g. "DXVK 2.4.1-…", gated by "DX ver"), then the
|
||||
// graphics wrapper beneath it (e.g. "Original", gated by "Wrapper"). ----
|
||||
val dxLine = dxVersionLabeled()
|
||||
val hasDx = showDxVer && dxLine.isNotBlank()
|
||||
if (hasDx) {
|
||||
@@ -1436,12 +1260,10 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
contentW = maxRight + pad
|
||||
contentH = y + pad
|
||||
addSubtleClock(pad) // subtle time-of-day, consistent with the other sizes
|
||||
addSubtleClock(pad)
|
||||
}
|
||||
|
||||
// ---- Measure / draw ---------------------------------------------------
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
// The Strip spans the parent, past the side margin the floating sizes keep.
|
||||
if (stripRect != null) { setMeasuredDimension(contentW.roundToInt(), resolveSize(contentH.roundToInt(), heightMeasureSpec)); return }
|
||||
setMeasuredDimension(
|
||||
resolveSize(contentW.roundToInt(), widthMeasureSpec),
|
||||
@@ -1461,7 +1283,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
drawTabPreview(canvas, blend)
|
||||
}
|
||||
|
||||
/** The docked tab drawn at its own size in the middle of the view, undoing the view's scale. */
|
||||
private fun drawTabPreview(canvas: Canvas, blend: Float) {
|
||||
val (w, h) = dockTabSize()
|
||||
if (scaleX <= 0f || scaleY <= 0f) return
|
||||
@@ -1487,7 +1308,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
|
||||
dPanel?.let { panel -> panelFrame(panel)?.let { canvas.drawBitmap(it, 0f, 0f, null) } }
|
||||
|
||||
// Tile backings
|
||||
if (tileRects.isNotEmpty()) {
|
||||
tilePaint.color = Color.argb((14 * bgOpacity).roundToInt().coerceIn(8, 40), 255, 255, 255)
|
||||
val tr = sp(6f)
|
||||
@@ -1498,7 +1318,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
tabRect?.let { drawTab(canvas, it) }
|
||||
stripRect?.let { drawStrip(canvas, it) }
|
||||
|
||||
// Text
|
||||
for (g in glyphs) {
|
||||
val paint = if (g.sans) wordmarkPaint else drawPaint
|
||||
paint.textSize = g.sizePx
|
||||
@@ -1509,14 +1328,11 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
headerMark?.let { DroidDeckMark.draw(canvas, it.left, it.top, it.height(), blend(colValue), accent) }
|
||||
|
||||
// Frametime graph
|
||||
graphRect?.let { drawGraph(canvas, it) }
|
||||
|
||||
lockController.drawBadge(canvas)
|
||||
}
|
||||
|
||||
// MangoHud's frametime graph: the last 200 frames on a fixed 0-50 ms scale, longer frames clipped
|
||||
// to 49.9 ms (hud_elements.cpp TransformForward_Custom).
|
||||
private fun drawGraph(canvas: Canvas, rect: RectF) {
|
||||
val stats = frames ?: return
|
||||
stats.graph(graphBuf)
|
||||
@@ -1528,7 +1344,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
if (i == 0) graphPath.moveTo(px, py) else graphPath.lineTo(px, py)
|
||||
}
|
||||
if (dPanel != null) {
|
||||
// Neon without a blur: a blur would be redrawn with every frame.
|
||||
graphPaint.strokeWidth = sp(4.5f)
|
||||
graphPaint.color = withAlpha(blend(colGraph), 56)
|
||||
canvas.drawPath(graphPath, graphPaint)
|
||||
@@ -1538,7 +1353,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
canvas.drawPath(graphPath, graphPaint)
|
||||
}
|
||||
|
||||
/** The bar, with the accent line on the side that faces the game (under it at the top, over it at the bottom). */
|
||||
private fun drawStrip(canvas: Canvas, rect: RectF) {
|
||||
headerPaint.shader = null
|
||||
headerPaint.color = panelColor()
|
||||
@@ -1560,8 +1374,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
private fun withAlpha(color: Int, alpha: Int): Int =
|
||||
Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color))
|
||||
|
||||
/** The D: straight left side with small corners, round on the right with radius [rad] (a half circle
|
||||
* when it is half the height). */
|
||||
private fun dShape(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float): Path {
|
||||
return Path().apply {
|
||||
moveTo(l, t + corner)
|
||||
@@ -1575,7 +1387,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the inside of the outline's round side is at height [y]. */
|
||||
private fun roundEdgeX(p: RectF, y: Float, stroke: Float): Float {
|
||||
val r = dRadius - stroke
|
||||
val cx = p.right - dRadius
|
||||
@@ -1605,7 +1416,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
headerPaint.color = withAlpha(accent, 90)
|
||||
c.drawRect(panel.left, dDividerTop, roundEdgeX(panel, dDividerTop, stroke), dividerBottom, headerPaint)
|
||||
}
|
||||
// The watermark fills the half circle on the short sizes and sits on the right of the tall ones.
|
||||
val halfRound = dRadius >= panel.height() / 2f - 0.5f
|
||||
val wmH = if (halfRound) panel.height() * 0.92f else min(panel.height(), panel.width()) * 0.55f
|
||||
DroidDeckMark.draw(c, panel.right - DroidDeckMark.widthFor(wmH) - (if (halfRound) panel.height() * 0.02f else sp(10f)),
|
||||
@@ -1616,7 +1426,6 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
val outline = dShape(panel.left + h, panel.top + h, panel.right - h, panel.bottom - h, dRadius - h, corner)
|
||||
val blur = BlurMaskFilter((sp(3.2f) - 0.5f).coerceAtLeast(0.5f) / 0.57735f, BlurMaskFilter.Blur.NORMAL)
|
||||
glowPaint.strokeWidth = stroke
|
||||
// Two blurred passes under the sharp one, as the 4-in-1 preview's neon.
|
||||
for (pass in 0..2) {
|
||||
glowPaint.maskFilter = if (pass < 2) blur else null
|
||||
glowPaint.style = Paint.Style.STROKE; glowPaint.color = outlineColor()
|
||||
@@ -1657,10 +1466,8 @@ class DroidDeckHudView(context: Context) : View(context) {
|
||||
DroidDeckMark.draw(canvas, rect.centerX() - markW / 2f, rect.centerY() - markH / 2f, markH, blend(colValue), accent)
|
||||
}
|
||||
|
||||
/** Screen point → whether an on-screen control under the HUD takes a touch there. */
|
||||
internal var passesTouchAt: ((rawX: Float, rawY: Float) -> Boolean)? = null
|
||||
|
||||
// Declining the down event hands the whole gesture to the views under the HUD.
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (dock != DroidDeckHudDock.NONE) return onTabTouch(event)
|
||||
if (event.actionMasked == MotionEvent.ACTION_DOWN && lockController.isLocked() &&
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
|
||||
/** artwork/droiddeck-mark.svg: the split D with its circular cut-out and the blue orb. */
|
||||
internal object DroidDeckMark {
|
||||
private const val VIEW_X = -55.98f
|
||||
const val VIEW_W = 239.92f
|
||||
@@ -36,7 +35,6 @@ internal object DroidDeckMark {
|
||||
|
||||
fun widthFor(height: Float): Float = height * VIEW_W / VIEW_H
|
||||
|
||||
/** Where the drawn mark starts and ends inside [widthFor]: the artwork has empty space on both sides. */
|
||||
fun inkLeftFor(height: Float): Float = (-31.98f - VIEW_X) * height / VIEW_H
|
||||
fun inkRightFor(height: Float): Float = (175.93f - VIEW_X) * height / VIEW_H
|
||||
|
||||
|
||||
@@ -3,13 +3,6 @@ package com.droiddeck.launcher.hud
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Frame statistics by MangoHud's rules, so the HUD and Steam's performance overlay agree: MangoHud
|
||||
* overlay.cpp update_hud_info_with_frametime and hud_elements.cpp fps() / frame_timing(). The FPS is the
|
||||
* frames counted over a sampling period of at least 500 ms, the frame time next to it is 1000 / FPS, and
|
||||
* the graph holds the last 200 frame times (zeros until filled) on a fixed 0-50 ms scale, min and max
|
||||
* taken over those 200. Average and lows are DroidDeck HUD's own readouts.
|
||||
*/
|
||||
internal class FrameStats {
|
||||
private val graph = FloatArray(GRAPH_FRAMES)
|
||||
private var graphHead = 0
|
||||
@@ -29,11 +22,9 @@ internal class FrameStats {
|
||||
@Volatile var fps = 0f
|
||||
private set
|
||||
|
||||
/** Completed sampling periods; MangoHud redraws its readouts when one completes. */
|
||||
@Volatile var fpsUpdates = 0L
|
||||
private set
|
||||
|
||||
/** Frames added so far; the HUD redraws its graph when this moves. */
|
||||
@Volatile var count = 0L
|
||||
private set
|
||||
|
||||
@@ -81,10 +72,8 @@ internal class FrameStats {
|
||||
count++
|
||||
}
|
||||
|
||||
/** MangoHud's frame time readout: 1000 / FPS. */
|
||||
fun frametimeMs(): Float = 1000f / maxOf(fps, 1f)
|
||||
|
||||
/** The graph, oldest first, into [out] (GRAPH_FRAMES long). */
|
||||
@Synchronized
|
||||
fun graph(out: FloatArray) {
|
||||
for (i in 0 until GRAPH_FRAMES) out[i] = graph[(graphHead + i) % GRAPH_FRAMES]
|
||||
@@ -96,7 +85,6 @@ internal class FrameStats {
|
||||
@Synchronized
|
||||
fun graphMaxMs(): Float = graph.max()
|
||||
|
||||
/** Average over the frames this session, from the first frame to [nowNs]'s last frame. */
|
||||
@Synchronized
|
||||
fun averageFps(): Float {
|
||||
if (sessionFrames < 2 || lastPresentNs <= sessionStartNs) return 0f
|
||||
@@ -112,7 +100,6 @@ internal class FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
/** FPS at the 99th, 99.9th and 99.99th percentile frame time of the last LOWS_FRAMES frames. */
|
||||
fun lows(nowNs: Long): Lows {
|
||||
val copy: LongArray
|
||||
synchronized(this) {
|
||||
@@ -145,11 +132,6 @@ internal class FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feeds [stats] with the frame times gamescope shares (the ones MangoHud shows). Sessions without the
|
||||
* shared file, or where gamescope stopped sending, fall back to the frames DroidDeck's compositor shows,
|
||||
* which stop at the screen's refresh rate.
|
||||
*/
|
||||
internal class GameFrameFeed(private val file: File, val stats: FrameStats = FrameStats()) {
|
||||
private var handle = 0L
|
||||
private var nextOpenNs = 0L
|
||||
@@ -160,7 +142,7 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
private val info = IntArray(5)
|
||||
@Volatile private var fromGamescope = false
|
||||
private var lastCompositorNs = 0L
|
||||
@Volatile private var compositorFrames = 0L // written by the compositor thread only
|
||||
@Volatile private var compositorFrames = 0L
|
||||
private var seenCompositorFrames = 0L
|
||||
|
||||
@Volatile var engine = ""
|
||||
@@ -176,7 +158,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
@Volatile var refreshMilliHz = 0
|
||||
private set
|
||||
|
||||
/** Main thread, once per display frame. True when new frames arrived. */
|
||||
fun poll(nowNs: Long): Boolean {
|
||||
val compositorAdded = compositorFrames != seenCompositorFrames
|
||||
seenCompositorFrames = compositorFrames
|
||||
@@ -185,7 +166,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
nextOpenNs = nowNs + REOPEN_NS
|
||||
handle = runCatching { HudFrames.nativeOpen(file.path) }.getOrDefault(0L)
|
||||
if (handle == 0L) return compositorAdded
|
||||
// Frames already in the file were written before this reader started.
|
||||
HudFrames.nativePoll(handle, frametimes, times)
|
||||
}
|
||||
val n = HudFrames.nativePoll(handle, frametimes, times)
|
||||
@@ -199,7 +179,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
} else if (lastRingFrameNs != 0L && nowNs - lastRingFrameNs > LIVE_NS) {
|
||||
fromGamescope = false
|
||||
}
|
||||
// A new session writes the file again from the start; reopen when gamescope has gone quiet.
|
||||
if (n <= 0 && nowNs - maxOf(lastRingFrameNs, nextOpenNs - REOPEN_NS) > STALE_NS) close()
|
||||
if (nowNs - lastInfoNs >= INFO_NS) {
|
||||
lastInfoNs = nowNs
|
||||
@@ -208,7 +187,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
return n > 0 || compositorAdded
|
||||
}
|
||||
|
||||
/** Compositor thread: one frame shown on the panel. */
|
||||
fun onCompositorFrame(nowNs: Long) {
|
||||
if (fromGamescope) {
|
||||
lastCompositorNs = 0L
|
||||
@@ -247,8 +225,6 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra
|
||||
private const val STALE_NS = 5_000_000_000L
|
||||
private const val INFO_NS = 500_000_000L
|
||||
|
||||
/** DroidDeck's mangoapp's names for gamescope's engine field (Valve's 20260902.1 build, app/main.cpp
|
||||
* engine_type_from_str and overlay.h engines); gamescope sends "gamescope" when it knows none. */
|
||||
fun engineLabel(name: String): String = when (name) {
|
||||
"DXVK" -> "DXVK"
|
||||
"vkd3d" -> "VKD3D"
|
||||
|
||||
Loaded 100 of 511 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user