diff --git a/.gitattributes b/.gitattributes index 53fa6915c..440bb5148 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,10 @@ tools/linuxfs/** text=auto eol=lf 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 +app/src/main/assets/desktop-removal/*.json text eol=lf +app/src/main/assets/desktop-removal/*.txt text eol=lf diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62403985b..98e50e792 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -506,7 +506,24 @@ jobs: run: python3 -m unittest discover -s tools/tests -p 'test_*.py' - name: Unit tests - run: ./gradlew testReleaseUnitTest --console=plain -PndkVersion="$NDK_VERSION" + id: unit-tests + run: | + set -o pipefail + mkdir -p app/build + ./gradlew testReleaseUnitTest --console=plain --info --stacktrace -Drobolectric.logging=stderr -PndkVersion="$NDK_VERSION" 2>&1 | tee app/build/unit-tests.log + + - name: Upload unit test diagnostics + id: unit-test-diagnostics + if: always() && steps.unit-tests.outcome != 'skipped' + uses: actions/upload-artifact@v4 + with: + name: unit-test-results + path: | + app/build/unit-tests.log + app/build/test-results/testReleaseUnitTest/ + app/build/reports/tests/testReleaseUnitTest/ + if-no-files-found: ignore + retention-days: 14 - name: Sign with the testkey (v1 + v2 + v3) run: | @@ -536,6 +553,15 @@ jobs: grep -qi "CN=Android, OU=Android, O=Android" /tmp/sig.txt \ || { echo "::error::apk is not signed with the AOSP testkey"; exit 1; } + - uses: actions/upload-artifact@v4 + id: apk-upload + with: + name: droiddeck-apk + path: app/build/outputs/apk/release/*.apk + if-no-files-found: error + retention-days: 30 + compression-level: 0 + - name: Report on the pull request if: always() && github.event_name == 'pull_request' continue-on-error: true @@ -544,6 +570,9 @@ jobs: PR: ${{ github.event.pull_request.number }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} BUILD_OUTCOME: ${{ steps.build.outcome }} + UNIT_TEST_OUTCOME: ${{ steps.unit-tests.outcome }} + TEST_DIAGNOSTICS_URL: ${{ steps.unit-test-diagnostics.outputs.artifact-url }} + APK_OUTCOME: ${{ steps.apk-upload.outcome }} SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} run: | set -uo pipefail @@ -554,8 +583,8 @@ jobs: echo [ -f /tmp/pr-merge.md ] && cat /tmp/pr-merge.md echo - case "$BUILD_OUTCOME" in - success) + case "$BUILD_OUTCOME:$UNIT_TEST_OUTCOME:$APK_OUTCOME" in + success:success:success) echo "### Build passed" echo echo "The \`droiddeck-apk\` artifact is [on this run]($RUN_URL), signed with the public test key." @@ -568,7 +597,28 @@ jobs: echo echo "Fallback: [open **PR Release Publish**]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/workflows/pr-release-publish.yml) on \`main\` and enter PR \`$PR\`." ;; - failure) + success:success:*) + echo "### APK publication incomplete" + echo + echo "Compilation and unit tests passed, but APK publication did not complete. See [the run]($RUN_URL)." + ;; + success:failure:*) + echo "### ERROR: Unit tests failed" + echo + echo "The APK compiled, but unit tests failed. No installable artifact was published." + echo + if [ -n "$TEST_DIAGNOSTICS_URL" ]; then + echo "The [unit-test-results]($TEST_DIAGNOSTICS_URL) artifact contains the test log and any generated reports." + else + echo "The test diagnostics upload did not complete. See [the test log]($RUN_URL)." + fi + ;; + success:*) + echo "### Build incomplete" + echo + echo "The APK compiled, but unit tests did not complete successfully. See [the run]($RUN_URL)." + ;; + failure:*) echo "### ERROR: Build failed" echo echo "The compiler's errors ([full log]($RUN_URL)):" @@ -586,7 +636,7 @@ jobs: echo '```' echo "" ;; - skipped|cancelled) + skipped:*|cancelled:*) echo "### Not built" echo echo "The build did not run (see above, or [the run]($RUN_URL))." @@ -602,14 +652,6 @@ jobs: gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@/tmp/pr-report.md >/dev/null fi - - uses: actions/upload-artifact@v4 - with: - name: droiddeck-apk - path: app/build/outputs/apk/release/*.apk - if-no-files-found: error - retention-days: 30 - compression-level: 0 - sign: needs: build if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' diff --git a/README.md b/README.md index 71534aa19..95ea46013 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, Rust throu After preparation, ordinary `./gradlew :app:assembleRelease` builds can reuse the verified inputs. If native sources, component pins or staged files change, packaging stops with instructions to rerun the helper. Generated audio stays under `app/build/`; the tracked base bundle is never modified. `./gradlew -PskipRust=true :app:testDebugUnitTest` needs no native preparation. For an APK without store engines, use `DROIDDECK_SKIP_RUST=1 tools/build_local.sh`; it omits cached engine libraries too. +For the prepared Windows checkout, run `./tools/run_windows.ps1` (optionally with `-Serial `), or select **DroidDeck XR** in Android Studio and run the **app** module on the connected phone. Machine-specific options live in the ignored `local.properties`: `droiddeck.python` selects a Python executable, `droiddeck.skipRust=true` omits the optional store download engines, and `droiddeck.debugApplicationIdSuffix=.xr` installs **DroidDeck XR Dev** alongside the signed release. The development app has its own runtime and Steam login. The local Java selection is in `.gradle/config.properties`. + +`python tools/prepare_windows_runtime.py` restores this checkout's audited runtime inputs from the pinned CI APK, three locally rebuilt preload libraries, and the checksum-pinned Gamescope archive in `build/windows-setup/`. Start Docker Desktop's Linux engine first: preparation now rebuilds DroidDeck's ARM64 VR driver and Vulkan layer from current source and runs their native regression tests. The compiler image is built automatically on first use. Vulkan headers come from the NDK beside `--llvm-strip`, or from an explicit `--vk-include ` directory. + +To reuse that build, pass `--rebuilt-vr build/windows-setup/vr`. Preparation verifies its `vr-build.json` against the current Git revision, source files, Vulkan headers, output checksums, and ARM64 architecture; missing or stale builds fail instead of falling back to the donor's VR libraries. The script checks the actual donor CI merge tree, still rejects unrelated unaudited runtime changes, and records component provenance in `app/build/windows-runtime-provenance.json`. Preserve these ignored preparation files when rebuilding locally; `clean` removes the prepared assets and their verification manifest. + Run the helper and preload regression suite with `bash tools/test_local.sh`. On macOS it runs in Linux through Docker, matching the helpers' runtime and CI instead of compiling Linux preloads against macOS headers. ## Limits @@ -38,3 +44,9 @@ Compatibility and performance vary by device; hardware validation is limited. De ## Credits and licence GPL-3.0. Runtime, shim, input, and controller work build on WinNative and Bannerlator (maxjivi05). Epic Games and GOG support build on [Phobos’s](https://github.com/phobos665) adaptation of [Legendary](https://github.com/legendary-gl/legendary) and [gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl), respectively. LSFG frame generation is from the work of Camille LaVey and the [Eden](https://eden-emu.dev) emulator project, following [lsfg-vk](https://github.com/PancakeTAS/lsfg-vk), ported to WinNative and DroidDeck by [@maxjivi05](https://github.com/maxjivi05); it needs your own copy of [Lossless Scaling](https://store.steampowered.com/app/993090/) and ships none of its shaders. x86 AppImages, and any whose own runtime cannot unpack them, are unpacked with [uruntime](https://github.com/VHSgunzo/uruntime) by VHSgunzo (MIT), shipped unmodified with its licence. mmap ring-buffer controller input was developed by ribbit384 ([WinNative #345](https://github.com/WinNative-Emu/WinNative/pull/345)). See [LICENSE](LICENSE). Steam and Proton belong to Valve Corporation; this project is not affiliated with Valve. + +XR glasses support builds on WinNative's VITURE glasses work. VITURE protocol details were checked against [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) by [@wheaney](https://github.com/wheaney), including the VITURE protocol header it ships, [ar-drivers-rs](https://github.com/badicsalex/ar-drivers-rs) by [@badicsalex](https://github.com/badicsalex) and the [go-macos/iokit VITURE package](https://pkg.go.dev/github.com/go-macos/iokit/viture); the optional VITURE SDK belongs to VITURE and is not included. RayNeo support adapts [@informalTechCode](https://github.com/informalTechCode)'s [RayNeo-Air-Series-3DoF-Scaffold](https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold) (Apache-2.0) and references [@verncat](https://github.com/verncat)'s [RayNeo-Air-3S-Pro-OpenVR](https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR) (MIT) and [Monado](https://gitlab.freedesktop.org/monado/monado) (BSL-1.0); see [docs/third-party/rayneo](docs/third-party/rayneo/README.md). XREAL 1S support references [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver) and [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit) by @wheaney, [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver) by [@rohitsangwan01](https://github.com/rohitsangwan01) (MIT) and [xr-tools](https://github.com/0xcaff/xr-tools) by [@0xcaff](https://github.com/0xcaff). The REDMAGIC glasses-display findings draw on [@mekhontsev](https://github.com/mekhontsev)'s [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) and [@Coding-BR](https://github.com/Coding-BR)'s [decompiled Nubia kernel](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube). VITURE, RayNeo, XREAL and REDMAGIC are trademarks of their owners; this project is not affiliated with them. + +Joy-Con VR controllers port the arm model of Google's [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) (Apache-2.0), adapt the hand skeleton of Valve's [OpenVR](https://github.com/ValveSoftware/openvr) `handskeletonsimulation` sample (BSD-3-Clause), which follows Moshi Turner's hand simulation in [Monado](https://gitlab.freedesktop.org/monado/monado), and use the Index controller offsets from Monado's `vive_poses.c` (BSL-1.0). Joy-Con behaviour was researched in Linux's [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) driver by Daniel J. Ogorchock, [@dekuNukem](https://github.com/dekuNukem)'s [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering), [SDL](https://github.com/libsdl-org/SDL) and the Android Open Source Project, and checked against [ALVR](https://github.com/alvr-org/ALVR), [OpenComposite](https://gitlab.com/znixian/OpenOVR), [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge), [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr), [@krnelpan1c](https://github.com/krnelpan1c)'s [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR), [@yoyobuae](https://github.com/yoyobuae)'s [joyconvr](https://github.com/yoyobuae/joyconvr), [Moonlight](https://github.com/moonlight-stream/moonlight-android) and [Dolphin](https://github.com/dolphin-emu/dolphin); see [docs/third-party/joycon](docs/third-party/joycon/README.md). Nintendo Switch and Joy-Con are trademarks of Nintendo; this project is not affiliated with Nintendo. + +Camera hand tracking runs Google's [MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision library with its Pose Landmarker (Lite) model, BlazePose GHUM 3D, and its Hand Landmarker model (all Apache-2.0), on frames from AndroidX [CameraX](https://developer.android.com/jetpack/androidx/releases/camera) (Apache-2.0); MediaPipe's usage reports are switched off. The design was checked against [@F8LEFT](https://github.com/F8LEFT)'s [JoyConVr](https://github.com/F8LEFT/JoyConVr) camera app (MIT), and the short Joy-Con movements follow the accelerometer mode of the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk)'s arm model, Eric Foxlin's zero-velocity updates and Frosio, Pedersini and Borghese's accelerometer autocalibration, and the camera and Joy-Con are joined by a Kalman filter (R. E. Kalman, 1960). Bare camera hands take their gestures after [ALVR](https://github.com/alvr-org/ALVR)'s `hand_gestures.rs` (MIT) and Meta Quest's menu pinch, with distances checked on photos from the [HaGRID](https://github.com/hukenovs/hagrid) gesture set (CC BY-SA 4.0, not included), fit the palm with Horn's quaternion method (1987) and smooth it with the 1€ filter (Casiez, Roussel and Vogel, 2012); the white glove model follows Valve's OpenVR render model reference and Index controller poses, is meshed with surface nets (Gibson, 1998; Mikola Lysenko) and blends and shades it with Inigo Quilez's smooth minimum and distance-field ambient occlusion, porting his polygon distance (MIT); see [docs/third-party/handtracking](docs/third-party/handtracking/README.md). diff --git a/app/build.gradle b/app/build.gradle index d3ed3382b..ac790fc39 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,6 +4,25 @@ 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()) { + localBuildOptions.load(new StringReader(providers.fileContents(localBuildOptionsFile).asText.get())) +} +def pythonExecutable = project.findProperty('pythonExecutable') + ?: localBuildOptions.getProperty('droiddeck.python', 'python3') +def vitureSdkDir = project.findProperty('vitureSdkDir') ?: localBuildOptions.getProperty('droiddeck.vitureSdk', '') +if (vitureSdkDir) { + ['android/arm64-v8a/libglasses.so', 'android/arm64-v8a/libcarina_vio.so', + 'android/arm64-v8a/libcloud_protocol.so', 'include/viture_glasses_provider.h', + 'android/LICENSE', 'android/NOTICE'].each { required -> + if (!new File(vitureSdkDir, required).isFile()) { + throw new GradleException("Incomplete VITURE Android SDK: missing ${required}") + } + } +} + def gitValue = { List arguments -> try { def output = providers.exec { @@ -100,6 +119,7 @@ android { externalNativeBuild { cmake { arguments '-DANDROID_STL=c++_shared' + arguments "-DDROIDDECK_VITURE_SDK_DIR=${vitureSdkDir.toString().replace('\\', '/')}" } } } @@ -128,6 +148,11 @@ android { debug { minifyEnabled false signingConfig signingConfigs.testkey + def localSuffix = localBuildOptions.getProperty('droiddeck.debugApplicationIdSuffix') + if (localSuffix) { + applicationIdSuffix localSuffix + resValue 'string', 'app_name', 'DroidDeck XR Dev' + } } release { minifyEnabled false @@ -158,6 +183,10 @@ android { testOptions { unitTests.returnDefaultValues = true unitTests.includeAndroidResources = true + unitTests.all { + def logTarget = providers.systemProperty('robolectric.logging').orNull + if (logTarget != null) systemProperty 'robolectric.logging', logTarget + } } compileOptions { @@ -169,6 +198,16 @@ 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) { + from(new File(vitureSdkDir, 'android')) { include 'LICENSE', 'NOTICE' } + into(layout.buildDirectory.dir('vitureAssets/licenses/viture')) + } + tasks.named('preBuild').configure { dependsOn(stageVitureNotices) } +} + def stageSessionScripts = tasks.register('stageSessionScripts', Copy) { from(rootProject.file('tools/linuxfs/overlay')) { include 'usr/local/bin/droiddeck-*', 'usr/local/bin/steam-compatibility', 'usr/bin/**' @@ -185,12 +224,13 @@ def stageRuntimeAssets = tasks.register('stageRuntimeAssets', Sync) { into(runtimeAssetsDir) } android.sourceSets.main.assets.setSrcDirs([runtimeAssetsDir]) +if (vitureSdkDir) android.sourceSets.main.assets.srcDir(layout.buildDirectory.dir('vitureAssets')) tasks.named('preBuild').configure { dependsOn(stageRuntimeAssets) } def verifyRuntimeInputs = tasks.register('verifyRuntimeInputs', Exec) { mustRunAfter(stageSessionScripts, stageRuntimeAssets) workingDir rootProject.projectDir - commandLine 'python3', 'tools/release/runtime_inputs.py', 'verify' + commandLine pythonExecutable, 'tools/release/runtime_inputs.py', 'verify' } tasks.configureEach { task -> if (task.name in ['packageDebug', 'packageRelease']) task.dependsOn(verifyRuntimeInputs) @@ -198,7 +238,8 @@ tasks.configureEach { task -> def rustCrateDir = layout.projectDirectory.dir('src/main/rust/stores') def rustJniLibsDir = layout.buildDirectory.dir('rustJniLibs') -def skipRust = (project.findProperty('skipRust') ?: 'false').toString().toBoolean() +def skipRust = (project.findProperty('skipRust') + ?: localBuildOptions.getProperty('droiddeck.skipRust', 'false')).toString().toBoolean() def buildRustStores = tasks.register('buildRustStores', Exec) { description = 'Cross-compiles the store download engines (libdroiddeckstores.so) with cargo-ndk.' onlyIf { !skipRust } @@ -242,6 +283,11 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'com.flyfishxu:kadb-android:1.2.1' implementation 'com.termux.termux-app:terminal-view:0.118.0' + implementation 'androidx.camera:camera-camera2:1.4.2' + implementation 'androidx.camera:camera-lifecycle:1.4.2' + implementation('com.google.mediapipe:tasks-vision:0.10.21') { + exclude group: 'com.google.auto.value', module: 'auto-value' + } testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.16.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bbcece3f6..869492a29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -19,9 +20,13 @@ + + + + + + + + + diff --git a/app/src/main/assets/handtracking/hand_landmarker.task b/app/src/main/assets/handtracking/hand_landmarker.task new file mode 100644 index 000000000..0d53faf37 Binary files /dev/null and b/app/src/main/assets/handtracking/hand_landmarker.task differ diff --git a/app/src/main/assets/handtracking/pose_landmarker_lite.task b/app/src/main/assets/handtracking/pose_landmarker_lite.task new file mode 100644 index 000000000..09576a930 Binary files /dev/null and b/app/src/main/assets/handtracking/pose_landmarker_lite.task differ diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 3e0492d5d..9aa175cae 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -38,3 +38,12 @@ add_library(deviceinfo SHARED deviceinfo/vkinfo.c) target_link_libraries(deviceinfo dl) add_library(termux SHARED termux/termux.c) + +# 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) + target_include_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/include") + target_link_directories(droiddeck_viture PRIVATE "${DROIDDECK_VITURE_SDK_DIR}/android/${ANDROID_ABI}") + target_link_libraries(droiddeck_viture PRIVATE glasses log) +endif() diff --git a/app/src/main/cpp/viture/display_modes.h b/app/src/main/cpp/viture/display_modes.h new file mode 100644 index 000000000..3a78e03ae --- /dev/null +++ b/app/src/main/cpp/viture/display_modes.h @@ -0,0 +1,36 @@ +#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 + default: return -4; + } +} +constexpr int from_native_mode(int native) { + switch (native) { + case 0x31: return 0x31; + case 0x32: return 0x33; + case 0x33: return 0x34; + case 0x37: return 0x32; + case 0x38: return 0x35; + case 0x34: return 0x41; + case 0x35: return 0x43; + case 0x36: return 0x44; + case 0x3a: return 0x42; + case 0x3b: return 0x45; + default: return -4; // Ultrawide is not stereo. + } +} +} diff --git a/app/src/main/cpp/viture/sdk_bridge.cpp b/app/src/main/cpp/viture/sdk_bridge.cpp new file mode 100644 index 000000000..a0be4f88d --- /dev/null +++ b/app/src/main/cpp/viture/sdk_bridge.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "viture_glasses_provider.h" +#include "viture_device.h" +#include "viture_device_carina.h" +#include "viture_protocol_public.h" +#include "viture_result.h" +#include "viture_version.h" +#include "display_modes.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 latest_pose{}; +int64_t latest_time = 0; +bool accepting_pose = false; +bool accepting_state = false; +std::array state_values{}; +unsigned state_pending = 0; + +void on_state(int id, int value) { + std::lock_guard lock(pose_mutex); + if (!accepting_state || id < 0 || id >= int(state_values.size())) return; + state_values[id] = value; + state_pending |= 1u << id; +} + +void clear_state(bool accept) { + std::lock_guard lock(pose_mutex); + accepting_state = accept; + state_pending = 0; +} + +int64_t now_ns() { + timespec ts{}; + clock_gettime(CLOCK_BOOTTIME, &ts); + return int64_t(ts.tv_sec) * 1000000000LL + ts.tv_nsec; +} + +void on_pose(float* data, uint64_t) { + if (!data) return; + std::lock_guard 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(); +} + +void clear_pose(bool accept) { + std::lock_guard lock(pose_mutex); + accepting_pose = accept; + latest_time = 0; +} + +struct Session { + XRDeviceProviderHandle device = nullptr; + int pid = 0; + int type = -1; + bool initialized = false; + bool started = false; + bool tracking = false; + bool native_dof = false; + bool switched_native = false; + int original_bypass_display = -1; + int saved_dof = -1; + int64_t consumed_time = 0; + jlong token = 0; +}; +std::unique_ptr session; +jlong next_token = 0; + +void report(const char* step, int rc) { + if (rc < 0) __android_log_print(ANDROID_LOG_WARN, "VitureSdk", "%s: %d", step, rc); +} + +int suspend_native_dof(Session& s) { + if (!s.native_dof) return 0; + int mode = xr_device_provider_native_get_mode(s.device); + if (mode < 0) return mode; + if (mode != 1) return 0; + if (s.saved_dof < 0) { + int dof = xr_device_provider_native_get_dof(s.device); + if (dof < 0) return dof; + s.saved_dof = dof; + } + return xr_device_provider_native_set_dof(s.device, VITURE_NATIVE_DOF_0); +} + +void restore_native_dof(Session& s) { + if (s.saved_dof >= 0 && xr_device_provider_native_get_mode(s.device) == 1) { + report("restore native DOF", xr_device_provider_native_set_dof(s.device, s.saved_dof)); + } + s.saved_dof = -1; +} + +int tracking(Session& s, bool enabled) { + if (s.tracking == enabled) return 0; + if (!enabled) { + clear_pose(false); + int rc = s.type == XR_DEVICE_TYPE_VITURE_CARINA ? 0 : + xr_device_provider_close_imu(s.device, VITURE_IMU_MODE_POSE); + s.tracking = false; + restore_native_dof(s); + return rc; + } + int rc = suspend_native_dof(s); + if (rc < 0) return rc; + clear_pose(true); + s.consumed_time = 0; + if (s.type != XR_DEVICE_TYPE_VITURE_CARINA) { + int frequency = VITURE_IMU_FREQUENCY_MEDIUM_HIGH; + while (frequency >= VITURE_IMU_FREQUENCY_LOW && + !xr_device_provider_is_product_support_imu_frequency(s.pid, VITURE_IMU_MODE_POSE, frequency)) --frequency; + rc = frequency < 0 ? VITURE_GLASSES_ERROR_NOT_SUPPORTED : + xr_device_provider_open_imu(s.device, VITURE_IMU_MODE_POSE, frequency); + } + if (rc == 0) s.tracking = true; + else { clear_pose(false); restore_native_dof(s); } + return rc; +} + +void destroy(Session& s) { + clear_pose(false); + clear_state(false); + if (s.device) { + if (s.tracking) report("close tracking", tracking(s, false)); + if (s.switched_native && s.original_bypass_display >= 0) { + int rc = xr_device_provider_native_set_mode(s.device, 0); + if (rc == 0) rc = xr_device_provider_set_display_mode(s.device, s.original_bypass_display); + report("restore bypass mode", rc); + } + if (s.started) report("stop", xr_device_provider_stop(s.device)); + if (s.initialized) report("shutdown", xr_device_provider_shutdown(s.device)); + xr_device_provider_destroy(s.device); + s.device = nullptr; + } +} + +int get_display(Session& s) { + if (s.native_dof) { + const int mode = xr_device_provider_native_get_mode(s.device); + if (mode < 0) return mode; + if (mode == 1) { + const int value = xr_device_provider_native_get_display_mode(s.device); + return value < 0 ? value : droiddeck::viture::from_native_mode(value); + } + } + return xr_device_provider_get_display_mode(s.device); +} + +int set_display(Session& s, int standard) { + if (!s.native_dof) return xr_device_provider_set_display_mode(s.device, standard); + int mode = xr_device_provider_native_get_mode(s.device); + if (mode < 0) return mode; + 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; + if (mode == 0) { + if (!s.switched_native) { + s.original_bypass_display = xr_device_provider_get_display_mode(s.device); + if (s.original_bypass_display < 0) return s.original_bypass_display; + } + int rc = xr_device_provider_native_set_mode(s.device, 1); + if (rc < 0) return rc; + s.switched_native = true; + } + int rc = xr_device_provider_native_set_display_mode(s.device, native); + if (rc == 0 && s.tracking) rc = suspend_native_dof(s); + if (rc < 0 && mode == 0) { + restore_native_dof(s); + int rollback = xr_device_provider_native_set_mode(s.device, 0); + if (rollback == 0) rollback = xr_device_provider_set_display_mode(s.device, s.original_bypass_display); + report("rollback display mode", rollback); + if (rollback == 0) s.switched_native = false; + } + return rc; +} +} + +#define JNI_METHOD(name) Java_com_droiddeck_launcher_glasses_VitureSdk_##name +extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nativeAvailable)(JNIEnv*, jobject) { + __android_log_print(ANDROID_LOG_INFO, "VitureSdk", "SDK %s", GetVersionString()); + return JNI_TRUE; +} + +extern "C" JNIEXPORT jstring JNICALL JNI_METHOD(nativeMarketName)(JNIEnv* env, jobject, jint pid) { + std::lock_guard lock(api_mutex); + if (!xr_device_provider_is_product_id_valid(pid)) return nullptr; + char name[64]{}; + int length = sizeof(name) - 1; + if (xr_device_provider_get_market_name(pid, name, &length) != 0) return nullptr; + name[sizeof(name) - 1] = 0; + return env->NewStringUTF(name); +} + +extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativeOpen)(JNIEnv* env, jobject, jint pid, jint fd, jstring cache) { + std::lock_guard lock(api_mutex); + if (session || fd < 0 || !cache || !xr_device_provider_is_product_id_valid(pid)) return 0; + auto s = std::make_unique(); + s->pid = pid; + s->device = xr_device_provider_create(pid, fd); + if (!s->device) return 0; + s->type = xr_device_provider_get_device_type(s->device); + s->native_dof = xr_device_provider_is_product_support_native_dof(pid) == 1; + int rc = s->type < 0 ? s->type : 0; + if (rc == 0) { + clear_state(true); + rc = xr_device_provider_register_state_callback(s->device, on_state); + } + if (rc == 0) { + rc = s->type == XR_DEVICE_TYPE_VITURE_CARINA ? + xr_device_provider_set_dof_type_carina(s->device, 0) : + xr_device_provider_register_imu_pose_callback(s->device, on_pose); + } + if (rc == 0) { + const char* directory = env->GetStringUTFChars(cache, nullptr); + if (!directory) { destroy(*s); return 0; } + rc = xr_device_provider_initialize(s->device, nullptr, directory); + env->ReleaseStringUTFChars(cache, directory); + s->initialized = rc == 0; + } + if (rc == 0) { + rc = xr_device_provider_start(s->device); + s->started = rc == 0; + } + if (rc != 0) { report("initialize/start", rc); destroy(*s); return 0; } + s->token = ++next_token; + session = std::move(s); + return session->token; +} + +extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeType)(JNIEnv*, jobject, jlong token) { + std::lock_guard lock(api_mutex); + return session && session->token == token ? session->type : -1; +} + +extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeTracking)(JNIEnv*, jobject, jlong token, jboolean enabled) { + std::lock_guard lock(api_mutex); + return session && session->token == token ? tracking(*session, enabled) : -1; +} + +extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeCommand)(JNIEnv*, jobject, jlong token, jint command, jint value) { + std::lock_guard lock(api_mutex); + if (!session || session->token != token) return -1; + auto& s = *session; + switch (command) { + case 1: return get_display(s); + case 2: return set_display(s, value); + case 3: return xr_device_provider_get_brightness_level(s.device); + case 4: return xr_device_provider_set_brightness_level(s.device, value); + case 5: return xr_device_provider_get_volume_level(s.device); + case 6: return xr_device_provider_set_volume_level(s.device, value); + case 7: return xr_device_provider_set_film_mode(s.device, value ? 1.0f : 0.0f); + default: return VITURE_GLASSES_ERROR_NOT_SUPPORTED; + } +} + +extern "C" JNIEXPORT jlong JNICALL JNI_METHOD(nativePose)(JNIEnv* env, jobject, jlong token, jfloatArray output) { + std::lock_guard lock(api_mutex); + if (!session || session->token != token || !output || env->GetArrayLength(output) != 4) return -1; + auto& s = *session; + if (!s.tracking) return 0; + if (s.type == XR_DEVICE_TYPE_VITURE_CARINA) { + float pose[7]{}; + int status = 1; + int rc = xr_device_provider_get_gl_pose_carina(s.device, pose, 0.0, &status); + if (rc < 0) return rc; + if (status != 0) return 0; + env->SetFloatArrayRegion(output, 0, 4, pose + 3); + return now_ns(); + } + std::lock_guard pose_lock(pose_mutex); + if (!latest_time || latest_time == s.consumed_time) return 0; + env->SetFloatArrayRegion(output, 0, 4, latest_pose.data()); + s.consumed_time = latest_time; + return latest_time; +} + +extern "C" JNIEXPORT jint JNICALL JNI_METHOD(nativeNextState)(JNIEnv* env, jobject, jlong token, jintArray output) { + std::lock_guard lock(api_mutex); + if (!session || session->token != token || !output || env->GetArrayLength(output) != 2) return -1; + std::lock_guard state_lock(pose_mutex); + for (int id = 0; id < int(state_values.size()); ++id) { + if (!(state_pending & (1u << id))) continue; + state_pending &= ~(1u << id); + const jint value[] = {id, state_values[id]}; + env->SetIntArrayRegion(output, 0, 2, value); + return 1; + } + return 0; +} + +extern "C" JNIEXPORT void JNICALL JNI_METHOD(nativeClose)(JNIEnv*, jobject, jlong token) { + std::lock_guard lock(api_mutex); + if (session && session->token == token) { destroy(*session); session.reset(); } +} diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.c b/app/src/main/cpp/waylandcomp/src/cardboard.c index 8031d61d7..5e4d81701 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.c +++ b/app/src/main/cpp/waylandcomp/src/cardboard.c @@ -34,16 +34,19 @@ 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); - p->fov_deg = clampf(p->fov_deg, 30.0f, 140.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); + p->mono = p->mono ? 1 : 0; + p->display_hz = clampf(p->display_hz, 0.0f, 240.0f); } void cardboard_push_constants(const struct cardboard_params *p, const float pose[9], int src_w, int src_h, int out_w, int out_h, float out[24]) { for (int r = 0; r < 3; r++) for (int c = 0; c < 3; c++) out[r * 4 + c] = pose[r * 3 + c]; - out[3] = p->ipd_m * 0.5f; + out[3] = p->mono ? 0.0f : p->ipd_m * 0.5f; out[7] = p->screen_dist_m; out[11] = p->screen_width_m * 0.5f; out[12] = p->k1; @@ -54,8 +57,9 @@ void cardboard_push_constants(const struct cardboard_params *p, const float pose out[17] = (src_w > 0 && src_h > 0) ? (float)src_w / (float)src_h : 16.0f / 9.0f; if (p->source == CARDBOARD_SOURCE_SBS) out[17] *= 0.5f; out[18] = (float)p->source; - out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * 0.5f) / (float)out_h : 1.0f; - out[20] = out[21] = out[22] = out[23] = 0.0f; + out[19] = (out_w > 0 && out_h > 0) ? ((float)out_w * (p->mono ? 1.0f : 0.5f)) / (float)out_h : 1.0f; + out[20] = p->mono ? 1.0f : 0.0f; + out[21] = out[22] = out[23] = 0.0f; } #ifndef CARDBOARD_HOST_TEST @@ -81,13 +85,21 @@ void vkp_cardboard_set(const struct cardboard_params *p) { shm_publish(g_live); pthread_mutex_unlock(&g_lock); if (was != c.enabled || c.enabled) - droiddeck_log("cardboard", "%s: %s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f", + droiddeck_log("cardboard", "%s: %s%s, ipd %.0f mm, screen %.1f m wide at %.1f m, lens k1 %.2f k2 %.2f, fov %.0f, inset %.3f, chroma %.3f", c.enabled ? "on" : "off", c.source == CARDBOARD_SOURCE_SBS ? "side-by-side source" : "virtual screen", - c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma); + c.mono ? " (one eye)" : "", c.ipd_m * 1000.0f, c.screen_width_m, c.screen_dist_m, c.k1, c.k2, c.fov_deg, c.lens_inset, c.chroma); } static struct droiddeck_headpose *g_shm; static int g_eye_w, g_eye_h; +static int g_render_height; + +void vkp_cardboard_set_render_height(int height) { + pthread_mutex_lock(&g_lock); + g_render_height = height > 0 ? height : 0; + shm_publish(g_live); + pthread_mutex_unlock(&g_lock); +} void vkp_cardboard_set_pose_file(const char *path) { pthread_mutex_lock(&g_lock); @@ -101,6 +113,7 @@ void vkp_cardboard_set_pose_file(const char *path) { g_shm->magic = DROIDDECK_HEADPOSE_MAGIC; g_shm->version = DROIDDECK_HEADPOSE_VERSION; memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot)); + shm_publish(g_live); } } if (fd >= 0) close(fd); @@ -119,7 +132,10 @@ static void shm_publish(int live) { atomic_thread_fence(memory_order_release); memcpy(g_shm->rot, g_pose, sizeof(g_shm->rot)); g_shm->ipd_m = g_set.ipd_m; - g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u); + g_shm->flags = (live ? DROIDDECK_HEADPOSE_LIVE : 0u) | (g_set.enabled ? DROIDDECK_HEADPOSE_ENABLED : 0u) | + (g_set.mono ? DROIDDECK_HEADPOSE_MONO : 0u); + g_shm->display_hz = g_set.display_hz; + g_shm->render_height = (uint32_t)g_render_height; g_shm->time_ns = (int64_t)ts.tv_sec * 1000000000LL + ts.tv_nsec; g_shm->tan_half_fov = tanf(g_set.fov_deg * 0.5f * (float)M_PI / 180.0f); g_shm->eye_w = (uint32_t)(g_eye_w > 0 ? g_eye_w : 0); @@ -401,14 +417,15 @@ VkImage vkp_cardboard_run(VkCommandBuffer cmd, VkImage src, int src_w, int src_h if (!said) { said = 1; droiddeck_log("error", "cardboard: no descriptor for the frame; shown flat"); } return VK_NULL_HANDLE; } - if (g_eye_w != out_w / 2 || g_eye_h != out_h) { + snapshot(); + int eye_w = g_cur.mono ? out_w : out_w / 2; + if (g_eye_w != eye_w || g_eye_h != out_h) { pthread_mutex_lock(&g_lock); - g_eye_w = out_w / 2; + g_eye_w = eye_w; g_eye_h = out_h; shm_publish(g_live); pthread_mutex_unlock(&g_lock); } - snapshot(); struct { float ndc[4]; float rest[24]; } pc = {{-1.0f, -1.0f, 1.0f, 1.0f}, {0}}; cardboard_push_constants(&g_cur, g_cur_pose, src_w, src_h, out_w, out_h, pc.rest); diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.frag b/app/src/main/cpp/waylandcomp/src/cardboard.frag index 4dbc67fc2..eb2628b2e 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.frag +++ b/app/src/main/cpp/waylandcomp/src/cardboard.frag @@ -10,7 +10,7 @@ layout(push_constant) uniform PC { vec4 rot2; vec4 lens; vec4 view; - + vec4 extra; } pc; const vec3 BACKDROP = vec3(0.015, 0.015, 0.02); @@ -40,9 +40,10 @@ vec3 eyeSample(vec2 tan, float eye) { } void main() { - float eye = uv.x < 0.5 ? 0.0 : 1.0; - vec2 local = vec2(uv.x * 2.0 - eye, uv.y); - float cx = 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x); + bool mono = pc.extra.x > 0.5; + float eye = mono || uv.x < 0.5 ? 0.0 : 1.0; + vec2 local = mono ? uv : vec2(uv.x * 2.0 - eye, uv.y); + float cx = mono ? 0.5 : 0.5 + (eye < 0.5 ? pc.view.x : -pc.view.x); vec2 p = local - vec2(cx, 0.5); float tanH = pc.lens.z; vec2 t = vec2(p.x * pc.view.w, p.y) * 2.0 * tanH; diff --git a/app/src/main/cpp/waylandcomp/src/cardboard.h b/app/src/main/cpp/waylandcomp/src/cardboard.h index 3fd99e6b7..b1ff59689 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard.h +++ b/app/src/main/cpp/waylandcomp/src/cardboard.h @@ -15,11 +15,14 @@ struct cardboard_params { float fov_deg; float lens_inset; float chroma; + int mono; + float display_hz; }; void vkp_cardboard_set(const struct cardboard_params *p); void vkp_cardboard_set_pose(const float m[9], int live); void vkp_cardboard_set_pose_file(const char *path); +void vkp_cardboard_set_render_height(int height); void vkp_cardboard_bind_device(VkDevice dev, const VkPhysicalDeviceMemoryProperties *memprops); int vkp_cardboard_active(void); diff --git a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h index 76f72467a..caebe0b5e 100644 --- a/app/src/main/cpp/waylandcomp/src/cardboard_frag.h +++ b/app/src/main/cpp/waylandcomp/src/cardboard_frag.h @@ -1,8 +1,8 @@ #pragma once const uint32_t cardboard_frag_code[] = { - 0x07230203,0x00010000,0x0008000b,0x0000016d,0x00000000,0x00020011,0x00000001,0x0006000b, + 0x07230203,0x00010000,0x0008000b,0x00000185,0x00000000,0x00020011,0x00000001,0x0006000b, 0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001, - 0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000ee,0x00000165,0x00030010, + 0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x000000f8,0x0000017d,0x00030010, 0x00000004,0x00000007,0x00030003,0x00000002,0x000001c2,0x00040005,0x00000004,0x6e69616d, 0x00000000,0x00070005,0x0000000d,0x69646e75,0x726f7473,0x66762874,0x31663b32,0x0000003b, 0x00030005,0x0000000b,0x00000074,0x00040005,0x0000000c,0x6c616373,0x00000065,0x00070005, @@ -11,255 +11,273 @@ const uint32_t cardboard_frag_code[] = { 0x0000001e,0x00004350,0x00040006,0x0000001e,0x00000000,0x0063646e,0x00050006,0x0000001e, 0x00000001,0x30746f72,0x00000000,0x00050006,0x0000001e,0x00000002,0x31746f72,0x00000000, 0x00050006,0x0000001e,0x00000003,0x32746f72,0x00000000,0x00050006,0x0000001e,0x00000004, - 0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00030005, - 0x00000020,0x00006370,0x00040005,0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042, - 0x00000073,0x00040005,0x00000068,0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052, - 0x00030005,0x00000094,0x00000064,0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3, - 0x00000074,0x00030005,0x000000bd,0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057, - 0x00040005,0x000000c6,0x666c6168,0x00000048,0x00030005,0x000000cb,0x00000073,0x00030005, - 0x000000ec,0x00657965,0x00030005,0x000000ee,0x00007675,0x00040005,0x000000f4,0x61636f6c, - 0x0000006c,0x00030005,0x000000fe,0x00007863,0x00030005,0x0000010c,0x00000070,0x00040005, - 0x00000111,0x486e6174,0x00000000,0x00030005,0x00000114,0x00000074,0x00030005,0x00000125, - 0x00000063,0x00040005,0x00000129,0x61726170,0x0000006d,0x00040005,0x0000012b,0x61726170, - 0x0000006d,0x00040005,0x0000012d,0x61726170,0x0000006d,0x00040005,0x0000012e,0x61726170, - 0x0000006d,0x00040005,0x00000133,0x61726170,0x0000006d,0x00040005,0x00000135,0x61726170, - 0x0000006d,0x00040005,0x00000137,0x61726170,0x0000006d,0x00040005,0x00000138,0x61726170, - 0x0000006d,0x00040005,0x00000140,0x61726170,0x0000006d,0x00040005,0x00000142,0x61726170, - 0x0000006d,0x00040005,0x00000144,0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170, - 0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d,0x61726170, - 0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150,0x61726170, - 0x0000006d,0x00030005,0x00000153,0x00000065,0x00040005,0x00000159,0x65646166,0x00000000, - 0x00050005,0x00000165,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e,0x00000002, - 0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e,0x00000001, - 0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020,0x00050048, - 0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004,0x00000023, - 0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00040047,0x00000068, - 0x00000021,0x00000000,0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000ee, - 0x0000001e,0x00000000,0x00040047,0x00000165,0x0000001e,0x00000000,0x00020013,0x00000002, - 0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007, - 0x00000006,0x00000002,0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009, - 0x00000007,0x00000006,0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017, - 0x0000000f,0x00000006,0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009, - 0x0004002b,0x00000006,0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004, - 0x0008001e,0x0000001e,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d, - 0x00040020,0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009, - 0x00040015,0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004, - 0x00040015,0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000, - 0x00040020,0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001, - 0x0004002b,0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002, - 0x0004002b,0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023, - 0x00000046,0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007, - 0x00000055,0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c, - 0x00000007,0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054, - 0x00000054,0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000, - 0x00000000,0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067, - 0x00000000,0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075, - 0x0000000f,0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021, - 0x00000078,0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021, - 0x0000007d,0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093, - 0x00000007,0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006, - 0x000000ab,0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006, - 0x000000b0,0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0, - 0x00040020,0x000000ed,0x00000001,0x00000007,0x0004003b,0x000000ed,0x000000ee,0x00000001, - 0x00040020,0x000000ef,0x00000001,0x00000006,0x0004002b,0x00000006,0x000000f7,0x40000000, - 0x0004002b,0x00000006,0x0000015a,0x3cf5c28f,0x00040020,0x00000164,0x00000003,0x0000001d, - 0x0004003b,0x00000164,0x00000165,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000, - 0x00000003,0x000200f8,0x00000005,0x0004003b,0x00000009,0x000000ec,0x00000007,0x0004003b, - 0x00000008,0x000000f4,0x00000007,0x0004003b,0x00000009,0x000000fe,0x00000007,0x0004003b, - 0x00000009,0x00000101,0x00000007,0x0004003b,0x00000008,0x0000010c,0x00000007,0x0004003b, - 0x00000009,0x00000111,0x00000007,0x0004003b,0x00000008,0x00000114,0x00000007,0x0004003b, - 0x00000093,0x00000125,0x00000007,0x0004003b,0x00000008,0x00000129,0x00000007,0x0004003b, - 0x00000009,0x0000012b,0x00000007,0x0004003b,0x00000008,0x0000012d,0x00000007,0x0004003b, - 0x00000009,0x0000012e,0x00000007,0x0004003b,0x00000008,0x00000133,0x00000007,0x0004003b, - 0x00000009,0x00000135,0x00000007,0x0004003b,0x00000008,0x00000137,0x00000007,0x0004003b, - 0x00000009,0x00000138,0x00000007,0x0004003b,0x00000008,0x00000140,0x00000007,0x0004003b, - 0x00000009,0x00000142,0x00000007,0x0004003b,0x00000008,0x00000144,0x00000007,0x0004003b, - 0x00000009,0x00000145,0x00000007,0x0004003b,0x00000008,0x0000014b,0x00000007,0x0004003b, - 0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008,0x0000014f,0x00000007,0x0004003b, - 0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008,0x00000153,0x00000007,0x0004003b, - 0x00000009,0x00000159,0x00000007,0x00050041,0x000000ef,0x000000f0,0x000000ee,0x00000024, - 0x0004003d,0x00000006,0x000000f1,0x000000f0,0x000500b8,0x0000003b,0x000000f2,0x000000f1, - 0x0000003a,0x000600a9,0x00000006,0x000000f3,0x000000f2,0x00000054,0x0000001c,0x0003003e, - 0x000000ec,0x000000f3,0x00050041,0x000000ef,0x000000f5,0x000000ee,0x00000024,0x0004003d, - 0x00000006,0x000000f6,0x000000f5,0x00050085,0x00000006,0x000000f8,0x000000f6,0x000000f7, - 0x0004003d,0x00000006,0x000000f9,0x000000ec,0x00050083,0x00000006,0x000000fa,0x000000f8, - 0x000000f9,0x00050041,0x000000ef,0x000000fb,0x000000ee,0x0000002b,0x0004003d,0x00000006, - 0x000000fc,0x000000fb,0x00050050,0x00000007,0x000000fd,0x000000fa,0x000000fc,0x0003003e, - 0x000000f4,0x000000fd,0x0004003d,0x00000006,0x000000ff,0x000000ec,0x000500b8,0x0000003b, - 0x00000100,0x000000ff,0x0000003a,0x000300f7,0x00000103,0x00000000,0x000400fa,0x00000100, - 0x00000102,0x00000106,0x000200f8,0x00000102,0x00060041,0x00000025,0x00000104,0x00000020, - 0x00000036,0x00000024,0x0004003d,0x00000006,0x00000105,0x00000104,0x0003003e,0x00000101, - 0x00000105,0x000200f9,0x00000103,0x000200f8,0x00000106,0x00060041,0x00000025,0x00000107, - 0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x00000108,0x00000107,0x0004007f, - 0x00000006,0x00000109,0x00000108,0x0003003e,0x00000101,0x00000109,0x000200f9,0x00000103, - 0x000200f8,0x00000103,0x0004003d,0x00000006,0x0000010a,0x00000101,0x00050081,0x00000006, - 0x0000010b,0x0000003a,0x0000010a,0x0003003e,0x000000fe,0x0000010b,0x0004003d,0x00000007, - 0x0000010d,0x000000f4,0x0004003d,0x00000006,0x0000010e,0x000000fe,0x00050050,0x00000007, - 0x0000010f,0x0000010e,0x0000003a,0x00050083,0x00000007,0x00000110,0x0000010d,0x0000010f, - 0x0003003e,0x0000010c,0x00000110,0x00060041,0x00000025,0x00000112,0x00000020,0x00000022, - 0x00000037,0x0004003d,0x00000006,0x00000113,0x00000112,0x0003003e,0x00000111,0x00000113, - 0x00050041,0x00000009,0x00000115,0x0000010c,0x00000024,0x0004003d,0x00000006,0x00000116, - 0x00000115,0x00060041,0x00000025,0x00000117,0x00000020,0x00000036,0x00000046,0x0004003d, - 0x00000006,0x00000118,0x00000117,0x00050085,0x00000006,0x00000119,0x00000116,0x00000118, - 0x00050041,0x00000009,0x0000011a,0x0000010c,0x0000002b,0x0004003d,0x00000006,0x0000011b, - 0x0000011a,0x00050050,0x00000007,0x0000011c,0x00000119,0x0000011b,0x0005008e,0x00000007, - 0x0000011d,0x0000011c,0x000000f7,0x0004003d,0x00000006,0x0000011e,0x00000111,0x0005008e, - 0x00000007,0x0000011f,0x0000011d,0x0000011e,0x0003003e,0x00000114,0x0000011f,0x00060041, - 0x00000025,0x00000120,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000121, - 0x00000120,0x000500ba,0x0000003b,0x00000122,0x00000121,0x00000054,0x000300f7,0x00000124, - 0x00000000,0x000400fa,0x00000122,0x00000123,0x0000014a,0x000200f8,0x00000123,0x00060041, - 0x00000025,0x00000126,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006,0x00000127, - 0x00000126,0x00050083,0x00000006,0x00000128,0x0000001c,0x00000127,0x0004003d,0x00000007, - 0x0000012a,0x00000114,0x0003003e,0x00000129,0x0000012a,0x0003003e,0x0000012b,0x00000128, - 0x00060039,0x00000007,0x0000012c,0x0000000d,0x00000129,0x0000012b,0x0003003e,0x0000012d, - 0x0000012c,0x0004003d,0x00000006,0x0000012f,0x000000ec,0x0003003e,0x0000012e,0x0000012f, - 0x00060039,0x0000000f,0x00000130,0x00000013,0x0000012d,0x0000012e,0x00050051,0x00000006, - 0x00000131,0x00000130,0x00000000,0x00050041,0x00000009,0x00000132,0x00000125,0x00000024, - 0x0003003e,0x00000132,0x00000131,0x0004003d,0x00000007,0x00000134,0x00000114,0x0003003e, - 0x00000133,0x00000134,0x0003003e,0x00000135,0x0000001c,0x00060039,0x00000007,0x00000136, - 0x0000000d,0x00000133,0x00000135,0x0003003e,0x00000137,0x00000136,0x0004003d,0x00000006, - 0x00000139,0x000000ec,0x0003003e,0x00000138,0x00000139,0x00060039,0x0000000f,0x0000013a, - 0x00000013,0x00000137,0x00000138,0x00050051,0x00000006,0x0000013b,0x0000013a,0x00000001, - 0x00050041,0x00000009,0x0000013c,0x00000125,0x0000002b,0x0003003e,0x0000013c,0x0000013b, - 0x00060041,0x00000025,0x0000013d,0x00000020,0x00000022,0x00000046,0x0004003d,0x00000006, - 0x0000013e,0x0000013d,0x00050081,0x00000006,0x0000013f,0x0000001c,0x0000013e,0x0004003d, - 0x00000007,0x00000141,0x00000114,0x0003003e,0x00000140,0x00000141,0x0003003e,0x00000142, - 0x0000013f,0x00060039,0x00000007,0x00000143,0x0000000d,0x00000140,0x00000142,0x0003003e, - 0x00000144,0x00000143,0x0004003d,0x00000006,0x00000146,0x000000ec,0x0003003e,0x00000145, - 0x00000146,0x00060039,0x0000000f,0x00000147,0x00000013,0x00000144,0x00000145,0x00050051, - 0x00000006,0x00000148,0x00000147,0x00000002,0x00050041,0x00000009,0x00000149,0x00000125, - 0x00000037,0x0003003e,0x00000149,0x00000148,0x000200f9,0x00000124,0x000200f8,0x0000014a, - 0x0004003d,0x00000007,0x0000014c,0x00000114,0x0003003e,0x0000014b,0x0000014c,0x0003003e, - 0x0000014d,0x0000001c,0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d, - 0x0003003e,0x0000014f,0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000ec,0x0003003e, - 0x00000150,0x00000151,0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150, - 0x0003003e,0x00000125,0x00000152,0x000200f9,0x00000124,0x000200f8,0x00000124,0x0004003d, - 0x00000007,0x00000154,0x000000f4,0x0004003d,0x00000007,0x00000155,0x000000f4,0x00050050, - 0x00000007,0x00000156,0x0000001c,0x0000001c,0x00050083,0x00000007,0x00000157,0x00000156, - 0x00000155,0x0007000c,0x00000007,0x00000158,0x00000001,0x00000025,0x00000154,0x00000157, - 0x0003003e,0x00000153,0x00000158,0x00050041,0x00000009,0x0000015b,0x00000153,0x00000024, - 0x0004003d,0x00000006,0x0000015c,0x0000015b,0x00060041,0x00000025,0x0000015d,0x00000020, - 0x00000036,0x00000046,0x0004003d,0x00000006,0x0000015e,0x0000015d,0x00050085,0x00000006, - 0x0000015f,0x0000015c,0x0000015e,0x00050041,0x00000009,0x00000160,0x00000153,0x0000002b, - 0x0004003d,0x00000006,0x00000161,0x00000160,0x0007000c,0x00000006,0x00000162,0x00000001, - 0x00000025,0x0000015f,0x00000161,0x0008000c,0x00000006,0x00000163,0x00000001,0x00000031, - 0x00000054,0x0000015a,0x00000162,0x0003003e,0x00000159,0x00000163,0x0004003d,0x0000000f, - 0x00000166,0x00000125,0x0004003d,0x00000006,0x00000167,0x00000159,0x0005008e,0x0000000f, - 0x00000168,0x00000166,0x00000167,0x00050051,0x00000006,0x00000169,0x00000168,0x00000000, - 0x00050051,0x00000006,0x0000016a,0x00000168,0x00000001,0x00050051,0x00000006,0x0000016b, - 0x00000168,0x00000002,0x00070050,0x0000001d,0x0000016c,0x00000169,0x0000016a,0x0000016b, - 0x0000001c,0x0003003e,0x00000165,0x0000016c,0x000100fd,0x00010038,0x00050036,0x00000007, - 0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008,0x0000000b,0x00030037,0x00000009, - 0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009,0x00000015,0x00000007,0x0004003d, - 0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007,0x00000017,0x0000000b,0x00050094, - 0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e,0x00000015,0x00000018,0x0004003d, - 0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006,0x0000001a,0x0000000c,0x0005008e, - 0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041,0x00000025,0x00000026,0x00000020, - 0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027,0x00000026,0x0004003d,0x00000006, - 0x00000028,0x00000015,0x00050085,0x00000006,0x00000029,0x00000027,0x00000028,0x00050081, - 0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041,0x00000025,0x0000002c,0x00000020, - 0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d,0x0000002c,0x0004003d,0x00000006, - 0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f,0x0000002d,0x0000002e,0x0004003d, - 0x00000006,0x00000030,0x00000015,0x00050085,0x00000006,0x00000031,0x0000002f,0x00000030, - 0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031,0x0005008e,0x00000007,0x00000033, - 0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038,0x00050036,0x0000000f,0x00000013, - 0x00000000,0x00000010,0x00030037,0x00000008,0x00000011,0x00030037,0x00000009,0x00000012, - 0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f,0x00000007,0x0004003b,0x00000008, - 0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077,0x00000007,0x0004003b,0x00000093, - 0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f,0x00000007,0x0004003b,0x00000009, - 0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd,0x00000007,0x0004003b,0x00000009, - 0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6,0x00000007,0x0004003b,0x00000008, - 0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038,0x00000020,0x00000036,0x00000037, - 0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba,0x0000003b,0x0000003c,0x00000039, - 0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa,0x0000003c,0x0000003d,0x0000003e, - 0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040,0x00000020,0x00000022,0x00000037, - 0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e,0x0000003f,0x00000041,0x00050041, - 0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000044,0x00000043, - 0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041,0x00000025,0x00000047,0x00000020, - 0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048,0x00000047,0x00050085,0x00000006, - 0x00000049,0x00000045,0x00000048,0x00050088,0x00000006,0x0000004a,0x00000044,0x00000049, - 0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b,0x0004003d,0x00000006,0x0000004c, - 0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f,0x00050088,0x00000006,0x0000004e, - 0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f,0x0000004a,0x0000004e,0x0005008e, - 0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050,0x00000007,0x00000051,0x0000003a, - 0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050,0x00000051,0x0003003e,0x00000042, - 0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042,0x000500b8,0x00000056,0x00000057, - 0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058,0x00000057,0x000400a8,0x0000003b, - 0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000,0x000400fa,0x00000059,0x0000005a, - 0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007,0x0000005c,0x00000042,0x000500ba, - 0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a,0x0000003b,0x0000005f,0x0000005e, - 0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5,0x0000003b,0x00000060,0x00000058, - 0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062,0x00000000,0x000400fa,0x00000060, - 0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe,0x00000063,0x000200f8,0x00000062, - 0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041,0x00000009,0x0000006a,0x00000042, - 0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a,0x0004003d,0x00000006,0x0000006c, - 0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b,0x0000006c,0x00050085,0x00000006, - 0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009,0x0000006f,0x00000042,0x0000002b, - 0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050,0x00000007,0x00000071,0x0000006e, - 0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069,0x00000071,0x0008004f,0x0000000f, - 0x00000073,0x00000072,0x00000072,0x00000000,0x00000001,0x00000002,0x000200fe,0x00000073, - 0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a,0x00000020,0x00000078,0x0004003d, - 0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f,0x0000007c,0x0000007b,0x0000007b, - 0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x0000007e,0x00000020,0x0000007d, - 0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f,0x0000000f,0x00000080,0x0000007f, - 0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079,0x00000082,0x00000020, - 0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082,0x0008004f,0x0000000f,0x00000084, - 0x00000083,0x00000083,0x00000000,0x00000001,0x00000002,0x00050051,0x00000006,0x00000085, - 0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086,0x0000007c,0x00000001,0x00050051, - 0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051,0x00000006,0x00000088,0x00000080, - 0x00000000,0x00050051,0x00000006,0x00000089,0x00000080,0x00000001,0x00050051,0x00000006, - 0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006,0x0000008b,0x00000084,0x00000000, - 0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001,0x00050051,0x00000006,0x0000008d, - 0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e,0x00000085,0x00000086,0x00000087, - 0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089,0x0000008a,0x00060050,0x0000000f, - 0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050,0x00000075,0x00000091,0x0000008e, - 0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092,0x00000091,0x0003003e,0x00000077, - 0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077,0x00050041,0x00000009,0x00000096, - 0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097,0x00000096,0x00050041,0x00000009, - 0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006,0x00000099,0x00000098,0x0004007f, - 0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f,0x0000009c,0x00000097,0x0000009a, - 0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001,0x00000045,0x0000009c,0x00050091, - 0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e,0x00000094,0x0000009e,0x0004003d, - 0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006,0x000000a1,0x00000012,0x000500b8, - 0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9,0x00000006,0x000000a3,0x000000a2, - 0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4,0x00000020,0x00000078,0x00000046, - 0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085,0x00000006,0x000000a6,0x000000a3, - 0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6,0x00000054,0x00000054,0x00050091, - 0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e,0x0000009f,0x000000a8,0x00050041, - 0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000aa,0x000000a9, - 0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab,0x000300f7,0x000000ae,0x00000000, - 0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8,0x000000ad,0x000200fe,0x000000b1, - 0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4,0x00000020,0x0000007d,0x00000046, - 0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f,0x00000006,0x000000b6,0x000000b5, - 0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037,0x0004003d,0x00000006,0x000000b8, - 0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6,0x000000b8,0x00050041,0x00000009, - 0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006,0x000000bb,0x000000ba,0x00050088, - 0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e,0x000000b3,0x000000bc,0x0004003d, - 0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f,0x000000bf,0x00000094,0x0004003d, - 0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f,0x000000c1,0x000000bf,0x000000c0, - 0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1,0x0003003e,0x000000bd,0x000000c2, - 0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081,0x00000046,0x0004003d,0x00000006, - 0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5,0x0004003d,0x00000006,0x000000c7, - 0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020,0x00000036,0x0000002b,0x0004003d, - 0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006,0x000000ca,0x000000c7,0x000000c9, - 0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009,0x000000cc,0x000000bd,0x00000024, - 0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d,0x00000006,0x000000ce,0x000000c3, - 0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce,0x00050041,0x00000009,0x000000d0, - 0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1,0x000000d0,0x0004007f,0x00000006, - 0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3,0x000000c6,0x00050088,0x00000006, - 0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007,0x000000d5,0x000000cf,0x000000d4, - 0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a,0x00050050,0x00000007,0x000000d7, - 0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8,0x000000d6,0x000000d7,0x0003003e, - 0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9,0x000000cb,0x000500b8,0x00000056, - 0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b,0x000000db,0x000000da,0x000400a8, - 0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de,0x00000000,0x000400fa,0x000000dc, - 0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d,0x00000007,0x000000df,0x000000cb, - 0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d,0x0004009a,0x0000003b,0x000000e1, - 0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de,0x000700f5,0x0000003b,0x000000e2, - 0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7,0x000000e4,0x00000000,0x000400fa, - 0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3,0x000200fe,0x000000b1,0x000200f8, - 0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068,0x0004003d,0x00000007,0x000000e7, - 0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6,0x000000e7,0x0008004f,0x0000000f, - 0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001,0x00000002,0x000200fe,0x000000e9, - 0x00010038 + 0x736e656c,0x00000000,0x00050006,0x0000001e,0x00000005,0x77656976,0x00000000,0x00050006, + 0x0000001e,0x00000006,0x72747865,0x00000061,0x00030005,0x00000020,0x00006370,0x00040005, + 0x0000003f,0x486e6174,0x00000000,0x00030005,0x00000042,0x00000073,0x00040005,0x00000068, + 0x72756f53,0x00006563,0x00030005,0x00000077,0x00000052,0x00030005,0x00000094,0x00000064, + 0x00030005,0x0000009f,0x0000006f,0x00030005,0x000000b3,0x00000074,0x00030005,0x000000bd, + 0x00746968,0x00040005,0x000000c3,0x666c6168,0x00000057,0x00040005,0x000000c6,0x666c6168, + 0x00000048,0x00030005,0x000000cb,0x00000073,0x00040005,0x000000ed,0x6f6e6f6d,0x00000000, + 0x00030005,0x000000f2,0x00657965,0x00030005,0x000000f8,0x00007675,0x00040005,0x000000ff, + 0x61636f6c,0x0000006c,0x00030005,0x00000110,0x00007863,0x00030005,0x00000124,0x00000070, + 0x00040005,0x00000129,0x486e6174,0x00000000,0x00030005,0x0000012c,0x00000074,0x00030005, + 0x0000013d,0x00000063,0x00040005,0x00000141,0x61726170,0x0000006d,0x00040005,0x00000143, + 0x61726170,0x0000006d,0x00040005,0x00000145,0x61726170,0x0000006d,0x00040005,0x00000146, + 0x61726170,0x0000006d,0x00040005,0x0000014b,0x61726170,0x0000006d,0x00040005,0x0000014d, + 0x61726170,0x0000006d,0x00040005,0x0000014f,0x61726170,0x0000006d,0x00040005,0x00000150, + 0x61726170,0x0000006d,0x00040005,0x00000158,0x61726170,0x0000006d,0x00040005,0x0000015a, + 0x61726170,0x0000006d,0x00040005,0x0000015c,0x61726170,0x0000006d,0x00040005,0x0000015d, + 0x61726170,0x0000006d,0x00040005,0x00000163,0x61726170,0x0000006d,0x00040005,0x00000165, + 0x61726170,0x0000006d,0x00040005,0x00000167,0x61726170,0x0000006d,0x00040005,0x00000168, + 0x61726170,0x0000006d,0x00030005,0x0000016b,0x00000065,0x00040005,0x00000171,0x65646166, + 0x00000000,0x00050005,0x0000017d,0x4374756f,0x726f6c6f,0x00000000,0x00030047,0x0000001e, + 0x00000002,0x00050048,0x0000001e,0x00000000,0x00000023,0x00000000,0x00050048,0x0000001e, + 0x00000001,0x00000023,0x00000010,0x00050048,0x0000001e,0x00000002,0x00000023,0x00000020, + 0x00050048,0x0000001e,0x00000003,0x00000023,0x00000030,0x00050048,0x0000001e,0x00000004, + 0x00000023,0x00000040,0x00050048,0x0000001e,0x00000005,0x00000023,0x00000050,0x00050048, + 0x0000001e,0x00000006,0x00000023,0x00000060,0x00040047,0x00000068,0x00000021,0x00000000, + 0x00040047,0x00000068,0x00000022,0x00000000,0x00040047,0x000000f8,0x0000001e,0x00000000, + 0x00040047,0x0000017d,0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003, + 0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000002, + 0x00040020,0x00000008,0x00000007,0x00000007,0x00040020,0x00000009,0x00000007,0x00000006, + 0x00050021,0x0000000a,0x00000007,0x00000008,0x00000009,0x00040017,0x0000000f,0x00000006, + 0x00000003,0x00050021,0x00000010,0x0000000f,0x00000008,0x00000009,0x0004002b,0x00000006, + 0x0000001c,0x3f800000,0x00040017,0x0000001d,0x00000006,0x00000004,0x0009001e,0x0000001e, + 0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x0000001d,0x00040020, + 0x0000001f,0x00000009,0x0000001e,0x0004003b,0x0000001f,0x00000020,0x00000009,0x00040015, + 0x00000021,0x00000020,0x00000001,0x0004002b,0x00000021,0x00000022,0x00000004,0x00040015, + 0x00000023,0x00000020,0x00000000,0x0004002b,0x00000023,0x00000024,0x00000000,0x00040020, + 0x00000025,0x00000009,0x00000006,0x0004002b,0x00000023,0x0000002b,0x00000001,0x0004002b, + 0x00000021,0x00000036,0x00000005,0x0004002b,0x00000023,0x00000037,0x00000002,0x0004002b, + 0x00000006,0x0000003a,0x3f000000,0x00020014,0x0000003b,0x0004002b,0x00000023,0x00000046, + 0x00000003,0x0004002b,0x00000006,0x00000054,0x00000000,0x0005002c,0x00000007,0x00000055, + 0x00000054,0x00000054,0x00040017,0x00000056,0x0000003b,0x00000002,0x0005002c,0x00000007, + 0x0000005d,0x0000001c,0x0000001c,0x0006002c,0x0000000f,0x00000063,0x00000054,0x00000054, + 0x00000054,0x00090019,0x00000065,0x00000006,0x00000001,0x00000000,0x00000000,0x00000000, + 0x00000001,0x00000000,0x0003001b,0x00000066,0x00000065,0x00040020,0x00000067,0x00000000, + 0x00000066,0x0004003b,0x00000067,0x00000068,0x00000000,0x00040018,0x00000075,0x0000000f, + 0x00000003,0x00040020,0x00000076,0x00000007,0x00000075,0x0004002b,0x00000021,0x00000078, + 0x00000001,0x00040020,0x00000079,0x00000009,0x0000001d,0x0004002b,0x00000021,0x0000007d, + 0x00000002,0x0004002b,0x00000021,0x00000081,0x00000003,0x00040020,0x00000093,0x00000007, + 0x0000000f,0x0004002b,0x00000006,0x0000009b,0xbf800000,0x0004002b,0x00000006,0x000000ab, + 0xb8d1b717,0x0004002b,0x00000006,0x000000af,0x3c75c28f,0x0004002b,0x00000006,0x000000b0, + 0x3ca3d70a,0x0006002c,0x0000000f,0x000000b1,0x000000af,0x000000af,0x000000b0,0x00040020, + 0x000000ec,0x00000007,0x0000003b,0x0004002b,0x00000021,0x000000ee,0x00000006,0x00040020, + 0x000000f7,0x00000001,0x00000007,0x0004003b,0x000000f7,0x000000f8,0x00000001,0x00040020, + 0x000000f9,0x00000001,0x00000006,0x0004002b,0x00000006,0x00000108,0x40000000,0x0004002b, + 0x00000006,0x00000172,0x3cf5c28f,0x00040020,0x0000017c,0x00000003,0x0000001d,0x0004003b, + 0x0000017c,0x0000017d,0x00000003,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003, + 0x000200f8,0x00000005,0x0004003b,0x000000ec,0x000000ed,0x00000007,0x0004003b,0x00000009, + 0x000000f2,0x00000007,0x0004003b,0x00000008,0x000000ff,0x00000007,0x0004003b,0x00000008, + 0x00000101,0x00000007,0x0004003b,0x00000009,0x00000110,0x00000007,0x0004003b,0x00000009, + 0x00000112,0x00000007,0x0004003b,0x00000009,0x00000118,0x00000007,0x0004003b,0x00000008, + 0x00000124,0x00000007,0x0004003b,0x00000009,0x00000129,0x00000007,0x0004003b,0x00000008, + 0x0000012c,0x00000007,0x0004003b,0x00000093,0x0000013d,0x00000007,0x0004003b,0x00000008, + 0x00000141,0x00000007,0x0004003b,0x00000009,0x00000143,0x00000007,0x0004003b,0x00000008, + 0x00000145,0x00000007,0x0004003b,0x00000009,0x00000146,0x00000007,0x0004003b,0x00000008, + 0x0000014b,0x00000007,0x0004003b,0x00000009,0x0000014d,0x00000007,0x0004003b,0x00000008, + 0x0000014f,0x00000007,0x0004003b,0x00000009,0x00000150,0x00000007,0x0004003b,0x00000008, + 0x00000158,0x00000007,0x0004003b,0x00000009,0x0000015a,0x00000007,0x0004003b,0x00000008, + 0x0000015c,0x00000007,0x0004003b,0x00000009,0x0000015d,0x00000007,0x0004003b,0x00000008, + 0x00000163,0x00000007,0x0004003b,0x00000009,0x00000165,0x00000007,0x0004003b,0x00000008, + 0x00000167,0x00000007,0x0004003b,0x00000009,0x00000168,0x00000007,0x0004003b,0x00000008, + 0x0000016b,0x00000007,0x0004003b,0x00000009,0x00000171,0x00000007,0x00060041,0x00000025, + 0x000000ef,0x00000020,0x000000ee,0x00000024,0x0004003d,0x00000006,0x000000f0,0x000000ef, + 0x000500ba,0x0000003b,0x000000f1,0x000000f0,0x0000003a,0x0003003e,0x000000ed,0x000000f1, + 0x0004003d,0x0000003b,0x000000f3,0x000000ed,0x000400a8,0x0000003b,0x000000f4,0x000000f3, + 0x000300f7,0x000000f6,0x00000000,0x000400fa,0x000000f4,0x000000f5,0x000000f6,0x000200f8, + 0x000000f5,0x00050041,0x000000f9,0x000000fa,0x000000f8,0x00000024,0x0004003d,0x00000006, + 0x000000fb,0x000000fa,0x000500b8,0x0000003b,0x000000fc,0x000000fb,0x0000003a,0x000200f9, + 0x000000f6,0x000200f8,0x000000f6,0x000700f5,0x0000003b,0x000000fd,0x000000f3,0x00000005, + 0x000000fc,0x000000f5,0x000600a9,0x00000006,0x000000fe,0x000000fd,0x00000054,0x0000001c, + 0x0003003e,0x000000f2,0x000000fe,0x0004003d,0x0000003b,0x00000100,0x000000ed,0x000300f7, + 0x00000103,0x00000000,0x000400fa,0x00000100,0x00000102,0x00000105,0x000200f8,0x00000102, + 0x0004003d,0x00000007,0x00000104,0x000000f8,0x0003003e,0x00000101,0x00000104,0x000200f9, + 0x00000103,0x000200f8,0x00000105,0x00050041,0x000000f9,0x00000106,0x000000f8,0x00000024, + 0x0004003d,0x00000006,0x00000107,0x00000106,0x00050085,0x00000006,0x00000109,0x00000107, + 0x00000108,0x0004003d,0x00000006,0x0000010a,0x000000f2,0x00050083,0x00000006,0x0000010b, + 0x00000109,0x0000010a,0x00050041,0x000000f9,0x0000010c,0x000000f8,0x0000002b,0x0004003d, + 0x00000006,0x0000010d,0x0000010c,0x00050050,0x00000007,0x0000010e,0x0000010b,0x0000010d, + 0x0003003e,0x00000101,0x0000010e,0x000200f9,0x00000103,0x000200f8,0x00000103,0x0004003d, + 0x00000007,0x0000010f,0x00000101,0x0003003e,0x000000ff,0x0000010f,0x0004003d,0x0000003b, + 0x00000111,0x000000ed,0x000300f7,0x00000114,0x00000000,0x000400fa,0x00000111,0x00000113, + 0x00000115,0x000200f8,0x00000113,0x0003003e,0x00000112,0x0000003a,0x000200f9,0x00000114, + 0x000200f8,0x00000115,0x0004003d,0x00000006,0x00000116,0x000000f2,0x000500b8,0x0000003b, + 0x00000117,0x00000116,0x0000003a,0x000300f7,0x0000011a,0x00000000,0x000400fa,0x00000117, + 0x00000119,0x0000011d,0x000200f8,0x00000119,0x00060041,0x00000025,0x0000011b,0x00000020, + 0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011c,0x0000011b,0x0003003e,0x00000118, + 0x0000011c,0x000200f9,0x0000011a,0x000200f8,0x0000011d,0x00060041,0x00000025,0x0000011e, + 0x00000020,0x00000036,0x00000024,0x0004003d,0x00000006,0x0000011f,0x0000011e,0x0004007f, + 0x00000006,0x00000120,0x0000011f,0x0003003e,0x00000118,0x00000120,0x000200f9,0x0000011a, + 0x000200f8,0x0000011a,0x0004003d,0x00000006,0x00000121,0x00000118,0x00050081,0x00000006, + 0x00000122,0x0000003a,0x00000121,0x0003003e,0x00000112,0x00000122,0x000200f9,0x00000114, + 0x000200f8,0x00000114,0x0004003d,0x00000006,0x00000123,0x00000112,0x0003003e,0x00000110, + 0x00000123,0x0004003d,0x00000007,0x00000125,0x000000ff,0x0004003d,0x00000006,0x00000126, + 0x00000110,0x00050050,0x00000007,0x00000127,0x00000126,0x0000003a,0x00050083,0x00000007, + 0x00000128,0x00000125,0x00000127,0x0003003e,0x00000124,0x00000128,0x00060041,0x00000025, + 0x0000012a,0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x0000012b,0x0000012a, + 0x0003003e,0x00000129,0x0000012b,0x00050041,0x00000009,0x0000012d,0x00000124,0x00000024, + 0x0004003d,0x00000006,0x0000012e,0x0000012d,0x00060041,0x00000025,0x0000012f,0x00000020, + 0x00000036,0x00000046,0x0004003d,0x00000006,0x00000130,0x0000012f,0x00050085,0x00000006, + 0x00000131,0x0000012e,0x00000130,0x00050041,0x00000009,0x00000132,0x00000124,0x0000002b, + 0x0004003d,0x00000006,0x00000133,0x00000132,0x00050050,0x00000007,0x00000134,0x00000131, + 0x00000133,0x0005008e,0x00000007,0x00000135,0x00000134,0x00000108,0x0004003d,0x00000006, + 0x00000136,0x00000129,0x0005008e,0x00000007,0x00000137,0x00000135,0x00000136,0x0003003e, + 0x0000012c,0x00000137,0x00060041,0x00000025,0x00000138,0x00000020,0x00000022,0x00000046, + 0x0004003d,0x00000006,0x00000139,0x00000138,0x000500ba,0x0000003b,0x0000013a,0x00000139, + 0x00000054,0x000300f7,0x0000013c,0x00000000,0x000400fa,0x0000013a,0x0000013b,0x00000162, + 0x000200f8,0x0000013b,0x00060041,0x00000025,0x0000013e,0x00000020,0x00000022,0x00000046, + 0x0004003d,0x00000006,0x0000013f,0x0000013e,0x00050083,0x00000006,0x00000140,0x0000001c, + 0x0000013f,0x0004003d,0x00000007,0x00000142,0x0000012c,0x0003003e,0x00000141,0x00000142, + 0x0003003e,0x00000143,0x00000140,0x00060039,0x00000007,0x00000144,0x0000000d,0x00000141, + 0x00000143,0x0003003e,0x00000145,0x00000144,0x0004003d,0x00000006,0x00000147,0x000000f2, + 0x0003003e,0x00000146,0x00000147,0x00060039,0x0000000f,0x00000148,0x00000013,0x00000145, + 0x00000146,0x00050051,0x00000006,0x00000149,0x00000148,0x00000000,0x00050041,0x00000009, + 0x0000014a,0x0000013d,0x00000024,0x0003003e,0x0000014a,0x00000149,0x0004003d,0x00000007, + 0x0000014c,0x0000012c,0x0003003e,0x0000014b,0x0000014c,0x0003003e,0x0000014d,0x0000001c, + 0x00060039,0x00000007,0x0000014e,0x0000000d,0x0000014b,0x0000014d,0x0003003e,0x0000014f, + 0x0000014e,0x0004003d,0x00000006,0x00000151,0x000000f2,0x0003003e,0x00000150,0x00000151, + 0x00060039,0x0000000f,0x00000152,0x00000013,0x0000014f,0x00000150,0x00050051,0x00000006, + 0x00000153,0x00000152,0x00000001,0x00050041,0x00000009,0x00000154,0x0000013d,0x0000002b, + 0x0003003e,0x00000154,0x00000153,0x00060041,0x00000025,0x00000155,0x00000020,0x00000022, + 0x00000046,0x0004003d,0x00000006,0x00000156,0x00000155,0x00050081,0x00000006,0x00000157, + 0x0000001c,0x00000156,0x0004003d,0x00000007,0x00000159,0x0000012c,0x0003003e,0x00000158, + 0x00000159,0x0003003e,0x0000015a,0x00000157,0x00060039,0x00000007,0x0000015b,0x0000000d, + 0x00000158,0x0000015a,0x0003003e,0x0000015c,0x0000015b,0x0004003d,0x00000006,0x0000015e, + 0x000000f2,0x0003003e,0x0000015d,0x0000015e,0x00060039,0x0000000f,0x0000015f,0x00000013, + 0x0000015c,0x0000015d,0x00050051,0x00000006,0x00000160,0x0000015f,0x00000002,0x00050041, + 0x00000009,0x00000161,0x0000013d,0x00000037,0x0003003e,0x00000161,0x00000160,0x000200f9, + 0x0000013c,0x000200f8,0x00000162,0x0004003d,0x00000007,0x00000164,0x0000012c,0x0003003e, + 0x00000163,0x00000164,0x0003003e,0x00000165,0x0000001c,0x00060039,0x00000007,0x00000166, + 0x0000000d,0x00000163,0x00000165,0x0003003e,0x00000167,0x00000166,0x0004003d,0x00000006, + 0x00000169,0x000000f2,0x0003003e,0x00000168,0x00000169,0x00060039,0x0000000f,0x0000016a, + 0x00000013,0x00000167,0x00000168,0x0003003e,0x0000013d,0x0000016a,0x000200f9,0x0000013c, + 0x000200f8,0x0000013c,0x0004003d,0x00000007,0x0000016c,0x000000ff,0x0004003d,0x00000007, + 0x0000016d,0x000000ff,0x00050050,0x00000007,0x0000016e,0x0000001c,0x0000001c,0x00050083, + 0x00000007,0x0000016f,0x0000016e,0x0000016d,0x0007000c,0x00000007,0x00000170,0x00000001, + 0x00000025,0x0000016c,0x0000016f,0x0003003e,0x0000016b,0x00000170,0x00050041,0x00000009, + 0x00000173,0x0000016b,0x00000024,0x0004003d,0x00000006,0x00000174,0x00000173,0x00060041, + 0x00000025,0x00000175,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000176, + 0x00000175,0x00050085,0x00000006,0x00000177,0x00000174,0x00000176,0x00050041,0x00000009, + 0x00000178,0x0000016b,0x0000002b,0x0004003d,0x00000006,0x00000179,0x00000178,0x0007000c, + 0x00000006,0x0000017a,0x00000001,0x00000025,0x00000177,0x00000179,0x0008000c,0x00000006, + 0x0000017b,0x00000001,0x00000031,0x00000054,0x00000172,0x0000017a,0x0003003e,0x00000171, + 0x0000017b,0x0004003d,0x0000000f,0x0000017e,0x0000013d,0x0004003d,0x00000006,0x0000017f, + 0x00000171,0x0005008e,0x0000000f,0x00000180,0x0000017e,0x0000017f,0x00050051,0x00000006, + 0x00000181,0x00000180,0x00000000,0x00050051,0x00000006,0x00000182,0x00000180,0x00000001, + 0x00050051,0x00000006,0x00000183,0x00000180,0x00000002,0x00070050,0x0000001d,0x00000184, + 0x00000181,0x00000182,0x00000183,0x0000001c,0x0003003e,0x0000017d,0x00000184,0x000100fd, + 0x00010038,0x00050036,0x00000007,0x0000000d,0x00000000,0x0000000a,0x00030037,0x00000008, + 0x0000000b,0x00030037,0x00000009,0x0000000c,0x000200f8,0x0000000e,0x0004003b,0x00000009, + 0x00000015,0x00000007,0x0004003d,0x00000007,0x00000016,0x0000000b,0x0004003d,0x00000007, + 0x00000017,0x0000000b,0x00050094,0x00000006,0x00000018,0x00000016,0x00000017,0x0003003e, + 0x00000015,0x00000018,0x0004003d,0x00000007,0x00000019,0x0000000b,0x0004003d,0x00000006, + 0x0000001a,0x0000000c,0x0005008e,0x00000007,0x0000001b,0x00000019,0x0000001a,0x00060041, + 0x00000025,0x00000026,0x00000020,0x00000022,0x00000024,0x0004003d,0x00000006,0x00000027, + 0x00000026,0x0004003d,0x00000006,0x00000028,0x00000015,0x00050085,0x00000006,0x00000029, + 0x00000027,0x00000028,0x00050081,0x00000006,0x0000002a,0x0000001c,0x00000029,0x00060041, + 0x00000025,0x0000002c,0x00000020,0x00000022,0x0000002b,0x0004003d,0x00000006,0x0000002d, + 0x0000002c,0x0004003d,0x00000006,0x0000002e,0x00000015,0x00050085,0x00000006,0x0000002f, + 0x0000002d,0x0000002e,0x0004003d,0x00000006,0x00000030,0x00000015,0x00050085,0x00000006, + 0x00000031,0x0000002f,0x00000030,0x00050081,0x00000006,0x00000032,0x0000002a,0x00000031, + 0x0005008e,0x00000007,0x00000033,0x0000001b,0x00000032,0x000200fe,0x00000033,0x00010038, + 0x00050036,0x0000000f,0x00000013,0x00000000,0x00000010,0x00030037,0x00000008,0x00000011, + 0x00030037,0x00000009,0x00000012,0x000200f8,0x00000014,0x0004003b,0x00000009,0x0000003f, + 0x00000007,0x0004003b,0x00000008,0x00000042,0x00000007,0x0004003b,0x00000076,0x00000077, + 0x00000007,0x0004003b,0x00000093,0x00000094,0x00000007,0x0004003b,0x00000093,0x0000009f, + 0x00000007,0x0004003b,0x00000009,0x000000b3,0x00000007,0x0004003b,0x00000093,0x000000bd, + 0x00000007,0x0004003b,0x00000009,0x000000c3,0x00000007,0x0004003b,0x00000009,0x000000c6, + 0x00000007,0x0004003b,0x00000008,0x000000cb,0x00000007,0x00060041,0x00000025,0x00000038, + 0x00000020,0x00000036,0x00000037,0x0004003d,0x00000006,0x00000039,0x00000038,0x000500ba, + 0x0000003b,0x0000003c,0x00000039,0x0000003a,0x000300f7,0x0000003e,0x00000000,0x000400fa, + 0x0000003c,0x0000003d,0x0000003e,0x000200f8,0x0000003d,0x00060041,0x00000025,0x00000040, + 0x00000020,0x00000022,0x00000037,0x0004003d,0x00000006,0x00000041,0x00000040,0x0003003e, + 0x0000003f,0x00000041,0x00050041,0x00000009,0x00000043,0x00000011,0x00000024,0x0004003d, + 0x00000006,0x00000044,0x00000043,0x0004003d,0x00000006,0x00000045,0x0000003f,0x00060041, + 0x00000025,0x00000047,0x00000020,0x00000036,0x00000046,0x0004003d,0x00000006,0x00000048, + 0x00000047,0x00050085,0x00000006,0x00000049,0x00000045,0x00000048,0x00050088,0x00000006, + 0x0000004a,0x00000044,0x00000049,0x00050041,0x00000009,0x0000004b,0x00000011,0x0000002b, + 0x0004003d,0x00000006,0x0000004c,0x0000004b,0x0004003d,0x00000006,0x0000004d,0x0000003f, + 0x00050088,0x00000006,0x0000004e,0x0000004c,0x0000004d,0x00050050,0x00000007,0x0000004f, + 0x0000004a,0x0000004e,0x0005008e,0x00000007,0x00000050,0x0000004f,0x0000003a,0x00050050, + 0x00000007,0x00000051,0x0000003a,0x0000003a,0x00050081,0x00000007,0x00000052,0x00000050, + 0x00000051,0x0003003e,0x00000042,0x00000052,0x0004003d,0x00000007,0x00000053,0x00000042, + 0x000500b8,0x00000056,0x00000057,0x00000053,0x00000055,0x0004009a,0x0000003b,0x00000058, + 0x00000057,0x000400a8,0x0000003b,0x00000059,0x00000058,0x000300f7,0x0000005b,0x00000000, + 0x000400fa,0x00000059,0x0000005a,0x0000005b,0x000200f8,0x0000005a,0x0004003d,0x00000007, + 0x0000005c,0x00000042,0x000500ba,0x00000056,0x0000005e,0x0000005c,0x0000005d,0x0004009a, + 0x0000003b,0x0000005f,0x0000005e,0x000200f9,0x0000005b,0x000200f8,0x0000005b,0x000700f5, + 0x0000003b,0x00000060,0x00000058,0x0000003d,0x0000005f,0x0000005a,0x000300f7,0x00000062, + 0x00000000,0x000400fa,0x00000060,0x00000061,0x00000062,0x000200f8,0x00000061,0x000200fe, + 0x00000063,0x000200f8,0x00000062,0x0004003d,0x00000066,0x00000069,0x00000068,0x00050041, + 0x00000009,0x0000006a,0x00000042,0x00000024,0x0004003d,0x00000006,0x0000006b,0x0000006a, + 0x0004003d,0x00000006,0x0000006c,0x00000012,0x00050081,0x00000006,0x0000006d,0x0000006b, + 0x0000006c,0x00050085,0x00000006,0x0000006e,0x0000006d,0x0000003a,0x00050041,0x00000009, + 0x0000006f,0x00000042,0x0000002b,0x0004003d,0x00000006,0x00000070,0x0000006f,0x00050050, + 0x00000007,0x00000071,0x0000006e,0x00000070,0x00050057,0x0000001d,0x00000072,0x00000069, + 0x00000071,0x0008004f,0x0000000f,0x00000073,0x00000072,0x00000072,0x00000000,0x00000001, + 0x00000002,0x000200fe,0x00000073,0x000200f8,0x0000003e,0x00050041,0x00000079,0x0000007a, + 0x00000020,0x00000078,0x0004003d,0x0000001d,0x0000007b,0x0000007a,0x0008004f,0x0000000f, + 0x0000007c,0x0000007b,0x0000007b,0x00000000,0x00000001,0x00000002,0x00050041,0x00000079, + 0x0000007e,0x00000020,0x0000007d,0x0004003d,0x0000001d,0x0000007f,0x0000007e,0x0008004f, + 0x0000000f,0x00000080,0x0000007f,0x0000007f,0x00000000,0x00000001,0x00000002,0x00050041, + 0x00000079,0x00000082,0x00000020,0x00000081,0x0004003d,0x0000001d,0x00000083,0x00000082, + 0x0008004f,0x0000000f,0x00000084,0x00000083,0x00000083,0x00000000,0x00000001,0x00000002, + 0x00050051,0x00000006,0x00000085,0x0000007c,0x00000000,0x00050051,0x00000006,0x00000086, + 0x0000007c,0x00000001,0x00050051,0x00000006,0x00000087,0x0000007c,0x00000002,0x00050051, + 0x00000006,0x00000088,0x00000080,0x00000000,0x00050051,0x00000006,0x00000089,0x00000080, + 0x00000001,0x00050051,0x00000006,0x0000008a,0x00000080,0x00000002,0x00050051,0x00000006, + 0x0000008b,0x00000084,0x00000000,0x00050051,0x00000006,0x0000008c,0x00000084,0x00000001, + 0x00050051,0x00000006,0x0000008d,0x00000084,0x00000002,0x00060050,0x0000000f,0x0000008e, + 0x00000085,0x00000086,0x00000087,0x00060050,0x0000000f,0x0000008f,0x00000088,0x00000089, + 0x0000008a,0x00060050,0x0000000f,0x00000090,0x0000008b,0x0000008c,0x0000008d,0x00060050, + 0x00000075,0x00000091,0x0000008e,0x0000008f,0x00000090,0x00040054,0x00000075,0x00000092, + 0x00000091,0x0003003e,0x00000077,0x00000092,0x0004003d,0x00000075,0x00000095,0x00000077, + 0x00050041,0x00000009,0x00000096,0x00000011,0x00000024,0x0004003d,0x00000006,0x00000097, + 0x00000096,0x00050041,0x00000009,0x00000098,0x00000011,0x0000002b,0x0004003d,0x00000006, + 0x00000099,0x00000098,0x0004007f,0x00000006,0x0000009a,0x00000099,0x00060050,0x0000000f, + 0x0000009c,0x00000097,0x0000009a,0x0000009b,0x0006000c,0x0000000f,0x0000009d,0x00000001, + 0x00000045,0x0000009c,0x00050091,0x0000000f,0x0000009e,0x00000095,0x0000009d,0x0003003e, + 0x00000094,0x0000009e,0x0004003d,0x00000075,0x000000a0,0x00000077,0x0004003d,0x00000006, + 0x000000a1,0x00000012,0x000500b8,0x0000003b,0x000000a2,0x000000a1,0x0000003a,0x000600a9, + 0x00000006,0x000000a3,0x000000a2,0x0000009b,0x0000001c,0x00060041,0x00000025,0x000000a4, + 0x00000020,0x00000078,0x00000046,0x0004003d,0x00000006,0x000000a5,0x000000a4,0x00050085, + 0x00000006,0x000000a6,0x000000a3,0x000000a5,0x00060050,0x0000000f,0x000000a7,0x000000a6, + 0x00000054,0x00000054,0x00050091,0x0000000f,0x000000a8,0x000000a0,0x000000a7,0x0003003e, + 0x0000009f,0x000000a8,0x00050041,0x00000009,0x000000a9,0x00000094,0x00000037,0x0004003d, + 0x00000006,0x000000aa,0x000000a9,0x000500ba,0x0000003b,0x000000ac,0x000000aa,0x000000ab, + 0x000300f7,0x000000ae,0x00000000,0x000400fa,0x000000ac,0x000000ad,0x000000ae,0x000200f8, + 0x000000ad,0x000200fe,0x000000b1,0x000200f8,0x000000ae,0x00060041,0x00000025,0x000000b4, + 0x00000020,0x0000007d,0x00000046,0x0004003d,0x00000006,0x000000b5,0x000000b4,0x0004007f, + 0x00000006,0x000000b6,0x000000b5,0x00050041,0x00000009,0x000000b7,0x0000009f,0x00000037, + 0x0004003d,0x00000006,0x000000b8,0x000000b7,0x00050083,0x00000006,0x000000b9,0x000000b6, + 0x000000b8,0x00050041,0x00000009,0x000000ba,0x00000094,0x00000037,0x0004003d,0x00000006, + 0x000000bb,0x000000ba,0x00050088,0x00000006,0x000000bc,0x000000b9,0x000000bb,0x0003003e, + 0x000000b3,0x000000bc,0x0004003d,0x0000000f,0x000000be,0x0000009f,0x0004003d,0x0000000f, + 0x000000bf,0x00000094,0x0004003d,0x00000006,0x000000c0,0x000000b3,0x0005008e,0x0000000f, + 0x000000c1,0x000000bf,0x000000c0,0x00050081,0x0000000f,0x000000c2,0x000000be,0x000000c1, + 0x0003003e,0x000000bd,0x000000c2,0x00060041,0x00000025,0x000000c4,0x00000020,0x00000081, + 0x00000046,0x0004003d,0x00000006,0x000000c5,0x000000c4,0x0003003e,0x000000c3,0x000000c5, + 0x0004003d,0x00000006,0x000000c7,0x000000c3,0x00060041,0x00000025,0x000000c8,0x00000020, + 0x00000036,0x0000002b,0x0004003d,0x00000006,0x000000c9,0x000000c8,0x00050088,0x00000006, + 0x000000ca,0x000000c7,0x000000c9,0x0003003e,0x000000c6,0x000000ca,0x00050041,0x00000009, + 0x000000cc,0x000000bd,0x00000024,0x0004003d,0x00000006,0x000000cd,0x000000cc,0x0004003d, + 0x00000006,0x000000ce,0x000000c3,0x00050088,0x00000006,0x000000cf,0x000000cd,0x000000ce, + 0x00050041,0x00000009,0x000000d0,0x000000bd,0x0000002b,0x0004003d,0x00000006,0x000000d1, + 0x000000d0,0x0004007f,0x00000006,0x000000d2,0x000000d1,0x0004003d,0x00000006,0x000000d3, + 0x000000c6,0x00050088,0x00000006,0x000000d4,0x000000d2,0x000000d3,0x00050050,0x00000007, + 0x000000d5,0x000000cf,0x000000d4,0x0005008e,0x00000007,0x000000d6,0x000000d5,0x0000003a, + 0x00050050,0x00000007,0x000000d7,0x0000003a,0x0000003a,0x00050081,0x00000007,0x000000d8, + 0x000000d6,0x000000d7,0x0003003e,0x000000cb,0x000000d8,0x0004003d,0x00000007,0x000000d9, + 0x000000cb,0x000500b8,0x00000056,0x000000da,0x000000d9,0x00000055,0x0004009a,0x0000003b, + 0x000000db,0x000000da,0x000400a8,0x0000003b,0x000000dc,0x000000db,0x000300f7,0x000000de, + 0x00000000,0x000400fa,0x000000dc,0x000000dd,0x000000de,0x000200f8,0x000000dd,0x0004003d, + 0x00000007,0x000000df,0x000000cb,0x000500ba,0x00000056,0x000000e0,0x000000df,0x0000005d, + 0x0004009a,0x0000003b,0x000000e1,0x000000e0,0x000200f9,0x000000de,0x000200f8,0x000000de, + 0x000700f5,0x0000003b,0x000000e2,0x000000db,0x000000ae,0x000000e1,0x000000dd,0x000300f7, + 0x000000e4,0x00000000,0x000400fa,0x000000e2,0x000000e3,0x000000e4,0x000200f8,0x000000e3, + 0x000200fe,0x000000b1,0x000200f8,0x000000e4,0x0004003d,0x00000066,0x000000e6,0x00000068, + 0x0004003d,0x00000007,0x000000e7,0x000000cb,0x00050057,0x0000001d,0x000000e8,0x000000e6, + 0x000000e7,0x0008004f,0x0000000f,0x000000e9,0x000000e8,0x000000e8,0x00000000,0x00000001, + 0x00000002,0x000200fe,0x000000e9,0x00010038 }; diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 27e2aee9e..dd7ee5f57 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -271,7 +272,9 @@ volatile int g_ubwc = 1; volatile int g_no_render_node; -volatile int g_output_refresh_mhz; +static atomic_int g_output_refresh_mhz = 60000; +static atomic_int g_output_refresh_pending; +static int g_output_announced_refresh_mhz = 60000; volatile int g_output_w, g_output_h; struct pending_release { @@ -797,7 +800,7 @@ static void feedback_discard_all(struct wl_list *list) { } static uint32_t nominal_refresh_ns(void) { - int mhz = g_output_refresh_mhz; + int mhz = atomic_load(&g_output_refresh_mhz); if (mhz <= 0) mhz = 60000; return (uint32_t)(1000000000000LL / (int64_t)mhz); } @@ -1510,10 +1513,10 @@ static const struct wl_output_interface output_impl = { .release = output_releas static struct wl_list g_outputs; static void output_resource_destroy(struct wl_resource *r) { wl_list_remove(wl_resource_get_link(r)); } -static void output_send_mode(struct wl_resource *r) { +static void output_send_mode(struct wl_resource *r, int mhz) { wl_output_send_mode(r, WL_OUTPUT_MODE_CURRENT | WL_OUTPUT_MODE_PREFERRED, g_output_w > 0 ? g_output_w : 1920, g_output_h > 0 ? g_output_h : 1080, - g_output_refresh_mhz > 0 ? g_output_refresh_mhz : 60000); + mhz); } static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t id) { @@ -1523,7 +1526,7 @@ static void bind_output(struct wl_client *c, void *data, uint32_t ver, uint32_t wl_list_insert(&g_outputs, wl_resource_get_link(r)); wl_output_send_geometry(r, 0, 0, 340, 190, WL_OUTPUT_SUBPIXEL_UNKNOWN, "DroidDeck", "Wayland", WL_OUTPUT_TRANSFORM_NORMAL); - output_send_mode(r); + output_send_mode(r, g_output_announced_refresh_mhz); if (ver >= WL_OUTPUT_NAME_SINCE_VERSION) { wl_output_send_name(r, "DroidDeck-1"); wl_output_send_description(r, "DroidDeck display"); @@ -2916,13 +2919,19 @@ static void key_event(uint32_t evdev, int pressed) { wl_display_flush_clients(g_display); } -static void output_resized(void) { - struct surface *s; +static void output_notify_mode(void) { + int mhz = atomic_load(&g_output_refresh_mhz); struct wl_resource *r; wl_resource_for_each(r, &g_outputs) { - output_send_mode(r); + output_send_mode(r, mhz); if (wl_resource_get_version(r) >= 2) wl_output_send_done(r); } + g_output_announced_refresh_mhz = mhz; +} + +static void output_resized(void) { + struct surface *s; + output_notify_mode(); wl_list_for_each(s, &g_surfaces, link) if (s->xdg_toplevel && s->toplevel_committed) send_toplevel_configure(s); droiddeck_log("display", "output resized to %dx%d", g_output_w, g_output_h); @@ -2931,13 +2940,15 @@ static void output_resized(void) { static int on_input_readable(int fd, uint32_t mask, void *data) { struct input_msg m; + int vsync = 0; + int64_t vsync_ns = 0; while (read(fd, &m, sizeof(m)) == (ssize_t)sizeof(m)) { switch (m.type) { case 1: deliver_key(&m); break; case 2: pointer_event(m.p1, m.p2, 0, 0); break; case 3: pointer_button(m.p1, m.p2); break; case 4: scroll_event(m.p1); break; - case 5: on_vsync(((int64_t)m.p1 << 32) | (uint32_t)m.p2); break; + case 5: vsync = 1; vsync_ns = ((int64_t)m.p1 << 32) | (uint32_t)m.p2; break; case 6: pointer_delta(m.p1 / 256.0, m.p2 / 256.0); break; case 7: deliver_touch(&m); break; case 8: output_resized(); break; @@ -2945,9 +2956,16 @@ static int on_input_readable(int fd, uint32_t mask, void *data) { vk_present_set_scale_mode(m.p1, m.p2); schedule_render(); break; + case 10: break; default: deliver_pointer(&m); break; } } + if (atomic_exchange(&g_output_refresh_pending, 0) && + atomic_load(&g_output_refresh_mhz) != g_output_announced_refresh_mhz) { + output_notify_mode(); + wl_display_flush_clients(g_display); + } + if (vsync) on_vsync(vsync_ns); return 0; } @@ -2989,6 +3007,16 @@ void droiddeck_wayland_output_resized(void) { (void)n; } +void droiddeck_wayland_set_output_refresh_rate(int mhz) { + if (mhz <= 0) mhz = 60000; + if (atomic_exchange(&g_output_refresh_mhz, mhz) == mhz || g_input_pipe[1] < 0) return; + atomic_store(&g_output_refresh_pending, 1); + struct input_msg m = { 10, 0, 0, 0, 0 }; + ssize_t n; + do { n = write(g_input_pipe[1], &m, sizeof(m)); } while (n < 0 && errno == EINTR); + (void)n; +} + void droiddeck_wayland_vsync(int64_t frame_time_ns) { if (g_input_pipe[1] < 0) return; struct input_msg m = { 5, (int)(frame_time_ns >> 32), (int)(uint32_t)frame_time_ns, 0, 0 }; @@ -3226,6 +3254,7 @@ int droiddeck_wayland_run(void) { if ((g_stats_timer = wl_event_loop_add_timer(wl_display_get_event_loop(display), on_stats_timer, NULL))) wl_event_source_timer_update(g_stats_timer, 10000); + g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz); wl_display_run(display); wl_display_destroy(display); diff --git a/app/src/main/cpp/waylandcomp/src/headpose_shm.h b/app/src/main/cpp/waylandcomp/src/headpose_shm.h index 1bed89e79..824232b43 100644 --- a/app/src/main/cpp/waylandcomp/src/headpose_shm.h +++ b/app/src/main/cpp/waylandcomp/src/headpose_shm.h @@ -9,6 +9,7 @@ enum { DROIDDECK_HEADPOSE_LIVE = 1u << 0, DROIDDECK_HEADPOSE_ENABLED = 1u << 1, + DROIDDECK_HEADPOSE_MONO = 1u << 2, }; struct droiddeck_headpose { @@ -22,7 +23,10 @@ struct droiddeck_headpose { float tan_half_fov; float eye_aspect; uint32_t eye_w, eye_h; - uint32_t reserved[12]; + float display_hz; + // Uses a formerly reserved word; older publishers leave this zero. + uint32_t render_height; + uint32_t reserved[10]; }; #ifdef __cplusplus diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c index accbd08a1..78cfb33f4 100644 --- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c +++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c @@ -27,13 +27,13 @@ extern void droiddeck_wayland_send_key(int evdev, int state); extern void droiddeck_wayland_send_scene_input(int type, int a, int b); extern void droiddeck_wayland_vsync(int64_t frame_time_ns); extern void droiddeck_wayland_output_resized(void); +extern void droiddeck_wayland_set_output_refresh_rate(int mhz); extern volatile int g_fps_limit; extern volatile int g_hide_shell; extern volatile int g_zero_copy; extern volatile unsigned g_zero_copy_last; extern volatile int g_ubwc; extern volatile int g_no_render_node; -extern volatile int g_output_refresh_mhz; extern volatile int g_output_w, g_output_h; #define TAG "DroidDeckWayland" @@ -328,7 +328,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetLayerFrameRate(JN JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputRefreshRate(JNIEnv *env, jclass clazz, jfloat hz) { - g_output_refresh_mhz = hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0; + droiddeck_wayland_set_output_refresh_rate(hz > 1.0f ? (int)(hz * 1000.0f + 0.5f) : 0); } extern void vkp_reset_first_frame(void); @@ -342,6 +342,7 @@ JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetOutputSize(JNIEnv *env, jclass clazz, jint w, jint h) { g_output_w = w > 0 ? w : 0; g_output_h = h > 0 ? h : 0; + vkp_cardboard_set_render_height(h); } JNIEXPORT void JNICALL @@ -350,6 +351,7 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeResizeOutput(JNIEnv if (w <= 0 || h <= 0) return; g_output_w = w; g_output_h = h; + vkp_cardboard_set_render_height(h); droiddeck_wayland_output_resized(); } @@ -402,10 +404,11 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetScreenEffects(JNI JNIEXPORT void JNICALL Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboard(JNIEnv *env, jclass clazz, jboolean enabled, - jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma) { + jint source, jfloat ipd, jfloat dist, jfloat width, jfloat k1, jfloat k2, jfloat fov, jfloat inset, jfloat chroma, + jboolean mono, jfloat hz) { struct cardboard_params p = {.enabled = enabled ? 1 : 0, .source = source, .ipd_m = ipd, .screen_dist_m = dist, .screen_width_m = width, .k1 = k1, .k2 = k2, .fov_deg = fov, .lens_inset = inset, - .chroma = chroma}; + .chroma = chroma, .mono = mono ? 1 : 0, .display_hz = hz}; vkp_cardboard_set(&p); } diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt index f4f6f2008..8de9e1e81 100644 --- a/app/src/main/java/com/droiddeck/launcher/App.kt +++ b/app/src/main/java/com/droiddeck/launcher/App.kt @@ -16,12 +16,17 @@ class App : Application() { override fun onCreate() { super.onCreate() + // The restart screen must not initialize USB, guest processes, or another graphics context. + val process = (getSystemService(ACTIVITY_SERVICE) as android.app.ActivityManager) + .runningAppProcesses?.firstOrNull { it.pid == android.os.Process.myPid() }?.processName + if (process == "$packageName:vr_recovery") return CrashHandler.install(this) com.droiddeck.launcher.session.QuestExperimental.initialize(this) com.droiddeck.launcher.session.SessionPrefs.settleGpuDriverMode(this) com.droiddeck.launcher.update.AppUpdates.init(this) com.droiddeck.launcher.core.DownloadSource.init(this) GpuClockPin.clearLeftover(this) + com.droiddeck.launcher.glasses.Glasses.init(this) BwrapSpawner.start(this) com.droiddeck.launcher.frontend.GameFileSync.start(this) com.droiddeck.launcher.stores.StoresState.init(this) diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 41f192cf9..5c9b0d994 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -192,6 +192,10 @@ class MainActivity : ComponentActivity() { SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this)) refreshWifiDiscovery() } + private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + com.droiddeck.launcher.handtracking.HandTracking.refreshPermission(this) + } + private var placementCheck by mutableStateOf(null) private val wifiPermissionSettings = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { SessionPrefs.setWifiDiscoveryEnabled(this, WifiDiscovery.permissionGranted(this)) refreshWifiDiscovery() @@ -507,19 +511,27 @@ class MainActivity : ComponentActivity() { android.widget.Toast.makeText(this, R.string.game_link_session_busy, android.widget.Toast.LENGTH_LONG).show() return true } - if (!SessionService.launchGame(this, game.gameIdString)) { + val steamVr = game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID + if (!steamVr && !SessionService.launchGame(this, game.gameIdString)) { android.widget.Toast.makeText(this, R.string.game_link_failed, android.widget.Toast.LENGTH_LONG).show() return false } startActivity(Intent(this, SessionActivity::class.java).setAction(SessionService.ACTION_RESUME) + .putExtra(SessionActivity.EXTRA_STEAMVR, steamVr) .putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)) return true } return startSession(Intent(this, SessionActivity::class.java) .putExtra(SessionActivity.EXTRA_RETURN_HOME, returnHome) - .putExtra(SessionService.EXTRA_STEAM_URL, - com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)), steamSession = true) + .apply { + if (game.gameIdString == com.droiddeck.launcher.gpu.SteamVr.APP_ID) { + putExtra(SessionActivity.EXTRA_STEAMVR, true) + } else { + putExtra(SessionService.EXTRA_STEAM_URL, + com.droiddeck.launcher.frontend.GameLaunchLink.steamUrl(game.gameIdString)) + } + }, steamSession = true) } private fun handleGameLink(games: List) { @@ -636,6 +648,7 @@ class MainActivity : ComponentActivity() { ), FrontEndActions( onPlay = { playSteam() }, + onPlaySteamVr = { playSteamVr() }, onSteamUrl = { url -> if (!SessionState.running) startSession( Intent(this, SessionActivity::class.java).putExtra(SessionService.EXTRA_STEAM_URL, url), steamSession = true, @@ -886,6 +899,7 @@ class MainActivity : ComponentActivity() { onQamButton = { on -> ControllerPrefs.setQamButton(this, on); refreshController() }, onKeyboardButton = { on -> ControllerPrefs.setKeyboardButton(this, on); refreshController() }, onSelectSteam = { on -> ControllerPrefs.setSelectSteam(this, on); refreshController() }, + onJoyConVr = { on -> ControllerPrefs.setJoyConVr(this, on); refreshController() }, onEditLayout = { startActivity(Intent(this, ControllerEditorActivity::class.java)) }, onResetLayout = { ControllerPrefs.resetAllLayouts(this); refreshController() }, onMapping = { showPerformance = false; showComponents = false; showMapping = true }, @@ -928,6 +942,12 @@ class MainActivity : ComponentActivity() { onDismiss = { lowSpace = null }, ) } + com.droiddeck.launcher.ui.HandCameraHost( + check = placementCheck, + onAskPermission = { cameraPermission.launch(Manifest.permission.CAMERA) }, + onPlay = { placementCheck = null; startSteamSession(steamVr = true) }, + onClose = { placementCheck = null }, + ) if (showResetSettings) com.droiddeck.launcher.ui.ResetSettingsDialog( onConfirm = { gameProfiles -> resetSettings(gameProfiles) }, onDismiss = { showResetSettings = false }, @@ -988,8 +1008,13 @@ class MainActivity : ComponentActivity() { } else startSteamSession() } - private fun startSteamSession() { - startSession(Intent(this, SessionActivity::class.java), steamSession = true) + private fun playSteamVr() { + if (com.droiddeck.launcher.handtracking.HandTracking.wanted(this)) placementCheck = true + else startSteamSession(steamVr = true) + } + + private fun startSteamSession(steamVr: Boolean = false) { + startSession(Intent(this, SessionActivity::class.java).putExtra(SessionActivity.EXTRA_STEAMVR, steamVr), steamSession = true) } override fun onWindowFocusChanged(hasFocus: Boolean) { diff --git a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt index 3709b5fa5..28270e195 100644 --- a/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/QuestXrActivity.kt @@ -26,7 +26,7 @@ class QuestXrActivity : NativeActivity() { if (active) { check(CompositorHost.isStarted) { "DroidDeck compositor is not running" } SessionService.resume(this) - WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f) + WaylandCompositor.nativeSetCardboard(false, 1, 0.064f, 3f, 4f, 0f, 0f, 90f, 0f, 0f, false, 0f) WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0) WaylandCompositor.nativeSetZeroCopy(false) CompositorHost.attachXr() diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 5f43e85e7..f03842acc 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -52,6 +52,11 @@ import com.droiddeck.launcher.ui.SessionAchievements import com.droiddeck.launcher.gpu.Cardboard import com.droiddeck.launcher.gpu.FrameGen import com.droiddeck.launcher.gpu.HeadTracker +import com.droiddeck.launcher.glasses.Glasses +import com.droiddeck.launcher.glasses.GlassesScreen +import com.droiddeck.launcher.glasses.GlassesRendering +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import com.droiddeck.launcher.gpu.SteamVr import com.droiddeck.launcher.gpu.ScreenEffectLooks import com.droiddeck.launcher.gpu.ScreenEffects @@ -141,6 +146,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private lateinit var loading: LoadingState private lateinit var hud: PerfHud private var padBridge: PadBridge? = null + private var joyConVr: com.droiddeck.launcher.input.JoyConVr? = null + private var handCamera: com.droiddeck.launcher.handtracking.HandCamera? = null private var padMotion: com.droiddeck.launcher.input.PadMotion? = null private var resumed = false private val deckPadListener: () -> Unit = { uiHandler.post { updatePadMotion(); openDeckRing("deck pad changed"); applyUnfoldedControls() } } @@ -278,6 +285,23 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var steamVrStatus by mutableStateOf(null) private var steamVrSwitchedPicture by mutableStateOf(false) private var steamVrSeenRunning = false + private val steamVrRecovery by lazy { com.droiddeck.launcher.gpu.SteamVrRecovery(this) { + resumed && !ended && SessionState.running && !isInPictureInPictureMode + } } + private var activeCardboard = Cardboard.OFF + private var glasses by mutableStateOf(Glasses.state.value) + private var glassesSettings by mutableStateOf(Glasses.settings.value) + private var glassesScreen: GlassesScreen? = null + private var glassesOutputActive by mutableStateOf(false) + private var glasses3dForVr = false + private var ended = false + private var glassesPresent = false + private var steamVrAutoTried = false + private var steamVrInstallTried = false + private var headTracking by mutableStateOf(false) + private var glassesSlot by mutableStateOf(0) + private var glassesKey: List = emptyList() + private val glassesScope = kotlinx.coroutines.MainScope() private val hasRotationSensor by lazy { (getSystemService(SENSOR_SERVICE) as android.hardware.SensorManager).let { it.getDefaultSensor(android.hardware.Sensor.TYPE_GAME_ROTATION_VECTOR) @@ -318,10 +342,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun followPanelRefresh(displayId: Int) { if (!CompositorHost.isStarted) return - val display = (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return - if (display.displayId != displayId) return - val hz = display.refreshRate - if (hz > 1f) WaylandCompositor.nativeSetOutputRefreshRate(hz) + val output = glassesScreen?.display + ?: (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay) ?: return + if (output.displayId != displayId) return + applyOutputPacing() } private var isHomeApp by mutableStateOf(false) private var androidApps by mutableStateOf>(emptyList()) @@ -329,17 +353,24 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private val deviceListener = object : InputManager.InputDeviceListener { override fun onInputDeviceAdded(deviceId: Int) { logInputDevice("connected", deviceId) + joyConVr?.refresh() padBridge?.syncConnectedControllers() refreshControls() updatePointerCapture() + updateHandCamera() } override fun onInputDeviceRemoved(deviceId: Int) { Log.i(TAG, "input device $deviceId disconnected") + joyConVr?.refresh() padBridge?.onDeviceRemoved(deviceId) refreshControls() updatePointerCapture() + updateHandCamera() + } + override fun onInputDeviceChanged(deviceId: Int) { + joyConVr?.refresh() + refreshControls() } - override fun onInputDeviceChanged(deviceId: Int) = refreshControls() } private var runtimeRemovalBlocked = false @@ -363,17 +394,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionState.programArgs = intent.getStringArrayExtra(SessionService.EXTRA_PROGRAM_ARGS)?.toList().orEmpty() SessionState.steamUi = intent.getStringExtra(SessionService.EXTRA_STEAM_UI) SessionState.steamUrl = intent.getStringExtra(SessionService.EXTRA_STEAM_URL) + SessionState.steamVr = intent.getBooleanExtra(EXTRA_STEAMVR, false) + } else if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) { + SessionState.steamVr = true } if (intent.action == SessionService.ACTION_AGENT_START) { SessionEvents.record("agent.start_requested", mapOf("mode" to SessionState.mode)) } + com.droiddeck.launcher.gpu.SteamVrHealth.begin(this, SessionState.sessionId) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) if (!SessionState.running) { SessionState.fpsLimit = SessionPrefs.fpsLimit( this, SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM), ) } - Log.i(TAG, "perf: " + PerfMode.apply(this, SessionState.fpsLimit)) + applyOutputPacing() if (!SessionState.running) SessionState.followScreen = followChoice() applyFollowOrientation(resources.configuration) goFullscreen() @@ -403,6 +438,16 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { bridge.setSelectSteam(ControllerPrefs.read(this).selectSteam) padBridge = bridge SessionState.padBridge = bridge + if (Build.VERSION.SDK_INT >= 31) { + val vr = com.droiddeck.launcher.input.JoyConVr(this, File(filesDir, ".wayland-rt/${com.droiddeck.launcher.input.JoyConVr.FILE}")) { taken, released -> + if (joyConVr == null) return@JoyConVr + taken.forEach(bridge::onDeviceRemoved) + if (released.isNotEmpty()) bridge.syncConnectedControllers() + updateHandCamera() + } + bridge.setExcluded(vr::owns) + joyConVr = vr + } SteamControllerBle.listener = { uiHandler.post { updatePadMotion(); refreshControls() } } steamController = SteamControllerBle(this).also { it.start() } padMotion = com.droiddeck.launcher.input.PadMotion(this) { @@ -450,6 +495,21 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { sessionOverlay = createSessionOverlay() root.addView(sessionOverlay) setContentView(root) + glassesScope.launch { Glasses.state.collect { glasses = it; onGlassesChanged() } } + glassesScope.launch { Glasses.settings.collect { glassesSettings = it; onGlassesChanged() } } + glassesScope.launch { com.droiddeck.launcher.handtracking.HandTracking.settings.collect { updateHandCamera() } } + glassesScope.launch { + while (true) { + if (SessionState.running && SessionState.mode == SessionService.MODE_STEAM && + (SessionState.steamVr || steamVrStatus == SteamVr.Status.RUNNING)) { + val lost = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + com.droiddeck.launcher.gpu.SteamVrHealth.deviceLost() + } + if (lost) steamVrRecovery.detected() + } + kotlinx.coroutines.delay(2000) + } + } pip = com.droiddeck.launcher.session.SessionPipController(this, ::pipBounds, ::showPipUi) surfaceView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> pip.refresh() } pip.refresh() @@ -496,6 +556,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CursorOverlay(cursorPos, cursorVisible, resources.displayMetrics.density, cursorImage, cursorHotX, cursorHotY, cursorImageScale) if (hud.text.isNotEmpty()) HudText(hud.text) + if (glassesOutputActive && !loading.visible) com.droiddeck.launcher.ui.GlassesPlayingHint(glasses.model) + if (GlassesRendering.usesGlasses(glasses, glassesOutputActive, steamVrStatus == SteamVr.Status.RUNNING) && + headTracking && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesRecenterButton( + controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) { headTracker?.recenter() } + if (glasses.present && !drawerOpen && !pcKeyboardOpen && !loading.visible) com.droiddeck.launcher.ui.GlassesCornerButton(glassesSlot, + controllerSettings?.tint ?: android.graphics.Color.rgb(90, 160, 255), controllerSettings?.opacity ?: 100) { + drawerPage = com.droiddeck.launcher.ui.SessionDrawerPage.GLASSES + drawerOpen = true + } if (loading.visible) LoadingOverlay( loading.step, loading.percent, loading.elapsed, loading.hint, loading.ended, topic = loading.topic, readable = loading.readable, @@ -514,7 +583,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { androidx.compose.runtime.LaunchedEffect(drawerOpen) { if (drawerOpen) { syncLossless() - padBridge?.releaseAll() + releasePads() androidx.compose.runtime.withFrameNanos { } val requested = sessionOverlay.requestFocus() Log.i(TAG, "drawer root focus requested=$requested focused=${sessionOverlay.hasFocus()}") @@ -522,8 +591,12 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { releaseDrawerDirection() } } - LaunchedEffect(cardboard.enabled, steamVrSwitchedPicture, drawerOpen, drawerPage) { - while (cardboard.enabled || steamVrSwitchedPicture || (drawerOpen && drawerPage == SessionDrawerPage.VR)) { + val glassesVr = glasses.usable && glasses.canControl + val steamVrRequested = SessionState.steamVr + val pollSteamVr = SteamVr.shouldPoll(steamVrStatus, cardboard.enabled, + steamVrSwitchedPicture, glassesVr, steamVrRequested) || (drawerOpen && drawerPage == SessionDrawerPage.VR) + LaunchedEffect(pollSteamVr) { + while (pollSteamVr) { val now = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { SteamVr.status(this@SessionActivity) } @@ -540,7 +613,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { achievementsInFront = found if (found == null) achievementsOpen = false } - androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) padBridge?.releaseAll() } + androidx.compose.runtime.LaunchedEffect(pcKeyboardOpen) { if (pcKeyboardOpen) releasePads() } if (pcKeyboardOpen) com.droiddeck.launcher.ui.PcKeyboard( sendKey = { code, down -> if (CompositorHost.isStarted) sendGuestKey(code, down) }, onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() }, @@ -559,6 +632,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { cardboardSensor = hasRotationSensor, onCardboard = { c -> SessionPrefs.setCardboard(this@SessionActivity, c); applyCardboard(c) }, onCardboardRecenter = { headTracker?.recenter() }, + glassesPresent = glasses.present, + glassesOutput = glassesOutputActive, + onGlassesRecenter = { headTracker?.recenter() }, steamVr = steamVrStatus, immersiveSteamVr = com.droiddeck.launcher.session.QuestExperimental.isSupported(), onSteamVr = { action -> steamVrAction(action) }, @@ -747,7 +823,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { )) LaunchedEffect(achievementsOpen) { if (achievementsOpen) { - padBridge?.releaseAll() + releasePads() withFrameNanos { } sessionOverlay.requestFocus() } @@ -1224,7 +1300,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } loading.percent = -1 loading.say(getString(R.string.session_starting_session), LoadingState.Topic.SESSION, readable = false) - if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder) + outputHolder().let { if (it.surface?.isValid == true) surfaceCreated(it) } } }, "runtime-install").start() } @@ -1288,7 +1364,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun surfaceCreated(holder: SurfaceHolder) { - if (installingRuntime || SessionState.stopRequested) return + if (holder !== outputHolder()) return + if (installingRuntime || SessionState.stopRequested || ended) return if (!SessionState.running) { SessionEvents.transition(SessionPhase.STARTING_COMPOSITOR, "compositor.starting") } @@ -1304,7 +1381,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val live = if (SessionState.followScreen && !isInPictureInPictureMode) com.droiddeck.launcher.session.SessionDisplay.followSize(holder.surfaceFrame.width(), holder.surfaceFrame.height()) else null - val size = live ?: if (SessionState.running) previous else outputSize() + val size = live ?: if (SessionState.running) previous else glassesOutputSize() ?: outputSize() SessionState.outputSize = size if (!SessionState.running) SessionState.refreshHz = refreshHz() val panel = panelBounds() @@ -1317,7 +1394,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { WaylandCompositor.nativeSetUpscaler(SessionPrefs.upscaler(this)) WaylandCompositor.nativeSetUpscaleSharpness(SessionPrefs.upscaleSharpness(this)) SessionPrefs.screenEffects(this).push(SessionPrefs.upscaler(this)) - panelSizeMm().let { (w, h) -> SessionPrefs.cardboard(this).push(w, h) } + activeCardboard = effectiveCardboard(SessionPrefs.cardboard(this)) + pushCardboard(activeCardboard) WaylandCompositor.nativeSetCardboardPoseFile(File(filesDir, ".wayland-rt/droiddeck-headpose").path) if (!SessionState.running) { val waylandLog = File(SessionPaths.beginOrCurrent(this), "wayland.log") @@ -1330,7 +1408,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (!CompositorHost.isStarted) { val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) - val probe = HdrSupport.probe(this) + val probe = HdrSupport.probe(this, glassesScreen?.display) WaylandCompositor.nativeSetHdrDisplay( probe.displayId, probe.name, probe.formats, probe.hdr10, probe.maxLuminance, probe.maxAverageLuminance, probe.minLuminance, @@ -1354,8 +1432,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { CompositorHost.startOrAttach( holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, - applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), SessionState.fpsLimit, + applicationInfo.nativeLibraryDir, size.first, size.second, refreshHz(), outputFpsLimit(), ) + onOutputAttached(holder) if (SessionState.running && size != previous) { Log.i(TAG, "follow screen: ${previous.first}x${previous.second} -> ${size.first}x${size.second} on a new surface") WaylandCompositor.nativeResizeOutput(size.first, size.second) @@ -1379,6 +1458,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var surfaceH = 0 override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + if (holder !== outputHolder()) return val resized = surfaceW != 0 && (width != surfaceW || height != surfaceH) surfaceW = width surfaceH = height @@ -1422,8 +1502,12 @@ 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 surfaceW = 0 surfaceH = 0 + glassesOutputActive = false CompositorHost.detach(holder.surface) } @@ -1448,14 +1532,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun refreshHz(): Float { + glassesScreen?.hz?.takeIf { it > 1f }?.let { return it } val display = if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay val wanted = window.attributes.preferredDisplayModeId val hz = display?.supportedModes?.firstOrNull { wanted != 0 && it.modeId == wanted }?.refreshRate ?: display?.refreshRate ?: 60f - val capped = minOf(hz, PerfMode.peakRefreshCap(this)) + val mirrorCap = mirrorRefreshLimit().takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY + val capped = minOf(hz, PerfMode.peakRefreshCap(this), mirrorCap) return if (capped > 1f) capped else 60f } + private fun mirrorRefreshLimit(): Int = + GlassesRendering.mirrorRefreshLimit(glassesSettings, glasses, glassesScreen != null) + + private fun outputFpsLimit(): Int = GlassesRendering.fpsLimit(SessionState.fpsLimit, mirrorRefreshLimit()) + + private fun applyOutputPacing() { + Log.i(TAG, "perf: " + PerfMode.apply(this, outputFpsLimit(), mirrorRefreshLimit())) + if (CompositorHost.isStarted) { + val hz = refreshHz() + val changed = kotlin.math.abs(SessionState.refreshHz - hz) > 0.1f + CompositorHost.updatePacing(hz, outputFpsLimit()) + pushCardboard(activeCardboard) + if (changed && ::surfaceView.isInitialized) applyFrameGen() + } + } + private fun applyFrameGen() { val hz = refreshHz() Thread({ @@ -1514,11 +1616,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun onSessionEnded(status: Int) { + ended = true if (isInPictureInPictureMode) { runOnUiThread { finish() } return } closeSecondScreen(reset = true) + updateGlassesScreen() + updateHeadTracker() if (status == 0) { runOnUiThread { if (isFinishing || isDestroyed) return@runOnUiThread @@ -1560,9 +1665,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { focusEndedScreen() } + private fun releasePads() { + padBridge?.releaseAll() + joyConVr?.releaseButtons() + } + private fun focusEndedScreen() { drawerOpen = false - padBridge?.releaseAll() + releasePads() uiHandler.post { sessionOverlay.requestFocus() } } @@ -1610,6 +1720,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return super.dispatchKeyEvent(event) } if (pipUi) return true + val pad = com.droiddeck.launcher.input.JoyCon.normalize(event) + if (pad !== event) return dispatchKeyEvent(pad) val fromController = PadBridge.isControllerKeyEvent(event) if (fromController && event.action == KeyEvent.ACTION_DOWN) { if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus() @@ -1632,7 +1744,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (drawerOpen) { if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) { if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { - drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty()) + drawerPage = sessionDrawerPages(SessionState.mode == SessionService.MODE_STEAM && secondScreenDisplays.isNotEmpty(), glasses.present) .step(drawerPage, if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else -1) releaseDrawerDirection() } @@ -1661,6 +1773,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { return true } if ((pcKeyboardOpen || achievementsOpen) && fromController) return super.dispatchKeyEvent(event) + if (joyConVr?.onKeyEvent(event) == true) return true if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true if (CompositorHost.isStarted && event.keyCode != KeyEvent.KEYCODE_BACK && !fromController) { val down = event.action == KeyEvent.ACTION_DOWN @@ -1704,6 +1817,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (drawerDirectionKey != KeyEvent.KEYCODE_UNKNOWN) releaseDrawerDirection() if ((pcKeyboardOpen || achievementsOpen) && PadBridge.isControllerMotionEvent(event)) return super.dispatchGenericMotionEvent(event) + if (joyConVr?.onMotionEvent(event) == true) return true if (padBridge?.onMotionEvent(event) == true) return true if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE) && !drawerOpen && onMouse(event)) return true return super.dispatchGenericMotionEvent(event) @@ -1868,6 +1982,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (!::displayManager.isInitialized) return val primaryId = display?.displayId ?: windowManager.defaultDisplay.displayId val candidates = SecondScreenDisplays.available(displayManager, primaryId) + .filter { glassesScreen == null || it.id != glasses.displayId } val oldIds = secondScreenDisplays.map { it.id }.toSet() secondScreenDisplays = candidates val newIds = candidates.map { it.id }.toSet() @@ -2219,6 +2334,17 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun updateOnScreenControls() { + updateOnScreenPad() + val c = onScreenControls + glassesSlot = when { + c == null || c.visibility != View.VISIBLE -> 0 + c.isButtonsOnly -> -1 + controllerSettings?.keyboardButton != false -> 2 + else -> 1 + } + } + + private fun updateOnScreenPad() { val forced = File(Environment.getExternalStorageDirectory(), "Download/droiddeck-osc") .takeIf { it.isFile } ?.let { FileUtils.readString(it)?.trim()?.lowercase() } @@ -2301,7 +2427,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { pendingBackAction = null releaseDrawerDirection() onScreenControls?.releaseAll() - padBridge?.releaseAll() + releasePads() touchpad.cancel() guestKeysDown.toList().forEach { sendGuestKey(it, false) } keyboard?.hide() @@ -2337,6 +2463,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (::pip.isInitialized) pip.changed(inPip) if (!inPip && surfaceW > 0) followWindow(surfaceW, surfaceH) applyUnfoldedControls() + updateGlassesScreen() } override fun startActivityForResult(intent: Intent, requestCode: Int, options: Bundle?) { @@ -2355,13 +2482,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { refreshSecondScreenDisplays() if (secondScreenMode != SecondScreenMode.NONE && secondScreenPresentation == null) showSecondScreen(secondScreenMode) SessionService.setActivityVisible(this, true) + Glasses.rescan() + updateGlassesScreen() + updateHandCamera() } override fun onStop() { if (runtimeRemovalBlocked) { super.onStop(); return } started = false + updateHandCamera() displayManager.unregisterDisplayListener(displayListener) closeSecondScreen(reset = false) + updateGlassesScreen() SessionService.setActivityVisible(this, false) super.onStop() } @@ -2379,6 +2511,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } if (this.intent.getBooleanExtra(EXTRA_RETURN_HOME, false)) intent.putExtra(EXTRA_RETURN_HOME, true) setIntent(intent) + if (intent.getBooleanExtra(EXTRA_STEAMVR, false) && SessionState.mode == SessionService.MODE_STEAM) { + SessionState.steamVr = true + steamVrAutoTried = false + } if (intent.action == SessionService.ACTION_HOME_GUIDE) { handleHomeGuideIntent(intent) } else if (intent.action == SessionService.ACTION_RESUME) { @@ -2400,6 +2536,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { (getSystemService(INPUT_SERVICE) as? InputManager) ?.registerInputDeviceListener(deviceListener, Handler(Looper.getMainLooper())) if (SessionState.guestPid > 1) padBridge?.syncConnectedControllers() + joyConVr?.refresh() onScreenControls?.reload() controllerSettings = ControllerPrefs.read(this) resumed = true @@ -2436,13 +2573,32 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { startActivity(android.content.Intent(this, QuestXrActivity::class.java)) return } + if (action == "start") { + SessionState.steamVr = true + steamVrAutoTried = true + } + if (action == "stop") SessionState.steamVr = false + 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 + steamVrWorker.execute { + val ok = SteamVr.run(this, action) + val now = SteamVr.status(this) + uiHandler.post { + onSteamVrStatus(now) + if (!ok) Toast.makeText(this, getString(R.string.cardboard_steamvr_failed), Toast.LENGTH_LONG).show() + } + } + return + } if (action == "start") { switchPictureForSteamVr() steamVrSeenRunning = false } if (action == "stop") restoreSteamVrPicture() if (action == "install") steamVrStatus = SteamVr.Status.INSTALLING - Thread({ + steamVrWorker.execute { val ok = SteamVr.run(this, action) val now = SteamVr.status(this) uiHandler.post { @@ -2450,7 +2606,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onSteamVrStatus(now) if (!ok) Toast.makeText(this, getString(R.string.cardboard_steamvr_failed), Toast.LENGTH_LONG).show() } - }, "steamvr-$action").start() + } } private fun switchPictureForSteamVr() { @@ -2463,8 +2619,41 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { steamVrSwitchedPicture = SessionPrefs.cardboardSourceBeforeSteamVr(this) >= 0 } + private fun updateHandCamera() { + val joyCons = joyConVr?.holding == true + val bare = !joyCons && !controllerConnected() + val want = started && steamVrStatus == SteamVr.Status.RUNNING && (joyCons || bare) && + com.droiddeck.launcher.handtracking.HandTracking.wanted(this) + handCamera?.bareHands = bare + if (want == (handCamera != null)) return + if (want) { + handCamera = com.droiddeck.launcher.handtracking.HandCamera(this, + File(filesDir, ".wayland-rt/${com.droiddeck.launcher.handtracking.HandCameraShm.FILE}"), preview = false) + .also { + it.bareHands = bare + it.start(com.droiddeck.launcher.handtracking.HandTracking.settings.value.side) + } + } else { + handCamera?.stop() + handCamera = null + } + } + + private fun controllerConnected(): Boolean = InputDevice.getDeviceIds().any { id -> + joyConVr?.owns(id) != true && com.droiddeck.launcher.input.PadBridge.isFromController(InputDevice.getDevice(id)) + } + private fun onSteamVrStatus(now: SteamVr.Status) { + val was = steamVrStatus steamVrStatus = now + joyConVr?.setActive(now == SteamVr.Status.RUNNING && ControllerPrefs.joyConVr(this)) + updateHandCamera() + autoStartSteamVr(now) + if ((was == SteamVr.Status.RUNNING) != (now == SteamVr.Status.RUNNING)) applyCardboard(cardboard) + if (glasses.present) onGlassesSteamVr(now) + if (GlassesRendering.usesGlasses(glasses, glassesScreen != null, now == SteamVr.Status.RUNNING)) { + return + } if (now == SteamVr.Status.RUNNING && cardboard.enabled && !steamVrSwitchedPicture && cardboard.source != Cardboard.SOURCE_SBS) { switchPictureForSteamVr() @@ -2487,23 +2676,172 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun applyCardboard(c: Cardboard) { - val was = cardboard + val was = activeCardboard cardboard = c - if (CompositorHost.isStarted) panelSizeMm().let { (w, h) -> c.push(w, h) } - if (c.enabled && !was.enabled) headTracker?.recenter() + val e = effectiveCardboard(c) + activeCardboard = e + if (CompositorHost.isStarted) pushCardboard(e) + if (e.enabled && !was.enabled) headTracker?.recenter() updateHeadTracker() } + private fun pushCardboard(c: Cardboard) { + val fov = when { + c.viewer != Cardboard.Viewer.VITURE.id -> 0f + c.source == Cardboard.SOURCE_SBS -> glassesSettings.vrFovDeg.toFloat() + else -> glasses.verticalFovDeg + } + panelSizeMm().let { (w, h) -> c.push(w, h, fov, refreshHz()) } + } + + private fun effectiveCardboard(c: Cardboard): Cardboard = GlassesRendering.cardboard( + base = c, + settings = glassesSettings, + state = glasses, + outputActive = glassesScreen != null, + steamVrRunning = steamVrStatus == SteamVr.Status.RUNNING, + ) + private fun updateHeadTracker() { - val want = resumed && cardboard.enabled && cardboard.headTracking + val onGlasses = GlassesRendering.usesGlasses(glasses, glassesScreen != null, + steamVrStatus == SteamVr.Status.RUNNING) + val c = activeCardboard + val want = !ended && (resumed || (glassesScreen != null && started)) && c.enabled && c.headTracking + val current = headTracker + if (current != null && (!want || current.usesGlasses != onGlasses)) { + current.stop() + headTracker = null + } if (want) { - val t = headTracker ?: HeadTracker(this) { + val t = headTracker ?: HeadTracker(this, glasses = onGlasses) { @Suppress("DEPRECATION") (if (Build.VERSION.SDK_INT >= 30) display else windowManager.defaultDisplay)?.rotation ?: android.view.Surface.ROTATION_0 }.also { headTracker = it } t.start() - } else { - headTracker?.stop() + } + headTracking = headTracker != null + } + + private fun outputHolder(): SurfaceHolder = glassesScreen?.holder ?: surfaceView.holder + + private fun onOutputAttached(holder: SurfaceHolder) { + if (holder !== outputHolder()) return + glassesOutputActive = glassesScreen?.holder === holder + // 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") + } + + private fun restorePhoneOutput(screen: GlassesScreen) { + if (glassesScreen !== screen) return + glassesScreen = null + glassesOutputActive = false + surfaceW = 0 + surfaceH = 0 + CompositorHost.detach(screen.holder.surface) + surfaceView.visibility = View.VISIBLE + applyOutputPacing() + if (surfaceView.holder.surface?.isValid == true) surfaceCreated(surfaceView.holder) + } + + private fun glassesOutputSize(): Pair? { + if (glassesScreen == null || glasses.width <= 0 || glasses.height <= 0) return null + val panel = (if (glasses.sbs) glasses.width / 2 else glasses.width) to glasses.height + val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM) + val display = com.droiddeck.launcher.session.SessionDisplay + val choice = SessionPrefs.resolutionChoice(this, mode, panel) + val size = display.resolveChoice(panel, choice) + val fixed = choice != display.MATCH_SCREEN && choice != display.FOLLOW_SCREEN && display.presetHeight(choice) == null + return if (fixed) display.resolve(panel, minOf(size.first, size.second), SessionPrefs.SHAPE_AUTO) else size + } + + private fun onGlassesChanged() { + val g = glasses + val s = glassesSettings + val key = listOf(g.present, g.usable, g.displayId, g.sbs, g.sbs90, g.switching3d, g.canControl, g.width, g.height, g.hz, s) + if (key == glassesKey) return + glassesKey = key + updateGlassesScreen() + applyCardboard(cardboard) + if (!g.switching3d) { + if (glassesPresent && !g.present) onGlassesUnplugged() + glassesPresent = g.present + } + } + + private fun onGlassesUnplugged() { + val hadVr = steamVrAutoTried + steamVrAutoTried = false + glasses3dForVr = false + if (hadVr && !cardboard.enabled) steamVrAction("stop") + } + + private fun autoStartSteamVr(now: SteamVr.Status) { + if (now == SteamVr.Status.RUNNING) steamVrAutoTried = true + val action = SteamVr.autoAction(now, SessionState.steamVr, loading.visible, + SessionState.mode == SessionService.MODE_STEAM, steamVrAutoTried, steamVrInstallTried) ?: return + if (action == "start") steamVrAutoTried = true else steamVrInstallTried = true + Log.i(TAG, "requested SteamVR $action (glasses=${glasses.model}, output=$glassesOutputActive, control=${glasses.canControl})") + steamVrAction(action) + } + + private fun updateGlassesScreen() { + if (!::surfaceView.isInitialized) return + val g = glasses + val want = started && !ended && !isInPictureInPictureMode && !runtimeRemovalBlocked && g.usable && glassesSettings.gameOnGlasses + val target = if (want) Glasses.glassesDisplay()?.takeIf { it.isValid } else null + val current = glassesScreen + if (current != null && current.display.displayId == target?.displayId) { + current.prefer(Glasses.outputHz()) + applyOutputPacing() + return + } + if (current != null) { + restorePhoneOutput(current) + runCatching { current.dismiss() } + } + if (target != null) { + val screen = GlassesScreen(this, target, Glasses.outputHz(), this) + // show() can create the surface synchronously. Select its holder + // first, otherwise surfaceCreated() rejects the glasses callback. + glassesScreen = screen + applyOutputPacing() + surfaceW = 0 + surfaceH = 0 + screen.setOnDismissListener { + if (glassesScreen === screen) { + restorePhoneOutput(screen) + refreshSecondScreenDisplays() + applyCardboard(cardboard) + } + } + try { + screen.show() + } catch (e: Exception) { + restorePhoneOutput(screen) + runCatching { screen.dismiss() } + Log.w(TAG, "could not show the game on ${target.name}", e) + return + } + Log.i(TAG, "requested game output on ${g.model} (display ${target.displayId})") + } + applyOutputPacing() + refreshSecondScreenDisplays() + applyCardboard(cardboard) + } + + private fun onGlassesSteamVr(now: SteamVr.Status) { + val g = glasses + if (now == SteamVr.Status.RUNNING) { + if (glassesSettings.auto3d && g.can3d && !g.sbs && !glasses3dForVr) { + glasses3dForVr = true + Glasses.set3dForVr(true) + } + } else if (glasses3dForVr && now != SteamVr.Status.UNAVAILABLE) { + glasses3dForVr = false + Glasses.set3dForVr(false) } } @@ -2575,6 +2913,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } override fun onDestroy() { + steamVrRecovery.close() uiHandler.removeCallbacks(steamTouchRetry) if (runtimeRemovalBlocked) { super.onDestroy(); return } watching = false @@ -2583,7 +2922,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { pendingBackAction = null if (::pip.isInitialized) pip.destroyed() closeSecondScreen(reset = false) + glassesScope.cancel() + glassesScreen?.let { screen -> glassesScreen = null; runCatching { screen.dismiss() } } + headTracker?.stop() if (::hud.isInitialized) hud.stop() + joyConVr?.close() + joyConVr = null + handCamera?.stop() + handCamera = null padBridge?.stop() steamController?.stop() if (SessionState.padBridge === padBridge) SessionState.padBridge = null @@ -2637,6 +2983,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { companion object { const val EXTRA_RETURN_HOME = "returnHome" + const val EXTRA_STEAMVR = "steamVr" private const val LEAVE_TIMEOUT_MS = 1_500L private const val TAG = "SessionActivity" private const val FEX_MISSING = "droiddeck-fex: x86 Linux programs need" @@ -2650,6 +2997,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private const val DRAWER_SLOW_REPEAT_FLOOR_MS = 240L private const val DRAWER_FAST_REPEAT_INTERVAL_MS = 90L private const val DRAWER_REPEAT_ACCELERATION_MS = 1_400L + private val steamVrWorker = java.util.concurrent.ThreadPoolExecutor(0, 1, 30, java.util.concurrent.TimeUnit.SECONDS, + java.util.concurrent.LinkedBlockingQueue()) { Thread(it, "steamvr") } private const val SCALE_FIT = 1 private const val ALIGN_CENTER = 0 private const val ALIGN_PICTURE_TOP = 3 diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt index 4294e5ad9..982deedaf 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentBridgeProvider.kt @@ -92,11 +92,75 @@ class AgentBridgeProvider : ContentProvider() { AgentEnv.override(context, request.optString("name")).put("ok", true).put("command", "override") } "prefs" -> prefs(context, request) + "glasses" -> glasses(context, request) "access" -> JSONObject().put("ok", true).put("commands", AgentAccess.commandsAllowed(context)) .put("debugBuild", BuildConfig.DEBUG).put("inbox", AgentEnv.inbox(context).absolutePath) else -> error("UNKNOWN_COMMAND", "Unknown agent command '$method'") } + private fun glasses(context: Context, request: JSONObject): JSONObject { + val g = com.droiddeck.launcher.glasses.Glasses + when (request.optString("op", "status")) { + "status" -> {} + "allow" -> g.requestControl() + "set" -> { + val key = request.optString("key") + val value = request.optString("value") + fun on() = value == "1" || value.equals("true", true) || value.equals("on", true) + val number = value.toIntOrNull() + val numeric = setOf("refreshHz", "sbsHz", "brightness", "volume", "picture3d", "screenWidthDm", "screenDistanceDm", "vrFovDeg", "sbsSupport") + if (key in numeric && number == null) return error("INVALID_VALUE", "'$key' takes a number") + when (key) { + "refreshHz" -> g.setRefreshHz(number!!) + "sbsHz" -> g.setSbsHz(number!!) + "brightness" -> g.setBrightness(number!!) + "volume" -> g.setVolume(number!!) + "sunblock" -> g.setSunblock(on()) + "3d" -> g.set3d(on()) + "gameOnGlasses" -> g.setGameOnGlasses(on()) + "headTracking" -> g.setHeadTracking(on()) + "auto3d" -> g.setAuto3d(on()) + "picture3d" -> g.setPicture3d(number!!) + "screenWidthDm" -> g.setScreenWidthDm(number!!) + "screenDistanceDm" -> g.setScreenDistanceDm(number!!) + "vrFovDeg" -> g.setVrFovDeg(number!!) + "sbsSupport" -> g.setSbsSupport(number!!) + else -> return error("INVALID_KEY", "Unknown glasses setting '$key'") + } + } + "simulate" -> { + fun angle(key: String) = if (request.has(key)) request.optDouble(key).takeIf { it.isFinite() }?.coerceIn(-90.0, 90.0) else null + if (!g.simulate(request.optInt("seconds", 20).coerceIn(1, 600), angle("yaw"), angle("pitch"))) return error("BUSY", "Already simulating") + } + "stop-simulate" -> g.stopSimulation() + "calibrate" -> g.startCalibration() + "cancel-calibration" -> g.cancelCalibration() + "reset-calibration" -> g.resetCalibration() + "query" -> { g.query(); Thread.sleep(600) } + "mode" -> { + val mode = hex(request.optString("mode"), 0xFF) ?: return error("INVALID_VALUE", "mode must be one hex byte, e.g. 0x31") + g.sendMode(mode) + Thread.sleep(600) + } + "imu" -> return g.sampleImu(request.optLong("ms", 2000).coerceIn(200, 20_000)).put("ok", true).put("command", "glasses") + "raw" -> { + AgentAccess.requireCommands(context) + val msgId = hex(request.optString("msgId"), 0xFFFF) ?: return error("INVALID_VALUE", "msgId must be a hex number up to 0xFFFF") + val digits = request.optString("payload").removePrefix("0x") + if (digits.length % 2 != 0 || digits.any { Character.digit(it, 16) < 0 }) return error("INVALID_VALUE", "payload must be whole hex bytes") + val payload = digits.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + if (!g.sendRaw(msgId, payload)) return error("NOT_CONNECTED", "The glasses' control link is not open") + Thread.sleep(300) + } + else -> return error("INVALID_OP", "Unknown glasses op") + } + if (request.optString("op") in setOf("set", "allow")) Thread.sleep(400) + return g.status().put("ok", true).put("command", "glasses").put("frames", g.recentFrames()) + } + + private fun hex(text: String, max: Int): Int? = + text.removePrefix("0x").removePrefix("0X").takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.takeIf { it in 0..max } + private fun state(context: Context): JSONObject { val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context) val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull() @@ -109,6 +173,7 @@ class AgentBridgeProvider : ContentProvider() { .put("steamUi", SessionState.steamUi ?: JSONObject.NULL) .put("steamUrl", SessionState.steamUrl ?: JSONObject.NULL) .put("suspended", SessionState.suspended) + .put("steamVr", SessionState.steamVr) .put("pip", SessionState.pipActive) .put("firstFrame", SessionState.firstFrameSeen) .put("output", JSONArray().put(SessionState.outputSize.first).put(SessionState.outputSize.second)) diff --git a/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt b/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt index 9b9d85bbd..f052df93c 100644 --- a/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/agent/AgentStartActivity.kt @@ -97,6 +97,7 @@ class AgentStartActivity : Activity() { request.optString("steamUrl").takeIf { it.startsWith("steam://") }?.let { putExtra(SessionService.EXTRA_STEAM_URL, it) } + if (request.optBoolean("steamVr")) putExtra(SessionActivity.EXTRA_STEAMVR, true) } } startActivity(sessionIntent) diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt new file mode 100644 index 000000000..4307e5e3a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/Glasses.kt @@ -0,0 +1,862 @@ +package com.droiddeck.launcher.glasses + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.display.DisplayManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.SystemClock +import android.util.Log +import android.view.Display +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.abs +import kotlin.math.sin +import com.droiddeck.launcher.R +import java.io.File + +data class GlassesState( + val present: Boolean = false, + val model: String = "VITURE", + val controllable: Boolean = false, + val permission: Glasses.Permission = Glasses.Permission.NONE, + val connected: Boolean = false, + val displayId: Int = Display.INVALID_DISPLAY, + val width: Int = 0, + val height: Int = 0, + val hz: Float = 0f, + val sbs: Boolean = false, + val switching3d: Boolean = false, + val brightness: Int = -1, + val volume: Int = -1, + val brightnessMax: Int = 8, + val volumeMax: Int = 8, + val sunblock: Boolean = false, + val hardwareControls: Boolean = true, + val hasSunblock: Boolean = true, + val controlError: String? = null, + val tracking: Boolean = false, + val diagonalFovDeg: Float = 46f, + val noSignal: Boolean = false, + val sbsSupport: Int = SBS_UNKNOWN, + val sbs90: Boolean = false, + val experimental: Boolean = false, +) { + val hasDisplay: Boolean get() = displayId != Display.INVALID_DISPLAY + val usable: Boolean get() = hasDisplay && !noSignal + val canControl: Boolean get() = connected && controllable + val can3d: Boolean get() = canControl && !noSignal && sbsSupport != SBS_FAILS + val verticalFovDeg: Float get() { + val half = Math.toRadians(diagonalFovDeg / 2.0) + val aspect = if (width > 0 && height > 0) + width.toDouble() / (if (sbs) 2 else 1) / height else 16.0 / 9.0 + val v = kotlin.math.atan(kotlin.math.tan(half) / kotlin.math.sqrt(1 + aspect * aspect)) + return Math.toDegrees(2 * v).toFloat() + } + + companion object { + const val SBS_UNKNOWN = 0 + const val SBS_WORKS = 1 + const val SBS_FAILS = -1 + } +} + +data class GlassesSettings( + val refreshHz: Int = 120, + val sbsHz: Int = 60, + val sunblock: Boolean? = null, + val gameOnGlasses: Boolean = true, + val headTracking: Boolean = true, + val auto3d: Boolean = false, + val picture3d: Int = PICTURE_AUTO, + val screenWidthDm: Int = 20, + val screenDistanceDm: Int = 30, + val vrFovDeg: Int = VR_FOV_DEFAULT, +) { + companion object { + const val VR_FOV_MIN = 24 + const val VR_FOV_MAX = 90 + const val VR_FOV_DEFAULT = 90 + const val PICTURE_AUTO = 0 + const val PICTURE_SCREEN = 1 + const val PICTURE_SBS = 2 + } +} + +@SuppressLint("StaticFieldLeak") +object Glasses { + enum class Permission { NONE, ASKING, DENIED, GRANTED } + + fun interface PoseSink { + fun onPose(x: Float, y: Float, z: Float, w: Float, timeNs: Long) + } + + private const val TAG = "Glasses" + private const val ACTION_PERMISSION = "com.droiddeck.launcher.glasses.USB_PERMISSION" + private const val PREFS = "glasses" + private const val KEY_SBS_FAILED = "sbsFailed" + private const val SBS_CONFIRM_MS = 20_000L + + private val _state = MutableStateFlow(GlassesState()) + val state: StateFlow = _state.asStateFlow() + private val _settings = MutableStateFlow(GlassesSettings()) + val settings: StateFlow = _settings.asStateFlow() + private val _calibration = MutableStateFlow(GlassesCalibrationState()) + val calibration: StateFlow = _calibration.asStateFlow() + private lateinit var calibrationStore: RayNeoCalibrationStore + private var calibrationKey: String? = null + private var calibrationRevision = 0L + + private lateinit var app: Context + private lateinit var handler: Handler + private lateinit var usb: UsbManager + private lateinit var displays: DisplayManager + @Volatile private var link: GlassesLink? = null + @Volatile private var linkGeneration = 0L + private var linkDevice: UsbDevice? = null + private var model: GlassesModel? = null + private var asked = false + private var lastModeSentAt = 0L + private val modeState = GlassesModeState(SBS_CONFIRM_MS) + private val confirmMode = Runnable { refresh() } + private var glassesMode = 0 + private var autoModeSent = false + + @Volatile private var sink: PoseSink? = null + @Volatile private var probe: ((ByteArray?, FloatArray?) -> Unit)? = null + @Volatile private var latest: FloatArray? = null + @Volatile private var imuPackets = 0L + private val simulating = java.util.concurrent.atomic.AtomicReference(null) + private val frames = ArrayDeque() + + fun init(context: Context) { + if (::handler.isInitialized) return + app = context.applicationContext + usb = app.getSystemService(Context.USB_SERVICE) as UsbManager + displays = app.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + _settings.value = load() + calibrationStore = RayNeoCalibrationStore(app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)) + handler = Handler(HandlerThread("droiddeck-glasses").apply { start() }.looper) + handler.post { + val filter = IntentFilter(ACTION_PERMISSION).apply { + addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + } + if (Build.VERSION.SDK_INT >= 33) app.registerReceiver(receiver, filter, null, handler, Context.RECEIVER_NOT_EXPORTED) + else app.registerReceiver(receiver, filter, null, handler) + displays.registerDisplayListener(displayListener, handler) + refresh() + } + } + + fun rescan() = post { refresh() } + + fun startCalibration() = post { + val l = link as? RayNeoLink ?: return@post + if (!l.ready || _calibration.value.running || simulating.get() != null) return@post + _calibration.value = _calibration.value.copy(phase = CalibrationPhase.SETTLING, progress = 0) + l.startCalibration(++calibrationRevision) + updateImu() + } + + fun cancelCalibration() = post { + (link as? RayNeoLink)?.cancelCalibration(++calibrationRevision, calibrationKey?.let(calibrationStore::load)) + _calibration.value = _calibration.value.copy(phase = CalibrationPhase.IDLE, progress = 0) + updateImu() + } + + fun resetCalibration() = post { + (link as? RayNeoLink)?.resetCalibration(++calibrationRevision) + calibrationKey?.let { calibrationStore.save(it, null) } + _calibration.value = _calibration.value.copy(saved = false, phase = CalibrationPhase.IDLE, progress = 0) + updateImu() + } + + fun query() = post { + val l = link ?: return@post + listOf(VitureProtocol.MSG_GET_DISPLAY, VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_IMU).forEachIndexed { i, msg -> + handler.postDelayed({ if (link === l) l.send(msg) }, VitureLink.COMMAND_GAP_MS * i) + } + } + + fun sendMode(mode: Int) = post { + if (!_state.value.canControl) return@post + if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) lastModeSentAt = SystemClock.uptimeMillis() + } + + fun requestControl() = post { + asked = false + refresh(ask = true) + } + + fun setRefreshHz(hz: Int) = post { + update { it.copy(refreshHz = hz.coerceIn(60, 120)) } + if (!_state.value.sbs) applyMode(force = true) + } + + fun setSbsHz(hz: Int) = post { + update { it.copy(sbsHz = if (hz >= 90) 90 else 60) } + if (_state.value.sbs && _state.value.can3d) sendMode(true) + } + + fun set3d(on: Boolean) = post { + val s = _state.value + if (!s.canControl || (on && s.noSignal)) return@post + modeState.manual() + if (on) { + markSbsFailed(linkDevice?.productId, false) + rememberSbs(GlassesState.SBS_UNKNOWN) + } + sendMode(on) + } + + fun set3dForVr(on: Boolean) = post { + val s = _state.value + if (on) { + if (!s.can3d || s.sbs || modeState.pendingStereo) return@post + if (sendMode(true)) modeState.startVr() + } else if (modeState.stopVr() && s.canControl) { + sendMode(false) + } + } + + fun setBrightness(level: Int) = post { + val s = _state.value + if (!s.canControl || s.brightnessMax == 0) return@post + val v = level.coerceIn(0, s.brightnessMax) + if (link?.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(v.toByte(), 0)) == true && link !is XrealLink) + publish { it.copy(brightness = v) } + } + + fun setVolume(level: Int) = post { + val s = _state.value + if (!s.canControl || s.volumeMax == 0) return@post + val v = level.coerceIn(0, s.volumeMax) + if (link?.send(VitureProtocol.MSG_VOLUME, byteArrayOf(v.toByte(), 0)) == true) publish { it.copy(volume = v) } + } + + fun setSunblock(on: Boolean) = post { + update { it.copy(sunblock = on) } + sendFilm(on) + } + + fun setSbsSupport(support: Int) = post { + val s = support.coerceIn(GlassesState.SBS_FAILS, GlassesState.SBS_WORKS) + markSbsFailed(linkDevice?.productId, s == GlassesState.SBS_FAILS) + rememberSbs(s) + } + fun setGameOnGlasses(on: Boolean) = post { update { it.copy(gameOnGlasses = on) } } + fun setHeadTracking(on: Boolean) = post { update { it.copy(headTracking = on) } } + fun setAuto3d(on: Boolean) = post { update { it.copy(auto3d = on) } } + fun setPicture3d(picture: Int) = post { update { it.copy(picture3d = picture.coerceIn(0, 2)) } } + fun setScreenWidthDm(dm: Int) = post { update { it.copy(screenWidthDm = dm.coerceIn(5, 200)) } } + fun setScreenDistanceDm(dm: Int) = post { update { it.copy(screenDistanceDm = dm.coerceIn(5, 200)) } } + fun setVrFovDeg(deg: Int) = post { update { it.copy(vrFovDeg = deg.coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX)) } } + + fun outputHz(): Int = GlassesRendering.outputHz(_settings.value, _state.value) + + fun glassesDisplay(): Display? = _state.value.displayId.takeIf { it != Display.INVALID_DISPLAY }?.let { displays.getDisplay(it) } + + fun setPoseSink(s: PoseSink) { + synchronized(this) { sink = s } + post { updateImu() } + } + + fun clearPoseSink(s: PoseSink) { + synchronized(this) { if (sink === s) sink = null } + post { updateImu() } + } + + fun simulate(seconds: Int, holdYaw: Double? = null, holdPitch: Double? = null): Boolean { + val hold = holdYaw != null || holdPitch != null + val t = Thread({ + val start = SystemClock.elapsedRealtimeNanos() + val end = start + seconds * 1_000_000_000L + while (SystemClock.elapsedRealtimeNanos() < end && simulating.get() === Thread.currentThread()) { + val now = SystemClock.elapsedRealtimeNanos() + val t = (now - start) / 1e9 + val yaw = if (hold) holdYaw ?: 0.0 else 40.0 * sin(t * 2 * Math.PI / 6.0) + val pitch = if (hold) holdPitch ?: 0.0 else 15.0 * sin(t * 2 * Math.PI / 4.0) + val roll = if (hold) 0.0 else 5.0 * sin(t * 2 * Math.PI / 5.0) + val q = VitureProtocol.eulerZxy(roll.toFloat(), pitch.toFloat(), yaw.toFloat()) + deliver(q, now) + SystemClock.sleep(4) + } + simulating.compareAndSet(Thread.currentThread(), null) + }, "glasses-simulate") + if (!simulating.compareAndSet(null, t)) return false + t.start() + return true + } + + fun stopSimulation() { + simulating.set(null) + } + + fun status(): JSONObject { + val s = _state.value + val g = _settings.value + val q = latest + return JSONObject() + .put("present", s.present).put("model", s.model).put("controllable", s.controllable) + .put("permission", s.permission.name).put("connected", s.connected) + .put("controlError", s.controlError ?: JSONObject.NULL) + .put("experimental", s.experimental) + .put("display", if (s.hasDisplay) JSONObject().put("id", s.displayId).put("width", s.width).put("height", s.height).put("hz", s.hz.toDouble()) else JSONObject.NULL) + .put("sbs", s.sbs).put("deviceMode", glassesMode).put("switching3d", s.switching3d) + .put("brightness", s.brightness).put("volume", s.volume) + .put("brightnessMax", s.brightnessMax).put("volumeMax", s.volumeMax).put("sunblock", s.sunblock) + .put("tracking", s.tracking).put("simulating", simulating.get() != null).put("imuPackets", imuPackets) + .put("calibration", JSONObject().put("supported", _calibration.value.supported) + .put("saved", _calibration.value.saved).put("phase", _calibration.value.phase.name) + .put("progress", _calibration.value.progress)) + .put("badFrames", link?.badFrames ?: 0L) + .put("pose", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL) + .put("fovDeg", s.diagonalFovDeg.toDouble()).put("noSignal", s.noSignal).put("sbsSupport", s.sbsSupport) + .put("settings", JSONObject().put("refreshHz", g.refreshHz).put("sbsHz", g.sbsHz).put("sunblock", g.sunblock ?: JSONObject.NULL) + .put("gameOnGlasses", g.gameOnGlasses).put("headTracking", g.headTracking).put("auto3d", g.auto3d) + .put("picture3d", g.picture3d).put("screenWidthDm", g.screenWidthDm).put("screenDistanceDm", g.screenDistanceDm) + .put("vrFovDeg", g.vrFovDeg)) + } + + fun recentFrames(): JSONArray = synchronized(frames) { JSONArray(frames.toList()) } + + fun sendRaw(msgId: Int, payload: ByteArray): Boolean { + val l = link ?: return false + return l.send(msgId, payload) + } + + private fun post(block: () -> Unit) { + if (!::handler.isInitialized) return + handler.post(block) + } + + private fun publish(transform: (GlassesState) -> GlassesState) { + _state.value = transform(_state.value) + } + + private fun update(transform: (GlassesSettings) -> GlassesSettings) { + val next = transform(_settings.value) + _settings.value = next + save(next) + } + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_PERMISSION -> { + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + Log.i(TAG, "USB permission ${if (granted) "granted" else "denied"}") + if (!granted) publish { it.copy(permission = Permission.DENIED) } + refresh() + } + UsbManager.ACTION_USB_DEVICE_ATTACHED -> { + if (intent.device()?.let { isGlassesVendor(it.vendorId) } == true) asked = false + refresh(ask = true) + } + UsbManager.ACTION_USB_DEVICE_DETACHED -> if (intent.device()?.let { isGlassesVendor(it.vendorId) } == true) { + refresh() + } + } + } + } + + private val displayListener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = refresh() + override fun onDisplayRemoved(displayId: Int) = refresh() + override fun onDisplayChanged(displayId: Int) { + if (displayId == _state.value.displayId || displayId != Display.DEFAULT_DISPLAY) refresh() + } + } + + private fun refresh(ask: Boolean = false) { + val devices = usb.deviceList.values.filter { + it.vendorId == VitureProtocol.VENDOR_ID || deviceModel(it) != null + } + val known = devices.mapNotNull { device -> + deviceModel(device)?.let { device to it } + } + val selected = known.firstOrNull { canOpenModel(it.second) } ?: known.firstOrNull() + 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) + } + if (modeState.updateDevice(identity, SystemClock.uptimeMillis())) { + rememberSbs(if (identity != null && sbsFailed(identity.productId)) GlassesState.SBS_FAILS else GlassesState.SBS_UNKNOWN) + } + if (device == null || (linkDevice != null && linkDevice?.deviceName != device.deviceName)) closeLink() + model = m + val display = findDisplay(devices.isNotEmpty()) + val mode = display?.mode + val width = mode?.physicalWidth ?: 0 + val height = mode?.physicalHeight ?: 0 + val sbsByDisplay = width >= 3000 + val noSignal = display != null && width in 1 until 1280 + when (modeState.observe(sbsByDisplay, SystemClock.uptimeMillis())) { + GlassesModeState.Confirmation.FAILED -> { + Log.w(TAG, "3D signal was not confirmed at ${width}x$height; restoring 2D") + if (noSignal) markSbsFailed(device?.productId, true) + rememberSbs(GlassesState.SBS_FAILS) + } + else -> Unit + } + if (sbsByDisplay) { + markSbsFailed(device?.productId, false) + rememberSbs(GlassesState.SBS_WORKS) + } + if (controllable && device != null && !permitted && (ask || !asked) && _state.value.permission != Permission.ASKING) askPermission(device) + if (device != null && permitted && link == null) openLink(device) + publish { + it.copy( + present = device != null || display != null, + model = m?.name ?: display?.let(::displayName) ?: "VITURE", + controllable = controllable, + permission = when { + !controllable -> Permission.NONE + permitted -> Permission.GRANTED + it.permission == Permission.ASKING || it.permission == Permission.DENIED -> it.permission + else -> Permission.NONE + }, + connected = link?.ready == true, + controlError = when { + m is VitureProtocol.Model && m.needsSdk && !VitureSdk.available -> app.getString(R.string.glasses_driver_missing) + device == null || link?.ready == true -> null + else -> it.controlError + }, + displayId = display?.displayId ?: Display.INVALID_DISPLAY, + width = width, height = height, hz = mode?.refreshRate ?: 0f, + sbs = sbsByDisplay, + switching3d = modeState.pendingStereo, + brightnessMax = m?.brightnessMax ?: 8, volumeMax = m?.volumeMax ?: 8, + sunblock = if (m === XrealModel) it.sunblock else _settings.value.sunblock ?: it.sunblock, + hardwareControls = device?.vendorId == VitureProtocol.VENDOR_ID || m === XrealModel, + hasSunblock = m?.hasSunblock == true, + diagonalFovDeg = m?.diagonalFovDeg ?: 46f, + tracking = it.tracking && link != null, + noSignal = noSignal, + sbs90 = m?.sbs90 == true, + experimental = m === XrealModel, + ) + } + if (link != null) applyMode(force = false) + scheduleModeCheck() + } + + internal fun isGlassesVendor(vid: Int) = vid == VitureProtocol.VENDOR_ID || RayNeoModel.isVendor(vid) || vid == XrealModel.VENDOR_ID + + private fun canOpenModel(m: GlassesModel): Boolean = m.controllable && + (m !is VitureProtocol.Model || !m.needsSdk || VitureSdk.available) + + internal fun deviceModel(device: UsbDevice): GlassesModel? { + val name = runCatching { device.productName }.getOrNull() + return if (device.vendorId == VitureProtocol.VENDOR_ID) { + VitureSdk.marketName(device.productId)?.let(VitureProtocol::sdkModel) + ?: VitureProtocol.model(device.productId, name) + } + else if (XrealModel.matches(device.vendorId, device.productId)) XrealModel + else RayNeoModel.model(device.vendorId, device.productId, name) + } + + internal fun isGlassesDisplayName(name: String) = + name.contains("VITURE", ignoreCase = true) || RayNeoModel.isDisplay(name) || XrealModel.isDisplay(name) + + private fun findDisplay(usbPresent: Boolean): Display? { + return selectGlassesDisplay(displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).toList(), usbPresent) + } + + internal fun selectGlassesDisplay(displays: List, 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 -> + product.name?.let(::isGlassesDisplayName) == true || + RayNeoModel.isDisplayProduct(product.name, product.manufacturerPnpId, product.productId) + } == true) + } + ?: candidates.singleOrNull()?.takeIf { usbPresent && unnamedPanel(it) } + } + + private fun unnamedPanel(d: Display): Boolean { + if (Build.VERSION.SDK_INT < 31) return true + return d.deviceProductInfo?.name.isNullOrBlank() + } + + private fun displayName(d: Display): String? { + if (Build.VERSION.SDK_INT >= 31) d.deviceProductInfo?.name?.takeIf { it.isNotBlank() }?.let { return it } + return d.name + } + + private fun askPermission(device: UsbDevice) { + asked = true + publish { it.copy(permission = Permission.ASKING) } + val flags = if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0 + val pi = PendingIntent.getBroadcast(app, 0, Intent(ACTION_PERMISSION).setPackage(app.packageName), flags) + runCatching { usb.requestPermission(device, pi) }.onFailure { Log.w(TAG, "requestPermission", it) } + } + + private fun openLink(device: UsbDevice) { + val generation = ++linkGeneration + calibrationKey = calibrationStore.key(device.vendorId, device.productId, + runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty()) + val l = if (device.vendorId == VitureProtocol.VENDOR_ID) { + if ((model as? VitureProtocol.Model)?.needsSdk == true) { + VitureSdkLink.open(usb, device, File(app.cacheDir, "viture"), sdkListener(generation), + (model as? VitureProtocol.Model)?.family) + } else VitureLink.open(usb, device, linkListener(generation)) + } else if (model === XrealModel) { + XrealLink.open(app, xrealListener(generation)) + } else { + val baseKey = calibrationKey!! + val gtUsb = RayNeoProtocol.isGt(device.vendorId, device.productId) + RayNeoLink.open(usb, device, rayNeoListener(generation)) { board -> + calibrationStore.load(RayNeoCalibrationStore.profileKey(baseKey, gtUsb, board)) + } + } + val opened = l ?: run { + linkGeneration++ + Log.w(TAG, "could not open ${device.deviceName}") + publish { it.copy(controlError = app.getString(R.string.glasses_connection_failed)) } + return + } + link = opened + _calibration.value = GlassesCalibrationState(supported = opened is RayNeoLink, + saved = opened is RayNeoLink && calibrationKey?.let(calibrationStore::load) != null) + linkDevice = device + glassesMode = 0 + autoModeSent = false + Log.i(TAG, "opened ${model?.name} pid=0x%04X imu=%s".format(device.productId, opened.hasImu)) + publish { it.copy(connected = opened.ready, controlError = null) } + listOf(VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_GET_DISPLAY).forEachIndexed { i, msg -> + handler.postDelayed({ if (link === opened) opened.send(msg) }, VitureLink.COMMAND_GAP_MS * (i + 1)) + } + handler.postDelayed({ + if (link !== opened) return@postDelayed + _settings.value.sunblock?.let { sendFilm(it) } + updateImu() + applyMode(force = false) + }, 300) + } + + private fun xrealListener(generation: Long) = object : XrealLink.Listener { + override fun onReady() = post { + if (generation != linkGeneration) return@post + publish { it.copy(connected = true, controlError = null) } + autoModeSent = false + updateImu() + applyMode(force = false) + } + override fun onValue(message: Int, value: Int) = post { + if (generation != linkGeneration) return@post + when (message) { + VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value + VitureProtocol.MSG_BRIGHTNESS -> publish { it.copy(brightness = value) } + VitureProtocol.MSG_FILM -> publish { it.copy(sunblock = value != 0) } + } + } + override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(raw, pose) + if (simulating.get() == null) deliver(pose, timeNs) + } + override fun onUnavailable(networkMissing: Boolean) = post { + if (generation != linkGeneration) return@post + publish { it.copy(connected = false, tracking = false, + controlError = app.getString(if (networkMissing) R.string.glasses_xreal_network_missing else R.string.glasses_xreal_connection_failed)) } + } + } + + private fun sdkListener(generation: Long) = object : VitureSdkLink.Listener { + override fun onValue(msgId: Int, value: Int) = post { + if (generation != linkGeneration) return@post + when (msgId) { + VitureProtocol.MSG_GET_DISPLAY -> glassesMode = value + VitureProtocol.MSG_GET_BRIGHTNESS -> publish { it.copy(brightness = value) } + VitureProtocol.MSG_GET_VOLUME -> publish { it.copy(volume = value) } + VitureProtocol.MSG_FILM -> publish { it.copy(sunblock = value != 0) } + } + } + override fun onPose(q: FloatArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(null, q) + if (simulating.get() == null) deliver(q, timeNs) + } + override fun onLost() = post { + 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. + } + } + + private fun rayNeoListener(generation: Long) = object : RayNeoLink.Listener { + override fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) = post { + if (generation != linkGeneration) return@post + val device = linkDevice ?: return@post + val base = calibrationStore.key(device.vendorId, device.productId, + runCatching { device.serialNumber }.getOrNull(), model?.name.orEmpty()) + calibrationKey = RayNeoCalibrationStore.profileKey(base, + RayNeoProtocol.isGt(device.vendorId, device.productId), boardId) + _calibration.value = _calibration.value.copy(saved = calibrationSaved) + } + override fun onCalibration(revision: Long, phase: CalibrationPhase, progress: Int) = post { + if (generation != linkGeneration || revision != calibrationRevision) return@post + if (phase != CalibrationPhase.IDLE && !_calibration.value.running) return@post + _calibration.value = _calibration.value.copy(phase = phase, progress = progress) + if (!_calibration.value.running) updateImu() + } + override fun onCalibrationSaved(revision: Long, value: RayNeoCalibration?) = post { + if (generation != linkGeneration || revision != calibrationRevision) return@post + if (value != null && !_calibration.value.running) return@post + calibrationKey?.let { calibrationStore.save(it, value) } + _calibration.value = _calibration.value.copy(saved = value != null) + } + override fun onReady() = post { + if (generation == linkGeneration) { + publish { it.copy(connected = true) } + updateImu() + applyMode(force = false) + } + } + override fun onPose(raw: ByteArray, xyzw: FloatArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(raw, xyzw) + if (simulating.get() == null) deliver(xyzw, timeNs) + } + override fun onLost() = post { + if (generation != linkGeneration) return@post + closeLink() + refresh() + } + } + + private fun closeLink() { + val l = link ?: return + linkGeneration++ + link = null + linkDevice = null + calibrationKey = null + _calibration.value = GlassesCalibrationState(phase = if (_calibration.value.running) CalibrationPhase.FAILED else CalibrationPhase.IDLE) + glassesMode = 0 + l.close() + publish { it.copy(connected = false, tracking = false) } + Log.i(TAG, "link closed") + } + + private fun rememberSbs(support: Int) { + if (_state.value.sbsSupport == support) return + publish { it.copy(sbsSupport = support) } + } + + private fun sbsFailed(productId: Int): Boolean = + app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getStringSet(KEY_SBS_FAILED, null)?.contains(productId.toString(16)) == true + + private fun markSbsFailed(productId: Int?, failed: Boolean) { + if (productId == null || sbsFailed(productId) == failed) return + val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val ids = prefs.getStringSet(KEY_SBS_FAILED, null).orEmpty() + val id = productId.toString(16) + prefs.edit().putStringSet(KEY_SBS_FAILED, if (failed) ids + id else ids - id).apply() + } + + private fun applyMode(force: Boolean) { + val s = _state.value + val m = model ?: return + if (!s.canControl) return + if (modeState.restorePending) { + sendMode(false) + return + } + if (!s.usable || s.sbs || modeState.pendingStereo) return + val hz = _settings.value.refreshHz + if (glassesMode == m.mode(hz, false)) return + if (!force) { + if (autoModeSent || SystemClock.uptimeMillis() - lastModeSentAt < 8_000) return + if (glassesMode == 0 && abs(s.hz - hz) < 1.5f) return + autoModeSent = true + } + sendMode(false) + } + + private fun sendMode(sbs: Boolean): Boolean { + val m = model ?: return false + val g = _settings.value + val mode = m.mode(if (sbs) g.sbsHz else g.refreshHz, sbs) + if (link?.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(mode.toByte())) == true) { + if (link is RayNeoLink) glassesMode = mode + lastModeSentAt = SystemClock.uptimeMillis() + modeState.sent(sbs, lastModeSentAt) + publish { it.copy(switching3d = modeState.pendingStereo) } + scheduleModeCheck() + return true + } + return false + } + + private fun scheduleModeCheck() { + handler.removeCallbacks(confirmMode) + modeState.nextCheckAt?.let { at -> + handler.postDelayed(confirmMode, (at - SystemClock.uptimeMillis()).coerceAtLeast(0)) + } + } + + private fun sendFilm(on: Boolean) { + if (!_state.value.canControl || !_state.value.hardwareControls || !_state.value.hasSunblock) return + if (link?.send(VitureProtocol.MSG_FILM, byteArrayOf(if (on) 1 else 0)) == true && link !is XrealLink) + publish { it.copy(sunblock = on) } + } + + private fun updateImu() { + val l = link ?: return + if (!l.ready) return + val want = (sink != null || probe != null || _calibration.value.running) && l.hasImu + if (want == _state.value.tracking) return + val accepted = l.send(VitureProtocol.MSG_IMU, byteArrayOf(if (want) 1 else 0)) + if (want && !accepted) return + if (want) l.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(VitureProtocol.IMU_RATE_240.toByte())) + publish { it.copy(tracking = want) } + } + + private fun linkListener(generation: Long) = object : VitureLink.Listener { + override fun onFrame(frame: VitureProtocol.Frame) = post { + if (generation != linkGeneration) return@post + val line = "%s 0x%04X %s".format( + when (frame.magic) { VitureProtocol.MAGIC_REPORT -> "FD"; VitureProtocol.MAGIC_COMMAND -> "FE"; else -> "FC" }, + frame.msgId, frame.payload.joinToString("") { "%02X".format(it) }, + ) + synchronized(frames) { + frames.addLast(line) + while (frames.size > 64) frames.removeFirst() + } + Log.i(TAG, "frame $line") + onReport(frame) + } + + override fun onImu(payload: ByteArray, timeNs: Long) { + if (generation != linkGeneration) return + imuPackets++ + probe?.invoke(payload, null) + if (simulating.get() != null) return + VitureProtocol.imuQuaternion(payload)?.let { deliver(it, timeNs) } + } + + override fun onLost() = post { + if (generation != linkGeneration) return@post + closeLink() + refresh() + } + } + + private fun deliver(q: FloatArray, timeNs: Long) { + latest = q + sink?.onPose(q[0], q[1], q[2], q[3], timeNs) + } + + private fun onReport(frame: VitureProtocol.Frame) { + val p = frame.payload + val event = p.firstOrNull()?.toInt()?.and(0xFF) ?: return + val answer = if (p.size >= 2 && event == 0) p[1].toInt() and 0xFF else null + when (frame.msgId) { + VitureProtocol.EVENT_DISPLAY -> { + Log.i(TAG, "glasses switched to mode 0x%02X".format(event)) + glassesMode = event + } + VitureProtocol.EVENT_DISPLAY_STEP -> model?.modeForStep(event)?.let { mode -> + Log.i(TAG, "glasses report mode 0x%02X".format(mode)) + glassesMode = mode + } + VitureProtocol.EVENT_BRIGHTNESS -> publish { it.copy(brightness = event) } + VitureProtocol.EVENT_VOLUME -> publish { it.copy(volume = event) } + VitureProtocol.MSG_GET_BRIGHTNESS, VitureProtocol.MSG_BRIGHTNESS -> answer?.let { v -> publish { it.copy(brightness = v) } } + VitureProtocol.MSG_GET_VOLUME, VitureProtocol.MSG_VOLUME -> answer?.let { v -> publish { it.copy(volume = v) } } + VitureProtocol.MSG_DISPLAY -> if (event != 0) { + Log.w(TAG, "glasses refused display mode 0x%02X".format(p.getOrNull(1)?.toInt()?.and(0xFF) ?: 0)) + modeState.cancelPending() + publish { it.copy(switching3d = false) } + scheduleModeCheck() + } else answer?.let { glassesMode = it } + VitureProtocol.MSG_GET_DISPLAY -> answer?.let { v -> + glassesMode = v + } + } + } + + fun sampleImu(ms: Long): JSONObject { + val raw = java.util.Collections.synchronizedList(mutableListOf>()) + val counter = java.util.concurrent.atomic.AtomicInteger() + val began = SystemClock.elapsedRealtime() + probe = { payload, pose -> val n = counter.incrementAndGet(); if (raw.size < 4 || n % 60 == 0) raw.add(payload?.copyOf() to pose?.copyOf()) } + post { updateImu() } + SystemClock.sleep(ms) + probe = null + post { updateImu() } + val seconds = (SystemClock.elapsedRealtime() - began) / 1000.0 + val count = counter.get() + val samples = JSONArray() + synchronized(raw) { + raw.forEach { (b, pose) -> + val rayneo = b != null && RayNeoProtocol.classifyPacket(b) is RayNeoPacket.Sensor + val q = pose ?: if (b == null || rayneo) null else VitureProtocol.imuQuaternion(b) + samples.put(JSONObject() + .put("hex", b?.joinToString("") { "%02X".format(it) } ?: JSONObject.NULL) + .put("euler", if (b == null || b.size < 12 || rayneo || pose != null) JSONObject.NULL else JSONArray().put(VitureProtocol.beFloat(b, 0).toDouble()).put(VitureProtocol.beFloat(b, 4).toDouble()).put(VitureProtocol.beFloat(b, 8).toDouble())) + .put("quat", q?.let { JSONArray().put(it[0].toDouble()).put(it[1].toDouble()).put(it[2].toDouble()).put(it[3].toDouble()) } ?: JSONObject.NULL)) + } + } + return JSONObject().put("packets", count).put("rateHz", count / seconds).put("samples", samples) + } + + private fun Intent.device(): UsbDevice? = + if (Build.VERSION.SDK_INT >= 33) getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + else @Suppress("DEPRECATION") getParcelableExtra(UsbManager.EXTRA_DEVICE) + + private fun load(): GlassesSettings { + val p = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return GlassesSettings( + refreshHz = p.getInt("refreshHz", 120), + sbsHz = p.getInt("sbsHz", 60), + sunblock = if (p.contains("sunblock")) p.getBoolean("sunblock", false) else null, + gameOnGlasses = p.getBoolean("gameOnGlasses", true), + headTracking = p.getBoolean("headTracking", true), + auto3d = p.getBoolean("auto3d", GlassesSettings().auto3d), + picture3d = p.getInt("picture3d", GlassesSettings.PICTURE_AUTO), + screenWidthDm = p.getInt("screenWidthDm", 20), + screenDistanceDm = p.getInt("screenDistanceDm", 30), + vrFovDeg = p.getInt("vrFovDeg", GlassesSettings.VR_FOV_DEFAULT).coerceIn(GlassesSettings.VR_FOV_MIN, GlassesSettings.VR_FOV_MAX), + ) + } + + private fun save(s: GlassesSettings) { + app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().apply { + putInt("refreshHz", s.refreshHz) + putInt("sbsHz", s.sbsHz) + s.sunblock?.let { putBoolean("sunblock", it) } ?: remove("sunblock") + putBoolean("gameOnGlasses", s.gameOnGlasses) + putBoolean("headTracking", s.headTracking) + putBoolean("auto3d", s.auto3d) + putInt("picture3d", s.picture3d) + putInt("screenWidthDm", s.screenWidthDm) + putInt("screenDistanceDm", s.screenDistanceDm) + putInt("vrFovDeg", s.vrFovDeg) + }.apply() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt new file mode 100644 index 000000000..708520c38 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesHardware.kt @@ -0,0 +1,25 @@ +package com.droiddeck.launcher.glasses + +/** Hardware properties; display refresh is selected from Android's advertised modes. */ +interface GlassesModel { + val name: String + val controllable: Boolean + val brightnessMax: Int + val volumeMax: Int + val diagonalFovDeg: Float + val sbs90: Boolean + val hasSunblock: Boolean get() = true + fun mode(hz: Int, sbs: Boolean): Int + fun modeForStep(step: Int): Int? +} + +/** The command IDs here are the existing glasses controller's internal vocabulary. + * Each transport translates them to its own wire protocol; unsupported controls return false. + */ +internal interface GlassesLink { + val hasImu: Boolean + val badFrames: Long get() = 0L + val ready: Boolean get() = true + fun send(msgId: Int, payload: ByteArray = ByteArray(0)): Boolean + fun close() +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt new file mode 100644 index 000000000..2bf61b813 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesModeState.kt @@ -0,0 +1,84 @@ +package com.droiddeck.launcher.glasses + +internal class GlassesModeState(private val timeoutMs: Long) { + enum class Confirmation { NONE, WAITING, CONFIRMED, FAILED } + data class Device(val productId: Int, val serial: String?) + + private var deadline: Long? = null + private var device: Device? = null + private var attached = false + private var reconnectDeadline: Long? = null + private var vrOwned = false + var restorePending = false + private set + val pendingStereo: Boolean get() = deadline != null + val nextCheckAt: Long? get() = listOfNotNull(deadline, reconnectDeadline).minOrNull() + + fun updateDevice(current: Device?, now: Long): Boolean { + if (reconnectDeadline?.let { now >= it } == true) abandon() + if (current == null) { + if (attached) reconnectDeadline = now + timeoutMs + attached = false + return false + } + val previous = device + val changed = previous == null || previous.productId != current.productId || + (previous.serial != null && current.serial != null && previous.serial != current.serial) + if (changed) abandon() + device = if (!changed && current.serial == null) previous else current + attached = true + reconnectDeadline = null + return changed + } + + private fun abandon() { + deadline = null + reconnectDeadline = null + vrOwned = false + restorePending = false + } + + fun manual() { + vrOwned = false + restorePending = false + } + + fun startVr() { + vrOwned = true + restorePending = false + } + + fun stopVr(): Boolean { + if (vrOwned) { + vrOwned = false + restorePending = true + } + return restorePending + } + + fun sent(stereo: Boolean, now: Long) { + deadline = if (stereo) now + timeoutMs else null + if (!stereo) restorePending = false + } + + fun cancelPending() { + deadline = null + } + + fun observe(stereo: Boolean, now: Long): Confirmation { + val until = deadline ?: return Confirmation.NONE + if (stereo) { + deadline = null + return Confirmation.CONFIRMED + } + if (now < until) return Confirmation.WAITING + if (!attached) { + abandon() + return Confirmation.NONE + } + deadline = null + vrOwned = false + restorePending = true + return Confirmation.FAILED + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt new file mode 100644 index 000000000..4edc730b2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesRendering.kt @@ -0,0 +1,67 @@ +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, VIRTUAL_SCREEN, STEREO } + + fun outputHz(settings: GlassesSettings, state: GlassesState): Int = + if (state.sbs) if (state.sbs90) settings.sbsHz else 60 else settings.refreshHz + + fun mirrorRefreshLimit(settings: GlassesSettings, state: GlassesState, outputActive: Boolean): Int = + if (state.usable && !outputActive) outputHz(settings, state) else 0 + + fun fpsLimit(requested: Int, mirrorRefreshLimit: Int): Int = when { + mirrorRefreshLimit <= 0 -> requested + requested <= 0 -> mirrorRefreshLimit + else -> minOf(requested, mirrorRefreshLimit) + } + + fun usesGlasses(state: GlassesState, outputActive: Boolean, steamVrRunning: Boolean): Boolean = + outputActive || (steamVrRunning && state.usable) + + fun picture(settings: GlassesSettings, stereoDisplay: Boolean, steamVrRunning: Boolean): Picture = + when (settings.picture3d) { + GlassesSettings.PICTURE_SCREEN -> Picture.VIRTUAL_SCREEN + GlassesSettings.PICTURE_SBS -> Picture.STEREO + else -> when { + steamVrRunning -> Picture.STEREO + stereoDisplay -> Picture.VIRTUAL_SCREEN + else -> Picture.FLAT + } + } + + fun cardboard( + base: Cardboard, + settings: GlassesSettings, + state: GlassesState, + 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, + viewer = Cardboard.Viewer.NONE.id, + mono = true, + ) else base + } + val picture = picture(settings, state.sbs, steamVrRunning) + if (picture == Picture.FLAT) return Cardboard.OFF + return Cardboard( + enabled = true, + source = if (picture == Picture.STEREO) Cardboard.SOURCE_SBS else Cardboard.SOURCE_SCREEN, + viewer = Cardboard.Viewer.VITURE.id, + ipdMm = base.ipdMm, + screenWidthDm = settings.screenWidthDm, + screenDistanceDm = settings.screenDistanceDm, + headTracking = settings.headTracking && state.canControl, + mono = !state.sbs, + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt new file mode 100644 index 000000000..ae14cfb56 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/GlassesScreen.kt @@ -0,0 +1,51 @@ +package com.droiddeck.launcher.glasses + +import android.app.Presentation +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.Display +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import kotlin.math.abs + +class GlassesScreen(context: Context, display: Display, refreshHz: Int, private val callback: SurfaceHolder.Callback) : Presentation(context, display) { + val surfaceView = SurfaceView(getContext()) + val holder: SurfaceHolder get() = surfaceView.holder + private var wantHz = refreshHz + + val hz: Float get() = display.mode.refreshRate + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val root = FrameLayout(context).apply { setBackgroundColor(Color.BLACK) } + root.addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + setContentView(root) + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + applyMode() + surfaceView.holder.addCallback(callback) + } + + fun prefer(hz: Int) { + wantHz = hz + // A 2D/SBS transition can replace the active mode without changing Hz. + applyMode() + } + + private fun applyMode() { + val w = window ?: return + val current = display.mode + val best = display.supportedModes.filter { + it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight + }.minByOrNull { abs(it.refreshRate - wantHz) } ?: return + if (w.attributes.preferredDisplayModeId != best.modeId) { + Log.i("GlassesScreen", "display ${display.displayId}: prefer mode ${best.modeId} " + + "${best.physicalWidth}x${best.physicalHeight}@${best.refreshRate} (requested $wantHz Hz)") + w.attributes = w.attributes.apply { preferredDisplayModeId = best.modeId } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt new file mode 100644 index 000000000..8e579aee3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoCalibration.kt @@ -0,0 +1,105 @@ +package com.droiddeck.launcher.glasses + +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +enum class CalibrationPhase { IDLE, SETTLING, COLLECTING, MOVING, TURN_OVER, SAVED, FAILED } + +data class GlassesCalibrationState( + val supported: Boolean = false, + val saved: Boolean = false, + val phase: CalibrationPhase = CalibrationPhase.IDLE, + val progress: Int = 0, +) { + val running: Boolean get() = phase in setOf(CalibrationPhase.SETTLING, CalibrationPhase.COLLECTING, + CalibrationPhase.MOVING, CalibrationPhase.TURN_OVER) +} + +internal data class RayNeoCalibration(val gyroBiasDps: Vec3, val level: Quaternion) { + fun encode(): String = JSONObject().put("version", 1) + .put("bias", JSONArray(listOf(gyroBiasDps.x, gyroBiasDps.y, gyroBiasDps.z))) + .put("level", JSONArray(listOf(level.w, level.x, level.y, level.z))).toString() + + companion object { + fun decode(json: String?): RayNeoCalibration? = runCatching { + val obj = JSONObject(json ?: return null) + require(obj.getInt("version") == 1) + val b = obj.getJSONArray("bias"); val q = obj.getJSONArray("level") + require(b.length() == 3 && q.length() == 4) + val bias = Vec3(b.getDouble(0).toFloat(), b.getDouble(1).toFloat(), b.getDouble(2).toFloat()) + val level = Quaternion(q.getDouble(0).toFloat(), q.getDouble(1).toFloat(), q.getDouble(2).toFloat(), q.getDouble(3).toFloat()) + require(listOf(bias.x, bias.y, bias.z, level.w, level.x, level.y, level.z).all { it.isFinite() }) + require(bias.length() <= 6f && level.w in 0.96f..1.001f) + val norm = level.w * level.w + level.x * level.x + level.y * level.y + level.z * level.z + require(norm in 0.99f..1.01f) + RayNeoCalibration(bias, level.normalized()) + }.getOrNull() + } +} + +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)) + fun save(key: String, value: RayNeoCalibration?) { + if (value == null) prefs.edit().remove(key).apply() + else prefs.edit().putString(key, value.encode()).apply() + } +} + +/** 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 + private var windowStart = -1L + private var lastMs = -1L + private var count = 0 + private var meanA = Vec3(0f, 0f, 0f) + private var meanG = Vec3(0f, 0f, 0f) + private var aVariance = 0f + private var gVariance = 0f + + private fun clear(next: CalibrationPhase) { + phase = next; progress = 0; windowStart = -1; count = 0 + meanA = Vec3(0f, 0f, 0f); meanG = meanA; aVariance = 0f; gVariance = 0f + } + + fun update(sample: RayNeoSensorSample, nowMs: Long): RayNeoCalibration? { + if (phase == CalibrationPhase.FAILED || phase == CalibrationPhase.SAVED) return null + if (nowMs - beganMs >= 30_000) { clear(CalibrationPhase.FAILED); return null } + val gap = lastMs >= 0 && nowMs - lastMs > 250 + lastMs = nowMs + if (nowMs - beganMs < 3000) return null + val a = axes.map(Vec3(sample.accelMps2[0], sample.accelMps2[1], sample.accelMps2[2])) + val g = Vec3(sample.gyroDps[0], sample.gyroDps[1], sample.gyroDps[2]) + if (a.length() !in 8.5f..11f || !g.length().isFinite() || g.length() > 6f || gap) { + clear(CalibrationPhase.MOVING); return null + } + if (a.normalized().y > -0.94f) { clear(CalibrationPhase.TURN_OVER); return null } + if (windowStart < 0) windowStart = nowMs + phase = CalibrationPhase.COLLECTING + count++ + val da = a - meanA; val dg = g - meanG + meanA += da * (1f / count); meanG += dg * (1f / count) + fun dot(x: Vec3, y: Vec3) = x.x * y.x + x.y * y.y + x.z * y.z + aVariance += dot(da, a - meanA); gVariance += dot(dg, g - meanG) + if (count > 25 && (aVariance / count > 0.04f || gVariance / count > 2.25f)) { + clear(CalibrationPhase.MOVING); return null + } + progress = ((nowMs - windowStart) * 100 / 5000).toInt().coerceIn(0, 99) + if (nowMs - windowStart < 5000 || count < 200) return null + val down = meanA.normalized() + val axis = down.cross(Vec3(0f, -1f, 0f)) + val result = RayNeoCalibration(meanG, Quaternion(1f - down.y, axis.x, axis.y, axis.z).normalized()) + phase = CalibrationPhase.SAVED; progress = 100 + return result + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt new file mode 100644 index 000000000..c9770ed22 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoLink.kt @@ -0,0 +1,202 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +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, + private val listener: Listener, + private val gt: Boolean, + private val loadCalibration: (Int) -> RayNeoCalibration?, +) : GlassesLink { + interface Listener { + fun onReady() + fun onPose(raw: ByteArray, xyzw: FloatArray, timeNs: Long) + fun onLost() + fun onCalibration(revision: Long, phase: CalibrationPhase, progress: Int) {} + fun onCalibrationSaved(revision: Long, value: RayNeoCalibration?) {} + fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) {} + } + + @Volatile private var running = true + @Volatile override var ready = false + private set + @Volatile private var wantImu = false + private val writeLock = Any() + private val assembler = RayNeoPacketAssembler() + private val readBuffer = ByteArray(maxOf(endpoints.inEndpoint.maxPacketSize, 64)) + private var failures = 0 + private lateinit var worker: Thread + private var calibration: RayNeoCalibration? = null + private data class CalibrationCommand(val revision: Long, val action: Int, val saved: RayNeoCalibration? = null) + private val calibrationCommand = java.util.concurrent.atomic.AtomicReference(null) + fun startCalibration(revision: Long) { calibrationCommand.set(CalibrationCommand(revision, 1)) } + fun cancelCalibration(revision: Long, saved: RayNeoCalibration?) { calibrationCommand.set(CalibrationCommand(revision, 2, saved)) } + fun resetCalibration(revision: Long) { calibrationCommand.set(CalibrationCommand(revision, 3)) } + override val hasImu = true + + override fun send(msgId: Int, payload: ByteArray): Boolean { + if (!running) return false + return when (msgId) { + VitureProtocol.MSG_IMU -> { + wantImu = payload.firstOrNull() == 1.toByte() + true + } + VitureProtocol.MSG_DISPLAY -> { + val mode = payload.firstOrNull()?.toInt()?.and(0xff) + 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 + } + } + + private fun command(cmd: Int): Boolean = synchronized(writeLock) { + if (!running) return false + val packet = RayNeoProtocol.buildCommandPacket(cmd, 0, endpoints.outEndpoint.maxPacketSize) + connection.bulkTransfer(endpoints.outEndpoint, packet, packet.size, 250) == packet.size + } + + private fun read(timeoutMs: Int): ByteArray? { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (running && SystemClock.elapsedRealtime() < deadline) { + assembler.nextPacket()?.let { return it } + val began = SystemClock.elapsedRealtime() + val count = connection.bulkTransfer(endpoints.inEndpoint, readBuffer, readBuffer.size, 50) + if (count > 0) { + failures = 0 + assembler.append(readBuffer, count) + } else if (count < 0 && SystemClock.elapsedRealtime() - began < 25) { + if (++failures >= 50) error("RayNeo USB link lost") + SystemClock.sleep(5) + } + } + return null + } + + private fun response(cmd: Int, timeoutMs: Int): RayNeoPacket.Response? { + val deadline = SystemClock.elapsedRealtime() + timeoutMs + while (running && SystemClock.elapsedRealtime() < deadline) { + val packet = read(100)?.let(RayNeoProtocol::classifyPacket) + if (packet is RayNeoPacket.Response && packet.cmd == cmd) return packet + } + return null + } + + private fun run() { + var streaming = false + try { + check(command(RayNeoProtocol.CMD_ACQUIRE_DEVICE_INFO)) { "RayNeo device-info write failed" } + val info = response(RayNeoProtocol.CMD_ACQUIRE_DEVICE_INFO, 2500) + ?.let { RayNeoProtocol.parseDeviceInfo(it.raw) } ?: error("RayNeo device-info timed out") + val profile = RayNeoSensorProfile.forDevice(gt, info.boardId) + val rotation = profile.rotationXDeg + val gtMaxAxes = profile.gtMaxAxes + calibration = loadCalibration(info.boardId) + listener.onDeviceInfo(info.boardId, calibration != null) + Log.i("RayNeoLink", "Device board=0x%02x gt=%s".format(info.boardId, gt)) + var filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration) + var collector: RayNeoCalibrationCollector? = null + var calibrationRevision = 0L + var lastCalibrationUpdate = 0L + var warmupUntil = 0L + var lastSensor = 0L + ready = true + listener.onReady() + while (running) { + val request = calibrationCommand.getAndSet(null) + if (request != null) calibrationRevision = request.revision + when (request?.action) { + 1 -> collector = RayNeoCalibrationCollector(RayNeoSensorAxes(rotation, gtMaxAxes), SystemClock.elapsedRealtime()) + 2 -> { + collector = null + if (calibration != request.saved) { + calibration = request.saved + filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration) + } + listener.onCalibration(calibrationRevision, CalibrationPhase.IDLE, 0) + } + 3 -> { + collector = null; calibration = null + filter = RayNeoOrientationFilter(rotation, gtMaxAxes) + listener.onCalibrationSaved(calibrationRevision, null) + listener.onCalibration(calibrationRevision, CalibrationPhase.IDLE, 0) + } + } + if (wantImu != streaming) { + val cmd = if (wantImu) RayNeoProtocol.CMD_OPEN_IMU else RayNeoProtocol.CMD_CLOSE_IMU + check(command(cmd) && response(cmd, 1500) != null) { "RayNeo IMU command timed out" } + streaming = cmd == RayNeoProtocol.CMD_OPEN_IMU + filter = RayNeoOrientationFilter(rotation, gtMaxAxes, calibration) + lastSensor = SystemClock.elapsedRealtime() + warmupUntil = lastSensor + 2000 + } + val raw = read(100) + if (streaming && SystemClock.elapsedRealtime() - lastSensor > 4000) error("RayNeo IMU stream stalled") + if (raw == null) continue + val packet = RayNeoProtocol.classifyPacket(raw) + if (streaming && packet is RayNeoPacket.Sensor) { + lastSensor = SystemClock.elapsedRealtime() + collector?.let { c -> + val result = c.update(packet.sample, lastSensor) + if (result != null && calibrationCommand.get() == null) { + calibration = result + filter = RayNeoOrientationFilter(rotation, gtMaxAxes, result) + warmupUntil = lastSensor + 2000 + listener.onCalibrationSaved(calibrationRevision, result) + } + if (lastSensor - lastCalibrationUpdate >= 200 || result != null || c.phase == CalibrationPhase.FAILED) { + listener.onCalibration(calibrationRevision, c.phase, c.progress) + lastCalibrationUpdate = lastSensor + } + if (result != null || c.phase == CalibrationPhase.FAILED) collector = null + } + 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()) + } + } + } + } catch (e: Exception) { + if (running) Log.w("RayNeoLink", "RayNeo stream failed", e) + } finally { + ready = false + if (running) listener.onLost() + } + } + + 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 + } + if (Thread.currentThread() !== worker) worker.join(500) + runCatching { connection.releaseInterface(endpoints.usbInterface) } + connection.close() + } + + companion object { + fun open(usb: UsbManager, device: UsbDevice, listener: Listener, loadCalibration: (Int) -> RayNeoCalibration? = { null }): RayNeoLink? { + val endpoints = RayNeoProtocol.selectEndpoints(device) ?: return null + val conn = usb.openDevice(device) ?: return null + if (!conn.claimInterface(endpoints.usbInterface, true)) { + conn.close() + return null + } + return RayNeoLink(conn, endpoints, listener, RayNeoProtocol.isGt(device.vendorId, device.productId), loadCalibration).also { link -> + link.worker = Thread({ link.run() }, "rayneo-imu").apply { isDaemon = true } + link.worker.start() + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt new file mode 100644 index 000000000..ccb19f744 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoModel.kt @@ -0,0 +1,42 @@ +package com.droiddeck.launcher.glasses + +object RayNeoModel { + private val modelName = Regex("(?i)(ray\\s*neo|tcl).*\\b(air|gt)(?=\\b|[0-9])") + + fun isVendor(vid: Int) = vid == RayNeoProtocol.VENDOR_ID || + vid == RayNeoProtocol.GT_VENDOR_ID || vid == 0x22d9 + + 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) && + !gt && + !modelName.containsMatchIn(name.orEmpty())) return null + return Model(name?.trim()?.takeIf { modelName.containsMatchIn(it) } + ?: if (gt) "RayNeo GT series" else "RayNeo Air") + } + + fun isDisplay(name: String): Boolean = + 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" + + private class Model(override val name: String) : GlassesModel { + 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 sbs90 = false + override fun mode(hz: Int, sbs: Boolean) = + if (sbs) RayNeoProtocol.CMD_SWITCH_TO_3D else RayNeoProtocol.CMD_SWITCH_TO_2D + override fun modeForStep(step: Int): Int? = null + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt new file mode 100644 index 000000000..96bff8500 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilter.kt @@ -0,0 +1,190 @@ +// 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. +package com.droiddeck.launcher.glasses + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +internal data class Vec3(val x: Float, val y: Float, val z: Float) { + fun length(): Float = sqrt(x * x + y * y + z * z) + + fun normalized(): Vec3 { + val n = length() + if (n < 1e-6f) { + return Vec3(0f, 0f, 0f) + } + return Vec3(x / n, y / n, z / n) + } + + fun cross(other: Vec3): Vec3 { + return Vec3( + y * other.z - z * other.y, + z * other.x - x * other.z, + x * other.y - y * other.x + ) + } + + operator fun plus(other: Vec3): Vec3 = Vec3(x + other.x, y + other.y, z + other.z) + operator fun minus(other: Vec3): Vec3 = Vec3(x - other.x, y - other.y, z - other.z) + operator fun times(scale: Float): Vec3 = Vec3(x * scale, y * scale, z * scale) +} + +internal data class Quaternion(val w: Float, val x: Float, val y: Float, val z: Float) { + operator fun times(other: Quaternion): Quaternion { + return Quaternion( + w = w * other.w - x * other.x - y * other.y - z * other.z, + x = w * other.x + x * other.w + y * other.z - z * other.y, + y = w * other.y - x * other.z + y * other.w + z * other.x, + z = w * other.z + x * other.y - y * other.x + z * other.w + ) + } + + operator fun times(scale: Float): Quaternion { + return Quaternion(w * scale, x * scale, y * scale, z * scale) + } + + fun normalized(): Quaternion { + val n = sqrt(w * w + x * x + y * y + z * z) + if (n < 1e-6f) { + return IDENTITY + } + return Quaternion(w / n, x / n, y / n, z / n) + } + + fun conjugate(): Quaternion = Quaternion(w, -x, -y, -z) + + fun rotate(v: Vec3): Vec3 { + val qv = Quaternion(0f, v.x, v.y, v.z) + val r = this * qv * conjugate() + return Vec3(r.x, r.y, r.z) + } + + fun integrateBodyRate(omegaRadPerSec: Vec3, dtSec: Float): Quaternion { + val omega = Quaternion(0f, omegaRadPerSec.x, omegaRadPerSec.y, omegaRadPerSec.z) + val qDot = (this * omega) * 0.5f + return Quaternion( + w + qDot.w * dtSec, + x + qDot.x * dtSec, + y + qDot.y * dtSec, + z + qDot.z * dtSec + ) + } + + companion object { + val IDENTITY = Quaternion(1f, 0f, 0f, 0f) + + fun fromAxisAngle(axis: Vec3, angleRad: Float): Quaternion { + val nAxis = axis.normalized() + val half = angleRad * 0.5f + val s = sin(half) + val c = cos(half) + return Quaternion(c, nAxis.x * s, nAxis.y * s, nAxis.z * s).normalized() + } + } +} + +internal data class RayNeoSensorProfile(val rotationXDeg: Float, val gtMaxAxes: Boolean) { + companion object { + fun forDevice(gtUsb: Boolean, boardId: Int) = when { + gtUsb && boardId == RayNeoProtocol.BOARD_GT -> RayNeoSensorProfile(-25f, false) + gtUsb -> RayNeoSensorProfile(35f, true) + boardId == RayNeoProtocol.BOARD_AIR_4_PRO -> RayNeoSensorProfile(-20f, false) + else -> RayNeoSensorProfile(0f, false) + } + } +} + +internal class RayNeoSensorAxes(imuRotationXDeg: Float, private val gt: Boolean) { + private val rotation = Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), + Math.toRadians((if (gt) 35f else imuRotationXDeg).toDouble()).toFloat()) + + fun map(v: Vec3): Vec3 = rotation.rotate(if (gt) Vec3(-v.y, v.z, -v.x) else v) +} + +internal class RayNeoOrientationFilter( + imuRotationXDeg: Float, + gt: Boolean = false, + private val calibration: RayNeoCalibration? = null, +) { + private val worldUp = Vec3(0f, 1f, 0f) + private val accelGain = 1.5f + private val dpsToRad = (Math.PI.toFloat() / 180f) + private val gravityMps2 = 9.81f + private val stationaryAccelTol = 1.25f + private val stationaryGyroRadPerSec = 0.18f + private val gyroBiasUpdateHz = 0.5f + private val axes = RayNeoSensorAxes(imuRotationXDeg, gt) + + private var q = Quaternion.IDENTITY + private var gravityInitialized = false + private var gyroBias = Vec3(0f, 0f, 0f) + private var lastTick100us: Long = -1L + 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]) + } + + fun update(sample: RayNeoSensorSample): FloatArray { + var accel = Vec3(sample.accelMps2[0], sample.accelMps2[1], sample.accelMps2[2]) + var gyro = Vec3(sample.gyroDps[0], sample.gyroDps[1], sample.gyroDps[2]) + calibration?.let { gyro -= it.gyroBiasDps } + accel = axes.map(accel) + gyro = axes.map(gyro) * dpsToRad + calibration?.let { + accel = it.level.rotate(accel) + gyro = it.level.rotate(gyro) + } + + val nowNs = System.nanoTime() + var dtSec = 0.01f + if (lastTick100us >= 0L && sample.deviceTick100us != lastTick100us) { + dtSec = (((sample.deviceTick100us - lastTick100us) and 0xffffffffL).toFloat() * 1e-4f) + } else if (lastRealtimeNs > 0L && nowNs > lastRealtimeNs) { + dtSec = ((nowNs - lastRealtimeNs).toFloat() * 1e-9f) + } + if (dtSec < 0.001f || dtSec > 0.1f) { + dtSec = 0.01f + } + lastTick100us = sample.deviceTick100us + lastRealtimeNs = nowNs + + val accelNorm = accel.length() + val stationary = + kotlin.math.abs(accelNorm - gravityMps2) < stationaryAccelTol && + gyro.length() < stationaryGyroRadPerSec + if (stationary) { + val alpha = minOf(1f, dtSec * gyroBiasUpdateHz) + gyroBias = gyroBias * (1f - alpha) + gyro * alpha + } + gyro -= gyroBias + + 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()) + } else { + Quaternion(1f + measuredUp.y, axis.x, axis.y, axis.z).normalized() + } + 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 + } + + q = q.integrateBodyRate(gyro, dtSec).normalized() + return floatArrayOf(q.w, q.x, q.y, q.z) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt new file mode 100644 index 000000000..9f65b04df --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/RayNeoProtocol.kt @@ -0,0 +1,270 @@ +// Adapted from informalTechCode/RayNeo-Air-Series-3DoF-Scaffold (Apache-2.0). +// See docs/third-party/rayneo/ for license and protocol attribution. +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import java.util.Arrays + +private fun Byte.u(): Int = toInt() and 0xFF + +data class RayNeoSensorSample( + val accelMps2: FloatArray, + val gyroDps: FloatArray, + val temperatureC: Float, + val magnet: FloatArray, + val proximity: Float, + val light: Float, + val deviceTick100us: Long +) + +data class RayNeoDeviceInfo( + val boardId: Int, + val sideBySideEnabled: Boolean +) + +sealed interface RayNeoPacket { + data class Sensor(val sample: RayNeoSensorSample) : RayNeoPacket + data class Response(val cmd: Int, val raw: ByteArray) : RayNeoPacket + data object Unknown : RayNeoPacket +} + +data class RayNeoEndpointSelection( + val usbInterface: UsbInterface, + val inEndpoint: UsbEndpoint, + val outEndpoint: UsbEndpoint +) + +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 PKT_MAGIC = 0x99 + const val PKT_SENSOR = 0x65 + const val PKT_RESPONSE = 0xC8 + + const val CMD_ACQUIRE_DEVICE_INFO = 0 + const val CMD_OPEN_IMU = 1 + const val CMD_CLOSE_IMU = 2 + const val CMD_SWITCH_TO_3D = 6 + const val CMD_SWITCH_TO_2D = 7 + + private const val RESPONSE_CMD_OFFSET = 8 + + private const val ACCEL_OFFSET = 4 + private const val GYRO_OFFSET = 16 + private const val TEMP_OFFSET = 28 + private const val MAG_X_OFFSET = 32 + private const val TICK_OFFSET = 40 + private const val PSENSOR_OFFSET = 44 + private const val LSENSOR_OFFSET = 48 + private const val MAG_Z_OFFSET = 52 + + private const val DEVINFO_BOARD_ID_OFFSET = 21 + private const val DEVINFO_SIDE_BY_SIDE_OFFSET = 43 + + fun buildCommandPacket(cmd: Int, arg: Int, packetSize: Int): ByteArray { + val len = maxOf(packetSize, 64) + val pkt = ByteArray(len) + pkt[0] = 0x66 + pkt[1] = cmd.toByte() + pkt[2] = arg.toByte() + return pkt + } + + fun classifyPacket(raw: ByteArray): RayNeoPacket { + if (raw.size < 4 || raw[0].u() != PKT_MAGIC || raw[2].u() != raw.size) { + return RayNeoPacket.Unknown + } + + return when (raw[1].u()) { + PKT_SENSOR -> { + val sample = parseSensorSample(raw) + if (sample != null) RayNeoPacket.Sensor(sample) else RayNeoPacket.Unknown + } + PKT_RESPONSE -> { + if (raw.size <= RESPONSE_CMD_OFFSET) { + RayNeoPacket.Unknown + } else { + RayNeoPacket.Response(raw[RESPONSE_CMD_OFFSET].u(), raw) + } + } + else -> RayNeoPacket.Unknown + } + } + + fun parseDeviceInfo(raw: ByteArray): RayNeoDeviceInfo? { + if (raw.size <= DEVINFO_SIDE_BY_SIDE_OFFSET || raw[2].u() != raw.size || raw[0].u() != PKT_MAGIC || + raw[1].u() != PKT_RESPONSE || raw[RESPONSE_CMD_OFFSET].u() != CMD_ACQUIRE_DEVICE_INFO) { + return null + } + + return RayNeoDeviceInfo( + boardId = raw[DEVINFO_BOARD_ID_OFFSET].u(), + sideBySideEnabled = raw[DEVINFO_SIDE_BY_SIDE_OFFSET].u() != 0 + ) + } + + fun selectEndpoints(device: UsbDevice): RayNeoEndpointSelection? { + val gt = isGt(device.vendorId, device.productId) + val gtCandidates = mutableListOf() + var fallback: RayNeoEndpointSelection? = null + + for (i in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(i) + var inEndpoint: UsbEndpoint? = null + var outEndpoint: UsbEndpoint? = null + + for (j in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(j) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_INT && + 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) { + inEndpoint = endpoint + } else if (endpoint.direction == UsbConstants.USB_DIR_OUT && outEndpoint == null) { + outEndpoint = endpoint + } + } + + if (inEndpoint != null && outEndpoint != null) { + val selection = RayNeoEndpointSelection(usbInterface, inEndpoint, outEndpoint) + if (gt) { + if (usbInterface.id == GT_CONTROL_INTERFACE) return selection + gtCandidates += selection + continue + } + if (inEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + outEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT + ) { + return selection + } + if (fallback == null) { + fallback = selection + } + } + } + + // 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 + } + + private fun parseSensorSample(raw: ByteArray): RayNeoSensorSample? { + if (raw.size < MAG_Z_OFFSET + 4 || + (ACCEL_OFFSET until TEMP_OFFSET step 4).any { !readF32LE(raw, it).isFinite() }) { + return null + } + + return RayNeoSensorSample( + accelMps2 = floatArrayOf( + readF32LE(raw, ACCEL_OFFSET + 0), + readF32LE(raw, ACCEL_OFFSET + 4), + readF32LE(raw, ACCEL_OFFSET + 8) + ), + gyroDps = floatArrayOf( + readF32LE(raw, GYRO_OFFSET + 0), + readF32LE(raw, GYRO_OFFSET + 4), + readF32LE(raw, GYRO_OFFSET + 8) + ), + temperatureC = readF32LE(raw, TEMP_OFFSET), + magnet = floatArrayOf( + readF32LE(raw, MAG_X_OFFSET), + readF32LE(raw, MAG_X_OFFSET + 4), + readF32LE(raw, MAG_Z_OFFSET) + ), + proximity = readF32LE(raw, PSENSOR_OFFSET), + light = readF32LE(raw, LSENSOR_OFFSET), + deviceTick100us = readU32LE(raw, TICK_OFFSET).toLong() and 0xffffffffL + ) + } + + private fun readU32LE(raw: ByteArray, offset: Int): Int { + val b0 = raw[offset].u() + val b1 = raw[offset + 1].u() + val b2 = raw[offset + 2].u() + val b3 = raw[offset + 3].u() + return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24) + } + + private fun readF32LE(raw: ByteArray, offset: Int): Float { + return Float.fromBits(readU32LE(raw, offset)) + } +} + +class RayNeoPacketAssembler { + private val stash = ByteArray(4096) + private var len = 0 + + fun append(data: ByteArray, count: Int) { + require(count in 0..data.size) + if (count <= 0) { + return + } + + if (count >= stash.size || len + count > stash.size) { + len = 0 + } + + val copyLen = minOf(count, stash.size - len) + System.arraycopy(data, 0, stash, len, copyLen) + len += copyLen + } + + fun nextPacket(): ByteArray? { + var offset = 0 + while (offset + 4 <= len) { + if (stash[offset].u() != RayNeoProtocol.PKT_MAGIC) { + offset++ + continue + } + + val packetLen = stash[offset + 2].u() + if (packetLen < 4 || packetLen > 64 || + stash[offset + 1].u() !in listOf(RayNeoProtocol.PKT_SENSOR, RayNeoProtocol.PKT_RESPONSE)) { + offset++ + continue + } + if (offset + packetLen > len) { + break + } + + val packet = Arrays.copyOfRange(stash, offset, offset + packetLen) + compact(offset + packetLen) + return packet + } + + if (offset > 0) { + compact(offset) + } + return null + } + + private fun compact(consumed: Int) { + if (consumed <= 0) { + return + } + + if (consumed >= len) { + len = 0 + return + } + + System.arraycopy(stash, consumed, stash, 0, len - consumed) + len -= consumed + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt new file mode 100644 index 000000000..3c6497a57 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureLink.kt @@ -0,0 +1,134 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.SystemClock +import android.util.Log +import java.util.concurrent.atomic.AtomicLong + +internal class VitureLink private constructor( + private val connection: UsbDeviceConnection, + private val mcu: UsbInterface, + private val mcuOut: UsbEndpoint, + private val mcuIn: UsbEndpoint?, + private val imu: UsbInterface?, + private val imuIn: UsbEndpoint?, + private val listener: Listener, +) : GlassesLink { + interface Listener { + fun onFrame(frame: VitureProtocol.Frame) + fun onImu(payload: ByteArray, timeNs: Long) + fun onLost() + } + + @Volatile private var running = true + private val writeLock = Any() + private var lastSendAt = 0L + private val readers = mutableListOf() + private val bad = AtomicLong() + + override val hasImu: Boolean get() = imuIn != null + override val badFrames: Long get() = bad.get() + + private fun startReaders() { + mcuIn?.let { ep -> readers += reader("viture-mcu", ep) { buf, n -> onPacket(buf, n, false) } } + imuIn?.let { ep -> + readers += reader("viture-imu", ep) { buf, n -> onPacket(buf, n, true) } + } + } + + private fun onPacket(buf: ByteArray, size: Int, imuEndpoint: Boolean) { + val frame = VitureProtocol.parse(buf, size) ?: return + if (!frame.crcOk) { + if (bad.incrementAndGet() == 1L) Log.w(TAG, "dropping frames with a bad checksum") + return + } + if (imuEndpoint && frame.magic == VitureProtocol.MAGIC_IMU) + listener.onImu(frame.payload, SystemClock.elapsedRealtimeNanos()) + else listener.onFrame(frame) + } + + private fun reader(name: String, ep: UsbEndpoint, onPacket: (ByteArray, Int) -> Unit): Thread = + Thread({ + val buf = ByteArray(VitureProtocol.PACKET_SIZE) + var failures = 0 + while (running) { + val started = SystemClock.uptimeMillis() + val n = connection.bulkTransfer(ep, buf, buf.size, READ_TIMEOUT_MS) + if (!running) break + if (n > 0) { + failures = 0 + runCatching { onPacket(buf, n) }.onFailure { Log.w(TAG, "$name packet", it) } + } else if (n < 0 && SystemClock.uptimeMillis() - started < READ_TIMEOUT_MS / 2) { + failures++ + SystemClock.sleep(2) + if (failures >= LOST_AFTER) { + Log.w(TAG, "$name: reads keep failing, link lost") + if (running) listener.onLost() + break + } + } + } + }, name).apply { isDaemon = true; priority = Thread.MAX_PRIORITY; start() } + + override fun send(msgId: Int, payload: ByteArray): Boolean { + val pkt = VitureProtocol.packet(msgId, payload) + val n = synchronized(writeLock) { + if (!running) return false + val wait = lastSendAt + COMMAND_GAP_MS - SystemClock.uptimeMillis() + if (wait > 0) SystemClock.sleep(wait) + connection.bulkTransfer(mcuOut, pkt, pkt.size, WRITE_TIMEOUT_MS).also { lastSendAt = SystemClock.uptimeMillis() } + } + Log.i(TAG, "send 0x%04X [%s] -> %d".format(msgId, payload.joinToString("") { "%02X".format(it) }, n)) + return n == pkt.size + } + + override fun close() { + synchronized(writeLock) { running = false } + val self = Thread.currentThread() + readers.forEach { if (it !== self) runCatching { it.join(READ_TIMEOUT_MS * 4L) } } + runCatching { connection.releaseInterface(mcu) } + imu?.let { runCatching { connection.releaseInterface(it) } } + runCatching { connection.close() } + } + + companion object { + private const val TAG = "VitureLink" + private const val READ_TIMEOUT_MS = 100 + private const val WRITE_TIMEOUT_MS = 250 + private const val LOST_AFTER = 50 + const val COMMAND_GAP_MS = 60L + + fun open(usb: UsbManager, device: UsbDevice, listener: Listener): VitureLink? { + val hid = (0 until device.interfaceCount).map(device::getInterface) + .filter { it.interfaceClass == UsbConstants.USB_CLASS_HID } + .sortedBy { it.id } + for (intf in hid) Log.i(TAG, "iface ${intf.id} eps " + (0 until intf.endpointCount).joinToString { + val ep = intf.getEndpoint(it) + "0x%02X".format(ep.address) + }) + val mcu = hid.lastOrNull { it.outEndpoint() != null } ?: return null + val imu = hid.firstOrNull { it.id != mcu.id && it.inEndpoint() != null } + val conn = usb.openDevice(device) ?: return null + if (!conn.claimInterface(mcu, true)) { + conn.close() + return null + } + val imuClaimed = imu != null && conn.claimInterface(imu, true) + val link = VitureLink(conn, mcu, mcu.outEndpoint()!!, mcu.inEndpoint(), + imu.takeIf { imuClaimed }, imu.takeIf { imuClaimed }?.inEndpoint(), listener) + link.startReaders() + return link + } + + private fun UsbInterface.outEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint) + .firstOrNull { it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_INT } + + private fun UsbInterface.inEndpoint(): UsbEndpoint? = (0 until endpointCount).map(::getEndpoint) + .firstOrNull { it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_INT } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt new file mode 100644 index 000000000..6c64500d5 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureProtocol.kt @@ -0,0 +1,186 @@ +package com.droiddeck.launcher.glasses + +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +object VitureProtocol { + const val VENDOR_ID = 0x35CA + const val PACKET_SIZE = 64 + private const val HEADER = 0x12 + + const val MAGIC_COMMAND = 0xFE + const val MAGIC_REPORT = 0xFD + const val MAGIC_IMU = 0xFC + + const val MSG_GET_BRIGHTNESS = 0x0005 + const val MSG_BRIGHTNESS = 0x0006 + const val MSG_GET_DISPLAY = 0x0007 + const val MSG_DISPLAY = 0x0008 + const val MSG_FILM = 0x000E + const val MSG_IMU = 0x0015 + const val MSG_GET_IMU = 0x0017 + const val MSG_IMU_RATE = 0x0018 + const val MSG_GET_VOLUME = 0x0032 + const val MSG_VOLUME = 0x0033 + + const val EVENT_BRIGHTNESS = 0x0301 + const val EVENT_DISPLAY = 0x0302 + const val EVENT_VOLUME = 0x0304 + const val EVENT_DISPLAY_STEP = 0x030D + + const val IMU_RATE_240 = 3 + + enum class Family { ONE, PRO, LUMA, ULTRA, BEAST, PRO2 } + + class Model(override val name: String, val family: Family) : GlassesModel { + override val controllable: Boolean get() = true + val needsSdk: Boolean get() = family == Family.ULTRA || family == Family.BEAST || family == Family.PRO2 + val tall: Boolean get() = family == Family.LUMA || family == Family.ULTRA || family == Family.BEAST + override val sbs90: Boolean get() = family == Family.ULTRA + override val brightnessMax: Int get() = 8 + override val volumeMax: Int get() = if (family == Family.BEAST) 15 else 8 + override val hasSunblock: Boolean get() = family != Family.PRO2 + override val diagonalFovDeg: Float get() = when (family) { + Family.ONE -> 43f + Family.PRO -> 46f + Family.PRO2, Family.LUMA, Family.ULTRA -> 50f + Family.BEAST -> 58f + } + + override fun mode(hz: Int, sbs: Boolean): Int = when { + tall && sbs -> if (hz >= 90 && sbs90) 0x45 else 0x42 + tall -> when { hz >= 120 -> 0x44; hz >= 90 -> 0x43; else -> 0x41 } + sbs -> if (hz >= 90 && sbs90) 0x35 else 0x32 + else -> when { hz >= 120 -> 0x34; hz >= 90 -> 0x33; else -> 0x31 } + } + + override fun modeForStep(step: Int): Int? = if (tall) null else when (step) { + 1 -> 0x31 + 2 -> 0x33 + 3 -> 0x34 + 4 -> 0x32 + else -> null + } + } + + /** 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 + "Pro" -> Family.PRO + "Pro 2" -> Family.PRO2 + "Luma", "Luma Pro", "Luma Cyber" -> Family.LUMA + "Luma Ultra" -> Family.ULTRA + "Beast" -> Family.BEAST + else -> return null + } + return Model("VITURE $marketName", family) + } + + fun model(productId: Int, productName: String?): Model? { + val family = when (productId) { + 0x1011, 0x1013, 0x1015, 0x1017, 0x101b -> Family.ONE + 0x1019, 0x101d -> Family.PRO + 0x1121, 0x1131, 0x1141, 0x1151 -> Family.LUMA + 0x1101, 0x1104 -> Family.ULTRA + 0x1201, 0x1211 -> Family.BEAST + else -> return null + } + val fallback = when (productId) { + 0x1015, 0x101b -> "VITURE One Lite" + 0x1011, 0x1013, 0x1017 -> "VITURE One" + 0x1019 -> "VITURE Pro" + 0x101d -> "VITURE Pro XR" + 0x1131 -> "VITURE Luma" + 0x1121, 0x1141, 0x1151 -> "VITURE Luma Pro" + 0x1101, 0x1104 -> "VITURE Luma Ultra" + else -> "VITURE Beast" + } + val name = productName?.trim()?.removeSuffix(" Glasses")?.takeIf { it.startsWith("VITURE", ignoreCase = true) } ?: fallback + return Model(name, family) + } + + private val CRC_TABLE = IntArray(256) { i -> + var c = i shl 8 + repeat(8) { c = if (c and 0x8000 != 0) (c shl 1) xor 0x1021 else c shl 1 } + c and 0xFFFF + } + + fun crc16(data: ByteArray, offset: Int, length: Int): Int { + var crc = 0 + for (i in offset until offset + length) { + crc = (CRC_TABLE[((data[i].toInt() and 0xFF) xor (crc ushr 8)) and 0xFF] xor (crc shl 8)) and 0xFFFF + } + return crc + } + + fun packet(msgId: Int, payload: ByteArray = ByteArray(0)): ByteArray { + require(payload.size <= PACKET_SIZE - HEADER) + val buf = ByteArray(PACKET_SIZE) + buf[0] = 0xFF.toByte() + buf[1] = MAGIC_COMMAND.toByte() + val len = payload.size + 0x0C + buf[4] = len.toByte() + buf[5] = (len shr 8).toByte() + buf[14] = msgId.toByte() + buf[15] = (msgId shr 8).toByte() + payload.copyInto(buf, HEADER) + val crc = crc16(buf, 4, len + 2) + buf[2] = crc.toByte() + buf[3] = (crc shr 8).toByte() + return buf + } + + class Frame(val magic: Int, val msgId: Int, val payload: ByteArray, val crcOk: Boolean) + + fun parse(buf: ByteArray, size: Int): Frame? { + if (size < HEADER || size > buf.size || (buf[0].toInt() and 0xFF) != 0xFF) return null + val magic = buf[1].toInt() and 0xFF + if (magic != MAGIC_COMMAND && magic != MAGIC_REPORT && magic != MAGIC_IMU) return null + val len = u16(buf, 4) + if (len < 0x0C || len + 6 > size) return null + val dataLen = len - 0x0C + val crcOk = crc16(buf, 4, len + 2) == u16(buf, 2) + return Frame(magic, u16(buf, 14), buf.copyOfRange(HEADER, HEADER + dataLen), crcOk) + } + + private fun u16(buf: ByteArray, at: Int) = (buf[at].toInt() and 0xFF) or ((buf[at + 1].toInt() and 0xFF) shl 8) + + fun beFloat(b: ByteArray, at: Int): Float = java.lang.Float.intBitsToFloat( + ((b[at].toInt() and 0xFF) shl 24) or ((b[at + 1].toInt() and 0xFF) shl 16) or + ((b[at + 2].toInt() and 0xFF) shl 8) or (b[at + 3].toInt() and 0xFF), + ) + + fun imuQuaternion(payload: ByteArray): FloatArray? { + if (payload.size >= 36) { + val w = beFloat(payload, 20) + val x = beFloat(payload, 24) + val y = beFloat(payload, 28) + val z = beFloat(payload, 32) + val n = sqrt(w * w + x * x + y * y + z * z) + if (n.isFinite() && n > 0.9f && n < 1.1f) return floatArrayOf(x / n, y / n, z / n, w / n) + } + if (payload.size < 12) return null + val roll = beFloat(payload, 0) + val pitch = beFloat(payload, 4) + val yaw = beFloat(payload, 8) + if (!roll.isFinite() || !pitch.isFinite() || !yaw.isFinite()) return null + return eulerZxy(roll, pitch, yaw) + } + + fun eulerZxy(rollDeg: Float, pitchDeg: Float, yawDeg: Float): FloatArray { + val r = Math.toRadians(rollDeg.toDouble()) / 2 + val p = Math.toRadians(pitchDeg.toDouble()) / 2 + val y = Math.toRadians(yawDeg.toDouble()) / 2 + val cx = cos(r); val sx = sin(r) + val cy = cos(p); val sy = sin(p) + val cz = cos(y); val sz = sin(y) + return floatArrayOf( + (sx * cy * cz - cx * sy * sz).toFloat(), + (cx * sy * cz + sx * cy * sz).toFloat(), + (cx * cy * sz + sx * sy * cz).toFloat(), + (cx * cy * cz - sx * sy * sz).toFloat(), + ) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt new file mode 100644 index 000000000..21742a9d1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdk.kt @@ -0,0 +1,52 @@ +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 { + System.loadLibrary("droiddeck_viture") + nativeAvailable() + }.onFailure { Log.w("VitureSdk", "VITURE driver unavailable", it) }.getOrDefault(false) + } + + fun marketName(pid: Int): String? = if (available) nativeMarketName(pid) else null + external fun nativeAvailable(): Boolean + external fun nativeMarketName(pid: Int): String? + external fun nativeOpen(pid: Int, fd: Int, cacheDirectory: String): Long + external fun nativeType(token: Long): Int + 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) + + const val CARINA = 2 + const val GET_DISPLAY = 1 + const val SET_DISPLAY = 2 + const val GET_BRIGHTNESS = 3 + const val SET_BRIGHTNESS = 4 + const val GET_VOLUME = 5 + const val SET_VOLUME = 6 + const val SET_FILM = 7 +} + +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()) + if (!length.isFinite() || length < 0.5f || length > 1.5f) return null + val q = if (openGlAxes) floatArrayOf(-wxyz[3], -wxyz[1], wxyz[2], wxyz[0]) + else floatArrayOf(wxyz[1], wxyz[2], wxyz[3], wxyz[0]) + return q.map { it / length }.toFloatArray() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt new file mode 100644 index 000000000..571c995aa --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/VitureSdkLink.kt @@ -0,0 +1,132 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbManager +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, + private val listener: Listener, + family: VitureProtocol.Family?, +) : GlassesLink { + interface Listener { + fun onValue(msgId: Int, value: Int) + fun onPose(q: FloatArray, timeNs: Long) + fun onLost() + } + + private val lock = Any() + @Volatile private var running = true + @Volatile private var tracking = false + override val ready: Boolean get() = running + override val hasImu = true + private val openGlAxes = VitureSdkPose.usesOpenGlAxes(VitureSdk.nativeType(token) == VitureSdk.CARINA, family) + private val worker = Thread({ poll() }, "viture-sdk-pose").apply { isDaemon = true; start() } + + override fun send(msgId: Int, payload: ByteArray): Boolean = synchronized(lock) { + if (!running) return false + val value = payload.firstOrNull()?.toInt()?.and(0xff) ?: 0 + when (msgId) { + VitureProtocol.MSG_IMU -> { + val wanted = value != 0 + if (wanted == tracking) return true + val rc = VitureSdk.nativeTracking(token, wanted) + 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 + } + val operation = when (msgId) { + VitureProtocol.MSG_GET_DISPLAY -> VitureSdk.GET_DISPLAY + VitureProtocol.MSG_DISPLAY -> VitureSdk.SET_DISPLAY + VitureProtocol.MSG_GET_BRIGHTNESS -> VitureSdk.GET_BRIGHTNESS + VitureProtocol.MSG_BRIGHTNESS -> VitureSdk.SET_BRIGHTNESS + VitureProtocol.MSG_GET_VOLUME -> VitureSdk.GET_VOLUME + VitureProtocol.MSG_VOLUME -> VitureSdk.SET_VOLUME + VitureProtocol.MSG_FILM -> VitureSdk.SET_FILM + else -> return false + } + val rc = VitureSdk.nativeCommand(token, operation, value) + if (rc < 0) return result("command $operation", rc) + if (operation == VitureSdk.GET_DISPLAY || operation == VitureSdk.GET_BRIGHTNESS || operation == VitureSdk.GET_VOLUME) + listener.onValue(msgId, rc) + else if (operation == VitureSdk.SET_DISPLAY) { + val actual = VitureSdk.nativeCommand(token, VitureSdk.GET_DISPLAY, 0) + if (actual >= 0) listener.onValue(VitureProtocol.MSG_GET_DISPLAY, actual) + } + true + } + + private fun result(operation: String, rc: Int): Boolean { + if (rc < 0) Log.w("VitureSdkLink", "$operation failed: $rc") + return rc == 0 + } + + private fun poll() { + val pose = FloatArray(4) + val event = IntArray(2) + var failures = 0 + while (running) { + val timestamp = synchronized(lock) { + if (running) repeat(6) { + 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]) + } + } + if (!running || !tracking) 0L else VitureSdk.nativePose(token, pose) + } + if (timestamp > 0) { + failures = 0 + VitureSdkPose.toGlasses(pose, openGlAxes)?.let { listener.onPose(it, timestamp) } + } else if (timestamp == -2L || timestamp == -3L) { + if (++failures >= 10) { + Log.w("VitureSdkLink", "closing after $failures pose USB errors; last SDK result: $timestamp") + listener.onLost() + break + } + } + SystemClock.sleep(if (tracking) 4 else 25) + } + } + + override fun close() { + synchronized(lock) { + 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) + } + + companion object { + fun open(usb: UsbManager, device: UsbDevice, cacheDirectory: File, listener: Listener, + family: VitureProtocol.Family? = null): VitureSdkLink? { + if (!VitureSdk.available) return null + val conn = usb.openDevice(device) ?: return null + val token = runCatching { + cacheDirectory.mkdirs() + VitureSdk.nativeOpen(device.productId, conn.fileDescriptor, cacheDirectory.absolutePath) + }.onFailure { Log.w("VitureSdkLink", "SDK initialization failed", it) }.getOrDefault(0) + if (token <= 0) { conn.close(); return null } + return runCatching { VitureSdkLink(conn, token, listener, family) }.getOrElse { + VitureSdk.nativeClose(token) + conn.close() + Log.w("VitureSdkLink", "SDK worker could not start", it) + null + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt new file mode 100644 index 000000000..3dbd22eb3 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealConnection.kt @@ -0,0 +1,88 @@ +package com.droiddeck.launcher.glasses + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.NetworkCapabilities +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket + +internal interface XrealChannels : Closeable { + val controlInput: InputStream + val controlOutput: OutputStream + val reports: InputStream +} + +internal interface XrealConnector : Closeable { + fun connect(): XrealChannels +} + +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) + private val lock = Any() + private val sockets = mutableSetOf() + private var closed = false + + companion object { + internal fun hasDirectRoute(properties: LinkProperties, address: InetAddress): Boolean = + properties.routes.any { !it.hasGateway() && it.destination.prefixLength > 0 && it.matches(address) } + } + + override fun connect(): XrealChannels { + val network = manager.allNetworks.firstOrNull { network -> + manager.getNetworkCapabilities(network)?.let { + it.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) && + !it.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + } == true && manager.getLinkProperties(network)?.let { hasDirectRoute(it, address) } == true + } ?: throw XrealNetworkMissing() + fun open(port: Int): Socket { + val socket = Socket() + synchronized(lock) { + if (closed) { socket.close(); throw IOException("XREAL connection closed") } + sockets.add(socket) + } + try { + network.bindSocket(socket) + socket.soTimeout = 2000 + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(address, port), 1500) + return socket + } catch (e: Exception) { + release(socket) + throw e + } + } + val control = open(XrealProtocol.CONTROL_PORT) + val reportSocket = try { open(XrealProtocol.REPORT_PORT) } catch (e: Exception) { + release(control) + throw e + } + return object : XrealChannels { + override val controlInput = control.getInputStream() + override val controlOutput = control.getOutputStream() + override val reports = reportSocket.getInputStream() + override fun close() { release(control); release(reportSocket) } + } + } + + private fun release(socket: Socket) = synchronized(lock) { + runCatching { socket.close() } + sockets.remove(socket) + Unit + } + + override fun close() = synchronized(lock) { + closed = true + sockets.forEach { runCatching { it.close() } } + sockets.clear() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt new file mode 100644 index 000000000..86f93fbef --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealLink.kt @@ -0,0 +1,201 @@ +package com.droiddeck.launcher.glasses + +import android.content.Context +import android.util.Log +import java.io.IOException +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, +) : GlassesLink { + interface Listener { + fun onReady() + fun onValue(message: Int, value: Int) + fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) + fun onUnavailable(networkMissing: Boolean) + } + + private data class Command(val message: Int, val value: Int) + private val commands = ArrayBlockingQueue(16) + private val connection = AtomicReference() + @Volatile private var running = true + @Volatile private var tracking = false + @Volatile override var ready = false + private set + override val hasImu = true + private val worker = Thread(::run, "xreal-control").apply { isDaemon = true; start() } + + companion object { + fun open(context: Context, listener: Listener) = XrealLink(AndroidXrealConnector(context), listener) + } + + override fun send(msgId: Int, payload: ByteArray): Boolean { + if (!running || !ready) return false + val value = payload.firstOrNull()?.toInt()?.and(255) ?: -1 + if (msgId == VitureProtocol.MSG_IMU) { + if (value !in 0..1) return false + tracking = value == 1 + return true + } + if (msgId == VitureProtocol.MSG_GET_IMU) return true + val valid = when (msgId) { + VitureProtocol.MSG_GET_DISPLAY -> true + 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. + } + return valid && commands.offer(Command(msgId, value)) + } + + private fun run() { + var lastError: String? = null + while (running) { + var channels: XrealChannels? = null + var reports: Thread? = null + val failure = AtomicReference() + try { + channels = connector.connect() + connection.set(channels) + if (!running) break + val control = XrealControl(channels) + listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.display()) + val current = channels + reports = Thread({ + try { + var filter = XrealOrientationFilter() + var wasTracking = false + var lastSensor = System.nanoTime() + while (running && connection.get() === current) { + val frame = XrealProtocol.read(current.reports) + val sample = XrealProtocol.sample(frame) + if (sample != null) { + lastSensor = System.nanoTime() + val enabled = tracking + if (enabled && !wasTracking) filter = XrealOrientationFilter() + wasTracking = enabled + if (enabled) filter.update(sample)?.let { + if (running && ready && tracking && connection.get() === current) + listener.onPose(frame.body, it, System.nanoTime()) + } + } else if (System.nanoTime() - lastSensor > 3_000_000_000L) { + throw IOException("No valid XREAL IMU reports") + } + } + } catch (e: Exception) { + failure.set(e) + current.close() // Wake a control read immediately. + } + }, "xreal-reports").apply { isDaemon = true; start() } + ready = true + lastError = null + listener.onReady() + var heartbeat = System.nanoTime() + while (running && failure.get() == null) { + val command = commands.poll(250, TimeUnit.MILLISECONDS) + if (command != null) { + when (command.message) { + VitureProtocol.MSG_GET_DISPLAY -> + listener.onValue(command.message, control.display()) + VitureProtocol.MSG_DISPLAY -> { + listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.changeDisplay(command.value)) + } + VitureProtocol.MSG_BRIGHTNESS -> { + control.set(XrealProtocol.SET_BRIGHTNESS, command.value) + listener.onValue(command.message, command.value) + } + VitureProtocol.MSG_FILM -> { + control.set(XrealProtocol.SET_DIMMER, if (command.value == 1) 2 else 0) + listener.onValue(command.message, command.value) + } + } + } + if (System.nanoTime() - heartbeat > 5_000_000_000L) { + listener.onValue(VitureProtocol.MSG_GET_DISPLAY, control.display()) + heartbeat = System.nanoTime() + } + } + failure.get()?.let { throw it } + } catch (e: Exception) { + ready = false + if (running && lastError != e.message) { + lastError = e.message + Log.w("XrealLink", "Connection unavailable: ${e.message}") + listener.onUnavailable(e is XrealNetworkMissing) + } + } finally { + ready = false + tracking = false + commands.clear() + connection.compareAndSet(channels, null) + channels?.close() + try { reports?.join(2500) } catch (_: InterruptedException) { } + } + if (running) try { Thread.sleep(2000) } catch (_: InterruptedException) { } + } + } + + override fun close() { + running = false + ready = false + tracking = false + commands.clear() + connection.getAndSet(null)?.close() + connector.close() // Includes sockets still inside connect(). + 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 { + transaction = if (transaction == Int.MAX_VALUE) 1 else transaction + 1 + 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. + if (System.nanoTime() > deadline) throw IOException("XREAL control timed out") + XrealProtocol.response(XrealProtocol.read(channels.controlInput), command, transaction)?.let { return it } + } + throw IOException("XREAL control response was not received") + } + fun display(): Int { + val reply = exchange(XrealProtocol.GET_DISPLAY) + return XrealProtocol.numeric(reply)?.takeIf { it in 2..5 || it == XrealModel.NATIVE_60HZ } + ?: throw IOException("Unsupported XREAL display response: ${reply.take(32).joinToString("") { "%02x".format(it) }}") + } + 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) + return awaitDisplay { it == mode } + } + private fun awaitDisplay(matches: (Int) -> Boolean): Int { + val deadline = System.nanoTime() + 8_000_000_000L + repeat(40) { + val reply = exchange(XrealProtocol.GET_DISPLAY) + val value = XrealProtocol.numeric(reply) + if (value != null && matches(value)) return value + if (value == null && !XrealProtocol.acknowledged(reply)) + throw IOException("Invalid XREAL display transition response") + if (System.nanoTime() > deadline) throw IOException("XREAL display transition timed out") + Thread.sleep(settleDelayMs) + } + throw IOException("XREAL display transition did not complete") + } + fun set(command: Int, value: Int) { + val reply = exchange(command, value) + if (!XrealProtocol.acknowledged(reply)) throw IOException("XREAL command %04x was not acknowledged: %s".format( + command, reply.take(32).joinToString("") { "%02x".format(it) })) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt new file mode 100644 index 000000000..19cd2097c --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealModel.kt @@ -0,0 +1,27 @@ +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. + override val name = "XREAL 1S" + override val controllable = true + override val brightnessMax = 9 + override val volumeMax = 0 + override val hasSunblock = true + override val diagonalFovDeg = 52f + override val sbs90 = false + + 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 + hz >= 90 -> 3 + else -> NATIVE_60HZ + } + override fun modeForStep(step: Int): Int? = null +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt new file mode 100644 index 000000000..f6ac28f69 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealOrientationFilter.kt @@ -0,0 +1,46 @@ +package com.droiddeck.launcher.glasses + +import kotlin.math.abs + +/** 1S radians/sec and device nanoseconds, mapped using directed hardware captures. */ +internal class XrealOrientationFilter { + private var previous = 0L + private var rotation = Quaternion.IDENTITY + private var initialized = false + private var bias = Vec3(0f, 0f, 0f) + + fun update(sample: XrealProtocol.Sample): FloatArray? { + if (sample.timeNs <= previous) return null + val dt = if (previous == 0L) 0f else (sample.timeNs - previous) * 1e-9f + previous = sample.timeNs + // 1S reports right/down/forward. Rotate 180 degrees about X into the + // shared right/up/back basis before either gravity alignment or fusion. + // Captures: level gravity -Y; looking left -gyroY; up +gyroX; right tilt +gyroZ. + val accel = Vec3(sample.accel.x, -sample.accel.y, -sample.accel.z) + val gyro = Vec3(sample.gyro.x, -sample.gyro.y, -sample.gyro.z) + 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 (!initialized) { + if (!gravityValid) return null + val up = accel.normalized() + val axis = up.cross(Vec3(0f, 1f, 0f)) + rotation = if (up.y < -0.9999f) Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat()) + else Quaternion(1f + up.y, axis.x, axis.y, axis.z).normalized() + initialized = true + } else if (dt in 0.0001f..0.05f) { + if (gravityValid && gyro.length() < 0.04f) { + val alpha = (dt * 0.5f).coerceAtMost(1f) + bias = bias * (1f - alpha) + gyro * alpha + } + var rate = gyro - bias + if (gravityValid) { + val predictedUp = rotation.conjugate().rotate(Vec3(0f, 1f, 0f)) + rate += accel.normalized().cross(predictedUp) * 1.5f + } + 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)) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt new file mode 100644 index 000000000..d18ca1a94 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/glasses/XrealProtocol.kt @@ -0,0 +1,78 @@ +package com.droiddeck.launcher.glasses + +import java.io.EOFException +import java.io.IOException +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 + const val CONTROL_PORT = 52999 + const val REPORT = 0x2836 + const val GET_DISPLAY = 0x275e + const val SET_DISPLAY = 0x275f + const val SET_INPUT = 0x2822 + const val SET_BRIGHTNESS = 0x271c + const val SET_DIMMER = 0x2727 + const val MAX_BODY = 64 * 1024 + + data class Frame(val command: Int, val body: ByteArray) + data class Sample(val timeNs: Long, val gyro: Vec3, val accel: Vec3) + + fun read(input: InputStream): Frame { + val deadline = System.nanoTime() + 3_000_000_000L + val header = ByteArray(6) + input.readFully(header, deadline) + val size = ByteBuffer.wrap(header, 2, 4).int + if (size !in 0..MAX_BODY) throw IOException("Invalid XREAL frame length: $size") + return Frame(((header[0].toInt() and 255) shl 8) or (header[1].toInt() and 255), + ByteArray(size).also { input.readFully(it, deadline) }) + } + + private fun InputStream.readFully(bytes: ByteArray, deadline: Long) { + var at = 0 + while (at < bytes.size) { + if (System.nanoTime() > deadline) throw IOException("XREAL frame timed out") + val count = read(bytes, at, bytes.size - at) + if (count < 0) throw EOFException("XREAL stream closed mid-frame") + if (count == 0) throw IOException("XREAL stream made no progress") + at += count + } + } + + 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. + 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)) + if (time <= 0 || !gyro.valid(100f) || !accel.valid(200f)) return null + return Sample(time, gyro, accel) + } + + private fun Vec3.valid(limit: Float) = listOf(x, y, z).all { it.isFinite() && kotlin.math.abs(it) <= limit } + + fun request(command: Int, transaction: Int, value: Int? = null): ByteArray { + require(transaction > 0) + require(value == null || value in 0..127) + val payload = if (value == null) byteArrayOf(0x18, 0) else byteArrayOf(0x1a, 2, 8, value.toByte()) + return ByteBuffer.allocate(10 + payload.size).putShort(command.toShort()) + .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 + return frame.body.copyOfRange(4, frame.body.size) + } + + fun acknowledged(payload: ByteArray) = payload.contentEquals(byteArrayOf(0x22, 0)) + fun numeric(payload: ByteArray): Int? = if (payload.size == 4 && + payload[0] == 0x22.toByte() && payload[1] == 2.toByte() && payload[2] == 0x10.toByte() && + payload[3].toInt() in 0..127) payload[3].toInt() else null +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt b/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt index 6c2522576..90de91e87 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/Cardboard.kt @@ -12,13 +12,15 @@ data class Cardboard( val screenWidthDm: Int = 40, val screenDistanceDm: Int = 30, val headTracking: Boolean = true, + val mono: Boolean = false, ) { val lens: Viewer get() = Viewer.byId(viewer) - fun push(panelWidthMm: Float, panelHeightMm: Float) { + fun push(panelWidthMm: Float, panelHeightMm: Float, fovOverrideDeg: Float = 0f, displayHz: Float = 0f) { val l = lens WaylandCompositor.nativeSetCardboard(enabled, source, ipdMm / 1000f, screenDistanceDm / 10f, - screenWidthDm / 10f, l.k1, l.k2, fovDeg(l, panelHeightMm), lensInset(panelWidthMm, l.lensSeparationMm), l.chroma) + screenWidthDm / 10f, l.k1, l.k2, if (fovOverrideDeg > 0f) fovOverrideDeg else fovDeg(l, panelHeightMm), + if (mono) 0f else lensInset(panelWidthMm, l.lensSeparationMm), l.chroma, mono, displayHz) } fun encode(): JSONObject = JSONObject() @@ -29,7 +31,8 @@ data class Cardboard( val lensSeparationMm: Float, val chroma: Float, val fallbackFovDeg: Float) { CARDBOARD_V2("cardboard2", 0.34f, 0.55f, 39.3f, 63.9f, 0.006f, 90f), CARDBOARD_V1("cardboard1", 0.441f, 0.156f, 42f, 60f, 0.004f, 80f), - NONE("none", 0f, 0f, 0f, 0f, 0f, 60f); + NONE("none", 0f, 0f, 0f, 0f, 0f, 60f), + VITURE("viture", 0f, 0f, 0f, 0f, 0f, 23.3f); companion object { fun byId(id: String): Viewer = entries.firstOrNull { it.id == id } ?: CARDBOARD_V2 @@ -123,6 +126,10 @@ object HeadPose { a[r * 3] * b[c] + a[r * 3 + 1] * b[3 + c] + a[r * 3 + 2] * b[6 + c] } + private val GLASSES_AXES = floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f) + + fun fromGlasses(x: Float, y: Float, z: Float, w: Float): FloatArray = multiply(fromQuaternion(x, y, z, w), GLASSES_AXES) + fun transpose(m: FloatArray): FloatArray = floatArrayOf(m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]) fun apply(m: FloatArray, v: FloatArray): FloatArray = floatArrayOf( diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt b/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt index 849c7870e..afe822617 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/HeadTracker.kt @@ -10,19 +10,42 @@ import android.os.HandlerThread import android.util.Log import com.droiddeck.launcher.wayland.WaylandCompositor -class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEventListener { +class HeadTracker(context: Context, private val glasses: Boolean = false, private val rotation: () -> Int) : SensorEventListener { private val sensors = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager private val sensor: Sensor? = sensors.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) ?: sensors.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) private var thread: HandlerThread? = null + private var started = false + private val glassesLock = Any() @Volatile private var reference: HeadPose.Reference? = null @Volatile private var recenterPending = true @Volatile private var latest: FloatArray? = null + private val glassesSink = com.droiddeck.launcher.glasses.Glasses.PoseSink { x, y, z, w, _ -> + val m = HeadPose.fromGlasses(x, y, z, w) + synchronized(glassesLock) { + if (!started) return@PoseSink + latest = m + apply(m) + } + } + + val usesGlasses: Boolean get() = glasses + val available: Boolean get() = sensor != null val sensorName: String get() = sensor?.let { "${it.name} (${if (it.type == Sensor.TYPE_GAME_ROTATION_VECTOR) "game rotation vector" else "rotation vector"})" } ?: "none" fun start() { + if (glasses) { + synchronized(glassesLock) { + if (started) return + started = true + recenterPending = true + } + com.droiddeck.launcher.glasses.Glasses.setPoseSink(glassesSink) + WaylandCompositor.nativeLog("cardboard", "head tracking on: VITURE glasses IMU") + return + } val s = sensor ?: run { WaylandCompositor.nativeLog("cardboard", "no rotation sensor: the virtual screen stays head-locked") WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false) @@ -37,6 +60,15 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve } fun stop() { + if (glasses) { + synchronized(glassesLock) { + if (!started) return + started = false + WaylandCompositor.nativeSetCardboardPose(HeadPose.IDENTITY, false) + } + com.droiddeck.launcher.glasses.Glasses.clearPoseSink(glassesSink) + return + } val t = thread ?: return sensors.unregisterListener(this) t.quitSafely() @@ -46,7 +78,8 @@ class HeadTracker(context: Context, private val rotation: () -> Int) : SensorEve fun recenter() { recenterPending = true - latest?.let { apply(it) } + if (glasses) synchronized(glassesLock) { if (started) latest?.let { apply(it) } } + else latest?.let { apply(it) } } override fun onSensorChanged(event: SensorEvent) { diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVr.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVr.kt index 93d71262c..b06498339 100644 --- a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVr.kt +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVr.kt @@ -5,6 +5,8 @@ import com.droiddeck.launcher.agent.AgentGuest import org.json.JSONObject object SteamVr { + const val APP_ID = "250820" + enum class Status { UNAVAILABLE, NOT_INSTALLED, @@ -13,6 +15,23 @@ object SteamVr { RUNNING, } + internal fun shouldPoll(status: Status?, cardboardEnabled: Boolean, pictureSwitched: Boolean, + glassesControl: Boolean, requested: Boolean): Boolean = + requested || cardboardEnabled || pictureSwitched || glassesControl || + status == Status.RUNNING || status == Status.INSTALLING + + internal fun autoAction( + status: Status, requested: Boolean, loading: Boolean, steamSession: Boolean, + startTried: Boolean, installTried: Boolean, + ): String? { + if (!requested || loading || !steamSession || startTried) return null + return when (status) { + Status.READY -> "start" + Status.NOT_INSTALLED -> if (installTried) null else "install" + else -> null + } + } + fun status(context: Context): Status = runCatching { parse(AgentGuest.call(context, JSONObject().put("kind", "vr").put("action", "status"), STATUS_TIMEOUT_MS)) }.getOrDefault(Status.UNAVAILABLE) diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt new file mode 100644 index 000000000..4d94b6628 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLog.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.gpu + +import org.json.JSONObject +import java.io.File +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() + private var prefix = runCatching { + RandomAccessFile(file, "r").use { input -> ByteArray(minOf(64, input.length()).toInt()).also(input::readFully) } + }.getOrDefault(byteArrayOf()) + private var partial = "" + private var handled = false + + private fun identity(): Any? = runCatching { + Files.readAttributes(file.toPath(), BasicFileAttributes::class.java).fileKey() + }.getOrNull() + + fun poll(): Boolean { + if (handled) return true + if (!file.isFile) return false + return runCatching { + val current = identity() + RandomAccessFile(file, "r").use { input -> + val head = ByteArray(minOf(64, input.length()).toInt()).also(input::readFully) + val replaced = prefix.isNotEmpty() && !head.take(prefix.size).toByteArray().contentEquals(prefix) + if (current != identity || input.length() < position || replaced) { + position = 0 + partial = "" + } + 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 = "" + } + input.seek(position) + val bytes = ByteArray(minOf(MAX_READ, input.length() - position).toInt()) + input.readFully(bytes) + position = input.filePointer + val text = partial + bytes.toString(Charsets.UTF_8) + val end = text.lastIndexOf('\n') + partial = text.substring(end + 1).takeLast(2048) + if (end >= 0 && isDeviceLoss(text.substring(0, end + 1))) handled = true + handled + } + }.getOrDefault(false) + } + + companion object { + private const val MAX_READ = 128 * 1024L + internal fun isDeviceLoss(text: String): Boolean = + Regex("\\bVK_ERROR_DEVICE_LOST\\b|\\bvkerror\\s*=\\s*-4\\b", RegexOption.IGNORE_CASE).containsMatchIn(text) + } +} + +internal object SteamVrRecoveryPolicy { + const val COOLDOWN_MS = 10 * 60 * 1000L + const val STOP_MS = 15_000L + const val LOGS_MS = 30_000L + private const val POLL_MS = 250L + + enum class Shutdown { COMPLETE, LOGS_PENDING, STUCK } + + fun canRestartAutomatically(lastAttempt: Long, now: Long): Boolean = + lastAttempt == 0L || (now >= lastAttempt && now - lastAttempt >= COOLDOWN_MS) + + fun awaitShutdown(session: () -> JSONObject?, now: () -> Long, sleep: (Long) -> Unit): Shutdown { + fun await(ms: Long, done: (JSONObject) -> Boolean): Boolean { + val deadline = now() + ms + while (true) { + if (session()?.let(done) == true) return true + if (now() >= deadline) return false + sleep(POLL_MS) + } + } + if (!await(STOP_MS) { it.optString("phase") in setOf("IDLE", "FAILED") }) return Shutdown.STUCK + return if (await(LOGS_MS) { !it.optBoolean("artifactsAvailable") || it.optBoolean("artifactsComplete") }) Shutdown.COMPLETE + else Shutdown.LOGS_PENDING + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt new file mode 100644 index 000000000..8c045b68e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecovery.kt @@ -0,0 +1,83 @@ +package com.droiddeck.launcher.gpu + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Handler +import android.os.Looper +import com.droiddeck.launcher.R +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 + + @Synchronized fun begin(context: Context, sessionId: String?) { + if (sessionId == session && log != null) return + session = sessionId + log = SteamVrDeviceLossLog(File(LinuxRuntime.rootDir(context), + "root/.local/share/Steam/logs/vrcompositor.txt")) + } + + @Synchronized fun deviceLost(): Boolean = log?.poll() == true +} + +internal class SteamVrRecovery(private val activity: Activity, private val foreground: () -> Boolean) { + private val handler = Handler(Looper.getMainLooper()) + private var dialog: AlertDialog? = null + private var handled = false + private var countdown: Runnable? = null + + fun detected() { + if (handled || !foreground() || activity.isFinishing || activity.isDestroyed) return + handled = true + SessionEvents.record("steamvr.device_lost") + val prefs = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val automatic = SteamVrRecoveryPolicy.canRestartAutomatically(prefs.getLong(LAST_ATTEMPT, 0), System.currentTimeMillis()) + var seconds = 8 + fun message() = if (automatic) activity.getString(R.string.steamvr_recovery_countdown, seconds) + else activity.getString(R.string.steamvr_recovery_repeated) + val prompt = AlertDialog.Builder(activity) + .setTitle(R.string.steamvr_recovery_title) + .setMessage(message()) + .setPositiveButton(R.string.steamvr_recovery_restart) { _, _ -> restart() } + .setNegativeButton(R.string.steamvr_recovery_cancel) { _, _ -> } + .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) { + countdown = object : Runnable { + override fun run() { + if (!foreground() || activity.isFinishing || activity.isDestroyed) { handled = false; prompt.dismiss(); return } + if (--seconds <= 0) { prompt.dismiss(); restart(); return } + prompt.setMessage(message()) + handler.postDelayed(this, 1000) + } + }.also { handler.postDelayed(it, 1000) } + } + } + + 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") + activity.startActivity(Intent(activity, SteamVrRestartActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + fun close() { countdown?.let(handler::removeCallbacks); countdown = null; dialog?.dismiss() } + + companion object { + internal const val PREFS = "steamvr_recovery" + internal const val LAST_ATTEMPT = "lastAttempt" + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt new file mode 100644 index 000000000..c05cc2129 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettings.kt @@ -0,0 +1,27 @@ +package com.droiddeck.launcher.gpu + +import android.util.AtomicFile +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() + try { + output.write(settings.toString(3).toByteArray(Charsets.UTF_8)) + atomic.finishWrite(output) + } catch (e: Exception) { + atomic.failWrite(output) + throw e + } + return true + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt new file mode 100644 index 000000000..671132c4f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/gpu/SteamVrRestartActivity.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.gpu + +import android.app.Activity +import android.app.ActivityManager +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.os.Process +import android.os.SystemClock +import android.view.Gravity +import android.widget.TextView +import com.droiddeck.launcher.R +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) + setContentView(TextView(this).apply { + setText(R.string.steamvr_recovery_restarting) + gravity = Gravity.CENTER + textSize = 22f + }) + Thread({ recover() }, "steamvr-process-recovery").start() + } + + private fun recover() { + try { + val manager = getSystemService(ACTIVITY_SERVICE) as ActivityManager + val main = manager.runningAppProcesses?.firstOrNull { + it.processName == packageName && it.uid == Process.myUid() && it.pid != Process.myPid() + } ?: error("DroidDeck process is unavailable") + val uri = Uri.parse("content://$packageName.agent") + contentResolver.call(uri, "stop", null, null) + val shutdown = SteamVrRecoveryPolicy.awaitShutdown({ + runCatching { + contentResolver.call(uri, "state", null, null)?.getString("json")?.let(::JSONObject)?.optJSONObject("session") + }.getOrNull() + }, 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) + check(!java.io.File("/proc/${main.pid}").exists()) { "DroidDeck process did not exit" } + val settings = java.io.File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(this), + "root/.local/share/Steam/config/steamvr.vrsettings") + runCatching { SteamVrRecoverySettings.unblockHeadset(settings) } + .onSuccess { if (it) android.util.Log.i("SteamVrRecovery", "Cleared the DroidDeck HMD crash block before restarting SteamVR") } + .onFailure { android.util.Log.w("SteamVrRecovery", "Could not read the SteamVR settings; restarting with them unchanged", it) } + runOnUiThread { + startActivity(Intent(this, SessionActivity::class.java) + .putExtra(SessionService.EXTRA_MODE, SessionService.MODE_STEAM) + .putExtra(SessionActivity.EXTRA_STEAMVR, true) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)) + finishAndRemoveTask() + } + } catch (e: Exception) { + android.util.Log.e("SteamVrRecovery", "Could not restart DroidDeck", e) + runOnUiThread { + setContentView(TextView(this).apply { + setText(R.string.steamvr_recovery_failed) + gravity = Gravity.CENTER + textSize = 22f + }) + } + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/BodyPose.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/BodyPose.kt new file mode 100644 index 000000000..52c26a743 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/BodyPose.kt @@ -0,0 +1,255 @@ +package com.droiddeck.launcher.handtracking + +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.abs +import kotlin.math.atan2 +import kotlin.math.hypot + +internal class Landmark(val x: Float, val y: Float, val visibility: Float) + +internal class WorldPoint(val x: Float, val y: Float, val z: Float) + +internal class Intrinsics(val fx: Float, val fy: Float, val cx: Float, val cy: Float, val width: Int, val height: Int) + +internal class CameraLevel(up: Vec3) { + val right: Vec3 + val up: Vec3 + val back: Vec3 + val valid: Boolean + + init { + val u = up.normalized() + val forward = Vec3(0f, 0f, 1f) + val along = u.x * forward.x + u.y * forward.y + u.z * forward.z + val horizontal = Vec3(forward.x - u.x * along, forward.y - u.y * along, forward.z - u.z * along) + valid = u.length() > 0.5f && horizontal.length() > MIN_HORIZONTAL + this.up = u + back = (horizontal * -1f).normalized() + right = this.up.cross(back) + } + + fun level(v: Vec3) = Vec3(dot(v, right), dot(v, up), dot(v, back)) + + private fun dot(a: Vec3, b: Vec3) = a.x * b.x + a.y * b.y + a.z * b.z + + companion object { + private const val MIN_HORIZONTAL = 0.25f + } +} + +internal class BodyFrame( + val hands: Array, + val faceYaw: Float?, + val distance: Float, + val shoulderSpan: Float, + val seen: Boolean, + val estimated: BooleanArray = BooleanArray(2), + val edgeRoom: Float = 1f, + val joints: Array = arrayOfNulls(2), +) + +internal class HandFix(val palm: Vec3, val x: Float, val y: Float, val extent: Float, val points: FloatArray, val world: List = emptyList()) + +internal object Pnp { + fun translation(count: Int, k: Intrinsics, x: (Int) -> Float, y: (Int) -> Float, world: (Int) -> WorldPoint): Vec3? { + val ata = Array(3) { DoubleArray(3) } + val atb = DoubleArray(3) + for (i in 0 until count) { + val a = ((x(i) - k.cx) / k.fx).toDouble() + val b = ((y(i) - k.cy) / k.fy).toDouble() + val w = world(i) + add(ata, atb, doubleArrayOf(1.0, 0.0, -a), a * w.z - w.x) + add(ata, atb, doubleArrayOf(0.0, 1.0, -b), b * w.z - w.y) + } + val t = solve3(ata, atb) ?: return null + return Vec3(t[0].toFloat(), t[1].toFloat(), t[2].toFloat()) + } + + private fun add(ata: Array, atb: DoubleArray, row: DoubleArray, rhs: Double) { + for (i in 0..2) { + atb[i] += row[i] * rhs + for (j in 0..2) ata[i][j] += row[i] * row[j] + } + } + + private fun solve3(a: Array, b: DoubleArray): DoubleArray? { + val det = det3(a) + if (abs(det) < 1e-12) return null + return DoubleArray(3) { col -> det3(Array(3) { r -> DoubleArray(3) { c -> if (c == col) b[r] else a[r][c] } }) / det } + } + + private fun det3(m: Array) = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - + m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) +} + +internal object BodyPose { + const val NOSE = 0 + const val LEFT_EYE = 2 + const val RIGHT_EYE = 5 + const val LEFT_EAR = 7 + const val RIGHT_EAR = 8 + const val RIGHT_MOUTH = 10 + const val LEFT_SHOULDER = 11 + const val RIGHT_SHOULDER = 12 + const val LEFT_ELBOW = 13 + const val RIGHT_ELBOW = 14 + const val LEFT_WRIST = 15 + const val RIGHT_WRIST = 16 + const val LEFT_PINKY = 17 + const val RIGHT_PINKY = 18 + const val LEFT_INDEX = 19 + const val RIGHT_INDEX = 20 + const val LEFT_HIP = 23 + const val RIGHT_HIP = 24 + + private val ANCHORS = intArrayOf(NOSE, LEFT_EYE, RIGHT_EYE, LEFT_EAR, RIGHT_EAR, LEFT_SHOULDER, RIGHT_SHOULDER, LEFT_HIP, RIGHT_HIP) + private val WRISTS = intArrayOf(LEFT_WRIST, RIGHT_WRIST) + private val PINKIES = intArrayOf(LEFT_PINKY, RIGHT_PINKY) + private val INDEXES = intArrayOf(LEFT_INDEX, RIGHT_INDEX) + const val MIN_VISIBILITY = 0.6f + + fun solve( + image: List, + world: List, + k: Intrinsics, + level: CameraLevel, + fixes: Array? = null, + held: FloatArray? = null, + ): BodyFrame? { + if (image.size <= RIGHT_HIP || world.size <= RIGHT_HIP || !level.valid) return null + val t = translation(image, world, k) ?: return null + fun at(i: Int): Vec3 { + val w = world[i] + val depth = w.z + t.z + val px = image[i].x * k.width + val py = image[i].y * k.height + return Vec3((px - k.cx) / k.fx * depth, (py - k.cy) / k.fy * depth, depth) + } + fun visible(i: Int) = image[i].visibility >= MIN_VISIBILITY && image[i].x in 0f..1f && image[i].y in 0f..1f + val eyesSeen = visible(LEFT_EYE) && visible(RIGHT_EYE) + val shouldersSeen = visible(LEFT_SHOULDER) && visible(RIGHT_SHOULDER) + val hands = arrayOfNulls(2) + val estimated = BooleanArray(2) + val joints = arrayOfNulls(2) + if (fixes == null) { + val head = if (eyesSeen) (at(LEFT_EYE) + at(RIGHT_EYE)) * 0.5f else null + if (head != null) for (side in 0..1) { + if (!visible(WRISTS[side])) continue + val grip = (at(WRISTS[side]) + at(PINKIES[side]) + at(INDEXES[side])) * (1f / 3f) + hands[side] = level.level(grip - head) + } + } else { + val head = if (eyesSeen) eyes(image, world, k) else null + if (head != null) for (side in 0..1) { + val fix = fixes.getOrNull(side) + if (fix != null) { + hands[side] = level.level(fix.palm - head) + joints[side] = handJoints(fix.world, level) + continue + } + val depth = held?.getOrNull(side)?.takeIf { it.isFinite() && it > 0f } ?: continue + if (!visible(WRISTS[side])) continue + val gx = (image[WRISTS[side]].x + image[PINKIES[side]].x + image[INDEXES[side]].x) / 3f * k.width + val gy = (image[WRISTS[side]].y + image[PINKIES[side]].y + image[INDEXES[side]].y) / 3f * k.height + hands[side] = level.level(Vec3((gx - k.cx) / k.fx * depth, (gy - k.cy) / k.fy * depth, depth) - head) + estimated[side] = true + } + } + val faceYaw = if (visible(NOSE) && visible(LEFT_EAR) && visible(RIGHT_EAR)) { + val n = world[NOSE] + val l = world[LEFT_EAR] + val r = world[RIGHT_EAR] + val f = level.level(Vec3(n.x - (l.x + r.x) / 2, n.y - (l.y + r.y) / 2, n.z - (l.z + r.z) / 2)) + if (hypot(f.x, f.z) > 0.02f) atan2(-f.x, -f.z) else null + } else null + val span = if (shouldersSeen) hypot(image[LEFT_SHOULDER].x - image[RIGHT_SHOULDER].x, + (image[LEFT_SHOULDER].y - image[RIGHT_SHOULDER].y) * k.height / k.width) else 0f + var room = 1f + for (i in EDGE_JOINTS) { + if (image[i].visibility < MIN_VISIBILITY) continue + room = minOf(room, image[i].x, 1f - image[i].x, image[i].y, 1f - image[i].y) + } + return BodyFrame(hands, faceYaw, t.z, span, eyesSeen && shouldersSeen, estimated, room, joints) + } + + fun handJoints(world: List, level: CameraLevel): FloatArray? { + if (world.size < HAND_POINTS) return null + var mx = 0f + var my = 0f + var mz = 0f + for (i in PALM) { + mx += world[i].x + my += world[i].y + mz += world[i].z + } + val n = PALM.size.toFloat() + val out = FloatArray(HAND_POINTS * 3) + for (i in 0 until HAND_POINTS) { + val v = level.level(Vec3(world[i].x - mx / n, world[i].y - my / n, world[i].z - mz / n)) + out[i * 3] = v.x + out[i * 3 + 1] = v.y + out[i * 3 + 2] = v.z + } + return out + } + + fun eyes(image: List, world: List, k: Intrinsics): Vec3? { + val used = HEAD.filter { image[it].visibility >= MIN_VISIBILITY } + if (used.size < MIN_HEAD_POINTS) return null + val t = Pnp.translation(used.size, k, { image[used[it]].x * k.width }, { image[used[it]].y * k.height }, { world[used[it]] }) + ?: return null + if (!(t.z > MIN_DISTANCE && t.z < MAX_DISTANCE)) return null + val l = world[LEFT_EYE] + val r = world[RIGHT_EYE] + return Vec3(t.x + (l.x + r.x) / 2, t.y + (l.y + r.y) / 2, t.z + (l.z + r.z) / 2) + } + + fun handFix(points: FloatArray, world: List, k: Intrinsics): HandFix? { + if (world.size < HAND_POINTS || points.size < HAND_POINTS * 2) return null + val t = Pnp.translation(HAND_POINTS, k, { points[it * 2] }, { points[it * 2 + 1] }, { world[it] }) ?: return null + if (!(t.z > MIN_HAND_DISTANCE && t.z < MAX_DISTANCE)) return null + var px = 0f + var py = 0f + var wx = 0f + var wy = 0f + var wz = 0f + for (i in PALM) { + px += points[i * 2] + py += points[i * 2 + 1] + wx += world[i].x + wy += world[i].y + wz += world[i].z + } + val n = PALM.size.toFloat() + var minX = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + for (i in 0 until HAND_POINTS) { + minX = minOf(minX, points[i * 2]) + maxX = maxOf(maxX, points[i * 2]) + minY = minOf(minY, points[i * 2 + 1]) + maxY = maxOf(maxY, points[i * 2 + 1]) + } + return HandFix(Vec3(t.x + wx / n, t.y + wy / n, t.z + wz / n), px / n, py / n, maxOf(maxX - minX, maxY - minY), points, world) + } + + private fun translation(image: List, world: List, k: Intrinsics): Vec3? { + val used = ANCHORS.filter { image[it].visibility >= MIN_VISIBILITY } + if (used.size < MIN_ANCHORS) return null + val t = Pnp.translation(used.size, k, { image[used[it]].x * k.width }, { image[used[it]].y * k.height }, { world[used[it]] }) + ?: return null + if (!(t.z > MIN_DISTANCE && t.z < MAX_DISTANCE)) return null + return t + } + + const val HAND_POINTS = 21 + private val HEAD = (NOSE..RIGHT_MOUTH).toList() + private val PALM = intArrayOf(0, 5, 9, 13, 17) + private val EDGE_JOINTS = intArrayOf(LEFT_ELBOW, RIGHT_ELBOW, LEFT_WRIST, RIGHT_WRIST) + private const val MIN_ANCHORS = 4 + private const val MIN_HEAD_POINTS = 4 + private const val MIN_DISTANCE = 0.2f + private const val MIN_HAND_DISTANCE = 0.05f + private const val MAX_DISTANCE = 6f +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt new file mode 100644 index 000000000..ff2fccb85 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCamera.kt @@ -0,0 +1,323 @@ +package com.droiddeck.launcher.handtracking + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.hardware.camera2.CaptureRequest +import android.os.Build +import android.os.PowerManager +import android.os.SystemClock +import android.util.Log +import android.util.Range +import android.util.Size +import androidx.camera.camera2.interop.Camera2CameraInfo +import androidx.camera.camera2.interop.Camera2Interop +import androidx.camera.camera2.interop.ExperimentalCamera2Interop +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.droiddeck.launcher.glasses.Vec3 +import com.google.mediapipe.framework.image.BitmapImageBuilder +import com.google.mediapipe.tasks.core.BaseOptions +import com.google.mediapipe.tasks.core.Delegate +import com.google.mediapipe.tasks.vision.core.RunningMode +import com.google.mediapipe.tasks.vision.poselandmarker.PoseLandmarker +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.Executors +import kotlin.math.abs + +@androidx.annotation.OptIn(ExperimentalCamera2Interop::class) +internal class HandCamera( + context: Context, + private val output: File?, + private val preview: Boolean, +) : LifecycleOwner { + class View( + val image: Bitmap, + val front: Boolean, + val landmarks: List?, + val placement: Placement, + val advice: Advice, + ) + + private val context = context.applicationContext + private val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle get() = registry + private val executor = Executors.newSingleThreadExecutor { Thread(it, "hand-camera") } + private val sensors = this.context.getSystemService(SensorManager::class.java) + private val power = this.context.getSystemService(PowerManager::class.java) + private val state = MutableStateFlow(null) + val view: StateFlow = state.asStateFlow() + private val broken = MutableStateFlow(false) + val failed: StateFlow = broken.asStateFlow() + @Volatile private var up: Vec3? = null + @Volatile private var thermal = 0 + @Volatile private var stopped = false + @Volatile var bareHands = false + set(value) { + if (field != value) Log.i(TAG, if (value) "no controllers: tracking bare hands" else "controllers in use: placing them only") + field = value + } + private var provider: ProcessCameraProvider? = null + private var analysis: ImageAnalysis? = null + private var landmarker: PoseLandmarker? = null + private var hands: HandModel? = null + private val heldDepth = FloatArray(2) { Float.NaN } + private val heldAt = LongArray(2) + private var shm: HandCameraShm? = null + private var camera: TrackingCamera? = null + private var zoom = 1f + private var lastRun = 0L + private var lastStampMs = 0L + private var skipped = 0 + private var recorder: HandCameraRecorder? = null + private var rawFrame: Bitmap? = null + private var turnedFrame: Bitmap? = null + + private val gravity = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val v = Vec3(event.values[0], event.values[1], event.values[2]) + val last = up + up = if (event.sensor.type == Sensor.TYPE_GRAVITY || last == null) v else last + (v - last) * ACCEL_SMOOTHING + } + + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {} + } + + private val heat: Any? = if (Build.VERSION.SDK_INT >= 29) PowerManager.OnThermalStatusChangedListener { thermal = it } else null + + init { + registry.currentState = Lifecycle.State.CREATED + } + + fun start(side: String?) { + val chosen = TrackingCameras.pick(HandTracking.cameras(context), side) + if (chosen == null) { + broken.value = true + return + } + camera = chosen + zoom = chosen.zoom() + recorder = HandCameraRecorder.start(context, output, preview, chosen) + val sensor = sensors?.getDefaultSensor(Sensor.TYPE_GRAVITY) ?: sensors?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + if (sensor != null) sensors?.registerListener(gravity, sensor, SensorManager.SENSOR_DELAY_UI) + if (Build.VERSION.SDK_INT >= 29) { + thermal = power?.currentThermalStatus ?: 0 + power?.addThermalStatusListener(ContextCompat.getMainExecutor(context), heat as PowerManager.OnThermalStatusChangedListener) + } + executor.execute { prepare() } + val future = ProcessCameraProvider.getInstance(context) + future.addListener({ + if (stopped) return@addListener + runCatching { bind(future.get(), chosen) }.onFailure { + Log.w(TAG, "camera ${chosen.id} could not start", it) + broken.value = true + } + }, ContextCompat.getMainExecutor(context)) + } + + fun stop() { + if (stopped) return + stopped = true + sensors?.unregisterListener(gravity) + if (Build.VERSION.SDK_INT >= 29) power?.removeThermalStatusListener(heat as PowerManager.OnThermalStatusChangedListener) + analysis?.let { + it.clearAnalyzer() + runCatching { provider?.unbind(it) } + } + registry.currentState = Lifecycle.State.DESTROYED + executor.execute { + runCatching { landmarker?.close() } + landmarker = null + hands?.close() + hands = null + shm?.close() + shm = null + recorder?.close() + recorder = null + } + executor.shutdown() + } + + private fun bind(cameras: ProcessCameraProvider, chosen: TrackingCamera) { + val selector = CameraSelector.Builder() + .addCameraFilter { infos -> infos.filter { Camera2CameraInfo.from(it).cameraId == chosen.id } } + .build() + val builder = ImageAnalysis.Builder() + val extender = Camera2Interop.Extender(builder) + chosen.fps?.let { extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, Range(it.first, it.second)) } + recorder?.let { extender.setSessionCaptureCallback(it.callback) } + val use = builder + .setResolutionSelector( + ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy(ResolutionStrategy(Size(ANALYSIS_WIDTH, ANALYSIS_HEIGHT), ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER)) + .build(), + ) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) + .build() + use.setAnalyzer(executor, ::analyze) + val bound = cameras.bindToLifecycle(this, selector, use) + if (zoom < 1f) bound.cameraControl.setZoomRatio(zoom) + provider = cameras + analysis = use + registry.currentState = Lifecycle.State.RESUMED + Log.i(TAG, "camera ${chosen.id} (${if (chosen.front) "front" else "rear"}, ${chosen.fovDegrees.toInt()}°, zoom $zoom, ${chosen.fps} fps) " + + if (preview) "checking placement" else "tracking hands") + } + + private fun prepare() { + if (stopped) return + runCatching { + val bytes = context.assets.open(MODEL).use { it.readBytes() } + val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes) + model.rewind() + val options = PoseLandmarker.PoseLandmarkerOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build()) + .setRunningMode(RunningMode.VIDEO) + .setNumPoses(1) + .setMinPoseDetectionConfidence(MIN_CONFIDENCE) + .setMinPosePresenceConfidence(MIN_CONFIDENCE) + .setOutputSegmentationMasks(false) + .build() + landmarker = PoseLandmarker.createFromOptions(context, options) + if (output != null) shm = HandCameraShm.open(output) + }.onFailure { + Log.w(TAG, "pose model could not load", it) + broken.value = true + } + if (landmarker != null) hands = runCatching { HandModel.open(context) }.onFailure { Log.w(TAG, "hand model could not load", it) }.getOrNull() + } + + private fun analyze(image: ImageProxy) { + try { + val chosen = camera ?: return + val pose = landmarker ?: return + val arrival = System.nanoTime() + if (stopped || arrival - lastRun < interval()) { + skipped++ + return + } + lastRun = arrival + val exposed = exposure(image.imageInfo.timestamp, arrival, chosen) + val stampMs = maxOf(exposed / 1_000_000L, lastStampMs + 1) + lastStampMs = stampMs + val gravity = up + val placement = if (gravity != null) CameraGeometry.placement(chosen.front, gravity) else Placement.UNKNOWN + val turns = if (gravity != null) CameraGeometry.uprightTurns(chosen.front, chosen.orientation, gravity) + else image.imageInfo.rotationDegrees / 90 + val frame = upright(raw(image), turns) + val result = pose.detectForVideo(BitmapImageBuilder(frame).build(), stampMs) + val inferred = System.nanoTime() + val found = result.landmarks().firstOrNull() + val marks = found?.map { Landmark(it.x(), it.y(), it.visibility().orElse(0f)) } + val world = result.worldLandmarks().firstOrNull()?.map { WorldPoint(it.x(), it.y(), it.z()) } + val k = chosen.intrinsics(image.width, image.height, zoom, turns) + val model = hands + val fixes = if (marks != null && model != null) model.find(frame, marks, k, stampMs) else null + val handsDone = System.nanoTime() + fixes?.forEachIndexed { side, fix -> + if (fix != null) { + heldDepth[side] = fix.palm.z + heldAt[side] = exposed + } + } + val held = FloatArray(2) { if (exposed - heldAt[it] <= HOLD_NS) heldDepth[it] else Float.NaN } + val body = if (marks != null && world != null && gravity != null) { + BodyPose.solve(marks, world, k, CameraLevel(CameraGeometry.upInCamera(chosen.front, chosen.orientation, turns, gravity)), + if (model != null) fixes ?: arrayOfNulls(2) else null, held) + } else null + shm?.write(exposed, body, bareHands) + if (preview) state.value = View(frame, chosen.front, marks, placement, PlacementAdvice.of(placement, body)) + recorder?.frame(HandCameraRecorder.Frame(exposed, image.imageInfo.timestamp, arrival, inferred, System.nanoTime(), skipped, + thermal, turns, placement, gravity, k, + found?.map { floatArrayOf(it.x(), it.y(), it.z(), it.visibility().orElse(0f), it.presence().orElse(0f)) }, + world, body, handsDone, fixes), frame) + skipped = 0 + } catch (e: Exception) { + Log.w(TAG, "camera frame skipped", e) + } finally { + image.close() + } + } + + private fun raw(image: ImageProxy): Bitmap { + val plane = image.planes[0] + if (plane.pixelStride != 4 || plane.rowStride != image.width * 4) return image.toBitmap() + val bitmap = rawFrame?.takeIf { it.width == image.width && it.height == image.height } + ?: Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888).also { rawFrame = it } + plane.buffer.rewind() + bitmap.copyPixelsFromBuffer(plane.buffer) + return bitmap + } + + private fun upright(raw: Bitmap, turns: Int): Bitmap { + val quarter = Math.floorMod(turns, 4) + if (quarter == 0) return if (preview) raw.copy(Bitmap.Config.ARGB_8888, false) else raw + val width = if (quarter % 2 == 1) raw.height else raw.width + val height = if (quarter % 2 == 1) raw.width else raw.height + val target = if (preview) Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + else turnedFrame?.takeIf { it.width == width && it.height == height } + ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { turnedFrame = it } + val matrix = Matrix() + matrix.postRotate(90f * quarter) + when (quarter) { + 1 -> matrix.postTranslate(raw.height.toFloat(), 0f) + 2 -> matrix.postTranslate(raw.width.toFloat(), raw.height.toFloat()) + else -> matrix.postTranslate(0f, raw.width.toFloat()) + } + Canvas(target).drawBitmap(raw, matrix, null) + return target + } + + private fun exposure(stamp: Long, arrival: Long, chosen: TrackingCamera): Long { + val monotonic = if (chosen.realtime) stamp - (SystemClock.elapsedRealtimeNanos() - System.nanoTime()) else stamp + return if (abs(arrival - monotonic) < MAX_DELAY_NS) monotonic else arrival - TYPICAL_DELAY_NS + } + + private fun interval(): Long { + val fps = when { + thermal >= SEVERE -> HOT_FPS + thermal >= MODERATE -> WARM_FPS + else -> FPS + } + return 1_000_000_000L / fps - SLACK_NS + } + + companion object { + private const val TAG = "HandCamera" + private const val MODEL = "handtracking/pose_landmarker_lite.task" + private const val ANALYSIS_WIDTH = 960 + private const val ANALYSIS_HEIGHT = 720 + private const val MIN_CONFIDENCE = 0.5f + private const val FPS = 15 + private const val WARM_FPS = 10 + private const val HOT_FPS = 6 + private const val MODERATE = 2 + private const val SEVERE = 3 + private const val SLACK_NS = 8_000_000L + private const val MAX_DELAY_NS = 1_000_000_000L + private const val TYPICAL_DELAY_NS = 60_000_000L + private const val ACCEL_SMOOTHING = 0.1f + private const val HOLD_NS = 500_000_000L + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraRecorder.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraRecorder.kt new file mode 100644 index 000000000..8bd4a17ae --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraRecorder.kt @@ -0,0 +1,203 @@ +package com.droiddeck.launcher.handtracking + +import android.content.Context +import android.graphics.Bitmap +import android.hardware.camera2.CameraCaptureSession +import android.hardware.camera2.CaptureRequest +import android.hardware.camera2.CaptureResult +import android.hardware.camera2.TotalCaptureResult +import android.util.Log +import com.droiddeck.launcher.glasses.Vec3 +import java.io.BufferedWriter +import java.io.File +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.max +import kotlin.math.roundToInt + +internal class HandCameraRecorder private constructor(private val dir: File, private val traceFlag: File?) { + class Capture(val exposureNs: Long, val frameDurationNs: Long, val iso: Int) + + class Frame( + val exposureNs: Long, + val sensorNs: Long, + val arrivalNs: Long, + val inferredNs: Long, + val doneNs: Long, + val skipped: Int, + val thermal: Int, + val turns: Int, + val placement: Placement, + val gravity: Vec3?, + val intrinsics: Intrinsics?, + val marks: List?, + val world: List?, + val body: BodyFrame?, + val handsNs: Long = 0L, + val fixes: Array? = null, + ) + + private val writer = Executors.newSingleThreadExecutor { Thread(it, "hand-camera-record") } + private val lines: BufferedWriter = File(dir, "frames.jsonl").bufferedWriter() + private val busy = AtomicBoolean(false) + private val captures = object : LinkedHashMap() { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > KEEP_CAPTURES + } + private var count = 0 + @Volatile private var closed = false + + val callback = object : CameraCaptureSession.CaptureCallback() { + override fun onCaptureCompleted(session: CameraCaptureSession, request: CaptureRequest, result: TotalCaptureResult) { + val stamp = result.get(CaptureResult.SENSOR_TIMESTAMP) ?: return + val capture = Capture(result.get(CaptureResult.SENSOR_EXPOSURE_TIME) ?: 0L, + result.get(CaptureResult.SENSOR_FRAME_DURATION) ?: 0L, result.get(CaptureResult.SENSOR_SENSITIVITY) ?: 0) + synchronized(captures) { captures[stamp] = capture } + } + } + + fun frame(frame: Frame, image: Bitmap) { + if (closed || count >= MAX_FRAMES) return + val index = ++count + val capture = synchronized(captures) { captures[frame.sensorNs] } + val small = if (busy.compareAndSet(false, true)) runCatching { shrink(image) }.getOrNull().also { if (it == null) busy.set(false) } else null + val name = if (small != null) "%05d.jpg".format(Locale.ROOT, index) else null + val line = json(index, frame, capture, name) + val queued = runCatching { writer.execute { + if (small != null) { + runCatching { FileOutputStream(File(dir, name!!)).use { small.compress(Bitmap.CompressFormat.JPEG, QUALITY, it) } } + small.recycle() + busy.set(false) + } + runCatching { + lines.write(line) + lines.newLine() + if (index % FLUSH_EVERY == 0) lines.flush() + } + } } + if (queued.isFailure) { + small?.recycle() + busy.set(false) + } + } + + fun close() { + if (closed) return + closed = true + traceFlag?.delete() + writer.execute { runCatching { lines.close() } } + writer.shutdown() + Log.i(TAG, "recorded $count frames to $dir") + } + + private fun shrink(image: Bitmap): Bitmap { + val scale = SIDE.toFloat() / max(image.width, image.height) + if (scale >= 1f) return image.copy(Bitmap.Config.ARGB_8888, false) + return Bitmap.createScaledBitmap(image, (image.width * scale).roundToInt(), (image.height * scale).roundToInt(), true) + } + + private fun json(index: Int, f: Frame, capture: Capture?, jpg: String?): String = buildString { + append("{\"i\":").append(index) + append(",\"exp\":").append(f.exposureNs) + append(",\"sensor\":").append(f.sensorNs) + append(",\"arr\":").append(f.arrivalNs) + append(",\"inf\":").append(f.inferredNs) + append(",\"done\":").append(f.doneNs) + append(",\"hand\":").append(f.handsNs) + append(",\"skip\":").append(f.skipped) + append(",\"heat\":").append(f.thermal) + append(",\"turns\":").append(f.turns) + append(",\"place\":\"").append(f.placement.name).append('"') + append(",\"g\":").vec(f.gravity) + f.intrinsics?.let { append(",\"k\":[").num(it.fx).append(',').num(it.cx).append(',').num(it.cy).append(',').append(it.width).append(',').append(it.height).append(']') } + capture?.let { append(",\"ae\":[").append(it.exposureNs).append(',').append(it.frameDurationNs).append(',').append(it.iso).append(']') } + append(",\"lm\":") + if (f.marks == null) append("null") else { + append('[') + f.marks.forEachIndexed { i, m -> + if (i > 0) append(',') + append('[') + m.forEachIndexed { j, v -> if (j > 0) append(','); num(v) } + append(']') + } + append(']') + } + append(",\"wl\":") + if (f.world == null) append("null") else { + append('[') + f.world.forEachIndexed { i, w -> + if (i > 0) append(',') + append('[').num(w.x).append(',').num(w.y).append(',').num(w.z).append(']') + } + append(']') + } + f.body?.let { b -> + append(",\"hands\":[").vec(b.hands[0]).append(',').vec(b.hands[1]).append(']') + append(",\"face\":"); if (b.faceYaw == null) append("null") else num(b.faceYaw) + append(",\"est\":[").append(b.estimated[0]).append(',').append(b.estimated[1]).append(']') + append(",\"room\":").num(b.edgeRoom) + append(",\"dist\":").num(b.distance) + append(",\"span\":").num(b.shoulderSpan) + append(",\"joints\":[") + b.joints.forEachIndexed { i, j -> + if (i > 0) append(',') + if (j == null) append("null") else { + append('[') + j.forEachIndexed { k, v -> if (k > 0) append(','); num(v) } + append(']') + } + } + append(']') + } + f.fixes?.let { fixes -> + append(",\"fix\":[") + fixes.forEachIndexed { i, fix -> + if (i > 0) append(',') + if (fix == null) append("null") else { + append("{\"palm\":").vec(fix.palm).append(",\"at\":[").num(fix.x).append(',').num(fix.y).append("],\"size\":").num(fix.extent) + append(",\"pts\":[") + fix.points.forEachIndexed { j, v -> if (j > 0) append(','); num(v) } + append("],\"world\":[") + fix.world.forEachIndexed { j, w -> if (j > 0) append(','); num(w.x).append(',').num(w.y).append(',').num(w.z) } + append("]}") + } + } + append(']') + } + append(",\"jpg\":"); if (jpg == null) append("null") else append('"').append(jpg).append('"') + append('}') + } + + private fun StringBuilder.num(v: Float): StringBuilder = if (v.isFinite()) append(v) else append("null") + + private fun StringBuilder.vec(v: Vec3?): StringBuilder = + if (v == null) append("null") else append('[').num(v.x).append(',').num(v.y).append(',').num(v.z).append(']') + + companion object { + private const val TAG = "HandCamera" + const val FLAG = "handcam-record" + const val TRACE_FLAG = "droiddeck-handtrace.on" + private const val MAX_FRAMES = 5400 + private const val KEEP_CAPTURES = 32 + private const val SIDE = 640 + private const val QUALITY = 85 + private const val FLUSH_EVERY = 30 + + fun start(context: Context, output: File?, preview: Boolean, camera: TrackingCamera): HandCameraRecorder? { + if (!File(context.filesDir, FLAG).exists()) return null + return runCatching { + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ROOT).format(Date()) + val dir = File(context.cacheDir, "handcam-rec/$stamp-${if (preview) "check" else "track"}").apply { mkdirs() } + File(dir, "camera.json").writeText("{\"id\":\"${camera.id}\",\"front\":${camera.front},\"orientation\":${camera.orientation}," + + "\"focal\":${camera.focalPixels},\"active\":[${camera.activeWidth},${camera.activeHeight}]," + + "\"fps\":${camera.fps?.let { "[${it.first},${it.second}]" } ?: "null"}}") + val flag = output?.parentFile?.let { File(it, TRACE_FLAG) }?.also { it.createNewFile() } + Log.i(TAG, "recording hand camera to $dir") + HandCameraRecorder(dir, flag) + }.onFailure { Log.w(TAG, "hand camera recording unavailable", it) }.getOrNull() + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraShm.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraShm.kt new file mode 100644 index 000000000..946018d2a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandCameraShm.kt @@ -0,0 +1,103 @@ +package com.droiddeck.launcher.handtracking + +import android.util.Log +import com.droiddeck.launcher.glasses.Vec3 +import com.droiddeck.launcher.input.FakeInputWriter +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteOrder +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel + +internal class HandCameraShm private constructor( + private val file: RandomAccessFile, + private val map: MappedByteBuffer, + private val fence: () -> Unit, +) { + private var seq = map.getInt(SEQ).let { if (it and 1 != 0) it + 1 else it } + private var frame = 0 + + init { + begin() + map.putInt(MAGIC_AT, MAGIC) + map.putInt(VERSION_AT, VERSION) + for (at in FLAGS until SIZE step 4) map.putInt(at, 0) + end() + } + + fun write(timeNs: Long, body: BodyFrame?, bareHands: Boolean = false) { + begin() + val face = body?.faceYaw + map.putInt(FLAGS, RUNNING or (if (face != null) FACE else 0) or (if (bareHands) BARE_HANDS else 0)) + map.putLong(TIME, timeNs) + map.putInt(FRAME, ++frame) + map.putFloat(FACE_YAW, face ?: 0f) + for (side in 0..1) { + val at = HANDS + side * HAND_SIZE + val hand: Vec3? = body?.hands?.getOrNull(side) + val joints = if (hand != null) body?.joints?.getOrNull(side)?.takeIf { it.size == JOINT_FLOATS } else null + val seen = if (body?.estimated?.getOrNull(side) == true) SEEN or ESTIMATED else SEEN + map.putInt(at, if (hand == null) 0 else if (joints != null) seen or JOINTS else seen) + map.putFloat(at + 4, hand?.x ?: 0f) + map.putFloat(at + 8, hand?.y ?: 0f) + map.putFloat(at + 12, hand?.z ?: 0f) + for (i in 0 until JOINT_FLOATS) map.putFloat(at + JOINTS_AT + i * 4, joints?.get(i) ?: 0f) + } + end() + } + + fun close() { + begin() + for (at in FLAGS until SIZE step 4) map.putInt(at, 0) + end() + runCatching { file.close() } + } + + private fun begin() { + map.putInt(SEQ, ++seq) + fence() + } + + private fun end() { + fence() + map.putInt(SEQ, ++seq) + } + + companion object { + const val FILE = "droiddeck-handcam" + const val SIZE = 568 + private const val MAGIC = 0x4D434844 + private const val VERSION = 2 + private const val MAGIC_AT = 0 + private const val VERSION_AT = 4 + private const val SEQ = 8 + private const val FLAGS = 12 + private const val TIME = 16 + private const val FRAME = 24 + private const val FACE_YAW = 28 + private const val HANDS = 32 + private const val HAND_SIZE = 268 + private const val JOINTS_AT = 16 + private const val JOINT_FLOATS = BodyPose.HAND_POINTS * 3 + private const val RUNNING = 1 + private const val FACE = 2 + private const val BARE_HANDS = 4 + private const val SEEN = 1 + private const val ESTIMATED = 2 + private const val JOINTS = 4 + + fun open(path: File, fence: () -> Unit = FakeInputWriter::storeFence): HandCameraShm? = runCatching { + path.parentFile?.mkdirs() + val file = RandomAccessFile(path, "rw") + try { + file.setLength(SIZE.toLong()) + val map = file.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()) + map.order(ByteOrder.LITTLE_ENDIAN) + HandCameraShm(file, map, fence) + } catch (e: Exception) { + file.close() + throw e + } + }.onFailure { Log.w("HandCamera", "hand camera file unavailable: $path", it) }.getOrNull() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt new file mode 100644 index 000000000..0f4e754d1 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandModel.kt @@ -0,0 +1,172 @@ +package com.droiddeck.launcher.handtracking + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import com.google.mediapipe.framework.image.BitmapImageBuilder +import com.google.mediapipe.tasks.components.containers.Landmark as MpLandmark +import com.google.mediapipe.tasks.components.containers.NormalizedLandmark +import com.google.mediapipe.tasks.core.BaseOptions +import com.google.mediapipe.tasks.core.Delegate +import com.google.mediapipe.tasks.vision.core.RunningMode +import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.hypot +import kotlin.math.max + +internal class HandModel private constructor(private val landmarkers: List, private val starter: HandLandmarker) { + private class Track(val x: Float, val y: Float, val size: Float) + + private val crops = List(2) { Bitmap.createBitmap(CROP, CROP, Bitmap.Config.ARGB_8888) } + private val startCrop = Bitmap.createBitmap(START_CROP, START_CROP, Bitmap.Config.ARGB_8888) + private val tracks = arrayOfNulls(2) + private val starts = IntArray(2) + private val paint = Paint(Paint.FILTER_BITMAP_FLAG) + private val matrix = Matrix() + + fun find(frame: Bitmap, marks: List, k: Intrinsics, stampMs: Long): Array { + val fixes = arrayOfNulls(2) + if (marks.size <= BodyPose.RIGHT_INDEX) return fixes + val shoulders = hypot((marks[BodyPose.LEFT_SHOULDER].x - marks[BodyPose.RIGHT_SHOULDER].x) * k.width, + (marks[BodyPose.LEFT_SHOULDER].y - marks[BodyPose.RIGHT_SHOULDER].y) * k.height) + for (side in 0..1) { + val wrist = marks[WRISTS[side]] + val wx = wrist.x * k.width + val wy = wrist.y * k.height + val fix = tracks[side]?.let { follow(frame, it, side, k, stampMs) }?.takeIf { belongs(it, marks, side, shoulders, k) } + ?: start(frame, marks, side, wx, wy, shoulders, k)?.takeIf { belongs(it, marks, side, shoulders, k) } + if (fix == null) { + tracks[side] = null + continue + } + fixes[side] = fix + starts[side] = 0 + tracks[side] = Track(fix.x, fix.y, max(fix.extent, shoulders * MIN_HAND)) + } + val left = fixes[0] + val right = fixes[1] + if (left != null && right != null && hypot(left.x - right.x, left.y - right.y) < max(left.extent, right.extent) * SAME_HAND) { + val drop = if (gap(left, marks, 0, k) <= gap(right, marks, 1, k)) 1 else 0 + fixes[drop] = null + tracks[drop] = null + } + return fixes + } + + fun close() { + landmarkers.forEach { runCatching { it.close() } } + runCatching { starter.close() } + crops.forEach { it.recycle() } + startCrop.recycle() + } + + private fun follow(frame: Bitmap, track: Track, side: Int, k: Intrinsics, stampMs: Long): HandFix? { + val size = max(track.size * CROP_HANDS, MIN_CROP) + val x0 = track.x - size / 2 + val y0 = track.y - size / 2 + draw(frame, crops[side], x0, y0, size) + val result = landmarkers[side].detectForVideo(BitmapImageBuilder(crops[side]).build(), stampMs) + val found = result.landmarks().firstOrNull() ?: return null + val world = result.worldLandmarks().firstOrNull() ?: return null + return fixOf(found, world, x0, y0, size, k) + } + + private fun start(frame: Bitmap, marks: List, side: Int, wx: Float, wy: Float, shoulders: Float, k: Intrinsics): HandFix? { + val wrist = marks[WRISTS[side]] + if (wrist.visibility < MIN_VISIBILITY || shoulders < 1f) return null + if (wrist.x < -EDGE || wrist.x > 1f + EDGE || wrist.y < -EDGE || wrist.y > 1f + EDGE) return null + val wide = starts[side]++ % 2 == 0 + val tx = (marks[PINKIES[side]].x + marks[INDEXES[side]].x) / 2 * k.width - wx + val ty = (marks[PINKIES[side]].y + marks[INDEXES[side]].y) / 2 * k.height - wy + val length = hypot(tx, ty) + val reach = if (wide) 0f else shoulders * TOWARD_FINGERS + val cx = if (length > 1e-3f) wx + tx / length * reach else wx + val cy = if (length > 1e-3f) wy + ty / length * reach else wy + val size = max(shoulders * if (wide) WIDE_SHOULDERS else START_SHOULDERS, MIN_CROP) + val x0 = cx - size / 2 + val y0 = cy - size / 2 + draw(frame, startCrop, x0, y0, size) + val result = starter.detect(BitmapImageBuilder(startCrop).build()) + var best: HandFix? = null + var bestGap = Float.MAX_VALUE + result.landmarks().forEachIndexed { i, found -> + val world = result.worldLandmarks().getOrNull(i) ?: return@forEachIndexed + val fix = fixOf(found, world, x0, y0, size, k) ?: return@forEachIndexed + val gap = hypot(fix.points[0] - wx, fix.points[1] - wy) + if (gap < bestGap) { + best = fix + bestGap = gap + } + } + return best + } + + private fun draw(frame: Bitmap, crop: Bitmap, x0: Float, y0: Float, size: Float) { + crop.eraseColor(Color.BLACK) + matrix.setTranslate(-x0, -y0) + matrix.postScale(crop.width / size, crop.height / size) + Canvas(crop).drawBitmap(frame, matrix, paint) + } + + private fun fixOf(found: List, world: List, x0: Float, y0: Float, size: Float, k: Intrinsics): HandFix? { + if (found.size < BodyPose.HAND_POINTS || world.size < BodyPose.HAND_POINTS) return null + val points = FloatArray(BodyPose.HAND_POINTS * 2) { i -> + val p = found[i / 2] + if (i % 2 == 0) x0 + p.x() * size else y0 + p.y() * size + } + return BodyPose.handFix(points, world.map { WorldPoint(it.x(), it.y(), it.z()) }, k) + } + + private fun belongs(fix: HandFix, marks: List, side: Int, shoulders: Float, k: Intrinsics): Boolean { + val own = marks[WRISTS[side]] + if (hypot(fix.x - own.x * k.width, fix.y - own.y * k.height) > max(fix.extent, shoulders) * MAX_WRIST_GAP) return false + val other = marks[WRISTS[1 - side]] + if (other.visibility < MIN_VISIBILITY || other.x !in 0f..1f || other.y !in 0f..1f) return true + return hypot(fix.points[0] - other.x * k.width, fix.points[1] - other.y * k.height) >= + hypot(fix.points[0] - own.x * k.width, fix.points[1] - own.y * k.height) + } + + private fun gap(fix: HandFix, marks: List, side: Int, k: Intrinsics): Float = + hypot(fix.x - marks[WRISTS[side]].x * k.width, fix.y - marks[WRISTS[side]].y * k.height) + + companion object { + private const val MODEL = "handtracking/hand_landmarker.task" + private val WRISTS = intArrayOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST) + private val PINKIES = intArrayOf(BodyPose.LEFT_PINKY, BodyPose.RIGHT_PINKY) + private val INDEXES = intArrayOf(BodyPose.LEFT_INDEX, BodyPose.RIGHT_INDEX) + private const val CROP = 256 + private const val START_CROP = 384 + private const val CROP_HANDS = 2.8f + private const val MIN_CROP = 48f + private const val START_SHOULDERS = 2.5f + private const val WIDE_SHOULDERS = 4f + private const val MIN_HAND = 0.25f + private const val TOWARD_FINGERS = 0.35f + private const val MAX_WRIST_GAP = 3f + private const val SAME_HAND = 0.5f + private const val EDGE = 0.05f + private const val MIN_VISIBILITY = 0.5f + private const val MIN_CONFIDENCE = 0.4f + + fun open(context: Context): HandModel { + val bytes = context.assets.open(MODEL).use { it.readBytes() } + fun landmarker(mode: RunningMode, hands: Int): HandLandmarker { + val model = ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()).put(bytes) + model.rewind() + return HandLandmarker.createFromOptions(context, HandLandmarker.HandLandmarkerOptions.builder() + .setBaseOptions(BaseOptions.builder().setModelAssetBuffer(model).setDelegate(Delegate.CPU).build()) + .setRunningMode(mode) + .setNumHands(hands) + .setMinHandDetectionConfidence(MIN_CONFIDENCE) + .setMinHandPresenceConfidence(MIN_CONFIDENCE) + .setMinTrackingConfidence(MIN_CONFIDENCE) + .build()) + } + return HandModel(List(2) { landmarker(RunningMode.VIDEO, 1) }, landmarker(RunningMode.IMAGE, 2)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/HandTracking.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/HandTracking.kt new file mode 100644 index 000000000..de2d4dbe2 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/HandTracking.kt @@ -0,0 +1,110 @@ +package com.droiddeck.launcher.handtracking + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import com.droiddeck.launcher.input.ControllerPrefs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal object HandTracking { + data class Settings( + val enabled: Boolean = false, + val asked: Boolean = false, + val side: String? = null, + val permitted: Boolean = false, + val sides: Set = emptySet(), + ) { + val usable: Boolean get() = sides.isNotEmpty() + val ready: Boolean get() = enabled && permitted && usable + } + + private val state = MutableStateFlow(Settings()) + val settings: StateFlow = state.asStateFlow() + private val checks = MutableStateFlow(0) + val checkRequests: StateFlow = checks.asStateFlow() + @Volatile private var cameras: List? = null + + val supported: Boolean get() = Build.VERSION.SDK_INT >= 31 + + fun load(context: Context): Settings { + val prefs = ControllerPrefs.prefs(context) + val found = cameras(context) + val next = Settings( + enabled = prefs.getBoolean(ENABLED, false), + asked = prefs.getBoolean(ASKED, false), + side = prefs.getString(SIDE, null), + permitted = permitted(context), + sides = if (supported) TrackingCameras.sides(found) else emptySet(), + ) + state.value = next + return next + } + + fun cameras(context: Context): List = + cameras ?: TrackingCameras.list(context.applicationContext).also { cameras = it } + + fun camera(context: Context): TrackingCamera? = TrackingCameras.pick(cameras(context), state.value.side) + + fun permitted(context: Context): Boolean = + context.checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + + fun refreshPermission(context: Context) { + state.value = state.value.copy(permitted = permitted(context)) + } + + fun allow(context: Context, side: String?) { + val edit = ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, true) + if (side != null) edit.putString(SIDE, side) + edit.apply() + load(context) + } + + fun decline(context: Context) { + ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, false).apply() + load(context) + } + + fun setEnabled(context: Context, on: Boolean) { + ControllerPrefs.prefs(context).edit().putBoolean(ASKED, true).putBoolean(ENABLED, on).apply() + load(context) + } + + fun setSide(context: Context, side: String) { + ControllerPrefs.prefs(context).edit().putString(SIDE, side).apply() + load(context) + } + + fun shouldAsk(settings: Settings, headset: Boolean): Boolean = headset && !settings.asked && settings.usable + + fun wanted(context: Context): Boolean = supported && load(context).ready + + fun requestCheck() { + checks.value++ + } + + private const val ENABLED = "handCamera" + private const val ASKED = "handCameraAsked" + private const val SIDE = "handCameraSide" +} + +internal enum class Advice { STARTING, STAND_UP, UPRIGHT, NOT_SEEN, MOVE_BACK, MOVE_CLOSER, SHOW_HANDS, GOOD } + +internal object PlacementAdvice { + const val MIN_SPAN = 0.12f + const val MAX_SPAN = 0.26f + const val MIN_ROOM = 0.05f + + fun of(placement: Placement, body: BodyFrame?): Advice = when { + placement == Placement.FACE_UP || placement == Placement.FACE_DOWN -> Advice.STAND_UP + placement == Placement.POINTS_UP || placement == Placement.POINTS_DOWN -> Advice.UPRIGHT + body == null || !body.seen -> Advice.NOT_SEEN + body.shoulderSpan > MAX_SPAN -> Advice.MOVE_BACK + body.shoulderSpan < MIN_SPAN -> Advice.MOVE_CLOSER + body.edgeRoom < MIN_ROOM -> Advice.MOVE_BACK + body.hands.any { it == null } -> Advice.SHOW_HANDS + else -> Advice.GOOD + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingCameras.kt b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingCameras.kt new file mode 100644 index 000000000..196e5188f --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/handtracking/TrackingCameras.kt @@ -0,0 +1,136 @@ +package com.droiddeck.launcher.handtracking + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import android.os.Build +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.asin +import kotlin.math.atan +import kotlin.math.max +import kotlin.math.min +import kotlin.math.tan + +internal class TrackingCamera( + val id: String, + val front: Boolean, + val orientation: Int, + val focalPixels: Float, + val activeWidth: Int, + val activeHeight: Int, + val minZoom: Float, + val realtime: Boolean, + val fps: Pair? = null, +) { + val fovDegrees: Float get() = Math.toDegrees(2.0 * atan(activeWidth / 2.0 / focalPixels)).toFloat() + + fun zoom(): Float { + if (minZoom >= 1f) return 1f + val needed = (tan(Math.toRadians(fovDegrees / 2.0)) / tan(Math.toRadians(MAX_FOV / 2.0))).toFloat() + return min(1f, max(minZoom, needed)) + } + + fun intrinsics(bufferWidth: Int, bufferHeight: Int, zoom: Float, turns: Int): Intrinsics { + val aspect = bufferWidth.toFloat() / bufferHeight + val region = min(activeWidth.toFloat(), activeHeight * aspect) / zoom + val f = focalPixels * bufferWidth / region + val quarter = turns and 1 == 1 + val w = if (quarter) bufferHeight else bufferWidth + val h = if (quarter) bufferWidth else bufferHeight + return Intrinsics(f, f, w / 2f, h / 2f, w, h) + } + + companion object { + const val MAX_FOV = 100.0 + } +} + +internal object TrackingCameras { + const val REAR = "rear" + const val FRONT = "front" + private const val MAX_FPS = 30 + + fun list(context: Context): List { + val manager = context.getSystemService(CameraManager::class.java) ?: return emptyList() + val ids = runCatching { manager.cameraIdList.toList() }.getOrDefault(emptyList()) + return ids.mapNotNull { id -> runCatching { describe(id, manager.getCameraCharacteristics(id)) }.getOrNull() } + } + + private fun describe(id: String, c: CameraCharacteristics): TrackingCamera? { + val facing = c.get(CameraCharacteristics.LENS_FACING) ?: return null + if (facing == CameraCharacteristics.LENS_FACING_EXTERNAL) return null + val caps = c.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES) ?: return null + if (CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE !in caps) return null + val active = c.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE) ?: return null + val pixels = c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE) ?: return null + val physical = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) ?: return null + val focal = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() ?: return null + val calibrated = c.get(CameraCharacteristics.LENS_INTRINSIC_CALIBRATION)?.firstOrNull()?.takeIf { it > 1f } + val focalPixels = calibrated ?: (focal * pixels.width / physical.width) + val minZoom = if (Build.VERSION.SDK_INT >= 30) c.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE)?.lower ?: 1f else 1f + val realtime = c.get(CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE) == CameraCharacteristics.SENSOR_INFO_TIMESTAMP_SOURCE_REALTIME + val fps = steadyFps(c.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)?.map { it.lower to it.upper }.orEmpty()) + return TrackingCamera(id, facing == CameraCharacteristics.LENS_FACING_FRONT, c.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0, + focalPixels, active.width(), active.height(), minZoom, realtime, fps) + } + + fun steadyFps(ranges: List>): Pair? = + ranges.filter { it.second <= MAX_FPS }.maxWithOrNull(compareBy> { it.first }.thenByDescending { it.second }) + + fun sides(cameras: List): Set = cameras.mapTo(LinkedHashSet()) { if (it.front) FRONT else REAR } + + fun pick(cameras: List, side: String?): TrackingCamera? { + val wanted = cameras.filter { side == null || (it.front == (side == FRONT)) }.ifEmpty { cameras } + val preferred = if (side == null) wanted.filter { !it.front }.ifEmpty { wanted } else wanted + return preferred.maxWithOrNull(compareBy { min(widest(it), TrackingCamera.MAX_FOV.toFloat()) } + .thenBy { it.activeWidth.toLong() * it.activeHeight }) + } + + private fun widest(c: TrackingCamera): Float = + Math.toDegrees(2.0 * atan(tan(Math.toRadians(c.fovDegrees / 2.0)) / c.zoom())).toFloat() +} + +internal object CameraGeometry { + fun axes(front: Boolean, orientation: Int, turns: Int): Pair { + val t = Math.toRadians(orientation.toDouble()) + val c = Math.cos(t).toFloat() + val s = Math.sin(t).toFloat() + var u = if (front) Vec3(-c, -s, 0f) else Vec3(c, -s, 0f) + var v = if (front) Vec3(s, -c, 0f) else Vec3(-s, -c, 0f) + repeat(Math.floorMod(turns, 4)) { + val nu = v * -1f + v = u + u = nu + } + return u to v + } + + fun upInCamera(front: Boolean, orientation: Int, turns: Int, up: Vec3): Vec3 { + val (u, v) = axes(front, orientation, turns) + val n = up.normalized() + return Vec3(dot(n, u), dot(n, v), dot(n, u.cross(v))) + } + + fun uprightTurns(front: Boolean, orientation: Int, up: Vec3): Int = + (0..3).minBy { turns -> dot(up.normalized(), axes(front, orientation, turns).second) } + + fun placement(front: Boolean, up: Vec3): Placement { + val n = up.normalized() + if (n.length() < 0.5f) return Placement.UNKNOWN + if (n.z > FLAT) return Placement.FACE_UP + if (n.z < -FLAT) return Placement.FACE_DOWN + val elevation = Math.toDegrees(asin((if (front) n.z else -n.z).toDouble().coerceIn(-1.0, 1.0))) + return when { + elevation > STEEP -> Placement.POINTS_UP + elevation < -STEEP -> Placement.POINTS_DOWN + else -> Placement.STANDING + } + } + + private fun dot(a: Vec3, b: Vec3) = a.x * b.x + a.y * b.y + a.z * b.z + + private const val FLAT = 0.9f + private const val STEEP = 45.0 +} + +internal enum class Placement { UNKNOWN, STANDING, FACE_UP, FACE_DOWN, POINTS_UP, POINTS_DOWN } diff --git a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt index 09b282d8a..9b08fd8ad 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/ControllerPrefs.kt @@ -47,6 +47,7 @@ object ControllerPrefs { val qamButton: Boolean, val keyboardButton: Boolean, val selectSteam: Boolean, + val joyConVr: Boolean, val snap: Boolean, val customLayout: Boolean, val mapping: Map, @@ -67,6 +68,7 @@ object ControllerPrefs { qamButton = p.getBoolean("qamButton", true), keyboardButton = p.getBoolean("keyboardButton", true), selectSteam = p.getBoolean("selectSteam", false), + joyConVr = joyConVr(context), snap = p.getBoolean("snap", true), customLayout = p.all.keys.any { it.startsWith("layout.") }, mapping = mappable.associate { (id, _) -> id to target(context, id) }, @@ -127,6 +129,12 @@ object ControllerPrefs { prefs(context).edit().putBoolean("selectSteam", on).apply() } + fun joyConVr(context: Context): Boolean = prefs(context).getBoolean("joyConVr", true) + + fun setJoyConVr(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("joyConVr", on).apply() + } + fun setSnap(context: Context, on: Boolean) { prefs(context).edit().putBoolean("snap", on).apply() } diff --git a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java index c3cc11f47..308b6add8 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java +++ b/app/src/main/java/com/droiddeck/launcher/input/FakeInputWriter.java @@ -119,6 +119,10 @@ public class FakeInputWriter { private static native void nativeStoreFence(); + public static void storeFence() { + nativeStoreFence(); + } + private final File eventFile; private final int slot; private final ByteBuffer buffer = ByteBuffer.allocateDirect(BUFFER_SIZE); diff --git a/app/src/main/java/com/droiddeck/launcher/input/JoyCon.kt b/app/src/main/java/com/droiddeck/launcher/input/JoyCon.kt new file mode 100644 index 000000000..57cb710ae --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/JoyCon.kt @@ -0,0 +1,143 @@ +package com.droiddeck.launcher.input + +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent + +object JoyCon { + const val LEFT = 0 + const val RIGHT = 1 + + const val VR_TRIGGER = 1 shl 0 + const val VR_GRIP = 1 shl 1 + const val VR_A = 1 shl 2 + const val VR_B = 1 shl 3 + const val VR_STICK = 1 shl 4 + const val VR_SYSTEM = 1 shl 5 + const val VR_RUMBLE_SLOT = 0x100 + + private const val VENDOR = 0x057e + private const val PRODUCT_LEFT = 0x2006 + private const val PRODUCT_RIGHT = 0x2007 + + private const val BTN_SOUTH = 0x130 + private const val BTN_EAST = 0x131 + private const val BTN_NORTH = 0x133 + private const val BTN_WEST = 0x134 + private const val BTN_Z = 0x135 + private const val BTN_TL = 0x136 + private const val BTN_TR = 0x137 + private const val BTN_TL2 = 0x138 + private const val BTN_TR2 = 0x139 + private const val BTN_SELECT = 0x13a + private const val BTN_START = 0x13b + private const val BTN_MODE = 0x13c + private const val BTN_THUMBL = 0x13d + private const val BTN_THUMBR = 0x13e + private const val BTN_DPAD_UP = 0x220 + private const val BTN_DPAD_DOWN = 0x221 + private const val BTN_DPAD_LEFT = 0x222 + private const val BTN_DPAD_RIGHT = 0x223 + private val NO_VR_DEVICES = intArrayOf(PadBridge.NO_CONTROLLER, PadBridge.NO_CONTROLLER) + private var vrOwner: Any? = null + @Volatile private var vrDevices = NO_VR_DEVICES + + @JvmStatic + fun side(device: InputDevice?): Int = when { + device == null || device.isVirtual || device.vendorId != VENDOR -> -1 + device.productId == PRODUCT_LEFT -> LEFT + device.productId == PRODUCT_RIGHT -> RIGHT + else -> -1 + } + + @JvmStatic + fun stickAxes(side: Int, device: InputDevice, source: Int): IntArray = when { + side == LEFT -> intArrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y) + device.getMotionRange(MotionEvent.AXIS_RX, source) != null -> intArrayOf(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY) + else -> intArrayOf(MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ) + } + + @JvmStatic + fun isRail(side: Int, scanCode: Int): Boolean = when (side) { + LEFT -> scanCode == BTN_TR || scanCode == BTN_TR2 + RIGHT -> scanCode == BTN_TL || scanCode == BTN_TL2 + else -> false + } + + internal fun padKey(side: Int, scanCode: Int): Int = when (side) { + LEFT -> when (scanCode) { + BTN_TL -> KeyEvent.KEYCODE_BUTTON_L1 + BTN_TL2 -> KeyEvent.KEYCODE_BUTTON_L2 + BTN_SELECT -> KeyEvent.KEYCODE_BUTTON_SELECT + BTN_THUMBL -> KeyEvent.KEYCODE_BUTTON_THUMBL + BTN_Z -> KeyEvent.KEYCODE_BUTTON_Z + BTN_DPAD_UP -> KeyEvent.KEYCODE_DPAD_UP + BTN_DPAD_DOWN -> KeyEvent.KEYCODE_DPAD_DOWN + BTN_DPAD_LEFT -> KeyEvent.KEYCODE_DPAD_LEFT + BTN_DPAD_RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT + else -> KeyEvent.KEYCODE_UNKNOWN + } + RIGHT -> when (scanCode) { + BTN_SOUTH -> KeyEvent.KEYCODE_BUTTON_A + BTN_EAST -> KeyEvent.KEYCODE_BUTTON_B + BTN_NORTH -> KeyEvent.KEYCODE_BUTTON_Y + BTN_WEST -> KeyEvent.KEYCODE_BUTTON_X + BTN_TR -> KeyEvent.KEYCODE_BUTTON_R1 + BTN_TR2 -> KeyEvent.KEYCODE_BUTTON_R2 + BTN_START -> KeyEvent.KEYCODE_BUTTON_START + BTN_THUMBR -> KeyEvent.KEYCODE_BUTTON_THUMBR + BTN_MODE -> KeyEvent.KEYCODE_BUTTON_MODE + else -> KeyEvent.KEYCODE_UNKNOWN + } + else -> KeyEvent.KEYCODE_UNKNOWN + } + + @JvmStatic + fun normalize(event: KeyEvent): KeyEvent { + val side = side(event.device) + if (side < 0) return event + val code = padKey(side, event.scanCode) + if (code == KeyEvent.KEYCODE_UNKNOWN || code == event.keyCode) return event + return KeyEvent(event.downTime, event.eventTime, event.action, code, event.repeatCount, event.metaState, + event.deviceId, event.scanCode, event.flags, event.source) + } + + internal fun vrButton(side: Int, scanCode: Int): Int = when (side) { + LEFT -> when (scanCode) { + BTN_TL2 -> VR_TRIGGER + BTN_TL, BTN_TR, BTN_TR2 -> VR_GRIP + BTN_DPAD_DOWN, BTN_DPAD_RIGHT -> VR_A + BTN_DPAD_UP, BTN_DPAD_LEFT, BTN_SELECT -> VR_B + BTN_THUMBL -> VR_STICK + BTN_Z -> VR_SYSTEM + else -> 0 + } + RIGHT -> when (scanCode) { + BTN_TR2 -> VR_TRIGGER + BTN_TR, BTN_TL, BTN_TL2 -> VR_GRIP + BTN_SOUTH, BTN_EAST -> VR_A + BTN_NORTH, BTN_WEST, BTN_START -> VR_B + BTN_THUMBR -> VR_STICK + BTN_MODE -> VR_SYSTEM + else -> 0 + } + else -> 0 + } + + @JvmStatic + fun vrRumbleDevice(slot: Int): Int? = + if (slot == VR_RUMBLE_SLOT + LEFT || slot == VR_RUMBLE_SLOT + RIGHT) vrDevices[slot - VR_RUMBLE_SLOT] else null + + @Synchronized + internal fun setVrDevices(owner: Any, devices: IntArray) { + vrOwner = owner + vrDevices = devices + } + + @Synchronized + internal fun clearVrDevices(owner: Any) { + if (vrOwner !== owner) return + vrOwner = null + vrDevices = NO_VR_DEVICES + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/JoyConMotion.kt b/app/src/main/java/com/droiddeck/launcher/input/JoyConMotion.kt new file mode 100644 index 000000000..fcfceaba6 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/JoyConMotion.kt @@ -0,0 +1,213 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import com.droiddeck.launcher.glasses.Quaternion +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.roundToInt +import kotlin.math.sqrt + +internal class JoyConAccelCalibration { + var bias = Vec3(0f, 0f, 0f) + private set + var scale = 1f + private set + var ready = false + private set + private val sums = HashMap() + + fun restore(saved: FloatArray?) { + if (saved == null || saved.size != 4 || saved.any { it.isNaN() || it.isInfinite() }) return + val b = Vec3(saved[0], saved[1], saved[2]) + if (!plausible(b, saved[3])) return + bias = b + scale = saved[3] + ready = true + } + + fun saved(): FloatArray? = if (ready) floatArrayOf(bias.x, bias.y, bias.z, scale) else null + + fun correct(raw: Vec3): Vec3 = if (ready) (raw - bias) * scale else raw + + fun addResting(raw: Vec3): Boolean { + val n = raw.length() + if (n < 1e-3f) return false + val key = bin(raw.x / n) * 9 + bin(raw.y / n) * 3 + bin(raw.z / n) + val s = sums.getOrPut(key) { DoubleArray(4) } + s[0] += raw.x.toDouble() + s[1] += raw.y.toDouble() + s[2] += raw.z.toDouble() + s[3] += 1.0 + return s[3] % SOLVE_EVERY == 0.0 && solve() + } + + private fun solve(): Boolean { + val points = sums.values.filter { it[3] >= MIN_SAMPLES }.map { doubleArrayOf(it[0] / it[3], it[1] / it[3], it[2] / it[3]) } + if (points.size < MIN_BINS || smallestSpread(points) < MIN_SPREAD) return false + val ata = Array(4) { DoubleArray(4) } + val atb = DoubleArray(4) + for (p in points) { + val row = doubleArrayOf(2 * p[0], 2 * p[1], 2 * p[2], 1.0) + val rhs = p[0] * p[0] + p[1] * p[1] + p[2] * p[2] + for (i in 0..3) { + atb[i] += row[i] * rhs + for (j in 0..3) ata[i][j] += row[i] * row[j] + } + } + val x = solve4(ata, atb) ?: return false + val radius2 = x[3] + x[0] * x[0] + x[1] * x[1] + x[2] * x[2] + if (radius2 <= 0) return false + val b = Vec3(x[0].toFloat(), x[1].toFloat(), x[2].toFloat()) + val s = (GRAVITY / sqrt(radius2)).toFloat() + if (!plausible(b, s)) return false + bias = b + scale = s + ready = true + return true + } + + private fun bin(v: Float) = (v * 1.5f).roundToInt().coerceIn(-1, 1) + 1 + + companion object { + private const val GRAVITY = 9.80665 + private const val MIN_SAMPLES = 20.0 + private const val MIN_BINS = 5 + private const val MIN_SPREAD = 2.0 + private const val SOLVE_EVERY = 25.0 + private const val MAX_BIAS = 1.5f + private const val MAX_SCALE_ERROR = 0.1f + + private fun plausible(b: Vec3, s: Float) = b.length() <= MAX_BIAS && abs(s - 1f) <= MAX_SCALE_ERROR + + private fun smallestSpread(points: List): Double { + val mean = DoubleArray(3) { i -> points.sumOf { it[i] } / points.size } + val c = Array(3) { DoubleArray(3) } + for (p in points) for (i in 0..2) for (j in 0..2) c[i][j] += (p[i] - mean[i]) * (p[j] - mean[j]) + return sqrt(maxOf(smallestEigenvalue(c), 0.0)) + } + + private fun smallestEigenvalue(m: Array): Double { + val a = Array(3) { m[it].copyOf() } + repeat(50) { + var p = 0 + var q = 1 + for ((i, j) in listOf(0 to 2, 1 to 2)) if (abs(a[i][j]) > abs(a[p][q])) { p = i; q = j } + if (abs(a[p][q]) < 1e-12) return minOf(a[0][0], a[1][1], a[2][2]) + val theta = (a[q][q] - a[p][p]) / (2 * a[p][q]) + val t = (if (theta >= 0) 1.0 else -1.0) / (abs(theta) + sqrt(theta * theta + 1)) + val c = 1 / sqrt(t * t + 1) + val s = t * c + for (k in 0..2) { + val kp = a[k][p] + val kq = a[k][q] + a[k][p] = c * kp - s * kq + a[k][q] = s * kp + c * kq + } + for (k in 0..2) { + val pk = a[p][k] + val qk = a[q][k] + a[p][k] = c * pk - s * qk + a[q][k] = s * pk + c * qk + } + } + return minOf(a[0][0], a[1][1], a[2][2]) + } + + private fun solve4(a: Array, b: DoubleArray): DoubleArray? { + val m = Array(4) { i -> DoubleArray(5) { j -> if (j < 4) a[i][j] else b[i] } } + for (col in 0..3) { + val pivot = (col..3).maxBy { abs(m[it][col]) } + if (abs(m[pivot][col]) < 1e-9) return null + val swap = m[col] + m[col] = m[pivot] + m[pivot] = swap + for (r in 0..3) { + if (r == col) continue + val f = m[r][col] / m[col][col] + for (k in col..4) m[r][k] -= f * m[col][k] + } + } + return DoubleArray(4) { m[it][4] / m[it][it] } + } + } +} + +internal class JoyConMotion { + var velocity = Vec3(0f, 0f, 0f) + private set + var displacement = Vec3(0f, 0f, 0f) + private set + var inertial = Vec3(0f, 0f, 0f) + private set + var resting = false + private set + private var gravity = Float.NaN + private var average = Vec3(0f, 0f, 0f) + private var calmFor = 0f + private var stillFor = 0f + private var movingFor = 0f + + fun update(rotation: Quaternion, accel: Vec3, rate: Vec3, calibrated: Boolean, dt: Float) { + if (dt <= 0f) return + if (gravity.isNaN()) { + gravity = accel.length() + average = accel + } + if (calibrated) gravity = GRAVITY + average += (accel - average) * (dt / AVERAGE_SECONDS).coerceAtMost(1f) + val world = rotation.rotate(accel) + val linear = Vec3(world.x, world.y - gravity, world.z) + inertial = inertial * exp(-dt / INERTIAL_SECONDS) + linear * dt + val calm = rate.length() < CALM_RATE && (accel - average).length() < CALM_ACCEL + calmFor = if (calm) calmFor + dt else 0f + val still = rate.length() < CALM_RATE && + (linear.length() < STILL_ACCEL || calmFor >= if (calibrated) CALIBRATED_CALM_SECONDS else UNCALIBRATED_CALM_SECONDS) + stillFor = if (still) stillFor + dt else 0f + resting = calmFor >= RESTING_SECONDS && rate.length() < RESTING_RATE + if (stillFor >= STILL_SECONDS) { + movingFor = 0f + velocity *= exp(-dt / SETTLE_SECONDS) + if (!calibrated) gravity += (accel.length() - gravity) * (dt / GRAVITY_SECONDS).coerceAtMost(1f) + } else { + movingFor += dt + val leak = if (movingFor < FREE_SECONDS) LEAK_SECONDS else LONG_MOVE_LEAK_SECONDS + velocity = velocity * exp(-dt / leak) + linear * dt + } + displacement += velocity * dt + } + + companion object { + private const val GRAVITY = 9.80665f + private const val AVERAGE_SECONDS = 0.1f + private const val CALM_RATE = 0.2f + private const val CALM_ACCEL = 0.25f + private const val STILL_ACCEL = 0.12f + private const val STILL_SECONDS = 0.2f + private const val UNCALIBRATED_CALM_SECONDS = 0.5f + private const val CALIBRATED_CALM_SECONDS = 2f + private const val SETTLE_SECONDS = 0.05f + private const val LEAK_SECONDS = 20f + private const val FREE_SECONDS = 1f + private const val LONG_MOVE_LEAK_SECONDS = 0.2f + private const val GRAVITY_SECONDS = 0.5f + private const val INERTIAL_SECONDS = 30f + private const val RESTING_SECONDS = 0.3f + private const val RESTING_RATE = 0.05f + } +} + +internal object JoyConCalibrations { + fun load(context: Context, descriptor: String?): FloatArray? { + if (descriptor.isNullOrEmpty()) return null + val text = ControllerPrefs.prefs(context).getString(KEY + descriptor, null) ?: return null + return text.split(',').mapNotNull { it.toFloatOrNull() }.toFloatArray().takeIf { it.size == 4 } + } + + fun save(context: Context, descriptor: String?, values: FloatArray?) { + if (descriptor.isNullOrEmpty() || values == null) return + ControllerPrefs.prefs(context).edit().putString(KEY + descriptor, values.joinToString(",")).apply() + } + + private const val KEY = "joyConAccel." +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/JoyConOrientation.kt b/app/src/main/java/com/droiddeck/launcher/input/JoyConOrientation.kt new file mode 100644 index 000000000..c94d2d500 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/JoyConOrientation.kt @@ -0,0 +1,113 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.glasses.Quaternion +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.abs + +internal class JoyConOrientation { + var rotation = Quaternion.IDENTITY + private set + var rate = Vec3(0f, 0f, 0f) + private set + var ready = false + private set + private var bias = Vec3(0f, 0f, 0f) + private var stillFor = 0f + + fun update(accelKernel: FloatArray, gyroKernel: FloatArray, dt: Float) = update(axes(accelKernel), axes(gyroKernel), dt) + + fun update(accel: Vec3, gyro: Vec3, dt: Float) { + val norm = accel.length() + val gravityValid = abs(norm - GRAVITY) < GRAVITY_TOLERANCE + if (!ready) { + if (!gravityValid) return + val up = accel.normalized() + val axis = up.cross(WORLD_UP) + rotation = if (up.y < -0.9999f) Quaternion.fromAxisAngle(Vec3(1f, 0f, 0f), Math.PI.toFloat()) + else Quaternion(1f + up.y, axis.x, axis.y, axis.z).normalized() + rate = Vec3(0f, 0f, 0f) + ready = true + return + } + if (dt <= 0f) return + val still = gravityValid && abs(norm - GRAVITY) < STILL_ACCEL && (gyro - bias).length() < STILL_RATE + stillFor = if (still) stillFor + dt else 0f + if (stillFor >= STILL_SECONDS) { + val alpha = (dt / BIAS_SECONDS).coerceAtMost(1f) + bias = bias * (1f - alpha) + gyro * alpha + } + rate = gyro - bias + var corrected = rate + if (gravityValid && rate.length() < MOVING_RATE) { + val predictedUp = rotation.conjugate().rotate(WORLD_UP) + corrected += accel.normalized().cross(predictedUp) * (GAIN * (1f - abs(norm - GRAVITY) / GRAVITY_TOLERANCE)) + } + rotation = rotation.integrateBodyRate(corrected, dt).normalized() + } + + companion object { + private val WORLD_UP = Vec3(0f, 1f, 0f) + private const val GRAVITY = 9.80665f + private const val GRAVITY_TOLERANCE = 1.0f + private const val STILL_ACCEL = 0.5f + private const val STILL_RATE = 0.05f + private const val STILL_SECONDS = 0.3f + private const val BIAS_SECONDS = 2f + private const val GAIN = 1.0f + private const val MOVING_RATE = 1.0f + + fun axes(v: FloatArray) = Vec3(-v[1], v[2], -v[0]) + } +} + +internal class JoyConReports { + class Sample(val accel: FloatArray, val gyro: FloatArray, val dt: Float) + + private val pending = ArrayList(4) + private var pendingAt = 0L + private var previousFirst: FloatArray? = null + private var previousLast: FloatArray? = null + private var previousSample: FloatArray? = null + private var previousAt = 0L + private var order = NEWEST_FIRST_START + + val size: Int get() = pending.size + + fun add(accel: FloatArray, gyro: FloatArray, arrivalNs: Long) { + if (pending.isEmpty()) pendingAt = arrivalNs + pending += floatArrayOf(accel[0], accel[1], accel[2], gyro[0], gyro[1], gyro[2]) + } + + fun flush(): List { + if (pending.isEmpty()) return emptyList() + val first = pending.first() + val last = pending.last() + if (pending.size > 1) { + if (previousFirst?.contentEquals(last) == true) order = (order + 1).coerceAtMost(ORDER_LIMIT) + else if (previousLast?.contentEquals(first) == true) order = (order - 1).coerceAtLeast(-ORDER_LIMIT) + } + val chronological = if (order >= 0) pending.asReversed() else pending + val fresh = ArrayList(chronological.size) + var before = previousSample + for (sample in chronological) { + if (before?.contentEquals(sample) != true) fresh += sample + before = sample + } + val used = fresh.ifEmpty { listOf(chronological.last()) } + val dt = if (previousAt == 0L) 0f else ((pendingAt - previousAt).coerceAtLeast(0L) * 1e-9f / used.size).coerceAtMost(MAX_STEP_S) + val out = used.map { Sample(it.copyOfRange(0, 3), it.copyOfRange(3, 6), dt) } + previousFirst = first + previousLast = last + previousSample = chronological.last() + previousAt = pendingAt + pending.clear() + return out + } + + companion object { + const val SAMPLES_PER_REPORT = 3 + private const val NEWEST_FIRST_START = 2 + private const val ORDER_LIMIT = 20 + private const val MAX_STEP_S = 0.05f + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/JoyConShm.kt b/app/src/main/java/com/droiddeck/launcher/input/JoyConShm.kt new file mode 100644 index 000000000..0fbb548ab --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/JoyConShm.kt @@ -0,0 +1,116 @@ +package com.droiddeck.launcher.input + +import android.util.Log +import com.droiddeck.launcher.glasses.Quaternion +import com.droiddeck.launcher.glasses.Vec3 +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteOrder +import java.nio.MappedByteBuffer +import java.nio.channels.FileChannel + +internal class JoyConShm private constructor( + private val file: RandomAccessFile, + private val map: MappedByteBuffer, + private val fence: () -> Unit, +) { + class Hand( + val connected: Boolean, + val charging: Boolean = false, + val buttons: Int = 0, + val rotation: Quaternion = Quaternion.IDENTITY, + val rate: Vec3 = Vec3(0f, 0f, 0f), + val displacement: Vec3 = Vec3(0f, 0f, 0f), + val velocity: Vec3 = Vec3(0f, 0f, 0f), + val inertial: Vec3 = Vec3(0f, 0f, 0f), + val stickX: Float = 0f, + val stickY: Float = 0f, + val battery: Float = -1f, + val recenter: Int = 0, + val timeNs: Long = 0L, + ) + + private var seq = map.getInt(SEQ).let { if (it and 1 != 0) it + 1 else it } + + init { + begin() + map.putInt(MAGIC_AT, MAGIC) + map.putInt(VERSION_AT, VERSION) + for (at in HANDS until SIZE step 4) map.putInt(at, 0) + end() + } + + fun write(side: Int, hand: Hand) { + val at = HANDS + side * HAND_SIZE + begin() + map.putInt(at, (if (hand.connected) CONNECTED else 0) or (if (hand.charging) CHARGING else 0)) + map.putInt(at + 4, hand.buttons) + map.putFloat(at + 8, hand.rotation.w) + map.putFloat(at + 12, hand.rotation.x) + map.putFloat(at + 16, hand.rotation.y) + map.putFloat(at + 20, hand.rotation.z) + map.putFloat(at + 24, hand.rate.x) + map.putFloat(at + 28, hand.rate.y) + map.putFloat(at + 32, hand.rate.z) + map.putFloat(at + 36, hand.stickX) + map.putFloat(at + 40, hand.stickY) + map.putFloat(at + 44, hand.battery) + map.putInt(at + 48, hand.recenter) + map.putLong(at + 56, hand.timeNs) + map.putFloat(at + 64, hand.displacement.x) + map.putFloat(at + 68, hand.displacement.y) + map.putFloat(at + 72, hand.displacement.z) + map.putFloat(at + 76, hand.velocity.x) + map.putFloat(at + 80, hand.velocity.y) + map.putFloat(at + 84, hand.velocity.z) + map.putFloat(at + 88, hand.inertial.x) + map.putFloat(at + 92, hand.inertial.y) + map.putFloat(at + 96, hand.inertial.z) + map.putInt(at + 100, 0) + end() + } + + fun close() { + write(JoyCon.LEFT, Hand(connected = false)) + write(JoyCon.RIGHT, Hand(connected = false)) + runCatching { file.close() } + } + + private fun begin() { + map.putInt(SEQ, ++seq) + fence() + } + + private fun end() { + fence() + map.putInt(SEQ, ++seq) + } + + companion object { + const val FILE = "droiddeck-joycon" + const val SIZE = 224 + private const val MAGIC = 0x4E434A44 + private const val VERSION = 3 + private const val MAGIC_AT = 0 + private const val VERSION_AT = 4 + private const val SEQ = 8 + private const val HANDS = 16 + private const val HAND_SIZE = 104 + private const val CONNECTED = 1 + private const val CHARGING = 2 + + fun open(path: File, fence: () -> Unit = FakeInputWriter::storeFence): JoyConShm? = runCatching { + path.parentFile?.mkdirs() + val file = RandomAccessFile(path, "rw") + try { + file.setLength(SIZE.toLong()) + val map = file.channel.map(FileChannel.MapMode.READ_WRITE, 0, SIZE.toLong()) + map.order(ByteOrder.LITTLE_ENDIAN) + JoyConShm(file, map, fence) + } catch (e: Exception) { + file.close() + throw e + } + }.onFailure { Log.w("JoyConVr", "Joy-Con pose file unavailable: $path", it) }.getOrNull() + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/JoyConVr.kt b/app/src/main/java/com/droiddeck/launcher/input/JoyConVr.kt new file mode 100644 index 000000000..8b9991a74 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/JoyConVr.kt @@ -0,0 +1,363 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.hardware.BatteryState +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.CombinedVibration +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.Process +import android.os.VibrationEffect +import android.util.Log +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import androidx.annotation.RequiresApi +import java.io.File +import kotlin.math.min +import kotlin.math.sqrt + +@RequiresApi(31) +class JoyConVr( + private val context: Context, + private val path: File, + private val onDevicesChanged: (taken: List, released: List) -> Unit, +) { + private val thread = HandlerThread("joycon-vr", Process.THREAD_PRIORITY_DISPLAY).apply { start() } + private val worker = Handler(thread.looper) + private val main = Handler(Looper.getMainLooper()) + @Volatile private var owned = IntArray(0) + private var wanted = false + private var closed = false + private var retries = 0 + private var shm: JoyConShm? = null + private val hands = arrayOfNulls(2) + private val retry = Runnable { apply() } + + fun setActive(on: Boolean) { + worker.post { + if (closed || wanted == on) return@post + wanted = on + retries = 0 + apply() + } + } + + fun refresh() { + worker.post { + if (closed) return@post + retries = 0 + apply() + } + } + + fun owns(deviceId: Int): Boolean = owned.contains(deviceId) + + val holding: Boolean get() = owned.isNotEmpty() + + fun onKeyEvent(event: KeyEvent): Boolean { + if (!owns(event.deviceId)) return false + if (event.repeatCount > 0) return true + val down = when (event.action) { + KeyEvent.ACTION_DOWN -> true + KeyEvent.ACTION_UP -> false + else -> return true + } + val id = event.deviceId + val scanCode = event.scanCode + worker.post { hand(id)?.key(scanCode, down) } + return true + } + + fun onMotionEvent(event: MotionEvent): Boolean { + if (!owns(event.deviceId)) return false + if (!event.isFromSource(InputDevice.SOURCE_JOYSTICK) || event.actionMasked != MotionEvent.ACTION_MOVE) return true + val device = event.device ?: return true + val side = JoyCon.side(device) + if (side < 0) return true + val axes = JoyCon.stickAxes(side, device, event.source) + val x = event.getAxisValue(axes[0]) + val y = event.getAxisValue(axes[1]) + val id = event.deviceId + worker.post { hand(id)?.stick(x, -y) } + return true + } + + fun releaseButtons() { + worker.post { hands.forEach { it?.release() } } + } + + fun close() { + worker.post { + closed = true + wanted = false + apply() + shm?.close() + shm = null + } + thread.quitSafely() + } + + private fun hand(id: Int): Hand? = hands.firstOrNull { it?.device?.id == id } + + private fun apply() { + worker.removeCallbacks(retry) + val found = arrayOfNulls(2) + var waiting = false + if (wanted && !closed) { + for (id in InputDevice.getDeviceIds()) { + val device = InputDevice.getDevice(id) ?: continue + val side = JoyCon.side(device) + if (side < 0 || found[side] != null) continue + if (hasMotion(device)) found[side] = device else waiting = true + } + } + val before = owned + for (side in 0..1) { + val current = hands[side] + val device = found[side] + if (current != null && current.device.id == device?.id) continue + current?.stop() + hands[side] = null + if (device != null) { + hands[side] = start(side, device) + if (hands[side] == null) waiting = true + } + } + val now = hands.mapNotNull { it?.device?.id }.toIntArray() + owned = now + if (closed) JoyCon.clearVrDevices(this) + else JoyCon.setVrDevices(this, IntArray(2) { hands[it]?.device?.id ?: PadBridge.NO_CONTROLLER }) + if (!closed) { + val taken = now.filter { it !in before } + val released = before.filter { it !in now } + if (taken.isNotEmpty() || released.isNotEmpty()) main.post { onDevicesChanged(taken, released) } + } + if (wanted && waiting && retries < MAX_RETRIES) { + retries++ + worker.postDelayed(retry, RETRY_MS) + } + } + + private fun hasMotion(device: InputDevice): Boolean = + runCatching { + val sensors = device.sensorManager + sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null && sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null + }.getOrDefault(false) + + private fun start(side: Int, device: InputDevice): Hand? { + val out = shm ?: JoyConShm.open(path)?.also { shm = it } ?: return null + val sensors = runCatching { device.sensorManager }.getOrNull() ?: return null + val gyro = runCatching { sensors.getDefaultSensor(Sensor.TYPE_GYROSCOPE) }.getOrNull() ?: return null + val accel = runCatching { sensors.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) }.getOrNull() ?: return null + val hand = Hand(side, device, sensors, out) + val listening = runCatching { + sensors.registerListener(hand, accel, SensorManager.SENSOR_DELAY_FASTEST, worker) && + sensors.registerListener(hand, gyro, SensorManager.SENSOR_DELAY_FASTEST, worker) + }.onFailure { Log.w(TAG, "Joy-Con motion unavailable for \"${device.name}\"", it) }.getOrDefault(false) + if (!listening) { + runCatching { sensors.unregisterListener(hand) } + return null + } + Log.i(TAG, "${SIDES[side]} Joy-Con \"${device.name}\" (id ${device.id}) is a VR controller") + return hand + } + + private inner class Hand( + val side: Int, + val device: InputDevice, + private val sensors: SensorManager, + private val out: JoyConShm, + ) : SensorEventListener { + private val filter = JoyConOrientation() + private val motion = JoyConMotion() + private val calibration = JoyConAccelCalibration().apply { restore(JoyConCalibrations.load(context, device.descriptor)) } + private val reports = JoyConReports() + private val accel = FloatArray(3) + private var accelStamp = 0L + private var haveAccel = false + private val pressed = HashSet() + private var stickX = 0f + private var stickY = 0f + private var systemDown = false + private var systemTap = false + private var held = false + private var recenter = 0 + private var battery = -1f + private var charging = false + private var batteryAt = 0L + private var sampleAt = 0L + private var live = true + private val hold = Runnable { + if (!systemDown || held) return@Runnable + held = true + recenter++ + confirm() + publish() + } + private val tapEnd = Runnable { + systemTap = false + publish() + } + private val flush = Runnable { flushReport() } + + override fun onSensorChanged(event: SensorEvent) { + if (!live) return + when (event.sensor.type) { + Sensor.TYPE_ACCELEROMETER -> { + if (event.timestamp == accelStamp) return + accelStamp = event.timestamp + event.values.copyInto(accel, 0, 0, 3) + haveAccel = true + } + Sensor.TYPE_GYROSCOPE -> { + if (!haveAccel) return + reports.add(accel, event.values, System.nanoTime()) + worker.removeCallbacks(flush) + if (reports.size >= JoyConReports.SAMPLES_PER_REPORT) flushReport() else worker.postDelayed(flush, FLUSH_MS) + } + } + } + + private fun flushReport() { + val samples = reports.flush() + if (samples.isEmpty() || !live) return + for (sample in samples) { + val raw = JoyConOrientation.axes(sample.accel) + val corrected = calibration.correct(raw) + filter.update(corrected, JoyConOrientation.axes(sample.gyro), sample.dt) + if (!filter.ready) continue + motion.update(filter.rotation, corrected, filter.rate, calibration.ready, sample.dt) + if (motion.resting && calibration.addResting(raw)) JoyConCalibrations.save(context, device.descriptor, calibration.saved()) + } + val now = System.nanoTime() + sampleAt = now + if (now - batteryAt > BATTERY_NS) refreshBattery(now) + publish() + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + fun key(scanCode: Int, down: Boolean) { + if (!live) return + val bit = JoyCon.vrButton(side, scanCode) + if (bit == 0) return + if (bit == JoyCon.VR_SYSTEM) { + system(down) + return + } + if (down) pressed += scanCode else pressed -= scanCode + publish() + } + + fun stick(x: Float, y: Float) { + if (!live) return + val length = sqrt(x * x + y * y) + if (length < STICK_DEADZONE) { + stickX = 0f + stickY = 0f + } else { + val scale = min((length - STICK_DEADZONE) / (1f - STICK_DEADZONE), 1f) / length + stickX = x * scale + stickY = y * scale + } + publish() + } + + fun release() { + if (!live) return + pressed.clear() + stickX = 0f + stickY = 0f + systemDown = false + systemTap = false + held = false + worker.removeCallbacks(hold) + worker.removeCallbacks(tapEnd) + publish() + } + + fun stop() { + live = false + runCatching { sensors.unregisterListener(this) } + worker.removeCallbacks(hold) + worker.removeCallbacks(tapEnd) + worker.removeCallbacks(flush) + out.write(side, JoyConShm.Hand(connected = false)) + Log.i(TAG, "${SIDES[side]} Joy-Con \"${device.name}\" (id ${device.id}) is a game pad again") + } + + private fun system(down: Boolean) { + if (down == systemDown) return + systemDown = down + if (down) { + held = false + worker.postDelayed(hold, HOLD_MS) + return + } + worker.removeCallbacks(hold) + if (held) return + systemTap = true + publish() + worker.removeCallbacks(tapEnd) + worker.postDelayed(tapEnd, TAP_MS) + } + + private fun buttons(): Int { + var bits = if (systemTap) JoyCon.VR_SYSTEM else 0 + for (scanCode in pressed) bits = bits or JoyCon.vrButton(side, scanCode) + return bits + } + + private fun publish() { + out.write(side, JoyConShm.Hand( + connected = filter.ready, + charging = charging, + buttons = buttons(), + rotation = filter.rotation, + rate = filter.rate, + displacement = motion.displacement, + velocity = motion.velocity, + inertial = motion.inertial, + stickX = stickX, + stickY = stickY, + battery = battery, + recenter = recenter, + timeNs = sampleAt, + )) + } + + private fun refreshBattery(now: Long) { + batteryAt = now + val state = runCatching { device.batteryState }.getOrNull() ?: return + battery = if (state.isPresent && !state.capacity.isNaN()) state.capacity else -1f + charging = state.isPresent && state.status == BatteryState.STATUS_CHARGING + } + + private fun confirm() { + if (!ControllerPrefs.rumbleEnabled(context)) return + runCatching { + device.vibratorManager.vibrate(CombinedVibration.createParallel( + VibrationEffect.createOneShot(CONFIRM_MS, VibrationEffect.DEFAULT_AMPLITUDE))) + } + } + } + + companion object { + const val FILE = JoyConShm.FILE + private const val TAG = "JoyConVr" + private val SIDES = arrayOf("left", "right") + private const val MAX_RETRIES = 6 + private const val RETRY_MS = 500L + private const val HOLD_MS = 800L + private const val TAP_MS = 120L + private const val CONFIRM_MS = 80L + private const val BATTERY_NS = 30_000_000_000L + private const val STICK_DEADZONE = 0.12f + private const val FLUSH_MS = 3L + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index d7ac2259f..50ffb17a8 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -92,6 +92,7 @@ class OnScreenControls( private val groups = controls.map { it.group }.distinct() private var buttonsOnly = false + val isButtonsOnly: Boolean get() = buttonsOnly private var quickHidden = false private var quickPressedBy = -1 private var keyboardPressedBy = -1 diff --git a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java index 9398ecd2f..4f0edcc31 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java +++ b/app/src/main/java/com/droiddeck/launcher/input/PadBridge.java @@ -46,6 +46,8 @@ public final class PadBridge { private final FakeInputWriter writer; private final PadState state = new PadState(); private final int[] owners = new int[SLOTS]; + private final int[] partners = new int[SLOTS]; + private volatile java.util.function.IntPredicate excluded = id -> false; private final FakeInputWriter[] playerWriters = new FakeInputWriter[SLOTS]; private final PadState[] playerStates = new PadState[SLOTS]; private final PadState effectiveState = new PadState(); @@ -72,8 +74,10 @@ public final class PadBridge { public static final int NO_CONTROLLER = -1; private static volatile int activeControllerId = NO_CONTROLLER; private static final AtomicIntegerArray slotControllers = new AtomicIntegerArray(SLOTS); + private static final AtomicIntegerArray slotJoyCons = new AtomicIntegerArray(SLOTS * 2); static { for (int slot = 0; slot < SLOTS; slot++) slotControllers.set(slot, NO_CONTROLLER); + for (int i = 0; i < SLOTS * 2; i++) slotJoyCons.set(i, NO_CONTROLLER); } public static int activeControllerId() { return activeControllerId; } @@ -82,10 +86,16 @@ public final class PadBridge { return slot >= 0 && slot < SLOTS ? slotControllers.get(slot) : activeControllerId; } + public static int joyConForSlot(int slot, int side) { + return slot >= 0 && slot < SLOTS && (side == JoyCon.LEFT || side == JoyCon.RIGHT) + ? slotJoyCons.get(slot * 2 + side) : NO_CONTROLLER; + } + public PadBridge(File fakeInputDir) { this.fakeInputDir = fakeInputDir; writer = new FakeInputWriter(fakeInputDir.getAbsolutePath(), 0); Arrays.fill(owners, NO_CONTROLLER); + Arrays.fill(partners, NO_CONTROLLER); for (int slot = 1; slot < SLOTS; slot++) playerStates[slot] = new PadState(); } @@ -93,6 +103,10 @@ public final class PadBridge { selectSteam = on; } + public void setExcluded(java.util.function.IntPredicate devices) { + excluded = devices; + } + public synchronized boolean start() { if (!open) { open = writer.open(); @@ -106,7 +120,9 @@ public final class PadBridge { activeControllerId = NO_CONTROLLER; for (int slot = 0; slot < SLOTS; slot++) { owners[slot] = NO_CONTROLLER; + partners[slot] = NO_CONTROLLER; slotControllers.set(slot, NO_CONTROLLER); + clearJoyCons(slot); if (playerWriters[slot] != null) { playerWriters[slot].destroy(); playerWriters[slot] = null; @@ -206,7 +222,7 @@ public final class PadBridge { public synchronized void syncConnectedControllers() { for (int deviceId : InputDevice.getDeviceIds()) { InputDevice device = InputDevice.getDevice(deviceId); - if (isFromController(device)) publish(slotFor(device)); + if (isFromController(device) && !excluded.test(deviceId)) publish(slotFor(device)); } } @@ -224,8 +240,10 @@ public final class PadBridge { } public synchronized boolean onKeyEvent(KeyEvent event) { - if (!isControllerKeyEvent(event)) return false; + if (!isControllerKeyEvent(event) || excluded.test(event.getDeviceId())) return false; int slot = slotFor(event.getDevice()); + int side = JoyCon.side(event.getDevice()); + if (side >= 0 && partners[slot] != NO_CONTROLLER && JoyCon.isRail(side, event.getScanCode())) return true; PadState pad = stateFor(slot); boolean pressed = event.getAction() == KeyEvent.ACTION_DOWN; switch (event.getKeyCode()) { @@ -269,16 +287,32 @@ public final class PadBridge { } public synchronized boolean onMotionEvent(MotionEvent event) { - if (!isControllerMotionEvent(event)) return false; + if (!isControllerMotionEvent(event) || excluded.test(event.getDeviceId())) return false; if (event.getAction() != MotionEvent.ACTION_MOVE) return false; int slot = slotFor(event.getDevice()); PadState pad = stateFor(slot); noteDevice(event.getDevice(), slot); statAxes++; scheduleStats(); + InputDevice device = event.getDevice(); + int side = JoyCon.side(device); + if (side >= 0) { + int[] stick = JoyCon.stickAxes(side, device, event.getSource()); + float x = axis(event, stick[0]); + float y = axis(event, stick[1]); + if (side == JoyCon.LEFT) { + pad.leftX = x; + pad.leftY = y; + } else { + pad.rightX = x; + pad.rightY = y; + } + if (Math.max(Math.abs(x), Math.abs(y)) > 0.5f) notePlayerInput(); + publish(slot); + return true; + } pad.leftX = axis(event, MotionEvent.AXIS_X); pad.leftY = axis(event, MotionEvent.AXIS_Y); - InputDevice device = event.getDevice(); boolean wiredXboxLayout = device != null && ((device.getVendorId() == 0x20d6 && device.getProductId() == 0x2063) || (device.getVendorId() == 0x0e6f && device.getProductId() == 0x02f0)) @@ -358,7 +392,7 @@ public final class PadBridge { public synchronized void onDeviceRemoved(int deviceId) { for (int slot = 0; slot < SLOTS; slot++) { - if (owners[slot] == deviceId) free(slot); + if (owners[slot] == deviceId || partners[slot] == deviceId) leave(slot, deviceId); } } @@ -425,12 +459,26 @@ public final class PadBridge { private int slotFor(InputDevice device) { int id = device.getId(); - if (owners[0] == id) return 0; + if (owners[0] == id || partners[0] == id) return 0; for (int slot = 1; slot < SLOTS; slot++) { - if (owners[slot] == id) return slot; + if (owners[slot] == id || partners[slot] == id) return slot; } for (int slot = 0; slot < SLOTS; slot++) { - if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) free(slot); + if (partners[slot] != NO_CONTROLLER && InputDevice.getDevice(partners[slot]) == null) leave(slot, partners[slot]); + if (owners[slot] != NO_CONTROLLER && InputDevice.getDevice(owners[slot]) == null) leave(slot, owners[slot]); + } + int side = JoyCon.side(device); + for (int slot = 0; side >= 0 && slot < SLOTS; slot++) { + if (owners[slot] == NO_CONTROLLER || partners[slot] != NO_CONTROLLER) continue; + InputDevice owner = InputDevice.getDevice(owners[slot]); + if (JoyCon.side(owner) != 1 - side) continue; + partners[slot] = id; + slotJoyCons.set(slot * 2 + side, id); + slotJoyCons.set(slot * 2 + 1 - side, owner.getId()); + if (slot == 0) releaseBackPaddles(); + stateFor(slot).clear(); + Log.i(TAG, "\"" + device.getName() + "\" (id " + id + ") joins \"" + owner.getName() + "\" as player " + (slot + 1)); + return slot; } for (int slot = 0; slot < SLOTS; slot++) { if (owners[slot] != NO_CONTROLLER) continue; @@ -446,7 +494,8 @@ public final class PadBridge { } private boolean isPrimary(KeyEvent event) { - return owners[0] == event.getDevice().getId(); + int id = event.getDevice().getId(); + return owners[0] == id || partners[0] == id; } private void releaseBackPaddles() { @@ -454,9 +503,32 @@ public final class PadBridge { DeckControls.INSTANCE.setGrip(DeckControls.R4, false); } + private void leave(int slot, int id) { + if (partners[slot] == NO_CONTROLLER) { + free(slot); + return; + } + Log.i(TAG, "player " + (slot + 1) + " Joy-Con (id " + id + ") left"); + if (owners[slot] == id) owners[slot] = partners[slot]; + partners[slot] = NO_CONTROLLER; + clearJoyCons(slot); + if (slotControllers.get(slot) == id) slotControllers.set(slot, owners[slot]); + if (activeControllerId == id) activeControllerId = owners[slot]; + if (slot == 0) releaseBackPaddles(); + stateFor(slot).clear(); + publish(slot); + } + + private static void clearJoyCons(int slot) { + slotJoyCons.set(slot * 2, NO_CONTROLLER); + slotJoyCons.set(slot * 2 + 1, NO_CONTROLLER); + } + private void free(int slot) { Log.i(TAG, "player " + (slot + 1) + " (id " + owners[slot] + ") left"); owners[slot] = NO_CONTROLLER; + partners[slot] = NO_CONTROLLER; + clearJoyCons(slot); slotControllers.set(slot, NO_CONTROLLER); if (slot == 0) { releaseBackPaddles(); diff --git a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt index f197800a4..a60c16035 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/PerfMode.kt @@ -7,26 +7,21 @@ import android.content.Context import android.os.Build import android.provider.Settings import android.util.Log +import android.view.Display object PerfMode { private const val TAG = "PerfMode" - fun apply(a: Activity, fpsLimit: Int = 0): String { + fun apply(a: Activity, fpsLimit: Int = 0, refreshLimit: Int = 0): String { val parts = ArrayList(2) parts += try { val display = if (Build.VERSION.SDK_INT >= 30) a.display else @Suppress("DEPRECATION") a.windowManager.defaultDisplay if (display == null || Build.VERSION.SDK_INT < 23) "display mode unchanged" else { val cur = display.mode - val cap = peakRefreshCap(a) - val sameSize = display.supportedModes - .filter { it.physicalWidth == cur.physicalWidth && it.physicalHeight == cur.physicalHeight } - .filter { it.refreshRate <= cap + 0.5f } - .ifEmpty { display.supportedModes.filter { it.modeId == cur.modeId } } - val best = sameSize.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate } - ?: sameSize.maxByOrNull { it.refreshRate } - if (best != null && best.modeId != cur.modeId && - (best.refreshRate > cur.refreshRate + 0.5f || !cadenceFits(cur.refreshRate, fpsLimit))) { + val cap = minOf(peakRefreshCap(a), refreshLimit.takeIf { it > 0 }?.toFloat() ?: Float.POSITIVE_INFINITY) + val best = selectMode(display.supportedModes, cur, fpsLimit, cap) + if (best != null && a.window.attributes.preferredDisplayModeId != best.modeId) { a.window.attributes = a.window.attributes.apply { preferredDisplayModeId = best.modeId } "display ${cur.refreshRate.toInt()} → ${best.refreshRate.toInt()} Hz (mode ${best.modeId})" } else "display ${cur.refreshRate.toInt()} Hz" @@ -59,6 +54,16 @@ object PerfMode { return if (peak >= 1f) peak else Float.POSITIVE_INFINITY } + internal fun selectMode(modes: Array, current: Display.Mode, fpsLimit: Int, cap: Float): Display.Mode? { + val sameSize = modes.filter { + it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight + } + val allowed = sameSize.filter { it.refreshRate <= cap + 0.5f } + return allowed.filter { cadenceFits(it.refreshRate, fpsLimit) }.maxByOrNull { it.refreshRate } + ?: allowed.maxByOrNull { it.refreshRate } + ?: sameSize.minByOrNull { it.refreshRate } + } + fun cadenceFits(hz: Float, fpsLimit: Int): Boolean { if (fpsLimit <= 0) return true val ratio = hz / fpsLimit diff --git a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt index d363177eb..f2b1c0a1e 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/RumbleComponent.kt @@ -16,6 +16,7 @@ import android.view.InputDevice import androidx.annotation.RequiresApi import com.droiddeck.launcher.core.SessionPart import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.JoyCon import com.droiddeck.launcher.input.PadBridge import java.io.DataInputStream import kotlin.math.max @@ -26,6 +27,7 @@ class RumbleComponent : SessionPart() { private val playing = HashMap() private val effects = HashMap() private val controllers = HashMap() + private val joyConPairs = HashMap() private var preferences: SharedPreferences? = null private var enabled = false private var motorThread: HandlerThread? = null @@ -78,6 +80,7 @@ class RumbleComponent : SessionPart() { motorThread = null phone = null controllers.clear() + joyConPairs.clear() val s = server server = null try { s?.close() } catch (e: Exception) { } @@ -125,10 +128,24 @@ class RumbleComponent : SessionPart() { } private fun target(slot: Int): Motors? { + JoyCon.vrRumbleDevice(slot)?.let { hand -> return if (hand == PadBridge.NO_CONTROLLER) null else motors(hand) } + val left = PadBridge.joyConForSlot(slot, JoyCon.LEFT) + val right = PadBridge.joyConForSlot(slot, JoyCon.RIGHT) + if (left != PadBridge.NO_CONTROLLER && right != PadBridge.NO_CONTROLLER) { + val key = (left.toLong() shl 32) or (right.toLong() and 0xFFFFFFFFL) + joyConPairs[key]?.let { return it } + val l = motors(left) + val r = motors(right) + if (l != null && r != null) return JoyConPairMotors(l, r).also { joyConPairs[key] = it } + } val id = PadBridge.controllerForSlot(slot) if (id == PadBridge.NO_CONTROLLER) return phone + return motors(id) ?: phone + } + + private fun motors(id: Int): Motors? { if (!controllers.containsKey(id)) controllers[id] = try { controllerMotors(id) } catch (e: Exception) { null } - return controllers[id] ?: phone + return controllers[id] } private fun cancelAll() { @@ -201,6 +218,25 @@ class RumbleComponent : SessionPart() { } } + @RequiresApi(31) + private class JoyConMotors(override val name: String, private val manager: VibratorManager) : Motors { + override fun play(strong: Int, weak: Int, ms: Long) = + manager.vibrate(CombinedVibration.createParallel(oneShot(manager.defaultVibrator, max(strong, weak), ms))) + override fun cancel() = manager.cancel() + } + + private class JoyConPairMotors(private val left: Motors, private val right: Motors) : Motors { + override val name = "${left.name} and ${right.name}" + override fun play(strong: Int, weak: Int, ms: Long) { + if (strong == 0) left.cancel() else left.play(strong, 0, ms) + if (weak == 0) right.cancel() else right.play(0, weak, ms) + } + override fun cancel() { + left.cancel() + right.cancel() + } + } + private class LegacyPadMotors(override val name: String, private val vibrator: Vibrator) : Motors { override fun play(strong: Int, weak: Int, ms: Long) = vibrator.vibrate(oneShot(vibrator, max(strong, weak), ms)) override fun cancel() { @@ -230,7 +266,8 @@ class RumbleComponent : SessionPart() { if (Build.VERSION.SDK_INT >= 31) { val manager = device.vibratorManager val ids = manager.vibratorIds.sortedArray() - return if (ids.isEmpty()) null else PadMotors(name, manager, ids) + if (ids.isEmpty()) return null + return if (JoyCon.side(device) >= 0) JoyConMotors(name, manager) else PadMotors(name, manager, ids) } @Suppress("DEPRECATION") val vibrator = device.vibrator diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt index c70845f6c..26b63fd5c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionEvents.kt @@ -31,6 +31,7 @@ object SessionEvents { SessionState.programArgs = emptyList() SessionState.steamUi = null SessionState.steamUrl = null + SessionState.steamVr = false SessionState.firstFrameSeen = false SessionState.suspended = false } diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt index a0df0c738..a4c3f4994 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionFiles.kt @@ -104,14 +104,19 @@ object SessionFiles { val msitools = listOf("msiinfo", "cabextract", "7z", "7z.so", "libmsi-1.0.so.0", "libgsf-1.so.114", "libgcab-1.0.so.0") .map { "usr/local/lib/droiddeck-msitools/$it" } + listOf("NOTICE", "GPL-2", "GPL-3", "LGPL-2.1", "7zip-License", "7zip-unRarLicense").map { "usr/local/share/licenses/droiddeck-msitools/$it" } - val vr = listOf( + val handModels = listOf("left", "right").flatMap { side -> + listOf("droiddeck_hand_$side.json", "hand.mtl", "hand.png", "hand_open.obj", "hand_fist.obj", "thumb_index_open.obj", + "thumb_index_pinch.obj") + .map { "droiddeck/resources/rendermodels/droiddeck_hand_$side/$it" } + } + val vr = (listOf( "droiddeck/bin/linuxarm64/driver_droiddeck.so", "droiddeck/bin/linuxarm64/xr-display-mode", "droiddeck/driver.vrdrivermanifest", "droiddeck/resources/settings/default.vrsettings", "layer/libVkLayer_droiddeck_vr.so", "layer/VK_LAYER_DROIDDECK_vr.json", - ).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } + + ) + handModels).map { "usr/local/lib/droiddeck-vr/$it" to "usr/local/lib/droiddeck-vr/$it" } + listOf("usr/local/lib/droiddeck-vr/layer/VK_LAYER_DROIDDECK_vr_implicit.json" to "usr/share/vulkan/implicit_layer.d/droiddeck_vr.json") val fexPreloads = listOf("x86_64", "i386").flatMap { arch -> diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt index a09c54242..6b8d4061f 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt @@ -1299,6 +1299,7 @@ class SessionService : Service() { val failureStatus = SessionState.failureStatus ?: status SessionEvents.fail(code, message, failureStatus) } + com.droiddeck.launcher.glasses.Glasses.set3dForVr(false) SessionState.notifyEnded(status) stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt index 8d9c7d637..8b407fc56 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt @@ -53,6 +53,9 @@ object SessionState { var steamUi: String? = null @Volatile var steamUrl: String? = null + // A Play SteamVR request can arrive while an existing session is visible. + // Wake the status/launch effect even if no glasses surface has attached yet. + var steamVr by mutableStateOf(false) var relaunch: android.content.Intent? = null @JvmStatic var hdr = false diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt index 40cb45076..ff39fb00f 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/ControllerSettings.kt @@ -29,6 +29,7 @@ class ControllerActions( val onQamButton: (Boolean) -> Unit, val onKeyboardButton: (Boolean) -> Unit, val onSelectSteam: (Boolean) -> Unit, + val onJoyConVr: (Boolean) -> Unit, val onEditLayout: () -> Unit, val onResetLayout: () -> Unit, val onMapping: () -> Unit, @@ -72,6 +73,7 @@ fun ColumnScope.ControllerRows(host: MenuHost, oscMode: String, c: ControllerPre ToggleRow(host, "controller-qam", stringResource(R.string.ctrl_qam_button), null, c.qamButton, onChange = a.onQamButton) ToggleRow(host, "controller-keyboard", stringResource(R.string.ctrl_keyboard_button), null, c.keyboardButton, onChange = a.onKeyboardButton) ToggleRow(host, "controller-select-steam", stringResource(R.string.ctrl_select_steam), stringResource(R.string.ctrl_select_steam_hint), c.selectSteam, onChange = a.onSelectSteam) + ToggleRow(host, "controller-joycon-vr", stringResource(R.string.ctrl_joycon_vr), stringResource(R.string.ctrl_joycon_vr_hint), c.joyConVr, onChange = a.onJoyConVr) SettingsRow(stringResource(R.string.ctrl_layout), if (c.customLayout) stringResource(R.string.ctrl_layout_custom) else stringResource(R.string.ctrl_layout_default)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { SecondaryButton(stringResource(R.string.ctrl_edit)) { a.onEditLayout() } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt new file mode 100644 index 000000000..317d9b0ce --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/DropSheet.kt @@ -0,0 +1,484 @@ +package com.droiddeck.launcher.ui + +import android.view.KeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInputModeManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import com.droiddeck.launcher.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private const val DROP_PIECES = 12 + +internal class DropSheetState(private val scope: CoroutineScope) { + var open by mutableStateOf(false) + var shown by mutableStateOf(false) + var lipBack by mutableStateOf(false) + var tab by mutableStateOf(0) + var travel by mutableStateOf(1) + var settledByDrag = false + var engaged = false + private set + val pull = Animatable(0f) + val lift = Animatable(0f) + val ride = Animatable(0f) + val sink = Animatable(0f) + val hot = Animatable(1f) + val lipShow = Animatable(0f) + val lipGone = Animatable(0f) + val pieces = List(DROP_PIECES) { Animatable(0f) } + var travelPx = 1f + private var from = 0f + var progress = 0f + private set + private var dragJob: Job? = null + + fun cancelDrag() { + dragJob?.cancel() + dragJob = null + } + + fun reset() { + cancelDrag() + engaged = false + settledByDrag = false + open = false + shown = false + lipBack = false + scope.launch { + listOf(pull, lift, ride, sink, lipGone).forEach { it.snapTo(0f) } + pieces.forEach { it.snapTo(0f) } + hot.snapTo(1f) + } + } + + fun beginDrag() { + cancelDrag() + settledByDrag = false + engaged = true + from = if (open) 1f else 0f + progress = from + if (!open) shown = true + } + + fun drag(dy: Float) = follow((progress + dy / travelPx).coerceIn(0f, 1.04f)) + + fun follow(p: Float) { + progress = p + scope.launch { + lift.snapTo(p); ride.snapTo(p); sink.snapTo(p.coerceIn(0f, 1f)); hot.snapTo(0f) + lipGone.snapTo(1f) + pieces.forEach { it.snapTo(p.coerceIn(0f, 1f)) } + } + } + + fun settle(v: Float, flick: Float) { + engaged = false + val opens = when { + v > flick -> true + v < -flick -> false + from > 0.5f -> progress > 0.7f + else -> progress > 0.3f + } + settledByDrag = true + cancelDrag() + dragJob = scope.launch { + val to = if (opens) 1f else 0f + coroutineScope { + launch { lift.animateTo(to, Motion.sp(if (opens) 0.7f else 1f, 380f)) } + launch { ride.animateTo(to, Motion.sp(if (opens) 0.7f else 0.55f, if (opens) 380f else 300f)) } + launch { sink.animateTo(to, Motion.tw(220)) } + pieces.forEachIndexed { i, piece -> launch { if (opens) delay(Motion.ms(i * 35).toLong()); piece.animateTo(to, Motion.tw(if (opens) 200 else 90)) } } + } + if (!opens) { shown = false; lipGone.snapTo(0f); hot.snapTo(1f); lipBack = false } + } + if (open != opens) open = opens else settledByDrag = false + } +} + +@Composable +internal fun rememberDropSheetState(): DropSheetState { + val scope = rememberCoroutineScope() + return remember { DropSheetState(scope) } +} + +@OptIn(ExperimentalComposeUiApi::class, androidx.compose.animation.ExperimentalAnimationApi::class) +@Composable +internal fun BoxWithConstraintsScope.DropSheet( + state: DropSheetState, + tabs: List>, + covered: () -> Float = { 0f }, + button: @Composable (enabled: Boolean) -> Unit, + rows: @Composable (tab: String) -> Unit, +) { + val density = LocalDensity.current + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val inputMode = LocalInputModeManager.current + val narrow = LocalNarrowPane.current + val chipFocus = remember(tabs.size) { List(tabs.size) { FocusRequester() } } + val buttonFocus = remember { FocusRequester() } + val leftForButton = remember { booleanArrayOf(false) } + var rest by remember { mutableStateOf(null) } + val openState = rememberUpdatedState(state.open) + if (state.tab > tabs.lastIndex) state.tab = 0 + fun focusButton() { if (inputMode.inputMode == InputMode.Keyboard) runCatching { buttonFocus.requestFocus() } } + fun close() { + if (!state.open) return + state.open = false + focusButton() + } + val lifecycle = androidx.compose.ui.platform.LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + val obs = androidx.lifecycle.LifecycleEventObserver { _, e -> + if (e == androidx.lifecycle.Lifecycle.Event.ON_STOP && (state.open || state.shown || state.engaged)) state.reset() + } + lifecycle.addObserver(obs) + onDispose { lifecycle.removeObserver(obs) } + } + LaunchedEffect(state.open) { + if (state.settledByDrag) { + state.settledByDrag = false + if (state.open) { + if (inputMode.inputMode == InputMode.Keyboard) { withFrameNanos { }; runCatching { chipFocus[state.tab].requestFocus() } } + } else focusButton() + return@LaunchedEffect + } + if (state.engaged) return@LaunchedEffect + state.cancelDrag() + if (state.open) { + state.lipBack = false + state.shown = true + coroutineScope { + launch { state.pull.animateTo(24f, Motion.sp(0.7f, 380f)) } + launch { state.sink.animateTo(1f, Motion.tw(260)) } + launch { + delay(Motion.ms(120).toLong()) + launch { state.lipGone.animateTo(1f, Motion.tw(140)) } + launch { state.pull.animateTo(0f, Motion.sp(1f, 500f)) } + launch { state.lift.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { state.ride.animateTo(1f, Motion.sp(0.7f, 380f)) } + launch { state.hot.snapTo(1f); delay(Motion.ms(100).toLong()); state.hot.animateTo(0f, Motion.tw(200)) } + } + state.pieces.forEachIndexed { i, p -> launch { delay(Motion.ms(120 + 260 + i * 55).toLong()); p.animateTo(1f, Motion.tw(220)) } } + if (inputMode.inputMode == InputMode.Keyboard) launch { + delay(Motion.ms(300).toLong()) + withFrameNanos { } + runCatching { chipFocus[state.tab].requestFocus() } + } + } + } else if (state.shown) { + coroutineScope { + state.pieces.forEach { launch { it.animateTo(0f, Motion.tw(90)) } } + launch { delay(Motion.ms(60).toLong()); state.lift.animateTo(0f, Motion.sp(1f, 380f)) } + launch { delay(Motion.ms(100).toLong()); state.ride.animateTo(0f, Motion.sp(0.55f, 300f)) } + launch { state.sink.animateTo(0f, Motion.tw(300)) } + launch { delay(Motion.ms(420).toLong()); state.lipGone.snapTo(0f); state.lipBack = true } + } + state.shown = false + state.lipBack = false + state.lipGone.snapTo(0f) + state.hot.snapTo(1f) + } else { + listOf(state.pull, state.sink, state.lift, state.ride, state.lipGone).forEach { it.snapTo(0f) } + state.pieces.forEach { it.snapTo(0f) } + state.hot.snapTo(1f) + } + } + BackHandler(enabled = state.open) { close() } + if (state.ride.isRunning || (state.shown && !state.open)) VeilRing() + + val lipWanted = !state.open && (!state.shown || state.lipBack) && covered() < 0.5f + LaunchedEffect(lipWanted) { + if (lipWanted) { + if (!state.lipBack) delay(Motion.ms(160).toLong()) + state.lipShow.animateTo(1f, Motion.tw(220)) + } else { + delay(Motion.ms(140).toLong()) + state.lipShow.animateTo(0f, Motion.tw(220)) + } + } + + val flick = with(density) { 600.dp.toPx() } + val rowsPush = remember(state) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!state.engaged || available.y <= 0f) return Offset.Zero + val before = state.progress + state.follow((before + available.y / state.travelPx).coerceAtMost(1f)) + return Offset(0f, (state.progress - before) * state.travelPx) + } + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (available.y >= 0f || source != NestedScrollSource.Drag || !openState.value) return Offset.Zero + if (!state.engaged) state.beginDrag() + state.follow((state.progress + available.y / state.travelPx).coerceIn(0f, 1f)) + return available + } + override suspend fun onPreFling(available: Velocity): Velocity { + if (!state.engaged) return Velocity.Zero + state.settle(available.y, flick) + return available + } + } + } + + val w = constraints.maxWidth.toFloat() + val h = constraints.maxHeight.toFloat() + val inset = with(density) { 12.dp.toPx() } + val edge = with(density) { 3.dp.toPx() } + val buttonH = rest?.height ?: with(density) { 56.dp.toPx() } + val openBottom = h - sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, edge) + val lower = rest?.let { (openBottom - edge - buttonH / 2f) - it.top } ?: 0f + state.travelPx = openBottom.coerceAtLeast(1f) + + if (state.shown || state.open) Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = state.sink.value }.background(Color.Black.copy(alpha = 0.42f)) + .focusProperties { canFocus = false } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = state.open) { close() }, + ) + + if (state.shown) { + val bottom = lerp(0f, openBottom, state.lift.value) + val sheetLeft = inset + val sheetRight = w - inset + val corner = with(density) { 18.dp.toPx() } + val rim = with(density) { 1.4.dp.toPx() } + val path = remember { Path() } + val rimPath = remember { Path() } + Canvas(Modifier.fillMaxSize()) { + val t = -corner + rimPath.reset() + rimPath.moveTo(sheetLeft, t) + rimPath.lineTo(sheetLeft, bottom - corner) + rimPath.arcTo(Rect(sheetLeft, bottom - 2 * corner, sheetLeft + 2 * corner, bottom), 180f, -90f, false) + rimPath.lineTo(sheetRight - corner, bottom) + rimPath.arcTo(Rect(sheetRight - 2 * corner, bottom - 2 * corner, sheetRight, bottom), 90f, -90f, false) + rimPath.lineTo(sheetRight, t) + path.reset() + path.addPath(rimPath) + path.close() + drawPath(path, lerp(colors.surface, pal.signal, 0.22f * state.hot.value)) + drawPath(rimPath, pal.signal.copy(alpha = lerp(0.55f, 0.9f, state.hot.value)), style = Stroke(rim)) + } + val pill = rest?.translate(0f, lower) + val sheetW = sheetRight - sheetLeft + val chipsH = with(density) { 44.dp.toPx() } + val closeW = with(density) { 64.dp.toPx() } + val chipsBottom = with(density) { + if (narrow && pill != null) pill.top - 10.dp.toPx() else openBottom - 14.dp.toPx() + } + val chipsTop = chipsBottom - chipsH + val chipsLeft = with(density) { + if (narrow || pill == null) 16.dp.toPx() else pill.right - sheetLeft + 20.dp.toPx() + } + val chipsRight = sheetW - closeW + val rowsBottom = chipsTop - with(density) { 8.dp.toPx() } + Box( + Modifier + .offset { IntOffset(sheetLeft.roundToInt(), (bottom - openBottom).roundToInt()) } + .size(with(density) { sheetW.toDp() }, with(density) { openBottom.toDp() }) + .pointerInput(Unit) { detectTapGestures { } } + .controllerBack { close() } + .bumpers( + onPrevious = { if (state.tab > 0) { state.travel = -1; state.tab--; runCatching { chipFocus[state.tab].requestFocus() } } }, + onNext = { if (state.tab < tabs.lastIndex) { state.travel = 1; state.tab++; runCatching { chipFocus[state.tab].requestFocus() } } }, + ) + .focusProperties { + exit = { dir -> + if (!openState.value) FocusRequester.Default + else if (dir == FocusDirection.Left) { leftForButton[0] = true; buttonFocus } + else FocusRequester.Cancel + } + } + .focusGroup(), + ) { + Box( + Modifier + .nestedScroll(rowsPush) + .fillMaxWidth() + .height(with(density) { rowsBottom.coerceAtLeast(0f).toDp() }), + ) { + AnimatedContent( + targetState = state.tab.coerceIn(0, tabs.lastIndex.coerceAtLeast(0)), + transitionSpec = { + val d = state.travel + val shift = with(density) { 24.dp.roundToPx() } + (slideInHorizontally(Motion.tw(240)) { shift * d } + fadeIn(Motion.tw(240))) + .togetherWith(slideOutHorizontally(Motion.tw(240)) { -shift * d } + fadeOut(Motion.tw(240))) + }, + label = "dropTab", + ) { t -> + val leaving = transition.targetState == androidx.compose.animation.EnterExitState.PostExit + val leavingNow = rememberUpdatedState(leaving) + CompositionLocalProvider(LocalSheetBeat provides remember { SheetBeat(state.pieces) }) { + Column( + Modifier.fillMaxSize() + .focusProperties { enter = { if (leavingNow.value) FocusRequester.Cancel else FocusRequester.Default } } + .focusGroup() + .verticalScroll(rememberScrollState()) + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { tabs.getOrNull(t)?.let { rows(it.first) } } + } + } + } + Box( + Modifier + .offset { IntOffset(chipsLeft.roundToInt(), chipsTop.roundToInt()) } + .zIndex(1f) + .width(with(density) { (chipsRight - chipsLeft).coerceAtLeast(0f).toDp() }) + .graphicsLayer { val v = state.pieces[0].value; alpha = v; translationY = (1f - v) * -6.dp.toPx() } + .onPreviewKeyEvent { e -> + val k = e.nativeKeyEvent + if (k.keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { if (k.action == KeyEvent.ACTION_DOWN) close(); true } else false + }, + ) { + SheetChips(tabs.map { it.second }, state.tab, chipFocus, width = LocalSheetChipWidth.current) { i -> state.travel = if (i > state.tab) 1 else -1; state.tab = i } + } + val closeSource = remember { MutableInteractionSource() } + val closeHot = rememberHot(closeSource) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { IntOffset((sheetW - 52.dp.toPx()).roundToInt(), chipsTop.roundToInt()) } + .zIndex(1f).size(44.dp).testTag("drop-close") + .clip(CircleShape).glideBorder(closeHot, CircleShape, pal.signal) + .hoverable(closeSource) + .clickable(interactionSource = closeSource, indication = LocalIndication.current, enabled = state.open, role = Role.Button) { close() } + .controllerConfirm(enabled = state.open) { close() }, + ) { Icon(Icons.Filled.Close, stringResource(R.string.common_close), tint = colors.onSurfaceVariant, modifier = Modifier.size(20.dp)) } + } + } + + val enabled = covered() < 0.5f + val sideInset = if (narrow) 20.dp else 44.dp + val play = LocalPlayRect.current + Box( + Modifier + .offset { IntOffset(play?.left?.roundToInt() ?: sideInset.roundToPx(), sideInset.roundToPx()) } + .then(if (play != null) Modifier.width(with(density) { play.width.toDp() }) else Modifier) + .onGloballyPositioned { rest = it.boundsInParent() } + .graphicsLayer { translationY = lower * state.ride.value; alpha = 1f - covered() } + .zIndex(2f) + .focusRequester(buttonFocus) + .focusProperties { canFocus = enabled } + .onFocusChanged { + if (it.hasFocus && leftForButton[0]) { leftForButton[0] = false; if (openState.value) close() } + } + .onPreviewKeyEvent { e -> + val k = e.nativeKeyEvent + if (k.keyCode != KeyEvent.KEYCODE_DPAD_UP && !(state.open && k.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT)) false + else { + if (k.action == KeyEvent.ACTION_DOWN && k.repeatCount == 0) { + if (state.open) runCatching { chipFocus[state.tab].requestFocus() } else if (enabled) state.open = true + } + true + } + }, + ) { button(enabled) } + + val r0 = rest + if (r0 != null && (state.lipShow.value > 0.01f || state.pull.value > 0.5f)) { + var handleSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + val lipH = 32.dp + state.pull.value.dp + val drop = { if (!state.open && enabled) state.open = true } + val settingsLabel = stringResource(R.string.drawer_page_settings) + Box( + contentAlignment = Alignment.BottomCenter, + modifier = Modifier + .offset { IntOffset((r0.center.x - handleSize.width / 2f).roundToInt(), (r0.top + lower * state.ride.value - handleSize.height + (1f - state.lipShow.value) * 8.dp.toPx()).roundToInt()) } + .onSizeChanged { handleSize = it } + .graphicsLayer { alpha = state.lipShow.value * (1f - state.lipGone.value) * (1f - covered()) } + .zIndex(2f) + .heightIn(min = 44.dp).testTag("drop-open") + .semantics { contentDescription = settingsLabel } + .focusProperties { canFocus = false } + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = drop), + ) { + Handle(lipH, above = true) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index ffbdb03a0..104739cfd 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -151,6 +151,7 @@ class FrontEndState( class FrontEndActions( val onPlay: () -> Unit, + val onPlaySteamVr: () -> Unit = {}, val onPlayDesktopUi: () -> Unit, val onSteamUrl: (String) -> Unit = {}, val onSteamGame: (Library.SteamGame) -> Unit, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt index 3916fe57a..53650cb76 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt @@ -206,7 +206,7 @@ internal fun SetupPanel( ), ) { PageHeader(stringResource(R.string.setup_title)) { - Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, pick) } + Box(Modifier.weight(1f)) { SheetChips(tabs, tab, chipFocus, onPick = pick) } } Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) { when (tab) { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt index 0c3c0904b..c6296300e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndWidgets.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -38,6 +39,7 @@ import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -57,6 +59,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -148,6 +152,7 @@ internal fun PrimaryButton( modifier: Modifier = Modifier, large: Boolean = false, icon: ImageVector? = null, + fill: Boolean = false, onClick: () -> Unit, ) { val colors = MaterialTheme.colorScheme @@ -165,7 +170,8 @@ internal fun PrimaryButton( val lift by animateFloatAsState(if (hot) 14f else 6f, Motion.tw(300), label = "btnLift") val placed = remember { arrayOfNulls(1) } Row( - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (fill) Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) else Arrangement.spacedBy(8.dp), modifier = modifier.then(track) .onGloballyPositioned { placed[0] = it } .heightIn(min = if (compact) 44.dp else 48.dp) @@ -181,18 +187,34 @@ internal fun PrimaryButton( onClick() } .padding( - start = if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, - end = if (large) 26.dp else if (compact) 10.dp else 18.dp, + start = if (fill) 12.dp else if (large) 20.dp else if (compact) 10.dp else if (icon != null) 14.dp else 18.dp, + end = if (fill) 12.dp else if (large) 26.dp else if (compact) 10.dp else 18.dp, top = if (large) 16.dp else if (compact) 7.dp else 11.dp, bottom = if (large) 16.dp else if (compact) 7.dp else 11.dp, ), ) { val fg = if (enabled) colors.onPrimary else colors.onSurfaceVariant if (icon != null) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (large) 20.dp else 17.dp)) - Text( - text, fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp, - fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, maxLines = 1, - ) + val style = LocalTextStyle.current.merge(TextStyle( + fontSize = if (large) 17.sp else if (compact) 12.sp else 15.sp, + fontWeight = if (large) FontWeight.Bold else FontWeight.SemiBold, letterSpacing = 0.5.sp, color = fg, + )) + if (fill) FittedLabel(text, style, Modifier.weight(1f, fill = false)) + else Text(text, style = style, maxLines = 1) + } +} + +@Composable +private fun FittedLabel(text: String, style: TextStyle, modifier: Modifier = Modifier) { + BoxWithConstraints(modifier) { + val measurer = rememberTextMeasurer() + val max = constraints.maxWidth + val size = remember(text, style, max) { + var size = style.fontSize + while (size.value > 10f && measurer.measure(text, style.copy(fontSize = size), maxLines = 1, softWrap = false).size.width > max) size = (size.value - 0.5f).sp + size + } + Text(text, style = style.copy(fontSize = size), maxLines = 1, softWrap = false) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesCalibrationControls.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesCalibrationControls.kt new file mode 100644 index 000000000..1f53c272d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesCalibrationControls.kt @@ -0,0 +1,64 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.ui.res.stringResource +import com.droiddeck.launcher.R +import com.droiddeck.launcher.glasses.CalibrationPhase +import com.droiddeck.launcher.glasses.GlassesCalibrationState + +@Composable +internal fun GlassesCalibrationControls( + state: GlassesCalibrationState, + onStart: () -> Unit, + onCancel: () -> Unit, + onReset: () -> Unit, + onDone: () -> Unit, +) { + var shown by remember { mutableStateOf(false) } + var reset by remember { mutableStateOf(false) } + val cancel by rememberUpdatedState(onCancel) + DisposableEffect(Unit) { onDispose { cancel() } } + ActionRow(stringResource(R.string.glasses_calibration), + stringResource(if (state.saved) R.string.glasses_calibration_saved_hint else R.string.glasses_calibration_hint), + stringResource(R.string.glasses_calibration_open), { onCancel(); shown = true }) + if (state.saved && !state.running) ActionRow(stringResource(R.string.glasses_calibration_reset), null, + stringResource(R.string.glasses_calibration_reset_button), { reset = true }) + if (reset) AlertDialog( + onDismissRequest = { reset = false }, + title = { Text(stringResource(R.string.glasses_calibration_reset)) }, + text = { Text(stringResource(R.string.glasses_calibration_reset_hint)) }, + confirmButton = { TextButton(onClick = { onReset(); reset = false }) { Text(stringResource(R.string.glasses_calibration_reset_button)) } }, + dismissButton = { TextButton(onClick = { reset = false }) { Text(stringResource(R.string.common_cancel)) } }, + ) + if (shown) AlertDialog( + onDismissRequest = { onCancel(); shown = false }, + title = { Text(stringResource(R.string.glasses_calibration)) }, + text = { Column { + Text(stringResource(when (state.phase) { + CalibrationPhase.IDLE -> R.string.glasses_calibration_instructions + CalibrationPhase.SETTLING -> R.string.glasses_calibration_settling + CalibrationPhase.COLLECTING -> R.string.glasses_calibration_collecting + CalibrationPhase.MOVING -> R.string.glasses_calibration_moving + CalibrationPhase.TURN_OVER -> R.string.glasses_calibration_turn_over + CalibrationPhase.SAVED -> R.string.glasses_calibration_complete + CalibrationPhase.FAILED -> R.string.glasses_calibration_failed + })) + if (state.running) { + LinearProgressIndicator(progress = { state.progress / 100f }) + Text("${state.progress}%") + } + } }, + confirmButton = { + if (!state.running) TextButton(onClick = { + if (state.phase == CalibrationPhase.SAVED) { onDone(); onCancel(); shown = false } + else onStart() + }) { Text(stringResource(if (state.phase == CalibrationPhase.SAVED) R.string.glasses_calibration_done else R.string.glasses_calibration_start)) } + }, + dismissButton = { TextButton(onClick = { onCancel(); shown = false }) { Text(stringResource(R.string.common_cancel)) } }, + ) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt new file mode 100644 index 000000000..5b8e3b30e --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/GlassesPanel.kt @@ -0,0 +1,268 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.sp +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.collectAsState +import com.droiddeck.launcher.R +import com.droiddeck.launcher.glasses.Glasses +import com.droiddeck.launcher.glasses.GlassesRendering +import com.droiddeck.launcher.glasses.GlassesSettings +import com.droiddeck.launcher.glasses.GlassesState +import com.droiddeck.launcher.handtracking.HandTracking +import java.util.Locale + +internal val GlassesIcon: ImageVector by lazy { + ImageVector.Builder("Glasses", 24.dp, 24.dp, 24f, 24f).apply { + path(fill = null, stroke = SolidColor(Color.Black), strokeLineWidth = 1.8f, strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round) { + moveTo(2.5f, 10f); lineTo(21.5f, 10f) + moveTo(3.2f, 10f); lineTo(3.8f, 15.2f); quadTo(4f, 17f, 6f, 17f); lineTo(8.6f, 17f); quadTo(10.2f, 17f, 10.6f, 15.4f); lineTo(11.2f, 12.6f) + quadTo(12f, 11.6f, 12.8f, 12.6f); lineTo(13.4f, 15.4f); quadTo(13.8f, 17f, 15.4f, 17f); lineTo(18f, 17f); quadTo(20f, 17f, 20.2f, 15.2f); lineTo(20.8f, 10f) + moveTo(2.5f, 10f); lineTo(1.8f, 7.6f) + moveTo(21.5f, 10f); lineTo(22.2f, 7.6f) + } + }.build() +} + +@Composable +internal fun rememberGlasses(): Pair { + val g by Glasses.state.collectAsState() + val s by Glasses.settings.collectAsState() + return g to s +} + +internal object GlassesTab { + const val DISPLAY = "display" + const val HARDWARE = "hardware" + const val TRACKING = "tracking" +} + +@Composable +internal fun glassesTabs(g: GlassesState): List> = buildList { + add(GlassesTab.DISPLAY to stringResource(R.string.glasses_display)) + if (g.canControl) { + if (g.hardwareControls) add(GlassesTab.HARDWARE to stringResource(R.string.glasses_hardware)) + add(GlassesTab.TRACKING to stringResource(R.string.glasses_tracking)) + } +} + +@Composable +internal fun GlassesGroups( + host: MenuHost, + g: GlassesState, + s: GlassesSettings, + track: (String) -> Modifier = { Modifier }, + onRecenter: (() -> Unit)? = null, + inSession: Boolean = true, + outputActive: Boolean = inSession, + steamVrRunning: Boolean = false, + tab: String? = null, + steamVr: @Composable () -> Unit = {}, +) { + val display = tab == null || tab == GlassesTab.DISPLAY + val picture = GlassesRendering.picture(s, g.sbs, steamVrRunning) + val usesGlasses = GlassesRendering.usesGlasses(g, outputActive, steamVrRunning) + val virtualScreen = usesGlasses && picture == GlassesRendering.Picture.VIRTUAL_SCREEN + val directView = usesGlasses && picture == GlassesRendering.Picture.STEREO + if (display) SettingsGroup(g.model) { + if (g.experimental) SettingsRow(stringResource(R.string.glasses_control), stringResource(R.string.glasses_xreal_experimental)) {} + val status = if (g.hasDisplay) stringResource(R.string.glasses_status, g.width, g.height, kotlin.math.round(g.hz).toInt(), + stringResource(if (g.sbs) R.string.glasses_mode_3d else R.string.glasses_mode_2d)) else null + when { + g.noSignal -> SettingsRow(stringResource(R.string.glasses_output), stringResource(R.string.glasses_no_signal)) {} + !g.controllable -> { + if (status != null) SettingsRow(stringResource(R.string.glasses_output), status) {} + SettingsRow(stringResource(R.string.glasses_control), g.controlError ?: stringResource(R.string.glasses_no_control)) {} + } + g.permission == Glasses.Permission.ASKING -> + SettingsRow(stringResource(R.string.glasses_control), stringResource(R.string.glasses_asking)) {} + !g.connected -> ActionRow(stringResource(R.string.glasses_control), + g.controlError ?: stringResource(if (g.permission == Glasses.Permission.DENIED) R.string.glasses_denied else R.string.glasses_allow_hint), + stringResource(R.string.glasses_allow), { Glasses.requestControl() }) + status != null -> SettingsRow(stringResource(R.string.glasses_output), status) {} + } + ToggleRow(host, "glasses-game", stringResource(R.string.glasses_game_on), stringResource(R.string.glasses_game_on_hint), + s.gameOnGlasses, chipModifier = track("glasses-game")) { Glasses.setGameOnGlasses(it) } + } + if (display && (g.canControl || g.hasDisplay)) SettingsGroup(stringResource(R.string.glasses_display)) { + if (g.canControl) { + if (!g.sbs) ChoiceRow(host, "glasses-hz", stringResource(R.string.glasses_refresh), null, + listOf(60, 90, 120).map { it to "$it Hz" }, s.refreshHz, chipModifier = track("glasses-hz")) { Glasses.setRefreshHz(it) } + if (g.sbsSupport == GlassesState.SBS_FAILS && !g.sbs) { + ActionRow(stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_failed), + stringResource(R.string.stores_dl_retry), { Glasses.set3d(true) }) + } else { + if (inSession || g.sbs) ToggleRow(host, "glasses-3d", stringResource(R.string.glasses_3d), stringResource(R.string.glasses_3d_hint), + g.sbs, chipModifier = track("glasses-3d")) { Glasses.set3d(it) } + if (g.sbs) { + if (g.sbs90) ChoiceRow(host, "glasses-3d-hz", stringResource(R.string.glasses_3d_refresh), null, + listOf(60, 90).map { it to "$it Hz" }, s.sbsHz, chipModifier = track("glasses-3d-hz")) { Glasses.setSbsHz(it) } + else SettingsRow(stringResource(R.string.glasses_3d_refresh), "60 Hz") {} + } + } + } + ToggleRow(host, "glasses-auto3d", stringResource(R.string.glasses_auto3d), stringResource(R.string.glasses_auto3d_hint), + s.auto3d, chipModifier = track("glasses-auto3d")) { Glasses.setAuto3d(it) } + ChoiceRow(host, "glasses-picture", stringResource(R.string.glasses_picture), stringResource(R.string.glasses_picture_hint), + listOf( + GlassesSettings.PICTURE_AUTO to stringResource(R.string.glasses_picture_auto), + GlassesSettings.PICTURE_SCREEN to stringResource(R.string.glasses_picture_screen), + GlassesSettings.PICTURE_SBS to stringResource(R.string.glasses_picture_sbs), + ), s.picture3d, chipModifier = track("glasses-picture"), hintLines = 2) { Glasses.setPicture3d(it) } + when { + !inSession -> SettingsRow(stringResource(R.string.glasses_picture_screen), stringResource(R.string.glasses_screen_next_session)) {} + !usesGlasses -> SettingsRow(stringResource(R.string.glasses_picture_screen), stringResource(R.string.glasses_screen_no_output)) {} + picture == GlassesRendering.Picture.FLAT -> ActionRow( + stringResource(R.string.glasses_picture_screen), stringResource(R.string.glasses_screen_flat_hint), + stringResource(R.string.glasses_screen_use), { Glasses.setPicture3d(GlassesSettings.PICTURE_SCREEN) }) + directView -> SettingsRow(stringResource(R.string.glasses_picture_screen), + stringResource(if (steamVrRunning) R.string.glasses_screen_direct_hint else R.string.glasses_screen_sbs_hint)) {} + } + if (virtualScreen || (!inSession && s.picture3d != GlassesSettings.PICTURE_SBS)) { + SliderRow(stringResource(R.string.glasses_screen_size), null, s.screenWidthDm, 5..60, step = 1, + format = { String.format(Locale.US, "%.1f m", it / 10f) }, modifier = track("glasses-width")) { Glasses.setScreenWidthDm(it) } + SliderRow(stringResource(R.string.glasses_screen_distance), null, s.screenDistanceDm, 5..100, step = 1, + format = { String.format(Locale.US, "%.1f m", it / 10f) }, modifier = track("glasses-distance")) { Glasses.setScreenDistanceDm(it) } + } + steamVr() + if ((directView && steamVrRunning) || !inSession) SliderRow(stringResource(R.string.glasses_vr_fov), stringResource(R.string.glasses_vr_fov_hint), s.vrFovDeg, + GlassesSettings.VR_FOV_MIN..GlassesSettings.VR_FOV_MAX, step = 1, + format = { "$it°" }, modifier = track("glasses-vr-fov")) { Glasses.setVrFovDeg(it) } + } + if ((tab == null || tab == GlassesTab.HARDWARE) && g.canControl && g.hardwareControls) SettingsGroup(stringResource(R.string.glasses_hardware)) { + if (g.brightnessMax > 0) SliderRow(stringResource(R.string.glasses_brightness), null, g.brightness.coerceAtLeast(0), 0..g.brightnessMax, step = 1, + modifier = track("glasses-brightness")) { Glasses.setBrightness(it) } + if (g.volumeMax > 0) SliderRow(stringResource(R.string.glasses_volume), null, g.volume.coerceAtLeast(0), 0..g.volumeMax, step = 1, + modifier = track("glasses-volume")) { Glasses.setVolume(it) } + if (g.hasSunblock) ToggleRow(host, "glasses-sunblock", stringResource(R.string.glasses_sunblock), stringResource(R.string.glasses_sunblock_hint), + g.sunblock, chipModifier = track("glasses-sunblock")) { Glasses.setSunblock(it) } + } + if ((tab == null || tab == GlassesTab.TRACKING) && g.canControl) SettingsGroup(stringResource(R.string.glasses_tracking)) { + val trackingHint = when { + inSession && !usesGlasses -> R.string.glasses_screen_no_output + inSession && picture == GlassesRendering.Picture.FLAT -> R.string.glasses_tracking_flat_hint + inSession && directView && !steamVrRunning -> R.string.glasses_tracking_sbs_hint + else -> R.string.glasses_head_tracking_hint + } + ToggleRow(host, "glasses-tracking", stringResource(R.string.glasses_head_tracking), stringResource(trackingHint), + s.headTracking, chipModifier = track("glasses-tracking")) { Glasses.setHeadTracking(it) } + if (onRecenter != null && s.headTracking && (virtualScreen || (directView && steamVrRunning))) ActionRow(stringResource(R.string.glasses_recenter), null, + stringResource(R.string.cardboard_recenter_button), onRecenter) + val calibration by Glasses.calibration.collectAsState() + if (calibration.supported) GlassesCalibrationControls(calibration, + onStart = Glasses::startCalibration, onCancel = Glasses::cancelCalibration, + onReset = Glasses::resetCalibration, onDone = { onRecenter?.invoke() }) + } + val context = androidx.compose.ui.platform.LocalContext.current + val hands by HandTracking.settings.collectAsState() + androidx.compose.runtime.LaunchedEffect(Unit) { HandTracking.load(context) } + if ((tab == null || tab == GlassesTab.TRACKING) && g.canControl && hands.usable) SettingsGroup(stringResource(R.string.hand_camera_group)) { + HandCameraRows(host, track, inSession) + } +} + +private fun padButtonColors(tint: Int, opacity: Int, pressed: Boolean): Pair { + val base = Color(tint) + val alpha = opacity.coerceIn(0, 100) / 100f + fun light(a: Int, f: Float) = Color(base.red + (1 - base.red) * f, base.green + (1 - base.green) * f, base.blue + (1 - base.blue) * f, a * alpha / 255f) + return Color(base.red * 0.12f, base.green * 0.12f, base.blue * 0.12f, 190 / 255f) to (if (pressed) light(230, 0.7f) else light(150, 0.25f)) +} + +@Composable +internal fun GlassesCornerButton(slot: Int, tint: Int, opacity: Int, onClick: () -> Unit) { + val density = androidx.compose.ui.platform.LocalDensity.current + androidx.compose.foundation.layout.BoxWithConstraints( + Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout), + ) { + val x = if (slot < 0) maxWidth / 2 + 56.dp else 34.dp + 56.dp * slot + val bottom = if (slot < 0) 44.dp else 34.dp + val source = remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val (fill, line) = padButtonColors(tint, opacity, pressed) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset { with(density) { IntOffset((x - 18.dp).roundToPx(), (maxHeight - bottom - 18.dp).roundToPx()) } } + .size(36.dp) + .clip(CircleShape) + .background(fill) + .border(1.5.dp, line, CircleShape) + .clickable(interactionSource = source, indication = null, role = Role.Button, onClick = onClick) + .testTag("glasses-button"), + ) { + Icon(GlassesIcon, stringResource(R.string.glasses_open), tint = line, modifier = Modifier.size(22.dp)) + } + } +} + +@Composable +internal fun GlassesRecenterButton(tint: Int, opacity: Int, onClick: () -> Unit) { + val source = remember { MutableInteractionSource() } + val pressed by source.collectIsPressedAsState() + val (fill, line) = padButtonColors(tint, opacity, pressed) + val shape = RoundedCornerShape(50) + Box( + Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.displayCutout).padding(top = 12.dp), + contentAlignment = Alignment.TopCenter, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .height(36.dp) + .clip(shape) + .background(fill) + .border(1.5.dp, line, shape) + .clickable(interactionSource = source, indication = null, role = Role.Button, onClick = onClick) + .padding(horizontal = 18.dp) + .testTag("glasses-recenter"), + ) { + Text(stringResource(R.string.glasses_recenter_button), color = line, fontSize = 14.sp) + } + } +} + +@Composable +internal fun GlassesPlayingHint(model: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(GlassesIcon, null, tint = Color.White.copy(alpha = 0.35f), modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(10.dp)) + Text(stringResource(R.string.glasses_playing, model), color = Color.White.copy(alpha = 0.45f), fontSize = 15.sp) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt new file mode 100644 index 000000000..58e6bdb13 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/HandCameraUi.kt @@ -0,0 +1,345 @@ +package com.droiddeck.launcher.ui + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.speech.tts.TextToSpeech +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.droiddeck.launcher.R +import com.droiddeck.launcher.handtracking.Advice +import com.droiddeck.launcher.handtracking.BodyPose +import com.droiddeck.launcher.handtracking.HandCamera +import com.droiddeck.launcher.handtracking.HandTracking +import com.droiddeck.launcher.handtracking.Landmark +import com.droiddeck.launcher.handtracking.TrackingCameras +import kotlinx.coroutines.delay +import java.util.Locale + +@Composable +internal fun HandCameraHost(check: Boolean?, onAskPermission: () -> Unit, onPlay: () -> Unit, onClose: () -> Unit) { + if (!HandTracking.supported) return + val context = LocalContext.current + val (glasses, _) = rememberGlasses() + val settings by HandTracking.settings.collectAsState() + LaunchedEffect(Unit) { HandTracking.load(context) } + var prompted by rememberSaveable { mutableStateOf(false) } + if (!prompted && HandTracking.shouldAsk(settings, glasses.present && glasses.canControl)) { + HandCameraPrompt(settings.sides, + onAllow = { side -> + prompted = true + HandTracking.allow(context, side) + if (!HandTracking.permitted(context)) onAskPermission() + }, + onNotNow = { + prompted = true + HandTracking.decline(context) + }) + } + val requests by HandTracking.checkRequests.collectAsState() + var handled by remember { mutableStateOf(requests) } + val fromSettings = requests != handled + if (check != null || fromSettings) { + PlacementCheck(settings, play = check == true && !fromSettings, + onPlay = { handled = requests; onPlay() }, + onClose = { handled = requests; onClose() }) + } +} + +@Composable +internal fun HandCameraPrompt(sides: Set, onAllow: (String?) -> Unit, onNotNow: () -> Unit) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + val choose = sides.size > 1 + var side by remember { mutableStateOf(TrackingCameras.REAR) } + AlertDialog( + onDismissRequest = onNotNow, + modifier = Modifier.testTag("hand-camera-prompt"), + title = { Text(stringResource(R.string.hand_camera_prompt_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.hand_camera_prompt_body), fontSize = 14.sp) + if (choose) listOf( + Triple(TrackingCameras.REAR, R.string.hand_camera_rear, R.string.hand_camera_rear_hint), + Triple(TrackingCameras.FRONT, R.string.hand_camera_front, R.string.hand_camera_front_hint), + ).forEach { (id, label, hint) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)) + .selectable(side == id, role = Role.RadioButton) { side = id } + .testTag("hand-camera-$id") + .padding(vertical = 4.dp), + ) { + RadioButton(side == id, onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = pal.signal, unselectedColor = colors.onSurfaceVariant)) + Column(Modifier.padding(start = 10.dp)) { + Text(stringResource(label), fontSize = 15.sp, color = colors.onBackground) + Text(stringResource(hint), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = { onAllow(if (choose) side else null) }, modifier = Modifier.testTag("hand-camera-allow")) { + Text(stringResource(R.string.hand_camera_allow)) + } + }, + dismissButton = { + TextButton(onClick = onNotNow, modifier = Modifier.testTag("hand-camera-not-now")) { Text(stringResource(R.string.hand_camera_not_now)) } + }, + ) +} + +@Composable +internal fun HandCameraRows(host: MenuHost, track: (String) -> Modifier, inSession: Boolean) { + if (!HandTracking.supported) return + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + HandTracking.load(context) + val watch = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) HandTracking.refreshPermission(context) } + lifecycle.addObserver(watch) + onDispose { lifecycle.removeObserver(watch) } + } + val s by HandTracking.settings.collectAsState() + if (!s.usable) return + var denied by remember { mutableStateOf(false) } + val ask = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + HandTracking.refreshPermission(context) + denied = !granted + } + ToggleRow(host, "hand-camera", stringResource(R.string.hand_camera_setting), stringResource(R.string.hand_camera_setting_hint), + s.enabled, chipModifier = track("hand-camera")) { on -> + HandTracking.setEnabled(context, on) + if (on && !s.permitted) ask.launch(Manifest.permission.CAMERA) + } + if (!s.enabled) return + if (s.sides.size > 1) ChoiceRow(host, "hand-camera-side", stringResource(R.string.hand_camera_choice), null, + listOf(TrackingCameras.REAR to stringResource(R.string.hand_camera_rear), TrackingCameras.FRONT to stringResource(R.string.hand_camera_front)), + s.side ?: TrackingCameras.REAR, chipModifier = track("hand-camera-side")) { HandTracking.setSide(context, it) } + when { + !s.permitted && denied -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_denied), + stringResource(R.string.hand_camera_open_settings), { + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }) + !s.permitted -> ActionRow(stringResource(R.string.hand_camera_access), stringResource(R.string.hand_camera_access_hint), + stringResource(R.string.hand_camera_allow_button), { ask.launch(Manifest.permission.CAMERA) }) + !inSession -> ActionRow(stringResource(R.string.hand_camera_placement), stringResource(R.string.hand_camera_placement_hint), + stringResource(R.string.hand_camera_check), { HandTracking.requestCheck() }) + } +} + +@Composable +private fun PlacementCheck(settings: HandTracking.Settings, play: Boolean, onPlay: () -> Unit, onClose: () -> Unit) { + val context = LocalContext.current + val side = settings.side + val camera = remember(side) { HandCamera(context, null, preview = true) } + DisposableEffect(camera) { + camera.start(side) + onDispose { camera.stop() } + } + val view by camera.view.collectAsState() + val failed by camera.failed.collectAsState() + val front = view?.front ?: (TrackingCameras.pick(HandTracking.cameras(context), side)?.front == true) + val advice = when { + failed -> null + else -> view?.advice ?: Advice.STARTING + } + val adviceText = stringResource(advice?.let(::adviceLabel) ?: R.string.hand_camera_failed) + val start by rememberUpdatedState(onPlay) + if (play) LaunchedEffect(advice == Advice.GOOD) { + if (advice == Advice.GOOD) { + delay(GOOD_HOLD_MS) + start() + } + } + if (!front) SpokenAdvice(advice, adviceText) + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + PlacementCheckContent(view, front, advice, adviceText, play, switchable = settings.sides.size > 1, + onSwitch = { HandTracking.setSide(context, if (front) TrackingCameras.REAR else TrackingCameras.FRONT) }, + onPlay = onPlay, onClose = onClose) + } +} + +@Composable +internal fun PlacementCheckContent( + view: HandCamera.View?, + front: Boolean, + advice: Advice?, + adviceText: String, + play: Boolean, + switchable: Boolean, + onSwitch: () -> Unit, + onPlay: () -> Unit, + onClose: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + val pal = LocalPalette.current + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.fillMaxSize().background(pal.background).padding(24.dp).testTag("hand-camera-check"), + ) { + Box(Modifier.weight(1.3f).fillMaxHeight(), contentAlignment = Alignment.Center) { CameraPreview(view) } + Column(Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text(stringResource(R.string.hand_camera_check_title), fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(stringResource(if (front) R.string.hand_camera_how_front else R.string.hand_camera_how_rear), fontSize = 15.sp, color = colors.onSurfaceVariant) + Text(adviceText, fontSize = 19.sp, fontWeight = FontWeight.SemiBold, + color = if (advice == Advice.GOOD) pal.good else colors.onBackground, modifier = Modifier.testTag("hand-camera-advice")) + Spacer(Modifier.heightIn(min = 8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + if (switchable) SecondaryButton(stringResource(R.string.hand_camera_switch), compact = true, onClick = onSwitch) + if (play) { + SecondaryButton(stringResource(R.string.hand_camera_skip), compact = true, modifier = Modifier.testTag("hand-camera-skip"), onClick = onPlay) + SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onClose) + } else { + SecondaryButton(stringResource(R.string.hand_camera_done), compact = true, modifier = Modifier.testTag("hand-camera-done"), onClick = onClose) + } + } + } + } +} + +@Composable +private fun CameraPreview(view: HandCamera.View?) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(16.dp) + if (view == null) { + Box(Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(shape).background(pal.surface), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = pal.signal, modifier = Modifier.size(36.dp)) + } + return + } + val image = remember(view) { view.image.asImageBitmap() } + Box(Modifier.aspectRatio(view.image.width.toFloat() / view.image.height).clip(shape)) { + Image(image, null, contentScale = ContentScale.FillBounds, + modifier = Modifier.fillMaxSize().graphicsLayer { if (view.front) scaleX = -1f }) + Canvas(Modifier.fillMaxSize()) { drawSkeleton(view.landmarks, view.front, pal.signal, pal.good) } + } +} + +private fun DrawScope.drawSkeleton(marks: List?, mirrored: Boolean, line: Color, hand: Color) { + if (marks == null || marks.size <= BodyPose.RIGHT_HIP) return + fun at(i: Int) = Offset((if (mirrored) 1f - marks[i].x else marks[i].x) * size.width, marks[i].y * size.height) + fun seen(i: Int) = marks[i].visibility >= BodyPose.MIN_VISIBILITY + val width = size.minDimension / 120f + for ((a, b) in BONES) if (seen(a) && seen(b)) drawLine(line, at(a), at(b), strokeWidth = width, cap = StrokeCap.Round) + for (i in JOINTS) if (seen(i)) drawCircle(line, width * 1.2f, at(i)) + for (wrist in listOf(BodyPose.LEFT_WRIST, BodyPose.RIGHT_WRIST)) { + if (!seen(wrist)) continue + val grip = (at(wrist) + at(wrist + 2) + at(wrist + 4)) / 3f + drawCircle(hand.copy(alpha = 0.35f), width * 5f, grip) + drawCircle(hand, width * 2.2f, grip) + } +} + +@Composable +private fun SpokenAdvice(advice: Advice?, text: String) { + val context = LocalContext.current + var ready by remember { mutableStateOf(false) } + val speech = remember { + var engine: TextToSpeech? = null + engine = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { + engine?.language = Locale.getDefault() + ready = true + } + } + engine + } + DisposableEffect(speech) { onDispose { speech.shutdown() } } + LaunchedEffect(advice, ready) { + if (!ready || advice == null || advice == Advice.STARTING) return@LaunchedEffect + delay(SPEAK_AFTER_MS) + while (true) { + speech.speak(text, TextToSpeech.QUEUE_FLUSH, null, "hand-camera") + if (advice == Advice.GOOD) break + delay(SPEAK_AGAIN_MS) + } + } +} + +private fun adviceLabel(advice: Advice): Int = when (advice) { + Advice.STARTING -> R.string.hand_camera_starting + Advice.STAND_UP -> R.string.hand_camera_stand_up + Advice.UPRIGHT -> R.string.hand_camera_upright + Advice.NOT_SEEN -> R.string.hand_camera_not_seen + Advice.MOVE_BACK -> R.string.hand_camera_move_back + Advice.MOVE_CLOSER -> R.string.hand_camera_move_closer + Advice.SHOW_HANDS -> R.string.hand_camera_show_hands + Advice.GOOD -> R.string.hand_camera_good +} + +private val BONES = listOf( + BodyPose.LEFT_SHOULDER to BodyPose.RIGHT_SHOULDER, + BodyPose.LEFT_SHOULDER to BodyPose.LEFT_ELBOW, BodyPose.LEFT_ELBOW to BodyPose.LEFT_WRIST, + BodyPose.RIGHT_SHOULDER to BodyPose.RIGHT_ELBOW, BodyPose.RIGHT_ELBOW to BodyPose.RIGHT_WRIST, + BodyPose.LEFT_SHOULDER to BodyPose.LEFT_HIP, BodyPose.RIGHT_SHOULDER to BodyPose.RIGHT_HIP, BodyPose.LEFT_HIP to BodyPose.RIGHT_HIP, + BodyPose.NOSE to BodyPose.LEFT_EYE, BodyPose.NOSE to BodyPose.RIGHT_EYE, + BodyPose.LEFT_EYE to BodyPose.LEFT_EAR, BodyPose.RIGHT_EYE to BodyPose.RIGHT_EAR, +) +private val JOINTS = listOf(BodyPose.NOSE, BodyPose.LEFT_SHOULDER, BodyPose.RIGHT_SHOULDER, BodyPose.LEFT_ELBOW, BodyPose.RIGHT_ELBOW, + BodyPose.LEFT_HIP, BodyPose.RIGHT_HIP) +private const val GOOD_HOLD_MS = 1200L +private const val SPEAK_AFTER_MS = 1200L +private const val SPEAK_AGAIN_MS = 6000L diff --git a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt index 69835b0ef..01240610d 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/LiftSheet.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -42,6 +43,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.ui.platform.testTag import kotlinx.coroutines.Job @@ -52,6 +54,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -81,6 +84,7 @@ import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.InputMode @@ -102,6 +106,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp import androidx.compose.ui.util.lerp import com.droiddeck.launcher.R @@ -138,6 +143,15 @@ internal class SheetBeat(private val pieces: List { null } +internal val LocalSheetChipWidth = compositionLocalOf { Dp.Unspecified } + +internal val LocalPlayRect = compositionLocalOf { null } + +private val ChipGap = 6.dp + +internal fun sheetChipWidth(row: Dp, count: Int): Dp = + if (count > 0 && row / count >= 96.dp) (row - ChipGap * (count - 1)) / count else 0.dp + @Composable internal fun Modifier.sheetPiece(): Modifier { val beat = LocalSheetBeat.current ?: return this @@ -173,6 +187,8 @@ internal fun LiftHero( onOpenChange: (Boolean) -> Unit = {}, start: Dp = 44.dp, bottom: Dp = 44.dp, + drop: DropSheetState? = null, + overlay: @Composable BoxWithConstraintsScope.(lifted: () -> Float) -> Unit = {}, ) { val density = LocalDensity.current val inputMode = LocalInputModeManager.current @@ -306,7 +322,7 @@ internal fun LiftHero( val lipFocus = remember { FocusRequester() } val settingsLabel = stringResource(R.string.drawer_page_settings) var lipFocused by remember { mutableStateOf(false) } - val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused) + val lipWanted = !open && (!shown || lipBack) && (playFocused || playHovered || touchOnly || lipFocused) && drop?.let { it.open || it.shown } != true LaunchedEffect(lipWanted) { if (lipWanted) { if (!lipBack) delay(Motion.ms(160).toLong()) @@ -385,18 +401,35 @@ internal fun LiftHero( } BoxWithConstraints( modifier.fillMaxSize().onGloballyPositioned { heroAt = it.boundsInRoot().topLeft } - .pointerInput(Unit) { + .pointerInput(drop) { val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker() var decided = false + val dropFlick = 600.dp.toPx() detectVerticalDragGestures( onDragStart = { tracker.resetTracking(); decided = false; drag.engaged = false }, - onDragEnd = { if (drag.engaged) settleDrag(tracker.calculateVelocity().y) }, - onDragCancel = { if (drag.engaged) settleDrag(0f) }, + onDragEnd = { + if (drag.engaged) settleDrag(tracker.calculateVelocity().y) + else if (drop?.engaged == true) drop.settle(tracker.calculateVelocity().y, dropFlick) + }, + onDragCancel = { + if (drag.engaged) settleDrag(0f) + else if (drop?.engaged == true) drop.settle(0f, dropFlick) + }, ) { change, amount -> if (launching) return@detectVerticalDragGestures if (!decided) { decided = true - if (if (open) amount > 0f else amount < 0f) beginDrag() + when { + drop != null && (drop.open || drop.shown) -> if (drop.open && amount < 0f) drop.beginDrag() + open || shown || amount < 0f -> if (if (open) amount > 0f else amount < 0f) beginDrag() + drop != null && amount > 0f -> drop.beginDrag() + } + } + if (drop?.engaged == true) { + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + drop.drag(amount) + return@detectVerticalDragGestures } if (!drag.engaged) return@detectVerticalDragGestures change.consume() @@ -413,9 +446,15 @@ internal fun LiftHero( val openTop = sheetTop(h, buttonH, with(density) { 16.dp.toPx() }, with(density) { 3.dp.toPx() }) val raise = restRect?.let { (openTop + with(density) { 3.dp.toPx() } - buttonH / 2f) - it.top } ?: 0f travelPx[0] = (h - openTop).coerceAtLeast(1f) + val pill = restRect?.translate(0f, raise) + val chipsX = with(density) { + if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - inset + } + val chipWidth = with(density) { sheetChipWidth((w - 2 * inset - chipsX - 64.dp.toPx()).coerceAtLeast(0f).toDp(), tabs.size) } val foot = h - Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * sink.value; scaleX = k; scaleY = k }) { background() } + val wallSink = { maxOf(sink.value, drop?.sink?.value ?: 0f) } + Box(Modifier.fillMaxSize().clipToBounds().graphicsLayer { val k = 1f - 0.05f * wallSink(); scaleX = k; scaleY = k }) { background() } scrim { sink.value } if (shown || open) Box( Modifier.fillMaxSize() @@ -432,7 +471,6 @@ internal fun LiftHero( val rimPath = remember { Path() } val corner = with(density) { SheetCorner.toPx() } val rim = with(density) { 1.4.dp.toPx() } - val pill = restRect?.translate(0f, raise) Canvas(Modifier.fillMaxSize()) { val f = fold.value val ft = foldTop.value @@ -459,9 +497,6 @@ internal fun LiftHero( drawPath(rimPath, rimColor, style = Stroke(rim)) } } - val chipsX = with(density) { - if (narrow || pill == null) 16.dp.toPx() else pill.right + 20.dp.toPx() - sheetLeft - } val chipsY = with(density) { if (narrow && pill != null) (pill.bottom - openTop) + 10.dp.toPx() else ChipsTop.toPx() } val rowsY = with(density) { maxOf(RowsTop.toPx(), chipsY + 52.dp.toPx()) } val sheetW = sheetRight - sheetLeft @@ -537,13 +572,13 @@ internal fun LiftHero( } Column(Modifier.align(Alignment.BottomStart).padding(start = start, bottom = bottom, end = 20.dp)) { - Box(Modifier.graphicsLayer { alpha = 1f - sink.value }) { words() } + Box(Modifier.graphicsLayer { alpha = 1f - wallSink() }) { words() } Spacer(Modifier.height(18.dp)) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { Box( Modifier .onGloballyPositioned { rest = it.boundsInRoot() } - .graphicsLayer { translationY = raise * ride.value } + .graphicsLayer { translationY = raise * ride.value; alpha = 1f - (drop?.sink?.value ?: 0f) } .onFocusChanged { if (it.hasFocus && leftForPlay[0]) { leftForPlay[0] = false; if (openState.value && !launchingState.value) close() } playFocused = it.hasFocus @@ -570,7 +605,7 @@ internal fun LiftHero( val besideShown = !(open || shown) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.graphicsLayer { alpha = 1f - sink.value } + modifier = Modifier.graphicsLayer { alpha = 1f - wallSink() } .focusProperties { canFocus = besideShown }, ) { if (besideShown || sink.value < 0.99f) beside() } } @@ -585,7 +620,7 @@ internal fun LiftHero( Modifier .offset { IntOffset((r0.center.x - handleW / 2f).roundToInt(), (r0.bottom + raise * ride.value + (lipShow.value - 1f) * 8.dp.toPx()).roundToInt()) } .onSizeChanged { handleW = it.width.toFloat() } - .graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) } + .graphicsLayer { alpha = lipShow.value * (1f - lipGone.value) * (1f - (drop?.sink?.value ?: 0f)) } .heightIn(min = 44.dp).testTag("sheet-open") .semantics { contentDescription = settingsLabel } .then( @@ -599,14 +634,15 @@ internal fun LiftHero( } } } + CompositionLocalProvider(LocalSheetChipWidth provides chipWidth, LocalPlayRect provides restRect) { overlay { sink.value } } } } @Composable -private fun Handle(height: Dp) { +internal fun Handle(height: Dp, above: Boolean = false) { val colors = MaterialTheme.colorScheme val pal = LocalPalette.current - val shape = RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + val shape = if (above) RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) else RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) val still = Motion.scale == 0f val bob = androidx.compose.animation.core.rememberInfiniteTransition(label = "handle") val lift by bob.animateFloat( @@ -625,18 +661,18 @@ private fun Handle(height: Dp) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.height(height).clip(shape).background(colors.surface) - .drawBorderSidesAndFoot(pal.line, 12.dp) + .drawBorderSidesAndFoot(pal.line, 12.dp, flip = above) .padding(start = 14.dp, end = 10.dp), ) { Text(stringResource(R.string.drawer_page_settings), fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onSurfaceVariant, maxLines = 1) Icon( - Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant, - modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else -3.dp.toPx() * lift }, + if (above) Icons.Filled.KeyboardArrowDown else Icons.Filled.KeyboardArrowUp, null, tint = colors.onSurfaceVariant, + modifier = Modifier.size(16.dp).graphicsLayer { translationY = if (still) 0f else (if (above) 3.dp else -3.dp).toPx() * lift }, ) } } -private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier = +private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp, flip: Boolean = false): Modifier = this.drawWithContent { drawContent() val c = corner.toPx() @@ -649,30 +685,40 @@ private fun Modifier.drawBorderSidesAndFoot(color: Color, corner: Dp): Modifier arcTo(Rect(size.width - 2 * c, size.height - 2 * c + sw / 2, size.width - sw / 2, size.height - sw / 2), 90f, -90f, false) lineTo(size.width - sw / 2, 0f) } - drawPath(p, color, style = Stroke(sw)) + if (flip) scale(1f, -1f) { drawPath(p, color, style = Stroke(sw)) } else drawPath(p, color, style = Stroke(sw)) } @OptIn(ExperimentalComposeUiApi::class) @Composable -internal fun SheetChips(labels: List, selected: Int, focus: List? = null, onPick: (Int) -> Unit) { +internal fun SheetChips( + labels: List, + selected: Int, + focus: List? = null, + width: Dp = Dp.Unspecified, + onPick: (Int) -> Unit, +) { val pal = LocalPalette.current val bounds = remember { mutableStateMapOf() } val tint = rememberUpdatedState(pal.signal) val chips = focus ?: remember(labels.size) { List(labels.size) { FocusRequester() } } var within by remember { mutableStateOf(false) } LaunchedEffect(selected) { if (within) runCatching { chips[selected].requestFocus() } } - BoxWithConstraints(Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }) { - val equal = maxWidth / labels.size.coerceAtLeast(1) >= 96.dp + BoxWithConstraints( + Modifier.fillMaxWidth().onFocusChanged { within = it.hasFocus }, + contentAlignment = Alignment.CenterStart, + ) { + val equal = !width.isSpecified && maxWidth / labels.size.coerceAtLeast(1) >= 96.dp + val least = if (width.isSpecified) maxOf(width, 64.dp) else 64.dp val scroll = rememberScrollState() Box(if (equal) Modifier.fillMaxWidth() else Modifier.edgeFade(scroll).horizontalScroll(scroll).padding(3.dp)) { Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(ChipGap), modifier = (if (equal) Modifier.fillMaxWidth() else Modifier) .focusProperties { enter = { chips.getOrNull(selected) ?: FocusRequester.Default } } .focusGroup(), ) { labels.forEachIndexed { i, label -> - val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = 64.dp)) + val m = (if (equal) Modifier.weight(1f) else Modifier.widthIn(min = least)) .onGloballyPositioned { bounds[i] = it.boundsInParent() } .focusRequester(chips[i]) SheetChip(label, i == selected, m.onFocusChanged { if (it.isFocused && i != selected) onPick(i) }) { onPick(i) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt index d2cf18ad8..fb123fbd8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionDrawerPage.kt @@ -2,6 +2,7 @@ package com.droiddeck.launcher.ui internal enum class SessionDrawerPage(val firstControl: String) { CONTROLLER("touch"), + GLASSES("glasses-game"), SECOND_SCREEN("second-screen:none"), DISPLAY("hud"), EFFECTS("look"), @@ -10,8 +11,10 @@ internal enum class SessionDrawerPage(val firstControl: String) { SESSION("suspend"), } -internal fun sessionDrawerPages(hasSecondScreen: Boolean): List = - SessionDrawerPage.entries.filter { hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN } +internal fun sessionDrawerPages(hasSecondScreen: Boolean, hasGlasses: Boolean = false): List = + SessionDrawerPage.entries.filter { + (hasSecondScreen || it != SessionDrawerPage.SECOND_SCREEN) && (hasGlasses || it != SessionDrawerPage.GLASSES) + } internal fun List.step(current: SessionDrawerPage, direction: Int): SessionDrawerPage { val index = indexOf(current).coerceAtLeast(0) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 531aeb4d6..57cd3bdfa 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -149,6 +149,7 @@ import java.util.Locale private val SessionDrawerPage.title: Int get() = when (this) { SessionDrawerPage.CONTROLLER -> R.string.drawer_page_controller + SessionDrawerPage.GLASSES -> R.string.drawer_page_glasses SessionDrawerPage.SECOND_SCREEN -> R.string.drawer_second_screen SessionDrawerPage.DISPLAY -> R.string.drawer_page_display SessionDrawerPage.VR -> R.string.vr_title @@ -159,6 +160,7 @@ private val SessionDrawerPage.title: Int get() = when (this) { private val SessionDrawerPage.icon get() = when (this) { SessionDrawerPage.CONTROLLER -> Icons.Outlined.SportsEsports + SessionDrawerPage.GLASSES -> GlassesIcon SessionDrawerPage.SECOND_SCREEN -> Icons.Outlined.ScreenShare SessionDrawerPage.DISPLAY -> Icons.Outlined.DesktopWindows SessionDrawerPage.VR -> VrHeadsetIcon @@ -211,6 +213,8 @@ class DrawerActions( val cardboard: Cardboard = Cardboard.OFF, val cardboardSensor: Boolean = false, val steamVr: SteamVr.Status? = null, + val glassesPresent: Boolean = false, + val glassesOutput: Boolean = false, val immersiveSteamVr: Boolean = false, val textureAnisotropy: Int? = 0, val textureLodBias: String? = TextureFiltering.LOD_BIAS_OFF, @@ -247,6 +251,7 @@ class DrawerActions( val onEffects: (ScreenEffects) -> Unit = {}, val onCardboard: (Cardboard) -> Unit = {}, val onCardboardRecenter: () -> Unit = {}, + val onGlassesRecenter: () -> Unit = {}, val onSteamVr: (String) -> Unit = {}, val onTextureAnisotropy: (Int?) -> Unit = {}, val onTextureLodBias: (String?) -> Unit = {}, @@ -295,7 +300,7 @@ class DrawerActions( @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, controllerActive: Boolean, onPageChange: (SessionDrawerPage) -> Unit, a: DrawerActions) { - val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty()) + val pages = sessionDrawerPages(a.steam && a.secondScreenDisplays.isNotEmpty(), a.glassesPresent) val page = requestedPage.takeIf { it in pages } ?: SessionDrawerPage.CONTROLLER LaunchedEffect(requestedPage, pages) { if (page != requestedPage) onPageChange(page) } val colors = MaterialTheme.colorScheme @@ -465,7 +470,7 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont } SessionDrawerPage.VR -> { SteamVrGroup(a) { key -> focus.track(page, key) } - CardboardGroup(host, a) { key -> focus.track(page, key) } + if (!a.glassesOutput) CardboardGroup(host, a) { key -> focus.track(page, key) } } SessionDrawerPage.CONTROLLER -> { SettingsGroup(stringResource(R.string.drawer_page_controller)) { @@ -532,6 +537,12 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont SessionDrawerPage.SECOND_SCREEN -> { SecondScreenGroup(host, a) { key -> focus.track(page, key) } } + SessionDrawerPage.GLASSES -> { + val (g, s) = rememberGlasses() + GlassesGroups(host, g, s, { key -> focus.track(page, key) }, onRecenter = a.onGlassesRecenter, + outputActive = a.glassesOutput, steamVrRunning = a.steamVr == SteamVr.Status.RUNNING, + steamVr = { SteamVrRow(a) }) + } SessionDrawerPage.GAMES -> { if (a.steam) SelectedGameProfileGroup(host, a) { key -> focus.track(page, key) } Text(stringResource(R.string.fex_next_launch), fontSize = 12.sp, @@ -954,6 +965,19 @@ private fun SteamVrGroup(a: DrawerActions, track: (String) -> Modifier) { } } +@Composable +private fun SteamVrRow(a: DrawerActions) { + val vrLabel = stringResource(R.string.cardboard_steamvr) + when (a.steamVr) { + SteamVr.Status.NOT_INSTALLED -> ActionRow(vrLabel, null, stringResource(R.string.cardboard_steamvr_install), { a.onSteamVr("install") }) + SteamVr.Status.INSTALLING -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_installing)) {} + SteamVr.Status.READY -> ActionRow(vrLabel, null, stringResource(R.string.cardboard_steamvr_start), { a.onSteamVr("start") }) + SteamVr.Status.RUNNING -> ActionRow(vrLabel, null, stringResource(R.string.cardboard_steamvr_stop), { a.onSteamVr("stop") }) + SteamVr.Status.UNAVAILABLE -> SettingsRow(vrLabel, stringResource(R.string.cardboard_steamvr_unavailable)) {} + null -> {} + } +} + @Composable private fun SecondScreenGroup(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { val colors = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt index 8851330c5..a9ebd8806 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SteamHome.kt @@ -1,6 +1,7 @@ package com.droiddeck.launcher.ui import androidx.compose.ui.platform.testTag +import androidx.compose.foundation.layout.fillMaxWidth import com.droiddeck.launcher.R import androidx.compose.ui.res.stringResource import androidx.compose.animation.core.FastOutSlowInEasing @@ -104,8 +105,24 @@ internal fun SteamHome(s: FrontEndState, a: FrontEndActions, modifier: Modifier) } }, ) + val (glasses, glassesSettings) = rememberGlasses() + val headset = glasses.present && glasses.canControl + val drop = rememberDropSheetState() + LaunchedEffect(headset) { if (!headset && (drop.open || drop.shown)) drop.reset() } LiftHero( key = "steam", tabs = tabs, + drop = drop.takeIf { headset }, + overlay = { lifted -> + if (headset) DropSheet( + drop, glassesTabs(glasses), covered = lifted, + button = { enabled -> + PrimaryButton(stringResource(R.string.games_play_steamvr), enabled = !s.busy && glasses.usable, large = true, icon = Icons.Filled.PlayArrow, fill = true, + modifier = Modifier.fillMaxWidth().testTag("play-steamvr")) { if (enabled) { drop.open = false; a.onPlaySteamVr() } } + }, + ) { tab -> + GlassesGroups(rememberMenuHost(), glasses, glassesSettings, inSession = false, tab = tab) + } + }, onOpen = a.onSteamSettings, onPrimary = a.onPlay, modifier = modifier.clipToBounds(), start = if (narrow) 20.dp else 44.dp, bottom = if (narrow) 20.dp else 44.dp, diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt index c35bb15e3..bb466c133 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/CompositorHost.kt @@ -41,7 +41,6 @@ object CompositorHost { resumeVsync() return false } - WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) com.droiddeck.launcher.gpu.TpUbwcHint.applyToProcess() WaylandCompositor.nativeStartWithSurface( surface, xdgRuntimeDir, driverPath, libraryName, nativeLibDir, @@ -55,6 +54,13 @@ object CompositorHost { private fun pace(refreshHz: Float, fpsLimit: Int) { WaylandCompositor.nativeSetFpsLimit(fpsLimit) WaylandCompositor.nativeSetLayerFrameRate(if (fpsLimit > 0) fpsLimit.toFloat() else refreshHz) + WaylandCompositor.nativeSetOutputRefreshRate(refreshHz) + SessionState.refreshHz = refreshHz + } + + @Synchronized + fun updatePacing(refreshHz: Float, fpsLimit: Int) { + if (started) pace(refreshHz, fpsLimit) } @Synchronized diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt index 605a1579d..91f408d91 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt +++ b/app/src/main/java/com/droiddeck/launcher/wayland/HdrSupport.kt @@ -20,8 +20,8 @@ class HdrProbe( ) object HdrSupport { - fun probe(context: Context): HdrProbe { - val display: Display? = if (Build.VERSION.SDK_INT >= 30) { + fun probe(context: Context, output: Display? = null): HdrProbe { + val display: Display? = output ?: if (Build.VERSION.SDK_INT >= 30) { try { context.display } catch (e: Exception) { null } } else { @Suppress("DEPRECATION") diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java index 1f946a4a4..cbf390098 100644 --- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java @@ -273,7 +273,7 @@ public final class WaylandCompositor { public static native void nativeSetCardboard(boolean enabled, int source, float ipdM, float screenDistM, float screenWidthM, float k1, float k2, float fovDeg, - float lensInset, float chroma); + float lensInset, float chroma, boolean mono, float displayHz); public static native void nativeSetCardboardPose(float[] rotation, boolean live); diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index cd055054a..25d9d0d64 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1,5 +1,12 @@ + توقفت رسومات SteamVR + سيُعاد تشغيل DroidDeck خلال %1$d ثانية لإعادة ضبط الرسومات وإعادة فتح SteamVR. ستُغلق لعبتك الجارية، ولا يمكن استعادة التقدم غير المحفوظ. + عاد خلل الرسومات بعد إعادة تشغيل حديثة. تم إيقاف الاسترداد التلقائي مؤقتًا لتجنّب تكراره بلا نهاية. ستؤدي إعادة التشغيل إلى إغلاق لعبتك، ولا يمكن استعادة التقدم غير المحفوظ. + إعادة التشغيل الآن + إبقاؤه مفتوحًا + جارٍ إعادة ضبط الرسومات وإعادة فتح SteamVR… + تعذّر على DroidDeck إعادة التشغيل تلقائيًا. أغلق التطبيق تمامًا، ثم أعد فتحه. DroidDeck يُنزَّل Steam عند التشغيل الأول. قد يستغرق ذلك بضع دقائق. @@ -959,6 +966,8 @@ زر لوحة المفاتيح اضغط على Select لفتح قائمة Steam بدلًا من View/Select. + أجهزة Joy-Con كوحدات تحكم VR + أثناء تشغيل SteamVR، يصبح كل Joy-Con يدًا. وجّهه إلى الأمام واضغط مطولًا على Home أو Capture لإعادة توسيطه. الرسوم المتحركة تظهر العصي حيث تلمس وتُخفى عند الرفع التخطيط @@ -1016,6 +1025,7 @@ KDE Plasma وFirefox ومحاكياتك الألعاب التي تثبّتها في Steam تظهر هنا بعد الجلسة. تشغيل Steam + تشغيل SteamVR إعدادات التشغيل تشغيل ملفات اللعبة @@ -2405,6 +2415,113 @@ تعذّر الاتصال بـ GOG تعذّر قراءة مكتبتك على GOG جارٍ التسجيل في Steam… + النظارة + نظارة VITURE + التحكم في النظارة + لا يتضمن هذا الإصدار برنامج التشغيل الخاص بهذه النظارة. + تعذّر الاتصال بالنظارة. أعد توصيل الكابل أو اضغط على «السماح» لإعادة المحاولة. + السماح + يسمح لتطبيق DroidDeck بضبط معدل تحديث النظارة ووضع 3D والسطوع ومستوى الصوت وحجب الشمس، وبقراءة تتبّع الرأس منها. + في انتظار سماحك بذلك في نافذة Android… + تم رفض التحكم. اسمح به لتغيير إعدادات النظارة. + تعرض هذه النظارة اللعبة، لكن لا يمكن تغيير إعداداتها من هنا. + الإخراج + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + اللعبة على النظارة + يعرض اللعبة على النظارة فقط. عند الإيقاف: يعرضها الهاتف ويعكس Android شاشة الهاتف. + العرض + معدل التحديث + 3D (جنبًا إلى جنب) + تحصل كل عين على نصفها الخاص من صورة بدقة 3840×1080. إذا انقطعت الصورة، فاضغط مطولًا على زر 2D/3D في النظارة. + لم يتم تأكيد إشارة 3D. حاول مجددًا بعد عودة صورة 2D. + لا توجد صورة: النظارة في وضع لا يستطيع هذا الهاتف إرساله. اضغط مطولًا على زر 2D/3D فيها، أو افصلها ثم أعد توصيلها. + معدل تحديث 3D + 3D لألعاب الواقع الافتراضي + يتحول إلى 3D أثناء تشغيل SteamVR، ثم يعود بعده. + صورة 3D + تلقائي: يملأ SteamVR مجال الرؤية؛ والألعاب الأخرى تظهر كشاشة معلّقة في 3D وتبقى مسطّحة في 2D. + تلقائي + شاشة افتراضية + لعبة جنبًا إلى جنب + عرض الشاشة + بُعد الشاشة + يُطبَّق عرض الشاشة وبُعدها عندما تعمل لعبة على النظارة كشاشة افتراضية. + تُطبَّق هذه الإعدادات عند تفعيل «اللعبة على النظارة» وعرض اللعبة عليها. + يُبقي الوضع «تلقائي» لعبة 2D هذه بملء الشاشة. استخدم شاشة افتراضية لضبط عرضها وبُعدها وتتبّع الرأس. + استخدام شاشة افتراضية + يملأ SteamVR مجال الرؤية بالكامل. اضبط مجال رؤيته أدناه؛ ولا يُطبَّق عرض الشاشة وبُعدها إلا على الشاشة الافتراضية. + تملأ ألعاب جنبًا إلى جنب رؤية كل عين. ولا يُطبَّق عرض الشاشة وبُعدها إلا على الشاشة الافتراضية. + يُبقي الوضع «تلقائي» لعبة 2D هذه ثابتة أمامك. اختر «شاشة افتراضية» في «العرض» لاستخدام تتبّع الرأس. + يُطبَّق تتبّع الرأس على الشاشة الافتراضية أو على لعبة SteamVR قيد التشغيل. + النظارة + السطوع + مستوى الصوت + حجب الشمس + يعتّم العدسات الأمامية. + تتبّع الرأس + تتبّع الرأس + تُدير مستشعرات النظارة نفسها الشاشة الافتراضية وSteamVR مع حركة رأسك. + إعادة توسيط العرض + إعادة التوسيط + مجال رؤية SteamVR + القيمة الأقل تقرّب SteamVR وتملأ النظارة؛ والأعلى تُظهر المزيد مما حولك. + جارٍ اللعب على %1$s + معايرة التتبّع + يقلّل الانجراف ويضبط المرجع الأفقي. + تُطبَّق المعايرة المحفوظة على هذه النظارة. + معايرة + ضع النظارة المتصلة مقلوبة على سطح مستوٍ، بحيث يستقر إطارها بالتساوي. اضغط على «بدء»، ثم اتركها دون لمس لمدة 8 ثوانٍ تقريبًا. أي حركة تعيد بدء القياس. يصحّح هذا الانجراف والميل، وليس الاتجاه الأفقي. + اترك النظارة مقلوبة ودون لمس. في انتظار استقرارها… + جارٍ قياس المستشعرات. أبقِ النظارة ثابتة. + تم رصد حركة. اترك النظارة دون لمس؛ سيُعاد بدء القياس تلقائيًا. + ضع النظارة مقلوبة على سطح مستوٍ. سيبدأ القياس عندما تكون في الوضع الصحيح. + تم حفظ المعايرة. ارتدِ النظارة وانظر إلى الأمام مباشرة، ثم اضغط على «تم» لإعادة التوسيط. + تعذّر إكمال المعايرة. تحقّق من الاتصال وأبقِ النظارة ثابتة، ثم حاول مجددًا. لم تتغير معايرتك السابقة. + بدء + تم + إعادة ضبط معايرة التتبّع + هل تريد إزالة المعايرة المحفوظة واستعادة الإعدادات الافتراضية للطراز؟ أعد توسيط العرض بعد ارتداء النظارة مجددًا. + إعادة ضبط + تتبّع اليدين + تتبّع يديك باستخدام الكاميرا؟ + يمكن للكاميرا رؤية يديك. عند عدم توصيل أي وحدة تحكم، تصبح يداك المجردتان هما يدا VR: اضغط الإبهام والسبابة معًا للنقر، وأغلق يدك للإمساك، ووجّه راحة يدك نحوك واضغط بإصبعيك لفتح القائمة. مع Joy-Con، تضعها الكاميرا حيث توجد يداك فعلًا. تُعالَج الصور على هذا الجهاز ولا تُحفظ أو تُرسَل أبدًا. + الكاميرا الخلفية + مُوصى بها. عادةً أوضح وأوسع. + الكاميرا الأمامية + لاستخدامها عندما لا يمكن توجيه ظهر الجهاز نحوك. + استخدام الكاميرا + ليس الآن + تتبّع اليدين بالكاميرا + عند عدم توصيل أي وحدة تحكم، تصبح يداك هما يدا VR: اضغط بإصبعيك للنقر، ووجّه راحة يدك نحوك واضغط لفتح القائمة. مع Joy-Con، تضعها حيث تراها الكاميرا. تبقى الصور على هذا الجهاز. + الكاميرا + الوصول إلى الكاميرا + اسمح بالوصول إلى الكاميرا حتى يبدأ تتبّع اليدين. + الوصول إلى الكاميرا متوقف. فعّله من إعدادات Android. + سماح + فتح الإعدادات + وضع الكاميرا + تحقّق من أن الكاميرا تراك قبل اللعب. + تحقّق + وضع الكاميرا + ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الكاميرا الخلفية نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان. + ضع الجهاز واقفًا على بُعد 1.5 إلى 2 متر تقريبًا بارتفاع الصدر، مع توجيه الشاشة نحوك. ثم ادخل إلى مجال الرؤية ويداك ممدودتان. + جارٍ بدء تشغيل الكاميرا… + الجهاز مستلقٍ بشكل مسطّح. ضعه واقفًا لتتجه الكاميرا نحوك. + ضع الجهاز بوضعية أكثر استقامةً لتتجه الكاميرا نحوك. + لا يمكن رؤيتك بعد. ادخل إلى مجال الرؤية بحيث يظهر رأسك وكتفاك. + تراجع قليلًا. + اقترب قليلًا. + ابسط يديك في مكان تراهما فيه الكاميرا. + يبدو جيدًا. + تعذّر تشغيل الكاميرا. أغلق التطبيقات الأخرى التي تستخدمها وحاول مرة أخرى. + تبديل الكاميرا + تخطّي + تم + في انتظار اتصال USB Ethernet الخاص بالنظارة. قد لا يتيحه هذا الهاتف للتطبيقات. + انقطع اتصال XREAL أو أعاد استجابة غير مدعومة. جارٍ إعادة المحاولة… + دعم تجريبي لـ 1S. استخدم وضع Follow وعطّل تثبيت الصورة من قائمة النظارة. لا تزال اتجاهات التتبّع وأوضاع العرض بحاجة إلى اختبار على الجهاز. Cardboard VR وضع Cardboard عرض للعين اليسرى وآخر لليمنى لنظارة واقع افتراضي للهاتف. diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 8272b316d..cfec247e1 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -1,5 +1,12 @@ + Los gráficos de SteamVR se detuvieron + DroidDeck se reiniciará en %1$d segundos para restablecer los gráficos y volver a abrir SteamVR. Tu juego en curso se cerrará; el progreso no guardado no se podrá recuperar. + El fallo gráfico volvió tras un reinicio reciente. La recuperación automática está en pausa para evitar un bucle. Reiniciar cerrará tu juego; el progreso no guardado no se podrá recuperar. + Reiniciar ahora + Mantener abierto + Restableciendo los gráficos y volviendo a abrir SteamVR… + DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla. DroidDeck La primera vez se descarga Steam. Puede tardar unos minutos. @@ -967,6 +974,44 @@ Botón QAM Botón de teclado Presiona Select para abrir el menú de Steam + Joy-Con como controles de RV + Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo. + La cámara puede ver tus manos. Sin ningún control conectado, tus manos desnudas pasan a ser las manos de RV: junta el pulgar y el índice para hacer clic, cierra la mano para agarrar y, para abrir el menú, gira la palma hacia ti y junta los dedos. Con Joy-Con, los coloca donde están de verdad tus manos. Las imágenes se procesan en este dispositivo y nunca se guardan ni se envían. + Cámara trasera + Recomendada. Suele ser más nítida y amplia. + Cámara frontal + Para cuando la parte trasera del dispositivo no pueda mirar hacia ti. + Usar cámara + Ahora no + Seguimiento de manos por cámara + Sin ningún control conectado, tus manos pasan a ser las manos de RV: junta los dedos para hacer clic y, con la palma hacia ti, júntalos para abrir el menú. Con Joy-Con, los coloca donde la cámara los ve. Las imágenes se quedan en este dispositivo. + Cámara + Acceso a la cámara + Permite el acceso a la cámara para que el seguimiento de manos pueda empezar. + El acceso a la cámara está desactivado. Actívalo en los ajustes de Android. + Permitir + Abrir ajustes + Posición de la cámara + Comprueba que la cámara pueda verte antes de jugar. + Comprobar + Posición de la cámara + Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas. + Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas. + Iniciando la cámara… + El dispositivo está tumbado. Ponlo de pie para que la cámara te mire. + Pon el dispositivo más vertical para que la cámara te mire. + Todavía no te ve. Entra en el encuadre para que se vean tu cabeza y tus hombros. + Aléjate un poco. + Acércate un poco más. + Muestra las dos manos donde la cámara pueda verlas. + Se ve bien. + La cámara no pudo iniciarse. Cierra otras apps que la usen y vuelve a intentarlo. + Cambiar de cámara + Omitir + Hecho + Esperando la conexión Ethernet por USB de las gafas. Puede que este teléfono no la ofrezca a las apps. + La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando… + Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla. En lugar de View/Select. Animaciones Los sticks aparecen donde tocas y se ocultan al soltar @@ -1025,6 +1070,7 @@ KDE Plasma, Firefox y tus emuladores Los juegos que instales en Steam aparecen aquí después de la sesión. Jugar en Steam + Jugar en SteamVR Ajustes de inicio Iniciar Archivos del juego @@ -2338,6 +2384,77 @@ No se pudo conectar con GOG No se pudo leer tu biblioteca de GOG Registrando con Steam… + Gafas + Gafas VITURE + Control de las gafas + Esta versión no incluye el controlador de estas gafas. + No se pudo conectar con las gafas. Vuelve a conectar el cable o toca Permitir para reintentar. + Permitir + Permite que DroidDeck ajuste la frecuencia de actualización, el modo 3D, el brillo, el volumen y el bloqueo solar de las gafas, y lea su seguimiento de cabeza. + Esperando a que lo permitas en el aviso de Android… + Se rechazó el control. Permítelo para cambiar los ajustes de las gafas. + Estas gafas muestran el juego, pero sus ajustes no se pueden cambiar desde aquí. + Salida + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Juego en las gafas + Muestra el juego solo en las gafas. Desactivado: el teléfono lo muestra y Android duplica la pantalla del teléfono. + Pantalla + Frecuencia de actualización + 3D (lado a lado) + Cada ojo recibe su propia mitad de una imagen de 3840×1080. Si la imagen se pierde, mantén presionado el botón 2D/3D de las gafas. + No se ha confirmado la señal 3D. Inténtalo de nuevo cuando vuelva la imagen 2D. + Sin imagen: las gafas están en un modo que este teléfono no puede enviar. Mantén presionado su botón 2D/3D o desconéctalas y vuelve a conectarlas. + Frecuencia en 3D + 3D para juegos de RV + Cambia a 3D mientras SteamVR está en marcha, y vuelve después. + Imagen 3D + Automático: SteamVR llena la vista; los demás juegos flotan como una pantalla en 3D y se ven planos en 2D. + Automático + Pantalla virtual + Juego lado a lado + Ancho de la pantalla + Distancia de la pantalla + El ancho y la distancia se aplican cuando un juego se muestra en las gafas como pantalla virtual. + Estos ajustes se aplican mientras Juego en las gafas está activado y el juego se muestra allí. + Automático mantiene este juego 2D a pantalla completa. Usa una pantalla virtual para ajustar su ancho, distancia y seguimiento de cabeza. + Usar pantalla virtual + SteamVR llena la vista. Ajusta su campo de visión abajo; el ancho y la distancia solo se aplican a una pantalla virtual. + Los juegos lado a lado llenan cada ojo. El ancho y la distancia solo se aplican a una pantalla virtual. + Automático mantiene este juego 2D fijo en la vista. Elige Pantalla virtual en la sección Pantalla para usar el seguimiento de cabeza. + El seguimiento de cabeza se aplica a una pantalla virtual o a un juego de SteamVR en marcha. + Gafas + Brillo + Volumen + Bloqueo solar + Oscurece las lentes frontales. + Seguimiento de cabeza + Seguimiento de cabeza + Los sensores de las gafas giran la pantalla virtual y SteamVR con tu cabeza. + Recentrar la vista + Recentrar + Campo de visión de SteamVR + Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor. + Jugando en %1$s + Calibración del seguimiento + Reduce la deriva y alinea la referencia horizontal. + Estas gafas usan la calibración guardada. + Calibrar + Coloca las gafas conectadas boca abajo sobre una superficie nivelada, con la montura bien apoyada. Toca Iniciar y no las muevas durante unos 8 segundos. Si se mueven, la medición vuelve a empezar. Esto corrige la deriva y la inclinación, no el rumbo. + Deja las gafas boca abajo y quietas. Esperando a que se estabilicen… + Midiendo los sensores. Mantén las gafas quietas. + Se detectó movimiento. No toques las gafas; la medición se reiniciará automáticamente. + Apoya las gafas boca abajo sobre una superficie nivelada. La medición empezará cuando estén bien colocadas. + Calibración guardada. Ponte las gafas, mira al frente y toca Hecho para recentrar. + No se pudo completar la calibración. Comprueba la conexión, mantén las gafas quietas y vuelve a intentarlo. Se conserva tu calibración anterior. + Iniciar + Hecho + Restablecer calibración del seguimiento + ¿Quitar la calibración guardada y restaurar los valores predeterminados del modelo? Recentra después de volver a ponerte las gafas. + Restablecer + Seguimiento de manos + ¿Seguir tus manos con la cámara? Posición de la imagen Arriba Abajo diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 5655e1998..aa3cfed8e 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -1,5 +1,12 @@ + Графиката на SteamVR спря + DroidDeck ще се рестартира след %1$d секунди, за да нулира графиката и да отвори отново SteamVR. Текущата ви игра ще бъде затворена; незапазеният напредък не може да бъде възстановен. + Графичната грешка се появи отново след скорошно рестартиране. Автоматичното възстановяване е на пауза, за да се избегне зацикляне. Рестартирането ще затвори играта ви; незапазеният напредък не може да бъде възстановен. + Рестартиране сега + Остави отворено + Нулиране на графиката и повторно отваряне на SteamVR… + DroidDeck не успя да се рестартира автоматично. Затворете приложението напълно и след това го отворете отново. DroidDeck Първото стартиране изтегля Steam. Това може да отнеме няколко минути. @@ -935,6 +942,8 @@ Бутон за клавиатура Натиснете Select, за да отворите менюто на Steam Вместо View/Select. + Joy-Con като VR контролери + Докато SteamVR работи, всеки Joy-Con става ръка. Насочете го напред и задръжте Home или Capture, за да го центрирате отново. Анимации Стиковете се появяват там, където докоснете, и се скриват при отпускане Разположение @@ -992,6 +1001,7 @@ KDE Plasma, Firefox и вашите емулатори Игрите, които инсталирате в Steam, се показват тук след сесията. Пускане на Steam + Пускане на SteamVR Настройки за стартиране Стартиране Файлове на играта @@ -2305,6 +2315,113 @@ Неуспешно свързване с GOG Библиотеката ви в GOG не можа да се прочете Регистриране в Steam… + Очила + Очила VITURE + Управление на очилата + Тази версия не включва драйвера за тези очила. + Неуспешно свързване с очилата. Свържете кабела отново или докоснете „Разрешаване“, за да опитате пак. + Разрешаване + Позволява на DroidDeck да задава честотата на опресняване, режима 3D, яркостта, силата на звука и затъмняването на очилата и да чете данните от проследяването на главата. + Изчаква се разрешението ви в прозореца на Android… + Управлението беше отказано. Разрешете го, за да променяте настройките на очилата. + Тези очила показват играта, но настройките им не могат да се променят оттук. + Изход + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Играта на очилата + Показва играта само на очилата. Изключено: телефонът я показва, а Android дублира екрана му. + Екран + Честота на опресняване + 3D (един до друг) + Всяко око получава своята половина от картина 3840×1080. Ако картината изчезне, задръжте бутона 2D/3D на очилата. + 3D сигналът не беше потвърден. Опитайте отново, когато 2D картината се върне. + Няма картина: очилата са в режим, който този телефон не може да изпрати. Задръжте бутона им 2D/3D или ги изключете и включете отново. + Честота на опресняване в 3D + 3D за VR игри + Превключва на 3D, докато SteamVR работи, и после обратно. + 3D картина + Автоматично: SteamVR запълва изгледа; другите игри висят като екран в 3D и остават плоски в 2D. + Автоматично + Виртуален екран + Игра 3D един до друг + Ширина на екрана + Разстояние до екрана + Ширината и разстоянието се прилагат, когато игра работи на очилата като виртуален екран. + Тези настройки се прилагат, докато опцията „Играта на очилата“ е включена и играта се показва там. + „Автоматично“ запазва тази 2D игра на цял екран. Използвайте виртуален екран, за да настроите ширината, разстоянието и проследяването на главата. + Използване на виртуален екран + SteamVR запълва изгледа. Настройте зрителното му поле по-долу; ширината и разстоянието се отнасят само за виртуален екран. + Игрите 3D един до друг запълват всяко око. Ширината и разстоянието се отнасят само за виртуален екран. + „Автоматично“ държи тази 2D игра неподвижна в изгледа. Изберете „Виртуален екран“ в „Екран“, за да използвате проследяване на главата. + Проследяването на главата се отнася за виртуален екран или за работеща игра в SteamVR. + Очила + Яркост + Сила на звука + Затъмняване + Затъмнява предните стъкла. + Проследяване на главата + Проследяване на главата + Собствените сензори на очилата завъртат виртуалния екран и SteamVR заедно с главата ви. + Центриране на изгледа + Центриране + Зрително поле на SteamVR + По-ниско приближава SteamVR и запълва очилата; по-високо показва повече около вас. + Играете на %1$s + Калибриране на проследяването + Намалява дрейфа и подравнява хоризонта. + За тези очила се прилага запазено калибриране. + Калибриране + Поставете свързаните очила обърнати наопаки на равна повърхност, така че рамката да лежи равномерно. Докоснете „Стартиране“ и не ги докосвайте около 8 секунди. При движение измерването започва отначало. Това коригира дрейфа и наклона, но не и посоката. + Оставете очилата обърнати наопаки и не ги докосвайте. Изчаква се да се стабилизират… + Измерване на сензорите. Дръжте очилата неподвижни. + Открито е движение. Не докосвайте очилата; измерването ще започне отначало автоматично. + Поставете очилата обърнати наопаки на равна повърхност. Измерването ще започне, когато са поставени правилно. + Калибрирането е запазено. Сложете очилата, погледнете право напред и докоснете „Готово“, за да центрирате изгледа. + Калибрирането не можа да завърши. Проверете връзката и дръжте очилата неподвижни, след което опитайте отново. Предишното ви калибриране не е променено. + Стартиране + Готово + Нулиране на калибрирането + Да се премахне ли запазеното калибриране и да се възстановят стойностите по подразбиране за модела? Центрирайте изгледа, след като сложите очилата отново. + Нулиране + Проследяване на ръцете + Да се проследяват ли ръцете ви с камерата? + Камерата вижда ръцете ви. Когато няма свързан контролер, голите ви ръце стават VR ръцете: съберете палеца и показалеца, за да щракнете, свийте ръка, за да хванете, а за менюто обърнете дланта към себе си и съберете пръсти. С Joy-Con камерата ги поставя там, където са ръцете ви. Изображенията се обработват на това устройство и никога не се запазват или изпращат. + Задна камера + Препоръчително. Обикновено по-рязка и по-широкоъгълна. + Предна камера + За случаите, когато задната част на устройството не може да е насочена към вас. + Използване на камерата + Не сега + Проследяване на ръцете с камерата + Когато няма свързан контролер, ръцете ви стават VR ръцете: съберете пръсти, за да щракнете, а за менюто обърнете дланта към себе си и съберете пръсти. С Joy-Con ги поставя там, където ги вижда камерата. Изображенията остават на това устройство. + Камера + Достъп до камерата + Разрешете достъп до камерата, за да започне проследяването на ръцете. + Достъпът до камерата е изключен. Включете го в настройките на Android. + Разрешаване + Отваряне на настройките + Разположение на камерата + Проверете дали камерата ви вижда, преди да играете. + Проверка + Разположение на камерата + Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, със задната камера, насочена към вас. След това застанете в кадър с протегнати ръце. + Поставете устройството изправено на 1,5–2 метра разстояние, приблизително на нивото на гърдите, с екрана, насочен към вас. След това застанете в кадър с протегнати ръце. + Стартиране на камерата… + Устройството лежи легнало. Изправете го, за да бъде камерата насочена към вас. + Изправете устройството повече, за да бъде камерата насочена към вас. + Все още не се виждате. Застанете в кадър така, че да се виждат главата и раменете ви. + Отстъпете малко назад. + Приближете се малко. + Протегнете двете си ръце така, че камерата да ги вижда. + Изглежда добре. + Камерата не можа да се стартира. Затворете другите приложения, които я използват, и опитайте отново. + Смяна на камерата + Пропускане + Готово + Изчаква се USB Ethernet връзката на очилата. Този телефон може да не я предоставя на приложенията. + Връзката с XREAL беше прекъсната или върна неподдържан отговор. Нов опит… + Експериментална поддръжка на 1S. Използвайте режим Follow и изключете стабилизацията в менюто на очилата. Посоките на проследяване и режимите на показване все още трябва да се тестват на устройство. Cardboard VR Режим Cardboard Изображения за лявото и дясното око за VR очила за телефон. diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 16d58ccb4..105fa13c9 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1,5 +1,12 @@ + Grafika SteamVR přestala fungovat + DroidDeck se za %1$d s restartuje, aby resetoval grafiku a znovu otevřel SteamVR. Spuštěná hra se zavře; neuložený postup nelze obnovit. + Chyba grafiky se po nedávném restartu vrátila. Automatická obnova je pozastavena, aby nevznikla smyčka. Restart zavře vaši hru; neuložený postup nelze obnovit. + Restartovat nyní + Nechat otevřené + Resetování grafiky a opětovné otevírání SteamVR… + DroidDeck se nepodařilo automaticky restartovat. Aplikaci úplně zavřete a pak ji znovu otevřete. DroidDeck První spuštění stáhne Steam. Může to trvat několik minut. @@ -947,6 +954,8 @@ Tlačítko klávesnice Otevřít nabídku Steam stisknutím Select Místo View/Select. + Joy-Cony jako ovladače VR + Když běží SteamVR, každý Joy-Con slouží jako ruka. Namiřte ho dopředu a podržením Home nebo Capture ho znovu vycentrujete. Animace Páčky se zobrazí tam, kde se dotknete, a skryjí se po uvolnění Rozložení @@ -1004,6 +1013,7 @@ KDE Plasma, Firefox a vaše emulátory Hry, které nainstalujete ve Steamu, se zde zobrazí po relaci. Hrát ve Steamu + Hrát ve SteamVR Nastavení spuštění Spustit Soubory hry @@ -2355,6 +2365,113 @@ Nepodařilo se spojit s GOG Vaši knihovnu GOG se nepodařilo načíst Registrace ve Steamu… + Brýle + Brýle VITURE + Ovládání brýlí + Toto sestavení neobsahuje ovladač pro tyto brýle. + K brýlím se nepodařilo připojit. Připojte znovu kabel, nebo to zkuste znovu klepnutím na Povolit. + Povolit + Umožní aplikaci DroidDeck nastavovat obnovovací frekvenci, režim 3D, jas, hlasitost a zatmavení brýlí a číst data ze sledování hlavy. + Čeká se na vaše povolení v dialogu Androidu… + Ovládání bylo odmítnuto. Povolte ho, abyste mohli měnit nastavení brýlí. + Tyto brýle zobrazují hru, ale jejich nastavení zde nelze změnit. + Výstup + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Hra v brýlích + Zobrazí hru jen v brýlích. Vypnuto: hru zobrazuje telefon a Android zrcadlí jeho obrazovku. + Displej + Obnovovací frekvence + 3D (vedle sebe) + Každé oko dostane vlastní polovinu obrazu 3840×1080. Pokud obraz vypadne, podržte na brýlích tlačítko 2D/3D. + Signál 3D se nepodařilo potvrdit. Zkuste to znovu, až se vrátí obraz 2D. + Žádný obraz: brýle jsou v režimu, který tento telefon neumí vyslat. Podržte jejich tlačítko 2D/3D, nebo je odpojte a znovu připojte. + Obnovovací frekvence ve 3D + 3D pro hry VR + Při běhu SteamVR přepne na 3D a potom zpět. + Obraz 3D + Automaticky: SteamVR vyplní zorné pole; ostatní hry visí ve 3D jako obrazovka a ve 2D zůstanou ploché. + Automaticky + Virtuální obrazovka + Hra 3D vedle sebe + Šířka obrazovky + Vzdálenost obrazovky + Šířka a vzdálenost se použijí, když hra běží v brýlích jako virtuální obrazovka. + Tato nastavení platí, když je zapnutá možnost „Hra v brýlích“ a hra se v nich zobrazuje. + Režim Automaticky drží tuto 2D hru na celou obrazovku. Chcete-li upravit její šířku, vzdálenost a sledování hlavy, použijte virtuální obrazovku. + Použít virtuální obrazovku + SteamVR vyplní celý pohled. Jeho zorné pole upravte níže; šířka a vzdálenost platí jen pro virtuální obrazovku. + Hry 3D vedle sebe vyplní zorné pole každého oka. Šířka a vzdálenost platí jen pro virtuální obrazovku. + Režim Automaticky drží tuto 2D hru pevně v zorném poli. Chcete-li použít sledování hlavy, zvolte v části Displej možnost „Virtuální obrazovka“. + Sledování hlavy platí pro virtuální obrazovku nebo spuštěnou hru SteamVR. + Brýle + Jas + Hlasitost + Zatmavení + Ztmaví přední skla. + Sledování hlavy + Sledování hlavy + Vlastní senzory brýlí otáčejí virtuální obrazovkou a SteamVR podle pohybu hlavy. + Vycentrovat pohled + Vycentrovat + Zorné pole SteamVR + Nižší hodnota přiblíží SteamVR a vyplní brýle; vyšší ukáže víc z okolí. + Hraje se na %1$s + Kalibrace sledování + Sníží drift a vyrovná vodorovnou referenci. + Pro tyto brýle se používá uložená kalibrace. + Kalibrovat + Položte připojené brýle vzhůru nohama na vodorovnou plochu tak, aby rám ležel rovnoměrně. Klepněte na Spustit a nechte je asi 8 sekund bez dotyku. Pohyb měření restartuje. Tím se upraví drift a náklon, ne směr natočení. + Nechte brýle vzhůru nohama bez dotyku. Čeká se, až se ustálí… + Probíhá měření senzorů. Nechte brýle v klidu. + Zjištěn pohyb. Nechte brýle bez dotyku; měření se automaticky spustí znovu. + Položte brýle vzhůru nohama na vodorovnou plochu. Měření začne, jakmile budou správně umístěny. + Kalibrace uložena. Nasaďte si brýle, dívejte se přímo vpřed a klepnutím na Hotovo pohled vycentrujte. + Kalibraci se nepodařilo dokončit. Zkontrolujte připojení, nechte brýle v klidu a zkuste to znovu. Předchozí kalibrace zůstává beze změny. + Spustit + Hotovo + Resetovat kalibraci sledování + Odebrat uloženou kalibraci a obnovit výchozí hodnoty modelu? Po opětovném nasazení brýlí pohled vycentrujte. + Resetovat + Sledování rukou + Sledovat ruce pomocí kamery? + Kamera vidí vaše ruce. Když není připojen žádný ovladač, stanou se vaše holé ruce rukama ve VR: spojením palce a ukazováčku kliknete, sevřením ruky uchopíte a menu otevřete, když dlaň otočíte k sobě a spojíte prsty. S Joy-Cony je umístí tam, kde vaše ruce skutečně jsou. Obrázky se zpracovávají na tomto zařízení a nikdy se neukládají ani neodesílají. + Zadní kamera + Doporučeno. Obvykle ostřejší a širší. + Přední kamera + Pro případy, kdy zadní strana zařízení nemůže směřovat k vám. + Použít kameru + Teď ne + Sledování rukou kamerou + Když není připojen žádný ovladač, stanou se vaše ruce rukama ve VR: spojením prstů kliknete, dlaní k sobě a spojením prstů otevřete menu. S Joy-Cony je umístí tam, kde je vidí kamera. Obrázky zůstávají na tomto zařízení. + Kamera + Přístup ke kameře + Povolte přístup ke kameře, aby mohlo začít sledování rukou. + Přístup ke kameře je vypnutý. Zapněte ho v nastavení Androidu. + Povolit + Otevřít nastavení + Umístění kamery + Než začnete hrát, zkontrolujte, že vás kamera vidí. + Zkontrolovat + Umístění kamery + Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby zadní kamera směřovala k vám. Pak se postavte do záběru s nataženýma rukama. + Postavte zařízení svisle do vzdálenosti 1,5 až 2 metry přibližně ve výšce hrudníku tak, aby obrazovka směřovala k vám. Pak se postavte do záběru s nataženýma rukama. + Spouštění kamery… + Zařízení leží naplocho. Postavte ho svisle, aby kamera směřovala k vám. + Postavte zařízení svisleji, aby kamera směřovala k vám. + Zatím vás nevidí. Postavte se do záběru tak, aby byla vidět hlava a ramena. + Ustupte trochu dozadu. + Přistupte trochu blíž. + Natáhněte obě ruce tak, aby je kamera viděla. + Vypadá to dobře. + Kameru se nepodařilo spustit. Zavřete ostatní aplikace, které ji používají, a zkuste to znovu. + Přepnout kameru + Přeskočit + Hotovo + Čeká se na připojení USB Ethernet brýlí. Tento telefon ho možná aplikacím nezpřístupňuje. + Připojení XREAL bylo přerušeno nebo vrátilo nepodporovanou odpověď. Zkouší se to znovu… + Experimentální podpora 1S. V nabídce brýlí použijte režim Follow a vypněte stabilizaci. Směry sledování a režimy zobrazení je ještě třeba otestovat na zařízení. Cardboard VR Režim Cardboard Obraz pro levé a pravé oko pro VR brýle pro telefon. diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index ce959d8d1..b6aef606e 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1,5 +1,12 @@ + SteamVR-grafikken stoppede + DroidDeck genstarter om %1$d sekunder for at nulstille grafikken og åbne SteamVR igen. Dit kørende spil lukkes; ikke-gemte fremskridt kan ikke gendannes. + Grafikfejlen opstod igen efter en nylig genstart. Automatisk gendannelse er sat på pause for at undgå en løkke. Genstart lukker dit spil; ikke-gemte fremskridt kan ikke gendannes. + Genstart nu + Hold åben + Nulstiller grafikken og åbner SteamVR igen… + DroidDeck kunne ikke genstarte automatisk. Luk appen helt, og åbn den så igen. DroidDeck Den første start downloader Steam. Det kan tage nogle minutter. @@ -935,6 +942,8 @@ Tastaturknap Tryk på Select for at åbne Steam-menuen I stedet for View/Select. + Joy-Cons som VR-controllere + Mens SteamVR kører, bliver hver Joy-Con en hånd. Peg den fremad, og hold Home eller Capture nede for at centrere den igen. Animationer Sticks vises, hvor du rører skærmen, og skjules, når du slipper Layout @@ -992,6 +1001,7 @@ KDE Plasma, Firefox og dine emulatorer Spil, du installerer i Steam, vises her efter sessionen. Spil Steam + Spil SteamVR Startindstillinger Start Spilfiler @@ -2305,6 +2315,113 @@ Kunne ikke oprette forbindelse til GOG Dit GOG-bibliotek kunne ikke læses Registrerer hos Steam… + Brille + VITURE-brille + Brillestyring + Denne build indeholder ikke driveren til denne brille. + Kunne ikke oprette forbindelse til brillen. Sæt kablet i igen, eller tryk på Tillad for at prøve igen. + Tillad + Lad DroidDeck indstille brillens opdateringshastighed, 3D-tilstand, lysstyrke, lydstyrke og solafskærmning og aflæse dens hovedsporing. + Venter på, at du tillader det i Androids dialogboks… + Styring blev afvist. Tillad den for at ændre brillens indstillinger. + Brillen viser spillet, men dens indstillinger kan ikke ændres herfra. + Udgang + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Spil på brillen + Vis kun spillet på brillen. Fra: telefonen viser det, og Android spejler telefonen. + Skærm + Opdateringshastighed + 3D (side-by-side) + Hvert øje får sin egen halvdel af et billede på 3840×1080. Forsvinder billedet, så hold brillens 2D/3D-knap inde. + 3D-signalet blev ikke bekræftet. Prøv igen, når 2D-billedet er tilbage. + Intet billede: brillen er i en tilstand, som telefonen ikke kan sende. Hold dens 2D/3D-knap inde, eller tag stikket ud og sæt det i igen. + 3D-opdateringshastighed + 3D til VR-spil + Skifter til 3D, mens SteamVR kører, og tilbage bagefter. + 3D-billede + Automatisk: SteamVR fylder synsfeltet; andre spil svæver som en skærm i 3D og forbliver flade i 2D. + Automatisk + Virtuel skærm + Side-by-side-spil + Skærmbredde + Skærmafstand + Bredde og afstand gælder, når et spil kører på brillen som virtuel skærm. + Disse indstillinger gælder, mens Spil på brillen er slået til, og spillet vises der. + Automatisk holder dette 2D-spil i fuld skærm. Brug en virtuel skærm for at justere bredde, afstand og hovedsporing. + Brug virtuel skærm + SteamVR fylder hele billedet. Juster synsfeltet nedenfor; bredde og afstand gælder kun for en virtuel skærm. + Side-by-side-spil fylder hvert øje. Bredde og afstand gælder kun for en virtuel skærm. + Automatisk holder dette 2D-spil fast i synsfeltet. Vælg Virtuel skærm under Skærm for at bruge hovedsporing. + Hovedsporing gælder for en virtuel skærm eller et kørende SteamVR-spil. + Brille + Lysstyrke + Lydstyrke + Solafskærmning + Gør de forreste glas mørkere. + Hovedsporing + Hovedsporing + Brillens egne sensorer drejer den virtuelle skærm og SteamVR med dit hoved. + Centrer visning + Centrer + SteamVR-synsfelt + Lavere bringer SteamVR tættere på og fylder brillen; højere viser mere omkring dig. + Spiller på %1$s + Sporingskalibrering + Mindsker drift og justerer den vandrette reference. + Gemt kalibrering anvendes på denne brille. + Kalibrer + Læg den tilsluttede brille på hovedet på en vandret flade, så stellet hviler jævnt. Tryk på Start, og lad den derefter ligge urørt i ca. 8 sekunder. Bevægelse starter målingen forfra. Dette justerer drift og hældning, ikke retning. + Lad brillen ligge urørt på hovedet. Venter på, at den falder til ro… + Måler sensorerne. Hold brillen i ro. + Bevægelse registreret. Lad brillen ligge urørt; målingen starter automatisk forfra. + Læg brillen på hovedet på en vandret flade. Målingen starter, når den ligger korrekt. + Kalibrering gemt. Tag brillen på, se lige frem, og tryk så på Udført for at centrere. + Kalibreringen kunne ikke gennemføres. Tjek forbindelsen, hold brillen i ro, og prøv igen. Din tidligere kalibrering er uændret. + Start + Udført + Nulstil sporingskalibrering + Vil du fjerne den gemte kalibrering og gendanne modellens standardværdier? Centrer, når du har taget brillen på igen. + Nulstil + Håndsporing + Spor dine hænder med kameraet? + Kameraet kan se dine hænder. Uden tilsluttet controller bliver dine bare hænder til VR-hænderne: knib tommel- og pegefinger sammen for at klikke, luk hånden for at gribe, og vend håndfladen mod dig og knib for at åbne menuen. Med Joy-Cons placerer det dem, hvor dine hænder virkelig er. Billeder behandles på denne enhed og gemmes eller sendes aldrig. + Bagkamera + Anbefales. Som regel skarpere og bredere. + Frontkamera + Til når enhedens bagside ikke kan vende mod dig. + Brug kamera + Ikke nu + Håndsporing med kamera + Uden tilsluttet controller bliver dine hænder til VR-hænderne: knib for at klikke, vend håndfladen mod dig og knib for menuen. Med Joy-Cons placeres de, hvor kameraet ser dem. Billeder bliver på denne enhed. + Kamera + Kameraadgang + Tillad kameraadgang, så håndsporing kan starte. + Kameraadgang er slået fra. Slå den til i Android-indstillingerne. + Tillad + Åbn indstillinger + Kameraplacering + Tjek, at kameraet kan se dig, før du spiller. + Tjek + Kameraplacering + Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med bagkameraet vendt mod dig. Træd så ind i billedet med hænderne ude. + Stil enheden op 1,5 til 2 meter væk i cirka brysthøjde med skærmen vendt mod dig. Træd så ind i billedet med hænderne ude. + Starter kameraet… + Enheden ligger fladt. Stil den op, så kameraet vender mod dig. + Stil enheden mere lodret, så kameraet vender mod dig. + Kan ikke se dig endnu. Træd ind i billedet, så hoved og skuldre ses. + Træd lidt tilbage. + Kom lidt tættere på. + Hold begge hænder ud, hvor kameraet kan se dem. + Ser godt ud. + Kameraet kunne ikke starte. Luk andre apps, der bruger det, og prøv igen. + Skift kamera + Spring over + Udført + Venter på brillens USB-Ethernet-forbindelse. Denne telefon gør den muligvis ikke tilgængelig for apps. + XREAL-forbindelsen blev afbrudt eller returnerede et svar, der ikke understøttes. Prøver igen… + Eksperimentel understøttelse af 1S. Brug Follow-tilstand, og slå stabilisering fra i brillens menu. Sporingsretninger og visningstilstande mangler stadig test på enheden. Cardboard VR Cardboard-tilstand Billeder til venstre og højre øje til en VR-brille til telefonen. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fcd77dd42..74ca22d28 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1,5 +1,12 @@ + SteamVR-Grafik ausgefallen + DroidDeck startet in %1$d Sekunden neu, um die Grafik zurückzusetzen und SteamVR wieder zu öffnen. Dein laufendes Spiel wird geschlossen; nicht gespeicherter Fortschritt lässt sich nicht wiederherstellen. + Der Grafikfehler ist nach einem kürzlichen Neustart erneut aufgetreten. Die automatische Wiederherstellung ist pausiert, um eine Endlosschleife zu vermeiden. Ein Neustart schließt dein Spiel; nicht gespeicherter Fortschritt lässt sich nicht wiederherstellen. + Jetzt neu starten + Geöffnet lassen + Grafik wird zurückgesetzt und SteamVR neu geöffnet … + DroidDeck konnte nicht automatisch neu starten. Schließe die App vollständig und öffne sie dann erneut. DroidDeck Beim ersten Start wird Steam heruntergeladen. Das kann ein paar Minuten dauern. @@ -924,6 +931,8 @@ Tastatur-Taste Mit Select das Steam-Menü öffnen Statt View/Select. + Joy-Cons als VR-Controller + Während SteamVR läuft, wird jeder Joy-Con zu einer Hand. Richte ihn nach vorn und halte HOME oder Capture gedrückt, um ihn neu auszurichten. Animationen Sticks erscheinen dort, wo du tippst, und verschwinden beim Loslassen Layout @@ -981,6 +990,7 @@ KDE Plasma, Firefox und deine Emulatoren Spiele, die du in Steam installierst, erscheinen hier nach der Sitzung. Mit Steam spielen + Mit SteamVR spielen Starteinstellungen Starten Spieldateien @@ -2291,6 +2301,113 @@ GOG konnte nicht erreicht werden Deine GOG-Bibliothek konnte nicht gelesen werden Wird bei Steam registriert… + Brille + VITURE-Brille + Brillensteuerung + Dieser Build enthält keinen Treiber für diese Brille. + Keine Verbindung zur Brille. Steck das Kabel neu ein oder tippe auf „Erlauben“, um es erneut zu versuchen. + Erlauben + Erlaube DroidDeck, Bildwiederholrate, 3D-Modus, Helligkeit, Lautstärke und Sonnenschutz der Brille einzustellen und ihr Kopf-Tracking auszulesen. + Warte, bis du es in der Android-Abfrage erlaubst … + Die Steuerung wurde abgelehnt. Erlaube sie, um die Einstellungen der Brille zu ändern. + Diese Brille zeigt das Spiel, aber ihre Einstellungen lassen sich hier nicht ändern. + Ausgabe + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Spiel auf der Brille + Zeigt das Spiel nur auf der Brille. Aus: Das Smartphone zeigt es und Android spiegelt das Smartphone. + Anzeige + Bildwiederholrate + 3D (Side-by-Side) + Jedes Auge bekommt seine eigene Hälfte eines 3840×1080-Bildes. Fällt das Bild aus, halte die 2D/3D-Taste der Brille gedrückt. + Das 3D-Signal wurde nicht bestätigt. Versuche es erneut, sobald das 2D-Bild wieder da ist. + Kein Bild: Die Brille ist in einem Modus, den dieses Smartphone nicht ausgeben kann. Halte ihre 2D/3D-Taste gedrückt oder steck sie aus und wieder ein. + 3D-Bildwiederholrate + 3D für VR-Spiele + Wechselt zu 3D, solange SteamVR läuft, und danach zurück. + 3D-Bild + Automatisch: SteamVR füllt die Sicht; andere Spiele schweben in 3D als Bildschirm und bleiben in 2D flach. + Automatisch + Virtueller Bildschirm + Side-by-Side-Spiel + Bildschirmbreite + Bildschirmabstand + Breite und Abstand gelten, wenn ein Spiel als virtueller Bildschirm auf der Brille läuft. + Diese Einstellungen gelten, solange „Spiel auf der Brille“ aktiviert ist und das Spiel dort angezeigt wird. + „Automatisch“ zeigt dieses 2D-Spiel im Vollbild. Nutze einen virtuellen Bildschirm, um Breite, Abstand und Kopf-Tracking anzupassen. + Virtuellen Bildschirm nutzen + SteamVR füllt die Sicht. Passe unten das Sichtfeld an; Breite und Abstand gelten nur für einen virtuellen Bildschirm. + Side-by-Side-Spiele füllen jedes Auge. Breite und Abstand gelten nur für einen virtuellen Bildschirm. + „Automatisch“ hält dieses 2D-Spiel fest im Blickfeld. Wähle unter „Anzeige“ die Option „Virtueller Bildschirm“, um Kopf-Tracking zu nutzen. + Kopf-Tracking gilt für einen virtuellen Bildschirm oder ein laufendes SteamVR-Spiel. + Brille + Helligkeit + Lautstärke + Sonnenschutz + Dunkelt die vorderen Gläser ab. + Kopf-Tracking + Kopf-Tracking + Die eigenen Sensoren der Brille drehen den virtuellen Bildschirm und SteamVR mit deinem Kopf mit. + Ansicht zentrieren + Zentrieren + SteamVR-Sichtfeld + Niedriger holt SteamVR näher heran und füllt die Brille; höher zeigt mehr von deiner Umgebung. + Spiel läuft auf %1$s + Tracking-Kalibrierung + Verringert Drift und richtet die waagerechte Referenz aus. + Für diese Brille wird eine gespeicherte Kalibrierung verwendet. + Kalibrieren + Leg die verbundene Brille verkehrt herum auf eine ebene Fläche, sodass der Rahmen gleichmäßig aufliegt. Tippe auf „Starten“ und lass sie dann etwa 8 Sekunden lang unberührt liegen. Bei Bewegung beginnt die Messung neu. Dies korrigiert Drift und Neigung, nicht die Blickrichtung. + Lass die Brille verkehrt herum und unberührt liegen. Warten, bis sie zur Ruhe kommt … + Sensoren werden gemessen. Halte die Brille ruhig. + Bewegung erkannt. Lass die Brille unberührt liegen; die Messung beginnt automatisch neu. + Leg die Brille verkehrt herum auf eine ebene Fläche. Die Messung beginnt, sobald sie richtig liegt. + Kalibrierung gespeichert. Setz die Brille auf, schau geradeaus und tippe dann auf „Fertig“, um die Ansicht zu zentrieren. + Die Kalibrierung konnte nicht abgeschlossen werden. Prüfe die Verbindung, halte die Brille ruhig und versuche es erneut. Deine bisherige Kalibrierung bleibt unverändert. + Starten + Fertig + Tracking-Kalibrierung zurücksetzen + Gespeicherte Kalibrierung entfernen und die Standardwerte des Modells wiederherstellen? Zentriere die Ansicht, nachdem du die Brille wieder aufgesetzt hast. + Zurücksetzen + Hand-Tracking + Deine Hände mit der Kamera verfolgen? + Die Kamera sieht deine Hände. Ist kein Controller verbunden, werden deine bloßen Hände zu den VR-Händen: Daumen und Zeigefinger zusammendrücken zum Klicken, die Hand schließen zum Greifen, und für das Menü die Handfläche zu dir drehen und zusammendrücken. Mit Joy-Cons setzt sie sie dorthin, wo deine Hände wirklich sind. Bilder werden auf diesem Gerät verarbeitet und nie gespeichert oder gesendet. + Rückkamera + Empfohlen. Meist schärfer und weitwinkliger. + Frontkamera + Falls die Rückseite des Geräts nicht zu dir zeigen kann. + Kamera verwenden + Nicht jetzt + Hand-Tracking per Kamera + Ist kein Controller verbunden, werden deine Hände zu den VR-Händen: zusammendrücken zum Klicken, Handfläche zu dir und zusammendrücken für das Menü. Mit Joy-Cons platziert sie diese dort, wo die Kamera sie sieht. Bilder bleiben auf diesem Gerät. + Kamera + Kamerazugriff + Erlaube den Kamerazugriff, damit das Hand-Tracking starten kann. + Kamerazugriff ist deaktiviert. Aktiviere ihn in den Android-Einstellungen. + Erlauben + Einstellungen öffnen + Kameraplatzierung + Prüfe, ob die Kamera dich sehen kann, bevor du spielst. + Prüfen + Kameraplatzierung + Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit der Rückkamera zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild. + Stell das Gerät etwa 1,5 bis 2 Meter entfernt in Brusthöhe auf, mit dem Bildschirm zu dir gerichtet. Tritt dann mit ausgestreckten Händen ins Bild. + Kamera wird gestartet… + Das Gerät liegt flach. Stell es auf, damit die Kamera zu dir zeigt. + Stell das Gerät aufrechter hin, damit die Kamera zu dir zeigt. + Kann dich noch nicht sehen. Tritt ins Bild, sodass Kopf und Schultern zu sehen sind. + Geh ein Stück zurück. + Komm etwas näher. + Halte beide Hände so, dass die Kamera sie sehen kann. + Sieht gut aus. + Die Kamera konnte nicht gestartet werden. Schließe andere Apps, die sie nutzen, und versuche es erneut. + Kamera wechseln + Überspringen + Fertig + Warte auf die USB-Ethernet-Verbindung der Brille. Dieses Smartphone stellt sie Apps möglicherweise nicht bereit. + Die XREAL-Verbindung wurde unterbrochen oder hat eine nicht unterstützte Antwort geliefert. Neuer Versuch … + Experimentelle Unterstützung für die 1S. Nutze den Follow-Modus und deaktiviere die Stabilisierung im Menü der Brille. Tracking-Richtungen und Anzeigemodi müssen noch auf Geräten getestet werden. Cardboard VR Cardboard-Modus Bilder für linkes und rechtes Auge für eine Smartphone-VR-Brille. diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 39693f9f3..91df88f93 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,5 +1,12 @@ + Τα γραφικά του SteamVR σταμάτησαν + Το DroidDeck θα κάνει επανεκκίνηση σε %1$d δευτερόλεπτα για να επαναφέρει τα γραφικά και να ανοίξει ξανά το SteamVR. Το παιχνίδι που εκτελείται θα κλείσει· η μη αποθηκευμένη πρόοδος δεν μπορεί να ανακτηθεί. + Το σφάλμα γραφικών επανεμφανίστηκε μετά από πρόσφατη επανεκκίνηση. Η αυτόματη ανάκτηση τέθηκε σε παύση για να αποφευχθεί ατέρμονος βρόχος. Η επανεκκίνηση θα κλείσει το παιχνίδι σας· η μη αποθηκευμένη πρόοδος δεν μπορεί να ανακτηθεί. + Επανεκκίνηση τώρα + Να μείνει ανοιχτό + Επαναφορά γραφικών και εκ νέου άνοιγμα του SteamVR… + Το DroidDeck δεν μπόρεσε να κάνει αυτόματη επανεκκίνηση. Κλείστε εντελώς την εφαρμογή και ανοίξτε την ξανά. DroidDeck Η πρώτη εκτέλεση κάνει λήψη του Steam. Αυτό μπορεί να διαρκέσει μερικά λεπτά. @@ -935,6 +942,8 @@ Κουμπί πληκτρολογίου Πατήστε Select για να ανοίξετε το μενού Steam Αντί για View/Select. + Joy-Con ως χειριστήρια VR + Όσο εκτελείται το SteamVR, κάθε Joy-Con γίνεται χέρι. Στρέψτε το μπροστά και κρατήστε πατημένο το Home ή το Capture για να το επανακεντράρετε. Κινήσεις Οι μοχλοί εμφανίζονται όπου αγγίζετε και εξαφανίζονται όταν αφήνετε Διάταξη @@ -992,6 +1001,7 @@ KDE Plasma, Firefox και οι προσομοιωτές σας Τα παιχνίδια που εγκαθιστάτε στο Steam εμφανίζονται εδώ μετά τη συνεδρία. Αναπαραγωγή Steam + Αναπαραγωγή SteamVR Ρυθμίσεις εκκίνησης Εκκίνηση Αρχεία παιχνιδιού @@ -2305,6 +2315,113 @@ Δεν ήταν δυνατή η σύνδεση με το GOG Δεν ήταν δυνατή η ανάγνωση της βιβλιοθήκης σας στο GOG Εγγραφή στο Steam… + Γυαλιά + Γυαλιά VITURE + Έλεγχος γυαλιών + Αυτό το build δεν περιλαμβάνει το πρόγραμμα οδήγησης για αυτά τα γυαλιά. + Δεν ήταν δυνατή η σύνδεση με τα γυαλιά. Συνδέστε ξανά το καλώδιο ή πατήστε «Να επιτρέπεται» για νέα προσπάθεια. + Να επιτρέπεται + Επιτρέπει στο DroidDeck να ρυθμίζει τον ρυθμό ανανέωσης, τη λειτουργία 3D, τη φωτεινότητα, την ένταση ήχου και τη σκίαση των γυαλιών, και να διαβάζει την παρακολούθηση κεφαλιού τους. + Αναμονή για την έγκρισή σας στο παράθυρο του Android… + Ο έλεγχος απορρίφθηκε. Επιτρέψτε τον για να αλλάξετε τις ρυθμίσεις των γυαλιών. + Αυτά τα γυαλιά δείχνουν το παιχνίδι, αλλά οι ρυθμίσεις τους δεν μπορούν να αλλάξουν από εδώ. + Έξοδος + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Παιχνίδι στα γυαλιά + Εμφανίζει το παιχνίδι μόνο στα γυαλιά. Ανενεργό: το δείχνει το τηλέφωνο και το Android κατοπτρίζει την οθόνη του τηλεφώνου. + Οθόνη + Ρυθμός ανανέωσης + 3D (δίπλα-δίπλα) + Κάθε μάτι παίρνει το δικό του μισό μιας εικόνας 3840×1080. Αν η εικόνα χαθεί, κρατήστε πατημένο το κουμπί 2D/3D των γυαλιών. + Το σήμα 3D δεν επιβεβαιώθηκε. Δοκιμάστε ξανά μόλις επανέλθει η εικόνα 2D. + Δεν υπάρχει εικόνα: τα γυαλιά είναι σε λειτουργία που αυτό το τηλέφωνο δεν μπορεί να στείλει. Κρατήστε πατημένο το κουμπί 2D/3D τους ή αποσυνδέστε τα και συνδέστε τα ξανά. + Ρυθμός ανανέωσης 3D + 3D για παιχνίδια VR + Μεταβαίνει σε 3D όσο εκτελείται το SteamVR και επιστρέφει μετά. + Εικόνα 3D + Αυτόματο: το SteamVR γεμίζει το οπτικό πεδίο· τα άλλα παιχνίδια αιωρούνται ως οθόνη στο 3D και μένουν επίπεδα στο 2D. + Αυτόματο + Εικονική οθόνη + Παιχνίδι δίπλα-δίπλα + Πλάτος οθόνης + Απόσταση οθόνης + Το πλάτος και η απόσταση ισχύουν όταν ένα παιχνίδι εκτελείται στα γυαλιά ως εικονική οθόνη. + Αυτές οι ρυθμίσεις ισχύουν όσο είναι ενεργό το «Παιχνίδι στα γυαλιά» και το παιχνίδι εμφανίζεται εκεί. + Το «Αυτόματο» κρατά αυτό το παιχνίδι 2D σε πλήρη οθόνη. Χρησιμοποιήστε εικονική οθόνη για να ρυθμίσετε το πλάτος, την απόσταση και την παρακολούθηση κεφαλιού. + Χρήση εικονικής οθόνης + Το SteamVR γεμίζει όλη την εικόνα. Ρυθμίστε το οπτικό του πεδίο παρακάτω· το πλάτος και η απόσταση ισχύουν μόνο για εικονική οθόνη. + Τα παιχνίδια δίπλα-δίπλα γεμίζουν το πεδίο κάθε ματιού. Το πλάτος και η απόσταση ισχύουν μόνο για εικονική οθόνη. + Το «Αυτόματο» κρατά αυτό το παιχνίδι 2D σταθερό στο οπτικό πεδίο. Επιλέξτε «Εικονική οθόνη» στην ενότητα «Οθόνη» για να χρησιμοποιήσετε παρακολούθηση κεφαλιού. + Η παρακολούθηση κεφαλιού ισχύει για εικονική οθόνη ή για παιχνίδι SteamVR που εκτελείται. + Γυαλιά + Φωτεινότητα + Ένταση ήχου + Σκίαση + Σκουραίνει τους μπροστινούς φακούς. + Παρακολούθηση κεφαλιού + Παρακολούθηση κεφαλιού + Οι αισθητήρες των ίδιων των γυαλιών περιστρέφουν την εικονική οθόνη και το SteamVR μαζί με το κεφάλι σας. + Επανακεντράρισμα προβολής + Επανακεντράρισμα + Οπτικό πεδίο SteamVR + Χαμηλότερο: το SteamVR έρχεται πιο κοντά και γεμίζει τα γυαλιά· υψηλότερο: βλέπετε περισσότερα γύρω σας. + Παίζετε στα %1$s + Βαθμονόμηση παρακολούθησης + Μειώνει την ολίσθηση και ευθυγραμμίζει την οριζόντια αναφορά. + Σε αυτά τα γυαλιά εφαρμόζεται αποθηκευμένη βαθμονόμηση. + Βαθμονόμηση + Τοποθετήστε τα συνδεδεμένα γυαλιά ανάποδα σε οριζόντια επιφάνεια, με τον σκελετό να ακουμπά ομοιόμορφα. Πατήστε «Έναρξη» και μην τα αγγίξετε για περίπου 8 δευτερόλεπτα. Αν κινηθούν, η μέτρηση ξεκινά από την αρχή. Αυτό διορθώνει την ολίσθηση και την κλίση, όχι την κατεύθυνση. + Αφήστε τα γυαλιά ανάποδα χωρίς να τα αγγίζετε. Αναμονή για να σταθεροποιηθούν… + Γίνεται μέτρηση των αισθητήρων. Κρατήστε τα γυαλιά ακίνητα. + Εντοπίστηκε κίνηση. Μην αγγίζετε τα γυαλιά· η μέτρηση θα ξεκινήσει ξανά αυτόματα. + Τοποθετήστε τα γυαλιά ανάποδα σε οριζόντια επιφάνεια. Η μέτρηση θα ξεκινήσει μόλις τοποθετηθούν σωστά. + Η βαθμονόμηση αποθηκεύτηκε. Φορέστε τα γυαλιά, κοιτάξτε ευθεία μπροστά και πατήστε «Τέλος» για επανακεντράρισμα. + Η βαθμονόμηση δεν ολοκληρώθηκε. Ελέγξτε τη σύνδεση, κρατήστε τα γυαλιά ακίνητα και δοκιμάστε ξανά. Η προηγούμενη βαθμονόμησή σας δεν άλλαξε. + Έναρξη + Τέλος + Επαναφορά βαθμονόμησης παρακολούθησης + Να καταργηθεί η αποθηκευμένη βαθμονόμηση και να επανέλθουν οι προεπιλογές του μοντέλου; Κάντε επανακεντράρισμα αφού φορέσετε ξανά τα γυαλιά. + Επαναφορά + Παρακολούθηση χεριών + Να παρακολουθούνται τα χέρια σας με την κάμερα; + Η κάμερα μπορεί να δει τα χέρια σας. Χωρίς συνδεδεμένο χειριστήριο, τα γυμνά χέρια σας γίνονται τα χέρια VR: ενώστε τον αντίχειρα με τον δείκτη για κλικ, κλείστε το χέρι για να πιάσετε, και για το μενού γυρίστε την παλάμη προς το μέρος σας και ενώστε τα δάχτυλα. Με Joy-Con, τα τοποθετεί εκεί όπου βρίσκονται πραγματικά τα χέρια σας. Οι εικόνες επεξεργάζονται σε αυτή τη συσκευή και δεν αποθηκεύονται ούτε αποστέλλονται ποτέ. + Πίσω κάμερα + Προτείνεται. Συνήθως πιο ευκρινής και ευρύτερη. + Μπροστινή κάμερα + Για όταν η πίσω πλευρά της συσκευής δεν μπορεί να είναι στραμμένη προς εσάς. + Χρήση κάμερας + Όχι τώρα + Παρακολούθηση χεριών με κάμερα + Χωρίς συνδεδεμένο χειριστήριο, τα χέρια σας γίνονται τα χέρια VR: ενώστε τα δάχτυλα για κλικ, και με την παλάμη προς εσάς ενώστε τα για το μενού. Με Joy-Con, τα τοποθετεί εκεί όπου τα βλέπει η κάμερα. Οι εικόνες παραμένουν σε αυτή τη συσκευή. + Κάμερα + Πρόσβαση στην κάμερα + Επιτρέψτε την πρόσβαση στην κάμερα για να ξεκινήσει η παρακολούθηση χεριών. + Η πρόσβαση στην κάμερα είναι απενεργοποιημένη. Ενεργοποιήστε την από τις ρυθμίσεις Android. + Να επιτρέπεται + Άνοιγμα ρυθμίσεων + Τοποθέτηση κάμερας + Βεβαιωθείτε ότι η κάμερα σάς βλέπει πριν παίξετε. + Έλεγχος + Τοποθέτηση κάμερας + Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την πίσω κάμερα στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα. + Στήστε τη συσκευή όρθια σε απόσταση 1,5 έως 2 μέτρων, περίπου στο ύψος του στήθους, με την οθόνη στραμμένη προς εσάς. Έπειτα μπείτε στο οπτικό πεδίο με τα χέρια τεντωμένα. + Εκκίνηση κάμερας… + Η συσκευή είναι ξαπλωμένη οριζόντια. Στήστε την όρθια ώστε η κάμερα να είναι στραμμένη προς εσάς. + Στήστε τη συσκευή πιο όρθια ώστε η κάμερα να είναι στραμμένη προς εσάς. + Δεν φαίνεστε ακόμη. Μπείτε στο οπτικό πεδίο ώστε να φαίνονται το κεφάλι και οι ώμοι σας. + Κάντε ένα βήμα πίσω. + Πλησιάστε λίγο. + Κρατήστε και τα δύο χέρια τεντωμένα εκεί όπου μπορεί να τα δει η κάμερα. + Φαίνεται καλό. + Η κάμερα δεν μπόρεσε να εκκινηθεί. Κλείστε άλλες εφαρμογές που τη χρησιμοποιούν και δοκιμάστε ξανά. + Αλλαγή κάμερας + Παράλειψη + Τέλος + Αναμονή για τη σύνδεση USB Ethernet των γυαλιών. Αυτό το τηλέφωνο ίσως δεν τη διαθέτει στις εφαρμογές. + Η σύνδεση XREAL διακόπηκε ή επέστρεψε μη υποστηριζόμενη απόκριση. Νέα προσπάθεια… + Πειραματική υποστήριξη για 1S. Χρησιμοποιήστε τη λειτουργία Follow και απενεργοποιήστε τη σταθεροποίηση στο μενού των γυαλιών. Οι κατευθύνσεις παρακολούθησης και οι λειτουργίες οθόνης χρειάζονται ακόμα δοκιμή σε συσκευή. Cardboard VR Λειτουργία Cardboard Εικόνες για το αριστερό και το δεξί μάτι, για γυαλιά VR κινητού. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 71305cd8f..8ce20744b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,5 +1,12 @@ + Los gráficos de SteamVR se detuvieron + DroidDeck se reiniciará en %1$d segundos para restablecer los gráficos y volver a abrir SteamVR. Tu juego en curso se cerrará; el progreso no guardado no se podrá recuperar. + El fallo gráfico volvió tras un reinicio reciente. La recuperación automática está en pausa para evitar un bucle. Reiniciar cerrará tu juego; el progreso no guardado no se podrá recuperar. + Reiniciar ahora + Mantener abierto + Restableciendo los gráficos y volviendo a abrir SteamVR… + DroidDeck no pudo reiniciarse automáticamente. Cierra la app por completo y vuelve a abrirla. DroidDeck La primera vez se descarga Steam. Puede tardar unos minutos. @@ -938,6 +945,8 @@ Botón de teclado Pulsa Select para abrir el menú de Steam En lugar de View/Select. + Joy-Con como mandos de RV + Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo. Animaciones Los sticks aparecen donde tocas y se ocultan al soltar Disposición @@ -995,6 +1004,7 @@ KDE Plasma, Firefox y tus emuladores Los juegos que instales en Steam aparecen aquí después de la sesión. Jugar en Steam + Jugar en SteamVR Ajustes de inicio Iniciar Archivos del juego @@ -2324,6 +2334,113 @@ No se pudo conectar con GOG No se pudo leer tu biblioteca de GOG Registrando con Steam… + Gafas + Gafas VITURE + Control de las gafas + Esta versión no incluye el controlador de estas gafas. + No se pudo conectar con las gafas. Vuelve a conectar el cable o toca Permitir para reintentar. + Permitir + Permite que DroidDeck ajuste la frecuencia de actualización, el modo 3D, el brillo, el volumen y el bloqueo solar de las gafas, y lea su seguimiento de cabeza. + Esperando a que lo permitas en el aviso de Android… + Se rechazó el control. Permítelo para cambiar los ajustes de las gafas. + Estas gafas muestran el juego, pero sus ajustes no se pueden cambiar desde aquí. + Salida + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Juego en las gafas + Muestra el juego solo en las gafas. Desactivado: el teléfono lo muestra y Android duplica la pantalla del teléfono. + Pantalla + Frecuencia de actualización + 3D (lado a lado) + Cada ojo recibe su propia mitad de una imagen de 3840×1080. Si la imagen se pierde, mantén pulsado el botón 2D/3D de las gafas. + No se ha confirmado la señal 3D. Inténtalo de nuevo cuando vuelva la imagen 2D. + Sin imagen: las gafas están en un modo que este teléfono no puede enviar. Mantén pulsado su botón 2D/3D o desconéctalas y vuelve a conectarlas. + Frecuencia en 3D + 3D para juegos de RV + Cambia a 3D mientras SteamVR está en marcha, y vuelve después. + Imagen 3D + Automático: SteamVR llena la vista; los demás juegos flotan como una pantalla en 3D y se ven planos en 2D. + Automático + Pantalla virtual + Juego lado a lado + Ancho de la pantalla + Distancia de la pantalla + El ancho y la distancia se aplican cuando un juego se muestra en las gafas como pantalla virtual. + Estos ajustes se aplican mientras Juego en las gafas está activado y el juego se muestra allí. + Automático mantiene este juego 2D a pantalla completa. Usa una pantalla virtual para ajustar su ancho, distancia y seguimiento de cabeza. + Usar pantalla virtual + SteamVR llena la vista. Ajusta su campo de visión abajo; el ancho y la distancia solo se aplican a una pantalla virtual. + Los juegos lado a lado llenan cada ojo. El ancho y la distancia solo se aplican a una pantalla virtual. + Automático mantiene este juego 2D fijo en la vista. Elige Pantalla virtual en la sección Pantalla para usar el seguimiento de cabeza. + El seguimiento de cabeza se aplica a una pantalla virtual o a un juego de SteamVR en marcha. + Gafas + Brillo + Volumen + Bloqueo solar + Oscurece las lentes frontales. + Seguimiento de cabeza + Seguimiento de cabeza + Los sensores de las gafas giran la pantalla virtual y SteamVR con tu cabeza. + Recentrar la vista + Recentrar + Campo de visión de SteamVR + Más bajo acerca SteamVR y llena las gafas; más alto muestra más a tu alrededor. + Jugando en %1$s + Calibración del seguimiento + Reduce la deriva y alinea la referencia horizontal. + Estas gafas usan la calibración guardada. + Calibrar + Coloca las gafas conectadas boca abajo sobre una superficie nivelada, con la montura bien apoyada. Toca Iniciar y no las muevas durante unos 8 segundos. Si se mueven, la medición vuelve a empezar. Esto corrige la deriva y la inclinación, no el rumbo. + Deja las gafas boca abajo y quietas. Esperando a que se estabilicen… + Midiendo los sensores. Mantén las gafas quietas. + Se detectó movimiento. No toques las gafas; la medición se reiniciará automáticamente. + Apoya las gafas boca abajo sobre una superficie nivelada. La medición empezará cuando estén bien colocadas. + Calibración guardada. Ponte las gafas, mira al frente y toca Hecho para recentrar. + No se pudo completar la calibración. Comprueba la conexión, mantén las gafas quietas y vuelve a intentarlo. Se conserva tu calibración anterior. + Iniciar + Hecho + Restablecer calibración del seguimiento + ¿Quitar la calibración guardada y restaurar los valores predeterminados del modelo? Recentra después de volver a ponerte las gafas. + Restablecer + Seguimiento de manos + ¿Seguir tus manos con la cámara? + La cámara puede ver tus manos. Sin ningún mando conectado, tus manos desnudas pasan a ser las manos de RV: junta el pulgar y el índice para hacer clic, cierra la mano para agarrar y, para abrir el menú, gira la palma hacia ti y junta los dedos. Con Joy-Con, los coloca donde están de verdad tus manos. Las imágenes se procesan en este dispositivo y nunca se guardan ni se envían. + Cámara trasera + Recomendada. Suele ser más nítida y amplia. + Cámara frontal + Para cuando la parte trasera del dispositivo no pueda mirar hacia ti. + Usar cámara + Ahora no + Seguimiento de manos por cámara + Sin ningún mando conectado, tus manos pasan a ser las manos de RV: junta los dedos para hacer clic y, con la palma hacia ti, júntalos para abrir el menú. Con Joy-Con, los coloca donde la cámara los ve. Las imágenes se quedan en este dispositivo. + Cámara + Acceso a la cámara + Permite el acceso a la cámara para que el seguimiento de manos pueda empezar. + El acceso a la cámara está desactivado. Actívalo en los ajustes de Android. + Permitir + Abrir ajustes + Posición de la cámara + Comprueba que la cámara pueda verte antes de jugar. + Comprobar + Posición de la cámara + Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la cámara trasera hacia ti. Luego entra en el encuadre con las manos extendidas. + Coloca el dispositivo de pie a 1,5-2 metros de distancia, a la altura del pecho, con la pantalla hacia ti. Luego entra en el encuadre con las manos extendidas. + Iniciando la cámara… + El dispositivo está tumbado. Ponlo de pie para que la cámara te mire. + Pon el dispositivo más vertical para que la cámara te mire. + Todavía no te ve. Entra en el encuadre para que se vean tu cabeza y tus hombros. + Aléjate un poco. + Acércate un poco más. + Muestra las dos manos donde la cámara pueda verlas. + Se ve bien. + La cámara no pudo iniciarse. Cierra otras apps que la usen y vuelve a intentarlo. + Cambiar de cámara + Omitir + Hecho + Esperando la conexión Ethernet por USB de las gafas. Puede que este teléfono no la ofrezca a las apps. + La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando… + Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla. Desarrollador Distribuidor Lanzamiento diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 934d4718c..d014a4e08 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1,5 +1,12 @@ + SteamVR:n grafiikka pysähtyi + DroidDeck käynnistyy uudelleen %1$d sekunnin kuluttua nollatakseen grafiikan ja avatakseen SteamVR:n uudelleen. Käynnissä oleva pelisi suljetaan; tallentamatonta edistymistä ei voi palauttaa. + Grafiikkavirhe palasi äskeisen uudelleenkäynnistyksen jälkeen. Automaattinen palautus on keskeytetty silmukan estämiseksi. Uudelleenkäynnistys sulkee pelisi; tallentamatonta edistymistä ei voi palauttaa. + Käynnistä uudelleen + Pidä auki + Nollataan grafiikkaa ja avataan SteamVR uudelleen… + DroidDeck ei pystynyt käynnistymään uudelleen automaattisesti. Sulje sovellus kokonaan ja avaa se sitten uudelleen. DroidDeck Ensimmäinen käynnistys lataa Steamin. Tämä voi kestää muutaman minuutin. @@ -935,6 +942,8 @@ Näppäimistöpainike Avaa Steam-valikko Select-painikkeella View/Select-painikkeen sijaan. + Joy-Conit VR-ohjaimina + Kun SteamVR on käynnissä, jokainen Joy-Con toimii kätenä. Osoita sillä eteenpäin ja pidä Home- tai Capture-painiketta pohjassa keskittääksesi sen uudelleen. Animaatiot Sauvat tulevat näkyviin siihen, mihin kosketat, ja piiloutuvat, kun vapautat Asettelu @@ -992,6 +1001,7 @@ KDE Plasma, Firefox ja emulaattorisi Steamissa asentamasi pelit näkyvät tässä istunnon jälkeen. Pelaa Steamissa + Pelaa SteamVR:ssä Käynnistysasetukset Käynnistä Pelitiedostot @@ -2305,6 +2315,113 @@ GOG:iin ei saatu yhteyttä GOG-kirjastoasi ei voitu lukea Rekisteröidytään Steamiin… + Lasit + VITURE-lasit + Lasien hallinta + Tämä versio ei sisällä näiden lasien ajuria. + Laseihin ei saatu yhteyttä. Kytke kaapeli uudelleen tai yritä uudestaan napauttamalla Salli. + Salli + Anna DroidDeckin säätää lasien virkistystaajuutta, 3D-tilaa, kirkkautta, äänenvoimakkuutta ja tummennusta sekä lukea niiden pään seurantaa. + Odotetaan, että sallit sen Androidin kehotteessa… + Hallinta hylättiin. Salli se, jotta voit muuttaa lasien asetuksia. + Nämä lasit näyttävät pelin, mutta niiden asetuksia ei voi muuttaa täältä. + Ulostulo + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Peli laseissa + Näyttää pelin vain laseissa. Pois: puhelin näyttää sen ja Android peilaa puhelimen näyttöä. + Näyttö + Virkistystaajuus + 3D (rinnakkain) + Kumpikin silmä saa oman puoliskonsa 3840×1080-kuvasta. Jos kuva katoaa, pidä lasien 2D/3D-painiketta pohjassa. + 3D-signaalia ei voitu vahvistaa. Yritä uudelleen, kun 2D-kuva palautuu. + Ei kuvaa: lasit ovat tilassa, jota tämä puhelin ei pysty lähettämään. Pidä niiden 2D/3D-painiketta pohjassa tai irrota ja kytke ne uudelleen. + 3D-virkistystaajuus + 3D VR-peleille + Vaihtaa 3D:hen SteamVR:n ollessa käynnissä ja takaisin sen jälkeen. + 3D-kuva + Automaattinen: SteamVR täyttää näkymän; muut pelit leijuvat näyttönä 3D:ssä ja pysyvät litteinä 2D:ssä. + Automaattinen + Virtuaalinen näyttö + Pelin rinnakkais-3D + Näytön leveys + Näytön etäisyys + Leveys ja etäisyys otetaan käyttöön, kun peli näkyy laseissa virtuaalisena näyttönä. + Nämä asetukset ovat käytössä, kun Peli laseissa on päällä ja peli näkyy niissä. + Automaattinen pitää tämän 2D-pelin koko näytön tilassa. Käytä virtuaalista näyttöä, jos haluat säätää sen leveyttä, etäisyyttä ja pään seurantaa. + Käytä virtuaalista näyttöä + SteamVR täyttää näkymän. Säädä sen näkökenttää alla; leveys ja etäisyys koskevat vain virtuaalista näyttöä. + Rinnakkais-3D-pelit täyttävät kummankin silmän näkymän. Leveys ja etäisyys koskevat vain virtuaalista näyttöä. + Automaattinen pitää tämän 2D-pelin paikallaan näkymässä. Valitse Näyttö-kohdasta Virtuaalinen näyttö, jos haluat käyttää pään seurantaa. + Pään seuranta koskee virtuaalista näyttöä tai käynnissä olevaa SteamVR-peliä. + Lasit + Kirkkaus + Äänenvoimakkuus + Tummennus + Tummentaa etulinssit. + Pään seuranta + Pään seuranta + Lasien omat anturit kääntävät virtuaalista näyttöä ja SteamVR:ää pääsi mukana. + Keskitä näkymä + Keskitä + SteamVR:n näkökenttä + Pienempi tuo SteamVR:n lähemmäs ja täyttää lasit; suurempi näyttää enemmän ympäriltäsi. + Pelataan laitteella %1$s + Seurannan kalibrointi + Vähentää ryömintää ja kohdistaa vaakatason. + Tallennettu kalibrointi on käytössä näissä laseissa. + Kalibroi + Aseta kytketyt lasit ylösalaisin vaakasuoralle alustalle niin, että kehys lepää tasaisesti. Napauta Aloita äläkä sitten koske niihin noin 8 sekuntiin. Liike aloittaa mittauksen alusta. Tämä korjaa ryömintää ja kallistusta, ei suuntaa. + Jätä lasit ylösalaisin äläkä koske niihin. Odotetaan, että ne asettuvat… + Mitataan antureita. Pidä lasit paikallaan. + Liikettä havaittu. Älä koske laseihin; mittaus alkaa automaattisesti alusta. + Aseta lasit ylösalaisin vaakasuoralle alustalle. Mittaus alkaa, kun ne ovat oikeassa asennossa. + Kalibrointi tallennettu. Laita lasit päähän ja katso suoraan eteenpäin, napauta sitten Valmis keskittääksesi näkymän. + Kalibrointia ei voitu viedä loppuun. Tarkista yhteys ja pidä lasit paikallaan, yritä sitten uudelleen. Aiempi kalibrointisi on ennallaan. + Aloita + Valmis + Nollaa seurannan kalibrointi + Poistetaanko tallennettu kalibrointi ja palautetaanko mallin oletukset? Keskitä näkymä, kun olet laittanut lasit takaisin päähän. + Nollaa + Käden seuranta + Seurataanko käsiäsi kameralla? + Kamera näkee kätesi. Kun ohjainta ei ole yhdistetty, paljaista käsistäsi tulee VR-kädet: napsauta nipistämällä peukalo ja etusormi yhteen, tartu puristamalla käsi nyrkkiin ja avaa valikko kääntämällä kämmen itseäsi kohti ja nipistämällä. Joy-Conien kanssa kamera sijoittaa ne sinne, missä kätesi oikeasti ovat. Kuvat käsitellään tällä laitteella eikä niitä koskaan tallenneta tai lähetetä. + Takakamera + Suositellaan. Yleensä tarkempi ja laajempi. + Etukamera + Kun laitteen takaosa ei voi olla sinua kohti. + Käytä kameraa + Ei nyt + Käden seuranta kameralla + Kun ohjainta ei ole yhdistetty, käsistäsi tulee VR-kädet: napsauta nipistämällä ja avaa valikko nipistämällä kämmen itseäsi kohti. Joy-Conien kanssa se sijoittaa ne sinne, missä kamera ne näkee. Kuvat pysyvät tällä laitteella. + Kamera + Kameran käyttöoikeus + Salli kameran käyttö, jotta käden seuranta voi käynnistyä. + Kameran käyttöoikeus on pois päältä. Ota se käyttöön Android-asetuksissa. + Salli + Avaa asetukset + Kameran sijoitus + Tarkista, että kamera näkee sinut ennen pelaamista. + Tarkista + Kameran sijoitus + Aseta laite pystyyn noin rinnan korkeudelle 1,5–2 metrin päähän niin, että takakamera on sinua kohti. Astu sitten näkyviin kädet ojennettuina. + Aseta laite pystyyn noin rinnan korkeudelle 1,5–2 metrin päähän niin, että näyttö on sinua kohti. Astu sitten näkyviin kädet ojennettuina. + Käynnistetään kameraa… + Laite makaa litteänä. Nosta se pystyyn niin, että kamera on sinua kohti. + Aseta laite pystympään niin, että kamera on sinua kohti. + Ei vielä näe sinua. Astu näkyviin niin, että pääsi ja hartiasi näkyvät. + Astu hieman taaksepäin. + Tule hieman lähemmäs. + Pidä molemmat kädet ojennettuina siellä, missä kamera näkee ne. + Näyttää hyvältä. + Kamera ei käynnistynyt. Sulje muut sitä käyttävät sovellukset ja yritä uudelleen. + Vaihda kamera + Ohita + Valmis + Odotetaan lasien USB-Ethernet-yhteyttä. Tämä puhelin ei ehkä tarjoa sitä sovelluksille. + XREAL-yhteys katkesi tai palautti vastauksen, jota ei tueta. Yritetään uudelleen… + Kokeellinen 1S-tuki. Käytä Follow-tilaa ja poista vakautus käytöstä lasien valikosta. Seurannan suunnat ja näyttötilat vaativat vielä testausta laitteella. Cardboard VR Cardboard-tila Vasemman ja oikean silmän kuvat puhelimen VR-laseille. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 3e160503a..1dc49fde9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,5 +1,12 @@ + Le rendu graphique de SteamVR s\'est arrêté + DroidDeck va redémarrer dans %1$d secondes pour réinitialiser le rendu graphique et rouvrir SteamVR. Votre jeu en cours sera fermé ; la progression non sauvegardée ne pourra pas être récupérée. + Le problème graphique est réapparu après un redémarrage récent. La récupération automatique est suspendue pour éviter une boucle. Redémarrer fermera votre jeu ; la progression non sauvegardée ne pourra pas être récupérée. + Redémarrer maintenant + Garder ouvert + Réinitialisation du rendu graphique et réouverture de SteamVR… + DroidDeck n\'a pas pu redémarrer automatiquement. Fermez complètement l\'application, puis rouvrez-la. DroidDeck Le premier lancement télécharge Steam. Cela peut prendre quelques minutes. @@ -938,6 +945,8 @@ Bouton clavier Appuyez sur Select pour ouvrir le menu Steam À la place de View/Select. + Joy-Con comme manettes VR + Pendant que SteamVR tourne, chaque Joy-Con devient une main. Pointez-le devant vous et maintenez Home ou Capture pour le recentrer. Animations Le joystick apparaîtra là où vous touchez et se masquera une fois relâché Disposition @@ -995,6 +1004,7 @@ KDE Plasma, Firefox et vos émulateurs Les jeux installés sur Steam apparaîtront ici après la fin de la session. Lancer Steam + Lancer SteamVR Paramètres de lancement Lancer Fichiers du jeu @@ -2324,6 +2334,113 @@ Impossible de contacter GOG Impossible de lire votre bibliothèque GOG Enregistrement auprès de Steam… + Lunettes + Lunettes VITURE + Contrôle des lunettes + Cette version n\'inclut pas le pilote de ces lunettes. + Impossible de se connecter aux lunettes. Rebranchez le câble ou appuyez sur Autoriser pour réessayer. + Autoriser + Permet à DroidDeck de régler la fréquence d\'affichage, le mode 3D, la luminosité, le volume et le pare-soleil des lunettes, et de lire leur suivi de la tête. + En attente de votre autorisation dans la fenêtre d\'Android… + Le contrôle a été refusé. Autorisez-le pour modifier les réglages des lunettes. + Ces lunettes affichent le jeu, mais leurs réglages ne peuvent pas être modifiés d\'ici. + Sortie + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Jeu sur les lunettes + Affiche le jeu uniquement sur les lunettes. Désactivé : le téléphone l\'affiche et Android duplique l\'écran du téléphone. + Affichage + Fréquence d\'affichage + 3D (côte à côte) + Chaque œil reçoit sa propre moitié d\'une image de 3840×1080. Si l\'image disparaît, maintenez le bouton 2D/3D des lunettes. + Le signal 3D n\'a pas été confirmé. Réessayez une fois l\'image 2D rétablie. + Pas d\'image : les lunettes sont dans un mode que ce téléphone ne peut pas envoyer. Maintenez leur bouton 2D/3D, ou débranchez-les puis rebranchez-les. + Fréquence en 3D + 3D pour les jeux VR + Passe en 3D pendant que SteamVR tourne, puis revient ensuite. + Image 3D + Automatique : SteamVR remplit la vue ; les autres jeux flottent comme un écran en 3D et restent plats en 2D. + Automatique + Écran virtuel + Jeu côte à côte + Largeur de l\'écran + Distance de l\'écran + La largeur et la distance s\'appliquent lorsqu\'un jeu s\'affiche sur les lunettes comme écran virtuel. + Ces réglages s\'appliquent lorsque Jeu sur les lunettes est activé et que le jeu y est affiché. + Automatique garde ce jeu 2D en plein écran. Utilisez un écran virtuel pour régler sa largeur, sa distance et le suivi de la tête. + Utiliser l\'écran virtuel + SteamVR remplit la vue. Réglez son champ de vision ci-dessous ; la largeur et la distance ne s\'appliquent qu\'à un écran virtuel. + Les jeux côte à côte remplissent chaque œil. La largeur et la distance ne s\'appliquent qu\'à un écran virtuel. + Automatique garde ce jeu 2D fixe dans la vue. Choisissez Écran virtuel dans Affichage pour utiliser le suivi de la tête. + Le suivi de la tête s\'applique à un écran virtuel ou à un jeu SteamVR en cours. + Lunettes + Luminosité + Volume + Pare-soleil + Assombrit les verres avant. + Suivi de la tête + Suivi de la tête + Les capteurs des lunettes font tourner l\'écran virtuel et SteamVR avec votre tête. + Recentrer la vue + Recentrer + Champ de vision SteamVR + Plus bas rapproche SteamVR et remplit les lunettes ; plus haut montre davantage autour de vous. + En jeu sur %1$s + Calibrage du suivi + Réduit la dérive et aligne la référence horizontale. + Le calibrage enregistré est appliqué à ces lunettes. + Calibrer + Posez les lunettes connectées à l\'envers sur une surface plane, la monture bien à plat. Appuyez sur Démarrer, puis n\'y touchez plus pendant environ 8 secondes. Tout mouvement relance la mesure. Cela corrige la dérive et l\'inclinaison, pas le cap. + Laissez les lunettes à l\'envers sans y toucher. En attente de leur stabilisation… + Mesure des capteurs. Gardez les lunettes immobiles. + Mouvement détecté. Ne touchez pas les lunettes ; la mesure redémarrera automatiquement. + Posez les lunettes à l\'envers sur une surface plane. La mesure démarrera dès qu\'elles seront bien placées. + Calibrage enregistré. Mettez les lunettes, regardez droit devant, puis appuyez sur Terminé pour recentrer. + Le calibrage n\'a pas pu aboutir. Vérifiez la connexion et gardez les lunettes immobiles, puis réessayez. Votre calibrage précédent est inchangé. + Démarrer + Terminé + Réinitialiser le calibrage du suivi + Supprimer le calibrage enregistré et rétablir les valeurs par défaut du modèle ? Recentrez après avoir remis les lunettes. + Réinitialiser + Suivi des mains + Suivre vos mains avec la caméra ? + La caméra peut voir vos mains. Sans manette connectée, vos mains nues deviennent les mains VR : pincez le pouce et l\'index pour cliquer, fermez la main pour saisir, et tournez la paume vers vous puis pincez pour ouvrir le menu. Avec des Joy-Con, elle les place là où se trouvent vraiment vos mains. Les images sont traitées sur cet appareil et ne sont jamais enregistrées ni envoyées. + Caméra arrière + Recommandée. Généralement plus nette et plus large. + Caméra avant + Pour quand l\'arrière de l\'appareil ne peut pas être tourné vers vous. + Utiliser la caméra + Pas maintenant + Suivi des mains par caméra + Sans manette connectée, vos mains deviennent les mains VR : pincez pour cliquer, paume vers vous et pincez pour le menu. Avec des Joy-Con, elle les place là où la caméra les voit. Les images restent sur cet appareil. + Caméra + Accès à la caméra + Autorisez l\'accès à la caméra pour démarrer le suivi des mains. + L\'accès à la caméra est désactivé. Activez-le dans les paramètres Android. + Autoriser + Ouvrir les paramètres + Positionnement de la caméra + Vérifiez que la caméra peut vous voir avant de jouer. + Vérifier + Positionnement de la caméra + Installez l\'appareil debout, entre 1,5 et 2 mètres de vous, à hauteur de poitrine, caméra arrière tournée vers vous. Puis entrez dans le champ, les mains tendues. + Installez l\'appareil debout, entre 1,5 et 2 mètres de vous, à hauteur de poitrine, écran tourné vers vous. Puis entrez dans le champ, les mains tendues. + Démarrage de la caméra… + L\'appareil est posé à plat. Redressez-le pour que la caméra soit tournée vers vous. + Redressez davantage l\'appareil pour que la caméra soit tournée vers vous. + Vous n\'êtes pas encore visible. Entrez dans le champ pour que votre tête et vos épaules soient visibles. + Reculez un peu. + Rapprochez-vous un peu. + Tendez les deux mains là où la caméra peut les voir. + C\'est bon. + La caméra n\'a pas pu démarrer. Fermez les autres applications qui l\'utilisent et réessayez. + Changer de caméra + Ignorer + Terminé + En attente de la connexion Ethernet USB des lunettes. Ce téléphone ne la rend peut-être pas accessible aux applications. + La connexion XREAL a été interrompue ou a renvoyé une réponse non prise en charge. Nouvelle tentative… + Prise en charge expérimentale des 1S. Utilisez le mode Follow et désactivez la stabilisation dans le menu des lunettes. Le sens du suivi et les modes d\'affichage restent à tester sur l\'appareil. Développeur Éditeur Sortie diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index fbacd72c1..ecfdd157c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1,5 +1,12 @@ + SteamVR ग्राफ़िक्स रुक गए + ग्राफ़िक्स रीसेट करने और SteamVR फिर से खोलने के लिए DroidDeck %1$d सेकंड में रीस्टार्ट होगा। आपका चल रहा गेम बंद हो जाएगा; बिना सेव की गई प्रोग्रेस वापस नहीं लाई जा सकती। + हाल ही में रीस्टार्ट के बाद ग्राफ़िक्स की गड़बड़ी फिर से आ गई। लूप से बचने के लिए ऑटोमैटिक रिकवरी रोक दी गई है। रीस्टार्ट करने पर आपका गेम बंद हो जाएगा; बिना सेव की गई प्रोग्रेस वापस नहीं लाई जा सकती। + अभी रीस्टार्ट करें + खुला रखें + ग्राफ़िक्स रीसेट हो रहे हैं और SteamVR फिर से खुल रहा है… + DroidDeck अपने-आप रीस्टार्ट नहीं हो सका। ऐप को पूरी तरह बंद करें, फिर उसे दोबारा खोलें। DroidDeck पहली बार चलाने पर Steam डाउनलोड होता है। इसमें कुछ मिनट लग सकते हैं। @@ -935,6 +942,8 @@ कीबोर्ड बटन Steam मेनू खोलने के लिए Select दबाएं View/Select के बदले। + Joy-Con को VR कंट्रोलर की तरह + SteamVR चलते समय हर Joy-Con एक हाथ बन जाता है। उसे सामने की ओर करें और फिर से केंद्रित करने के लिए Home या Capture दबाए रखें। एनिमेशन आप जहां टच करें वहां स्टिक दिखते हैं और छोड़ने पर छिप जाते हैं लेआउट @@ -992,6 +1001,7 @@ KDE Plasma, Firefox और आपके एमुलेटर Steam में इंस्टॉल किए गेम सेशन के बाद यहां दिखते हैं। Steam चलाएं + SteamVR चलाएं लॉन्च सेटिंग लॉन्च करें गेम फ़ाइलें @@ -2305,6 +2315,113 @@ GOG से कनेक्ट नहीं हो सका आपकी GOG लाइब्रेरी पढ़ी नहीं जा सकी Steam में रजिस्टर हो रहा है… + चश्मा + VITURE चश्मा + चश्मे का कंट्रोल + इस बिल्ड में इस चश्मे का ड्राइवर शामिल नहीं है। + चश्मे से कनेक्ट नहीं हो सका। केबल फिर से लगाएं या दोबारा कोशिश करने के लिए अनुमति दें पर टैप करें। + अनुमति दें + DroidDeck को चश्मे का रिफ्रेश रेट, 3D मोड, ब्राइटनेस, वॉल्यूम और सनब्लॉक सेट करने और उसकी हेड ट्रैकिंग पढ़ने दें। + Android के प्रॉम्प्ट में आपकी अनुमति का इंतज़ार है… + कंट्रोल की अनुमति नहीं दी गई। चश्मे की सेटिंग बदलने के लिए इसकी अनुमति दें। + यह चश्मा गेम दिखाता है, लेकिन इसकी सेटिंग यहां से नहीं बदली जा सकतीं। + आउटपुट + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + चश्मे पर गेम + गेम सिर्फ़ चश्मे पर दिखाता है। बंद: गेम फ़ोन पर दिखता है और Android फ़ोन की स्क्रीन मिरर करता है। + डिस्प्ले + रिफ्रेश रेट + 3D (साइड-बाय-साइड) + हर आंख को 3840×1080 तस्वीर का अपना आधा हिस्सा मिलता है। अगर तस्वीर चली जाए, तो चश्मे का 2D/3D बटन दबाकर रखें। + 3D सिग्नल की पुष्टि नहीं हुई। 2D तस्वीर वापस आने पर फिर कोशिश करें। + कोई तस्वीर नहीं: चश्मा ऐसे मोड में है जिसे यह फ़ोन नहीं भेज सकता। उसका 2D/3D बटन दबाकर रखें, या केबल निकालकर फिर से लगाएं। + 3D रिफ्रेश रेट + VR गेम के लिए 3D + SteamVR चलने के दौरान 3D पर चला जाता है, और बाद में वापस आ जाता है। + 3D तस्वीर + ऑटोमैटिक: SteamVR पूरा व्यू भर देता है; दूसरे गेम 3D में स्क्रीन की तरह हवा में दिखते हैं और 2D में सपाट रहते हैं। + ऑटोमैटिक + वर्चुअल स्क्रीन + साइड-बाय-साइड गेम + स्क्रीन की चौड़ाई + स्क्रीन की दूरी + जब कोई गेम चश्मे पर वर्चुअल स्क्रीन के रूप में चलता है, तब चौड़ाई और दूरी लागू होती हैं। + ये सेटिंग तब लागू होती हैं जब चश्मे पर गेम चालू हो और गेम वहीं दिख रहा हो। + ऑटोमैटिक इस 2D गेम को फ़ुल-स्क्रीन रखता है। इसकी चौड़ाई, दूरी और हेड ट्रैकिंग बदलने के लिए वर्चुअल स्क्रीन इस्तेमाल करें। + वर्चुअल स्क्रीन इस्तेमाल करें + SteamVR पूरा व्यू भर देता है। नीचे इसका फ़ील्ड ऑफ़ व्यू बदलें; चौड़ाई और दूरी सिर्फ़ वर्चुअल स्क्रीन पर लागू होती हैं। + साइड-बाय-साइड गेम हर आंख का व्यू भर देते हैं। चौड़ाई और दूरी सिर्फ़ वर्चुअल स्क्रीन पर लागू होती हैं। + ऑटोमैटिक इस 2D गेम को व्यू में एक जगह टिकाए रखता है। हेड ट्रैकिंग इस्तेमाल करने के लिए डिस्प्ले में वर्चुअल स्क्रीन चुनें। + हेड ट्रैकिंग वर्चुअल स्क्रीन या चल रहे SteamVR गेम पर लागू होती है। + चश्मा + ब्राइटनेस + वॉल्यूम + सनब्लॉक + आगे के लेंस गहरे करता है। + हेड ट्रैकिंग + हेड ट्रैकिंग + चश्मे के अपने सेंसर वर्चुअल स्क्रीन और SteamVR को आपके सिर के साथ घुमाते हैं। + व्यू को फिर से बीच में लाएं + बीच में लाएं + SteamVR फ़ील्ड ऑफ़ व्यू + कम करने पर SteamVR पास आता है और चश्मा भर देता है; ज़्यादा करने पर आपके आस-पास ज़्यादा दिखता है। + %1$s पर खेल रहे हैं + ट्रैकिंग कैलिब्रेशन + ड्रिफ़्ट कम करता है और क्षैतिज संदर्भ को सीध में लाता है। + इस चश्मे पर सेव किया गया कैलिब्रेशन लागू है। + कैलिब्रेट करें + जुड़े हुए चश्मे को किसी समतल सतह पर उल्टा रखें, ताकि फ़्रेम बराबर टिका रहे। शुरू करें पर टैप करें, फिर लगभग 8 सेकंड तक उसे न छुएं। हिलने पर माप फिर से शुरू हो जाता है। इससे ड्रिफ़्ट और झुकाव ठीक होते हैं, सामने की दिशा नहीं। + चश्मे को उल्टा रहने दें और उसे न छुएं। उसके स्थिर होने का इंतज़ार है… + सेंसर का माप लिया जा रहा है। चश्मे को स्थिर रखें। + हलचल मिली। चश्मे को न छुएं; माप अपने-आप फिर से शुरू होगा। + चश्मे को किसी समतल सतह पर उल्टा रखें। सही तरह रखते ही माप शुरू हो जाएगा। + कैलिब्रेशन सेव हो गया। चश्मा पहनें और सीधे सामने देखें, फिर व्यू बीच में लाने के लिए हो गया पर टैप करें। + कैलिब्रेशन पूरा नहीं हो सका। कनेक्शन जांचें और चश्मे को स्थिर रखें, फिर दोबारा कोशिश करें। आपका पिछला कैलिब्रेशन नहीं बदला है। + शुरू करें + हो गया + ट्रैकिंग कैलिब्रेशन रीसेट करें + सेव किया गया कैलिब्रेशन हटाकर मॉडल का डिफ़ॉल्ट वापस लाएं? चश्मा दोबारा पहनने के बाद व्यू बीच में लाएं। + रीसेट करें + हाथ ट्रैकिंग + क्या कैमरे से आपके हाथों को ट्रैक करें? + कैमरा आपके हाथ देख सकता है। कोई कंट्रोलर कनेक्ट न होने पर आपके खाली हाथ ही VR हाथ बन जाते हैं: क्लिक करने के लिए अंगूठे और तर्जनी को मिलाएं, पकड़ने के लिए मुट्ठी बंद करें, और मेनू खोलने के लिए हथेली अपनी ओर करके उंगलियां मिलाएं। Joy-Con के साथ, यह उन्हें वहीं रखता है जहां आपके हाथ सच में हैं। तस्वीरें इसी डिवाइस पर प्रोसेस होती हैं और कभी सेव या भेजी नहीं जातीं। + रियर कैमरा + सुझाया गया। आमतौर पर ज़्यादा शार्प और चौड़ा। + फ्रंट कैमरा + जब डिवाइस का पिछला हिस्सा आपकी ओर न हो, तब इस्तेमाल करें। + कैमरा इस्तेमाल करें + अभी नहीं + कैमरे से हाथ ट्रैकिंग + कोई कंट्रोलर कनेक्ट न होने पर आपके हाथ ही VR हाथ बन जाते हैं: क्लिक के लिए उंगलियां मिलाएं, मेनू के लिए हथेली अपनी ओर करके उंगलियां मिलाएं। Joy-Con के साथ, यह उन्हें वहां रखता है जहां कैमरा उन्हें देखता है। तस्वीरें इसी डिवाइस पर रहती हैं। + कैमरा + कैमरा एक्सेस + हाथ ट्रैकिंग शुरू करने के लिए कैमरा एक्सेस की अनुमति दें। + कैमरा एक्सेस बंद है। इसे Android सेटिंग में चालू करें। + अनुमति दें + सेटिंग खोलें + कैमरे की स्थिति + खेलने से पहले जांच लें कि कैमरा आपको देख पा रहा है। + जांचें + कैमरे की स्थिति + डिवाइस को करीब सीने की ऊंचाई पर, 1.5 से 2 मीटर दूर खड़ा करें, जिसमें रियर कैमरा आपकी ओर हो। फिर हाथ फैलाकर नज़र के दायरे में आएं। + डिवाइस को करीब सीने की ऊंचाई पर, 1.5 से 2 मीटर दूर खड़ा करें, जिसमें स्क्रीन आपकी ओर हो। फिर हाथ फैलाकर नज़र के दायरे में आएं। + कैमरा शुरू हो रहा है… + डिवाइस सपाट लेटा है। इसे ऐसे खड़ा करें कि कैमरा आपकी ओर हो। + डिवाइस को ज़्यादा सीधा खड़ा करें ताकि कैमरा आपकी ओर हो। + अभी आप दिख नहीं रहे। नज़र के दायरे में आएं ताकि आपका सिर और कंधे दिखें। + थोड़ा पीछे हटें। + थोड़ा और पास आएं। + दोनों हाथ वहां फैलाएं जहां कैमरा उन्हें देख सके। + ठीक लग रहा है। + कैमरा शुरू नहीं हो सका। इसे इस्तेमाल करने वाले दूसरे ऐप बंद करें और फिर कोशिश करें। + कैमरा बदलें + छोड़ें + हो गया + चश्मे के USB Ethernet कनेक्शन का इंतज़ार है। हो सकता है यह फ़ोन इसे ऐप्स को उपलब्ध न कराए। + XREAL कनेक्शन टूट गया या उसने ऐसा जवाब दिया जो समर्थित नहीं है। फिर से कोशिश हो रही है… + 1S के लिए प्रायोगिक सपोर्ट। चश्मे के मेनू में Follow मोड चुनें और स्टेबिलाइज़ेशन बंद करें। ट्रैकिंग की दिशाएं और डिस्प्ले मोड अभी डिवाइस पर टेस्ट होने बाकी हैं। Cardboard VR Cardboard मोड फ़ोन VR व्यूअर के लिए बाईं और दाईं आंख के व्यू। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 75853ed89..e44f50b33 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,5 +1,12 @@ + A SteamVR grafikája leállt + A DroidDeck %1$d másodperc múlva újraindul, hogy visszaállítsa a grafikát, és újra megnyissa a SteamVR-t. A futó játék bezárul; a nem mentett előrehaladás nem állítható vissza. + A grafikus hiba egy nemrégi újraindítás után ismét jelentkezett. Az automatikus helyreállítás szünetel, hogy ne alakuljon ki végtelen ciklus. Az újraindítás bezárja a játékot; a nem mentett előrehaladás nem állítható vissza. + Újraindítás most + Nyitva tartás + A grafika visszaállítása és a SteamVR újranyitása… + A DroidDeck nem tudott automatikusan újraindulni. Zárja be teljesen az alkalmazást, majd nyissa meg újra. DroidDeck Az első indítás letölti a Steamet. Ez néhány percet vehet igénybe. @@ -935,6 +942,8 @@ Billentyűzet gomb Nyomja meg a Select gombot a Steam-menü megnyitásához A View/Select helyett. + Joy-Conok VR-kontrollerként + Amíg a SteamVR fut, minden Joy-Con egy kéz lesz. Irányítsa előre, és tartsa lenyomva a Home vagy a Capture gombot az újraközpontosításhoz. Animációk A karok ott jelennek meg, ahol megérinti őket, és elengedéskor elrejtőznek Elrendezés @@ -992,6 +1001,7 @@ KDE Plasma, Firefox és az emulátorai A Steamben telepített játékok a munkamenet után itt jelennek meg. Steam elindítása + SteamVR elindítása Indítási beállítások Indítás Játékfájlok @@ -2305,6 +2315,113 @@ Nem sikerült csatlakozni a GOG-hoz A GOG-könyvtáradat nem sikerült beolvasni Regisztráció a Steamnél… + Szemüveg + VITURE szemüveg + Szemüveg vezérlése + Ez a verzió nem tartalmazza a szemüveg illesztőprogramját. + Nem sikerült csatlakozni a szemüveghez. Csatlakoztassa újra a kábelt, vagy koppintson az Engedélyezés gombra az újrapróbáláshoz. + Engedélyezés + Engedélyezze, hogy a DroidDeck beállítsa a szemüveg frissítési frekvenciáját, 3D módját, fényerejét, hangerejét és elsötétítését, valamint olvassa a fejkövetési adatait. + Várakozás az engedélyére az Android ablakában… + A vezérlés elutasítva. Engedélyezze, hogy módosíthassa a szemüveg beállításait. + Ez a szemüveg megjeleníti a játékot, de a beállításai innen nem módosíthatók. + Kimenet + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Játék a szemüvegen + A játék csak a szemüvegen jelenik meg. Ki: a telefon mutatja, és az Android tükrözi a telefon képernyőjét. + Kijelző + Frissítési frekvencia + 3D (egymás melletti) + Mindkét szem egy 3840×1080-as kép saját felét kapja. Ha a kép eltűnik, tartsa lenyomva a szemüveg 2D/3D gombját. + Nem sikerült megerősíteni a 3D-jelet. Próbáld újra, ha visszatér a 2D-kép. + Nincs kép: a szemüveg olyan módban van, amelyet ez a telefon nem tud küldeni. Tartsa lenyomva a 2D/3D gombját, vagy húzza ki, és dugja vissza. + 3D frissítési frekvencia + 3D a VR-játékokhoz + 3D-re vált, amíg a SteamVR fut, utána vissza. + 3D kép + Automatikus: a SteamVR kitölti a látóteret; más játékok 3D-ben képernyőként lebegnek, 2D-ben laposak maradnak. + Automatikus + Virtuális képernyő + Egymás melletti 3D játék + Képernyő szélessége + Képernyő távolsága + A szélesség és a távolság akkor érvényes, ha a játék virtuális képernyőként fut a szemüvegen. + Ezek a beállítások akkor érvényesek, ha a Játék a szemüvegen be van kapcsolva, és a játék ott jelenik meg. + Az Automatikus mód teljes képernyőn tartja ezt a 2D-s játékot. Használjon virtuális képernyőt a szélesség, a távolság és a fejkövetés beállításához. + Virtuális képernyő használata + A SteamVR kitölti a látóteret. A látómezőt lent állíthatja; a szélesség és a távolság csak virtuális képernyőre vonatkozik. + Az egymás melletti 3D-s játékok mindkét szem látóterét kitöltik. A szélesség és a távolság csak virtuális képernyőre vonatkozik. + Az Automatikus mód rögzítve tartja ezt a 2D-s játékot a látótérben. A fejkövetéshez válassza a Kijelző részben a Virtuális képernyő lehetőséget. + A fejkövetés virtuális képernyőre vagy futó SteamVR-játékra vonatkozik. + Szemüveg + Fényerő + Hangerő + Elsötétítés + Elsötétíti az elülső lencséket. + Fejkövetés + Fejkövetés + A szemüveg saját érzékelői a fejével együtt forgatják a virtuális képernyőt és a SteamVR-t. + Nézet középre igazítása + Középre + SteamVR látómező + Kisebb érték közelebb hozza a SteamVR-t és kitölti a szemüveget; nagyobb többet mutat a környezetéből. + Játék ezen: %1$s + Követés kalibrálása + Csökkenti a sodródást, és beállítja a vízszintes referenciát. + A szemüveg a mentett kalibrációt használja. + Kalibrálás + Helyezze a csatlakoztatott szemüveget fejjel lefelé egy vízszintes felületre úgy, hogy a keret egyenletesen feküdjön. Koppintson az Indítás gombra, majd kb. 8 másodpercig ne érjen hozzá. Mozgás esetén a mérés újraindul. Ez a sodródást és a dőlést korrigálja, az irányt nem. + Hagyja a szemüveget fejjel lefelé, és ne érjen hozzá. Várakozás, amíg nyugalomba kerül… + Az érzékelők mérése. Tartsa mozdulatlanul a szemüveget. + Mozgás észlelve. Ne érjen a szemüveghez; a mérés automatikusan újraindul. + Helyezze a szemüveget fejjel lefelé egy vízszintes felületre. A mérés akkor indul, amikor megfelelő helyzetben van. + Kalibráció mentve. Vegye fel a szemüveget, nézzen egyenesen előre, majd koppintson a Kész gombra a középre igazításhoz. + Nem sikerült befejezni a kalibrálást. Ellenőrizze a kapcsolatot, tartsa mozdulatlanul a szemüveget, majd próbálja újra. Az előző kalibráció változatlan. + Indítás + Kész + Követési kalibráció visszaállítása + Törli a mentett kalibrációt, és visszaállítja a modell alapértékeit? A szemüveg visszavétele után igazítsa középre a nézetet. + Visszaállítás + Kézkövetés + Nyomon kövesse a kamera a kezeit? + A kamera látja a kezeit. Ha nincs csatlakoztatva kontroller, a puszta kezei lesznek a VR-kezek: kattintáshoz érintse össze a hüvelyk- és mutatóujját, megragadáshoz zárja ökölbe a kezét, a menühöz pedig fordítsa maga felé a tenyerét, és érintse össze az ujjait. Joy-Conokkal oda helyezi őket, ahol a kezei valóban vannak. A képeket ez az eszköz dolgozza fel, és soha nem menti el vagy küldi el őket. + Hátlapi kamera + Ajánlott. Általában élesebb és szélesebb látószögű. + Előlapi kamera + Ha az eszköz hátlapja nem fordítható Ön felé. + Kamera használata + Most nem + Kézkövetés kamerával + Ha nincs csatlakoztatva kontroller, a kezei lesznek a VR-kezek: kattintáshoz érintse össze az ujjait, a menühöz tenyérrel maga felé tegye ugyanezt. Joy-Conokkal oda helyezi őket, ahol a kamera látja. A képek ezen az eszközön maradnak. + Kamera + Kamerahozzáférés + Engedélyezze a kamerahozzáférést, hogy elindulhasson a kézkövetés. + A kamerahozzáférés ki van kapcsolva. Kapcsolja be az Android beállításaiban. + Engedélyezés + Beállítások megnyitása + Kamera elhelyezése + Játék előtt ellenőrizze, hogy a kamera látja-e Önt. + Ellenőrzés + Kamera elhelyezése + Állítsa az eszközt függőlegesen 1,5–2 méter távolságra, nagyjából mellkasmagasságban úgy, hogy a hátlapi kamera Ön felé nézzen. Ezután lépjen a látótérbe kinyújtott kezekkel. + Állítsa az eszközt függőlegesen 1,5–2 méter távolságra, nagyjából mellkasmagasságban úgy, hogy a képernyő Ön felé nézzen. Ezután lépjen a látótérbe kinyújtott kezekkel. + Kamera indítása… + Az eszköz laposan fekszik. Állítsa fel úgy, hogy a kamera Ön felé nézzen. + Állítsa az eszközt még függőlegesebbre, hogy a kamera Ön felé nézzen. + Még nem látja Önt. Lépjen a látótérbe úgy, hogy a feje és a válla is látszódjon. + Lépjen hátrébb egy kicsit. + Jöjjön egy kicsit közelebb. + Nyújtsa ki mindkét kezét oda, ahol a kamera látja őket. + Jól néz ki. + A kamera indítása nem sikerült. Zárja be a többi, azt használó alkalmazást, és próbálja újra. + Kamera váltása + Kihagyás + Kész + Várakozás a szemüveg USB-s Ethernet-kapcsolatára. Lehet, hogy ez a telefon nem teszi elérhetővé az alkalmazások számára. + Az XREAL-kapcsolat megszakadt, vagy nem támogatott választ adott. Újrapróbálkozás… + Kísérleti 1S-támogatás. Használja a Follow módot, és kapcsolja ki a stabilizálást a szemüveg menüjében. A követési irányokat és a megjelenítési módokat még tesztelni kell az eszközön. Cardboard VR Cardboard mód Bal és jobb szem képe telefonos VR-szemüveghez. diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index ce3bac164..6303c8a33 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1,5 +1,12 @@ + Grafis SteamVR berhenti + DroidDeck akan dimulai ulang dalam %1$d detik untuk mereset grafis dan membuka kembali SteamVR. Game Anda yang sedang berjalan akan ditutup; progres yang belum disimpan tidak dapat dipulihkan. + Gangguan grafis muncul lagi setelah mulai ulang baru-baru ini. Pemulihan otomatis dijeda agar tidak terjadi pengulangan terus-menerus. Memulai ulang akan menutup game Anda; progres yang belum disimpan tidak dapat dipulihkan. + Mulai ulang sekarang + Biarkan terbuka + Mereset grafis dan membuka kembali SteamVR… + DroidDeck tidak bisa dimulai ulang secara otomatis. Tutup aplikasi sepenuhnya, lalu buka lagi. DroidDeck Proses pertama mengunduh Steam. Ini mungkin memerlukan beberapa menit. @@ -929,6 +936,8 @@ Tombol keyboard Tekan Select untuk membuka menu Steam Bukan View/Select. + Joy-Con sebagai pengontrol VR + Saat SteamVR berjalan, setiap Joy-Con menjadi tangan. Arahkan ke depan lalu tahan Home atau Capture untuk memusatkannya kembali. Animasi Stik muncul di tempat Anda menyentuh dan tersembunyi saat dilepas Tata letak @@ -986,6 +995,7 @@ KDE Plasma, Firefox, dan emulator Anda Gim yang Anda instal di Steam akan muncul di sini setelah sesi. Mainkan Steam + Mainkan SteamVR Setelan peluncuran Luncurkan File gim @@ -2280,6 +2290,113 @@ Tidak dapat menghubungi GOG Pustaka GOG kamu tidak dapat dibaca Mendaftar ke Steam… + Kacamata + Kacamata VITURE + Kontrol kacamata + Versi ini tidak menyertakan driver untuk kacamata ini. + Tidak bisa terhubung ke kacamata. Sambungkan ulang kabel atau ketuk Izinkan untuk mencoba lagi. + Izinkan + Izinkan DroidDeck mengatur refresh rate, mode 3D, kecerahan, volume, dan peredup lensa kacamata, serta membaca pelacakan kepalanya. + Menunggu Anda mengizinkannya di dialog Android… + Kontrol ditolak. Izinkan untuk mengubah setelan kacamata. + Kacamata ini menampilkan game, tetapi setelannya tidak bisa diubah dari sini. + Output + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Game di kacamata + Tampilkan game hanya di kacamata. Nonaktif: ponsel menampilkannya dan Android mencerminkan layar ponsel. + Tampilan + Refresh rate + 3D (berdampingan) + Setiap mata mendapat separuhnya sendiri dari gambar 3840×1080. Jika gambar hilang, tahan tombol 2D/3D kacamata. + Sinyal 3D belum terkonfirmasi. Coba lagi setelah gambar 2D kembali. + Tidak ada gambar: kacamata dalam mode yang tidak bisa dikirim ponsel ini. Tahan tombol 2D/3D-nya, atau cabut lalu colokkan lagi. + Refresh rate 3D + 3D untuk game VR + Beralih ke 3D saat SteamVR berjalan, lalu kembali setelahnya. + Gambar 3D + Otomatis: SteamVR memenuhi pandangan; game lain melayang sebagai layar di 3D dan tetap datar di 2D. + Otomatis + Layar virtual + Game 3D berdampingan + Lebar layar + Jarak layar + Lebar dan jarak berlaku saat game berjalan di kacamata sebagai layar virtual. + Setelan ini berlaku saat Game di kacamata aktif dan game ditampilkan di sana. + Otomatis menjaga game 2D ini tetap layar penuh. Gunakan layar virtual untuk mengatur lebar, jarak, dan pelacakan kepalanya. + Gunakan layar virtual + SteamVR memenuhi pandangan. Atur bidang pandangnya di bawah; lebar dan jarak hanya berlaku untuk layar virtual. + Game berdampingan memenuhi pandangan setiap mata. Lebar dan jarak hanya berlaku untuk layar virtual. + Otomatis menjaga game 2D ini tetap diam dalam pandangan. Pilih Layar virtual di Tampilan untuk menggunakan pelacakan kepala. + Pelacakan kepala berlaku untuk layar virtual atau game SteamVR yang sedang berjalan. + Kacamata + Kecerahan + Volume + Peredup lensa + Menggelapkan lensa depan. + Pelacakan kepala + Pelacakan kepala + Sensor milik kacamata memutar layar virtual dan SteamVR mengikuti kepala Anda. + Posisikan ulang tampilan + Posisikan ulang + Bidang pandang SteamVR + Lebih rendah membawa SteamVR lebih dekat dan memenuhi kacamata; lebih tinggi menampilkan lebih banyak di sekitar Anda. + Bermain di %1$s + Kalibrasi pelacakan + Mengurangi drift dan menyelaraskan acuan horizontal. + Kalibrasi tersimpan diterapkan ke kacamata ini. + Kalibrasi + Letakkan kacamata yang terhubung secara terbalik di permukaan datar, dengan bingkai bertumpu rata. Ketuk Mulai, lalu jangan sentuh kacamata selama sekitar 8 detik. Gerakan akan mengulang pengukuran. Ini mengoreksi drift dan kemiringan, bukan arah hadap. + Biarkan kacamata terbalik dan jangan disentuh. Menunggu kacamata stabil… + Mengukur sensor. Jaga kacamata tetap diam. + Gerakan terdeteksi. Jangan sentuh kacamata; pengukuran akan diulang otomatis. + Letakkan kacamata terbalik di permukaan datar. Pengukuran dimulai saat posisinya sudah benar. + Kalibrasi tersimpan. Pakai kacamata dan lihat lurus ke depan, lalu ketuk Selesai untuk memosisikan ulang tampilan. + Kalibrasi tidak dapat diselesaikan. Periksa koneksi dan jaga kacamata tetap diam, lalu coba lagi. Kalibrasi Anda sebelumnya tidak berubah. + Mulai + Selesai + Reset kalibrasi pelacakan + Hapus kalibrasi tersimpan dan pulihkan default model? Posisikan ulang tampilan setelah memakai kacamata kembali. + Reset + Pelacakan tangan + Lacak tangan Anda dengan kamera? + Kamera dapat melihat tangan Anda. Tanpa pengontrol yang terhubung, tangan kosong Anda menjadi tangan VR: satukan ibu jari dan telunjuk untuk mengeklik, kepalkan tangan untuk menggenggam, dan hadapkan telapak tangan ke arah Anda lalu satukan jari untuk membuka menu. Dengan Joy-Con, kamera menempatkannya di posisi tangan Anda yang sebenarnya. Gambar diproses di perangkat ini dan tidak pernah disimpan atau dikirim. + Kamera belakang + Direkomendasikan. Biasanya lebih tajam dan lebih lebar. + Kamera depan + Untuk saat bagian belakang perangkat tidak bisa menghadap Anda. + Gunakan kamera + Nanti saja + Pelacakan tangan dengan kamera + Tanpa pengontrol yang terhubung, tangan Anda menjadi tangan VR: satukan jari untuk mengeklik, hadapkan telapak tangan ke arah Anda dan satukan jari untuk menu. Dengan Joy-Con, kamera menempatkannya di posisi yang dilihatnya. Gambar tetap di perangkat ini. + Kamera + Akses kamera + Izinkan akses kamera agar pelacakan tangan bisa dimulai. + Akses kamera nonaktif. Aktifkan di setelan Android. + Izinkan + Buka setelan + Posisi kamera + Pastikan kamera bisa melihat Anda sebelum bermain. + Periksa + Posisi kamera + Dirikan perangkat sejauh 1,5 hingga 2 meter kira-kira setinggi dada, dengan kamera belakang menghadap Anda. Lalu masuk ke bidang pandang dengan tangan terentang. + Dirikan perangkat sejauh 1,5 hingga 2 meter kira-kira setinggi dada, dengan layar menghadap Anda. Lalu masuk ke bidang pandang dengan tangan terentang. + Memulai kamera… + Perangkat terbaring datar. Dirikan agar kamera menghadap Anda. + Dirikan perangkat lebih tegak agar kamera menghadap Anda. + Belum bisa melihat Anda. Masuk ke bidang pandang agar kepala dan bahu Anda terlihat. + Mundur sedikit. + Mendekat sedikit. + Rentangkan kedua tangan di tempat yang bisa dilihat kamera. + Terlihat bagus. + Kamera tidak dapat dimulai. Tutup aplikasi lain yang menggunakannya, lalu coba lagi. + Ganti kamera + Lewati + Selesai + Menunggu koneksi Ethernet USB dari kacamata. Ponsel ini mungkin tidak menyediakannya untuk aplikasi. + Koneksi XREAL terputus atau memberikan respons yang tidak didukung. Mencoba lagi… + Dukungan 1S eksperimental. Gunakan mode Follow dan nonaktifkan stabilisasi di menu kacamata. Arah pelacakan dan mode tampilan masih perlu diuji di perangkat. Cardboard VR Mode Cardboard Tampilan mata kiri dan kanan untuk kacamata VR ponsel. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8a5641dbf..d9eebebd7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,5 +1,12 @@ + Grafica di SteamVR interrotta + DroidDeck si riavvierà tra %1$d secondi per ripristinare la grafica e riaprire SteamVR. Il gioco in esecuzione verrà chiuso; i progressi non salvati non potranno essere recuperati. + Il problema grafico si è ripresentato dopo un riavvio recente. Il ripristino automatico è sospeso per evitare un ciclo. Il riavvio chiuderà il gioco; i progressi non salvati non potranno essere recuperati. + Riavvia ora + Lascia aperto + Ripristino della grafica e riapertura di SteamVR… + DroidDeck non è riuscito a riavviarsi automaticamente. Chiudi completamente l\'app, poi riaprila. DroidDeck Al primo avvio viene scaricato Steam. Potrebbero volerci alcuni minuti. @@ -930,6 +937,8 @@ Tasto tastiera Premi Select per aprire il menu Steam Invece di View/Select. + Joy-Con come controller VR + Mentre SteamVR è in esecuzione, ogni Joy-Con diventa una mano. Puntalo in avanti e tieni premuto Home o Capture per ricentrarlo. Animazioni Le levette appaiono dove tocchi e si nascondono al rilascio Layout @@ -987,6 +996,7 @@ KDE Plasma, Firefox e i tuoi emulatori I giochi che installi in Steam compaiono qui dopo la sessione. Gioca su Steam + Gioca su SteamVR Impostazioni di avvio Avvia File di gioco @@ -2316,6 +2326,113 @@ Impossibile contattare GOG Impossibile leggere la tua libreria GOG Registrazione su Steam… + Occhiali + Occhiali VITURE + Controllo degli occhiali + Questa build non include il driver per questi occhiali. + Impossibile connettersi agli occhiali. Ricollega il cavo o tocca Consenti per riprovare. + Consenti + Consenti a DroidDeck di impostare frequenza di aggiornamento, modalità 3D, luminosità, volume e oscuramento degli occhiali, e di leggerne il tracciamento della testa. + In attesa che tu lo consenta nella richiesta di Android… + Controllo rifiutato. Consentilo per modificare le impostazioni degli occhiali. + Questi occhiali mostrano il gioco, ma le loro impostazioni non si possono modificare da qui. + Uscita + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Gioco sugli occhiali + Mostra il gioco solo sugli occhiali. Disattivato: lo mostra il telefono e Android ne duplica lo schermo. + Schermo + Frequenza di aggiornamento + 3D (affiancato) + Ogni occhio riceve la propria metà di un\'immagine 3840×1080. Se l\'immagine scompare, tieni premuto il pulsante 2D/3D degli occhiali. + Il segnale 3D non è stato confermato. Riprova quando torna l\'immagine 2D. + Nessuna immagine: gli occhiali sono in una modalità che questo telefono non può inviare. Tieni premuto il loro pulsante 2D/3D, oppure scollegali e ricollegali. + Frequenza di aggiornamento 3D + 3D per i giochi VR + Passa al 3D mentre SteamVR è in esecuzione, poi torna indietro. + Immagine 3D + Automatico: SteamVR riempie la vista; gli altri giochi appaiono come uno schermo sospeso in 3D e restano piatti in 2D. + Automatico + Schermo virtuale + Gioco affiancato + Larghezza schermo + Distanza schermo + Larghezza e distanza si applicano quando un gioco è mostrato sugli occhiali come schermo virtuale. + Queste impostazioni si applicano quando Gioco sugli occhiali è attivo e il gioco è mostrato sugli occhiali. + Con Automatico questo gioco 2D resta a schermo intero. Usa uno schermo virtuale per regolarne larghezza, distanza e tracciamento della testa. + Usa schermo virtuale + SteamVR riempie la vista. Regola qui sotto il suo campo visivo; larghezza e distanza si applicano solo a uno schermo virtuale. + I giochi affiancati riempiono ciascun occhio. Larghezza e distanza si applicano solo a uno schermo virtuale. + Con Automatico questo gioco 2D resta fisso nella vista. Scegli Schermo virtuale nella sezione Schermo per usare il tracciamento della testa. + Il tracciamento della testa si applica a uno schermo virtuale o a un gioco SteamVR in esecuzione. + Occhiali + Luminosità + Volume + Oscuramento + Scurisce le lenti frontali. + Tracciamento della testa + Tracciamento della testa + I sensori degli occhiali ruotano lo schermo virtuale e SteamVR insieme alla tua testa. + Ricentra vista + Ricentra + Campo visivo di SteamVR + Più basso avvicina SteamVR e riempie gli occhiali; più alto mostra di più intorno a te. + Gioco in corso su %1$s + Calibrazione del tracciamento + Riduce la deriva e allinea il riferimento orizzontale. + La calibrazione salvata è applicata a questi occhiali. + Calibra + Posa gli occhiali collegati capovolti su una superficie piana, con la montatura ben appoggiata. Tocca Avvia, poi lasciali fermi per circa 8 secondi. Qualsiasi movimento fa ripartire la misurazione. Corregge deriva e inclinazione, non la direzione. + Lascia gli occhiali capovolti e non toccarli. In attesa che si stabilizzino… + Misurazione dei sensori in corso. Tieni fermi gli occhiali. + Movimento rilevato. Non toccare gli occhiali; la misurazione ripartirà automaticamente. + Appoggia gli occhiali capovolti su una superficie piana. La misurazione inizierà quando saranno posizionati correttamente. + Calibrazione salvata. Indossa gli occhiali e guarda dritto davanti a te, poi tocca Fine per ricentrare. + Impossibile completare la calibrazione. Controlla il collegamento e tieni fermi gli occhiali, poi riprova. La calibrazione precedente non è cambiata. + Avvia + Fine + Ripristina calibrazione del tracciamento + Rimuovere la calibrazione salvata e ripristinare i valori predefiniti del modello? Ricentra dopo aver rimesso gli occhiali. + Ripristina + Tracciamento delle mani + Tracciare le mani con la fotocamera? + La fotocamera vede le tue mani. Senza controller collegati, le tue mani nude diventano le mani VR: unisci pollice e indice per fare clic, chiudi la mano per afferrare e, per aprire il menu, gira il palmo verso di te e unisci le dita. Con i Joy-Con, li posiziona dove si trovano davvero le tue mani. Le immagini vengono elaborate su questo dispositivo e non vengono mai salvate né inviate. + Fotocamera posteriore + Consigliata. Di solito più nitida e ampia. + Fotocamera anteriore + Per quando il retro del dispositivo non può essere rivolto verso di te. + Usa la fotocamera + Non ora + Tracciamento mani con fotocamera + Senza controller collegati, le tue mani diventano le mani VR: unisci le dita per fare clic, palmo verso di te e dita unite per il menu. Con i Joy-Con, li posiziona dove li vede la fotocamera. Le immagini restano su questo dispositivo. + Fotocamera + Accesso alla fotocamera + Consenti l\'accesso alla fotocamera per avviare il tracciamento delle mani. + L\'accesso alla fotocamera è disattivato. Attivalo nelle impostazioni di Android. + Consenti + Apri impostazioni + Posizione della fotocamera + Controlla che la fotocamera possa vederti prima di giocare. + Controlla + Posizione della fotocamera + Metti il dispositivo in piedi a una distanza di 1,5-2 metri, all\'altezza del petto, con la fotocamera posteriore rivolta verso di te. Poi entra nell\'inquadratura con le mani tese. + Metti il dispositivo in piedi a una distanza di 1,5-2 metri, all\'altezza del petto, con lo schermo rivolto verso di te. Poi entra nell\'inquadratura con le mani tese. + Avvio della fotocamera… + Il dispositivo è appoggiato in piano. Mettilo in piedi perché la fotocamera sia rivolta verso di te. + Metti il dispositivo più in verticale perché la fotocamera sia rivolta verso di te. + Non riesce ancora a vederti. Entra nell\'inquadratura in modo che si vedano testa e spalle. + Fai un passo indietro. + Avvicinati un po\'. + Tieni entrambe le mani dove la fotocamera possa vederle. + Va bene così. + Impossibile avviare la fotocamera. Chiudi le altre app che la usano e riprova. + Cambia fotocamera + Salta + Fine + In attesa della connessione USB Ethernet degli occhiali. Questo telefono potrebbe non renderla disponibile alle app. + La connessione XREAL si è interrotta o ha restituito una risposta non supportata. Nuovo tentativo… + Supporto sperimentale per 1S. Usa la modalità Follow e disattiva la stabilizzazione nel menu degli occhiali. Direzioni del tracciamento e modalità di visualizzazione devono ancora essere testate sul dispositivo. Cardboard VR Modalità Cardboard Immagini per l\'occhio sinistro e destro, per un visore VR per telefono. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index e573fefad..1fc82caa0 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,5 +1,12 @@ + SteamVR のグラフィックが停止しました + グラフィックをリセットして SteamVR を開き直すため、DroidDeck は%1$d秒後に再起動します。実行中のゲームは終了し、保存されていない進行状況は復元できません。 + 最近の再起動の後、グラフィックの障害が再発しました。ループを防ぐため、自動復旧を一時停止しています。再起動するとゲームは終了し、保存されていない進行状況は復元できません。 + 今すぐ再起動 + 開いたままにする + グラフィックをリセットして SteamVR を開き直しています… + DroidDeck を自動的に再起動できませんでした。アプリを完全に終了してから、もう一度開いてください。 DroidDeck 初回起動時はSteamをダウンロードします。数分かかる場合があります。 @@ -929,6 +936,8 @@ キーボードボタン Selectを押してSteamメニューを開く View/Selectの代わり。 + Joy-ConをVRコントローラーとして使う + SteamVRの実行中は各Joy-Conが手になります。前に向けてHOMEボタンかキャプチャーボタンを長押しすると、向きを合わせ直します。 アニメーション スティックは触れた位置に表示され、離すと非表示になります レイアウト @@ -986,6 +995,7 @@ KDE Plasma、Firefox、エミュレーター Steamでインストールされたゲームは、セッション終了後にここに表示されます。 Steamを実行 + SteamVRを実行 起動設定 起動 ゲームファイル @@ -2280,6 +2290,113 @@ GOGに接続できませんでした GOGのライブラリを読み込めませんでした Steamに登録中… + グラス + VITURE グラス + グラスの制御 + このビルドには、このグラス用のドライバーが含まれていません。 + グラスに接続できませんでした。ケーブルを接続し直すか、「許可」をタップして再試行してください。 + 許可 + DroidDeck がグラスのリフレッシュレート、3D モード、明るさ、音量、サンブロックを設定し、ヘッドトラッキングを読み取れるようにします。 + Android のダイアログで許可されるのを待っています… + 制御が拒否されました。グラスの設定を変更するには許可してください。 + このグラスにはゲームが表示されますが、ここから設定は変更できません。 + 出力 + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + グラスにゲームを表示 + ゲームをグラスだけに表示します。オフ: スマートフォンに表示し、Android がスマートフォンの画面をミラーリングします。 + 表示 + リフレッシュレート + 3D(サイドバイサイド) + 左右の目に 3840×1080 の画像の半分ずつを表示します。映像が消えたら、グラスの 2D/3D ボタンを長押ししてください。 + 3D 信号を確認できませんでした。2D 映像が戻ってから再試行してください。 + 映像なし: グラスがこのスマートフォンで出力できないモードになっています。2D/3D ボタンを長押しするか、抜き差ししてください。 + 3D のリフレッシュレート + VR ゲームで 3D + SteamVR の実行中は 3D に切り替え、終了後に戻します。 + 3D の映像 + 自動: SteamVR は視界全体に表示し、ほかのゲームは 3D ではスクリーンとして浮かび、2D では平面のままです。 + 自動 + 仮想スクリーン + サイドバイサイドのゲーム + スクリーンの幅 + スクリーンまでの距離 + 幅と距離は、ゲームを仮想スクリーンとしてグラスに表示しているときに適用されます。 + これらの設定は、「グラスにゲームを表示」がオンで、ゲームがグラスに表示されているときに適用されます。 + 「自動」では、この 2D ゲームは全画面のままです。幅、距離、ヘッドトラッキングを調整するには、仮想スクリーンを使用してください。 + 仮想スクリーンを使用 + SteamVR は視界全体に表示されます。視野角は下で調整できます。幅と距離は仮想スクリーンにのみ適用されます。 + サイドバイサイドのゲームは、左右の目の視界いっぱいに表示されます。幅と距離は仮想スクリーンにのみ適用されます。 + 「自動」では、この 2D ゲームは視界の中で固定されます。ヘッドトラッキングを使うには、「表示」で「仮想スクリーン」を選んでください。 + ヘッドトラッキングは、仮想スクリーンまたは実行中の SteamVR ゲームに適用されます。 + グラス + 明るさ + 音量 + サンブロック + 前面のレンズを暗くします。 + ヘッドトラッキング + ヘッドトラッキング + グラスのセンサーで、頭の動きに合わせて仮想スクリーンと SteamVR を動かします。 + 視点をリセット + 中央に戻す + SteamVR の視野角 + 小さくすると SteamVR が近くなりグラスいっぱいに広がり、大きくすると周囲が広く見えます。 + %1$s でプレイ中 + トラッキングのキャリブレーション + ドリフトを減らし、水平の基準を合わせます。 + 保存したキャリブレーションをこのグラスに適用しています。 + キャリブレーション + 接続したグラスを、フレームが均等に接するように水平な面に逆さまに置いてください。「開始」をタップしたら、約8秒間そのまま触れないでください。動かすと測定がやり直しになります。ドリフトと傾きは補正されますが、左右の向きは補正されません。 + グラスを逆さまにしたまま、触れずに置いておいてください。安定するのを待っています… + センサーを測定しています。グラスを動かさないでください。 + 動きを検出しました。グラスに触れないでください。測定は自動的にやり直されます。 + グラスを水平な面に逆さまに置いてください。正しい位置に置かれると測定が始まります。 + キャリブレーションを保存しました。グラスを装着して正面を見てから、「完了」をタップして中央に戻してください。 + キャリブレーションを完了できませんでした。接続を確認し、グラスを動かさずにもう一度お試しください。以前のキャリブレーションは変更されていません。 + 開始 + 完了 + トラッキングのキャリブレーションをリセット + 保存したキャリブレーションを削除して、モデルの初期設定に戻しますか?グラスを装着し直したら、中央に戻してください。 + リセット + ハンドトラッキング + カメラで手をトラッキングしますか? + カメラで手を認識できます。コントローラーが接続されていないときは、素手がそのまま VR の手になります。親指と人差し指をつまむとクリック、手を握るとつかむ、手のひらを自分に向けてつまむとメニューが開きます。Joy-Con を使うときは、手が実際にある位置に Joy-Con を配置します。映像はこのデバイス上で処理され、保存や送信は一切行われません。 + 背面カメラ + おすすめです。通常はより鮮明で広角です。 + 前面カメラ + デバイスの背面をあなたに向けられないときに使います。 + カメラを使う + 後で + カメラハンドトラッキング + コントローラーが接続されていないときは、手が VR の手になります。つまむとクリック、手のひらを自分に向けてつまむとメニューです。Joy-Con はカメラが見た位置に配置します。映像はこのデバイスの外に出ません。 + カメラ + カメラの権限 + ハンドトラッキングを開始するには、カメラへのアクセスを許可してください。 + カメラの権限がオフになっています。Android設定でオンにしてください。 + 許可 + 設定を開く + カメラの位置 + プレイ前に、カメラにあなたが映っているか確認してください。 + 確認 + カメラの位置 + デバイスを胸の高さくらいで1.5〜2m離れたところに立て、背面カメラがあなたの方を向くようにしてください。そのあと、手を広げて画面内に入ってください。 + デバイスを胸の高さくらいで1.5〜2m離れたところに立て、画面があなたの方を向くようにしてください。そのあと、手を広げて画面内に入ってください。 + カメラを起動しています… + デバイスが横に倒れています。カメラがあなたの方を向くように立ててください。 + デバイスをもっと立てて、カメラがあなたの方を向くようにしてください。 + まだ映っていません。頭と肩が見えるように画面内に入ってください。 + 少し下がってください。 + もう少し近づいてください。 + カメラに見える位置で、両手を広げて見せてください。 + 良好です。 + カメラを起動できませんでした。カメラを使用している他のアプリを閉じてから、もう一度お試しください。 + カメラを切り替え + スキップ + 完了 + グラスの USB Ethernet 接続を待っています。このスマートフォンでは、アプリがこの接続を利用できない場合があります。 + XREAL との接続が中断されたか、サポートされていない応答が返されました。再試行しています… + 1S のサポートは試験運用中です。グラスのメニューで Follow モードを使い、安定化をオフにしてください。トラッキングの方向と表示モードは、まだ実機でのテストが必要です。 開発元 パブリッシャー 発売日 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 37626d8d6..5e7d374a8 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1,5 +1,12 @@ + SteamVR 그래픽 중지됨 + 그래픽을 초기화하고 SteamVR을 다시 열기 위해 %1$d초 후 DroidDeck이 다시 시작됩니다. 실행 중인 게임은 종료되며, 저장하지 않은 진행 상황은 복구할 수 없습니다. + 최근 다시 시작한 후 그래픽 오류가 다시 발생했습니다. 반복을 막기 위해 자동 복구를 일시 중지했습니다. 다시 시작하면 게임이 종료되며, 저장하지 않은 진행 상황은 복구할 수 없습니다. + 지금 다시 시작 + 열어 두기 + 그래픽을 초기화하고 SteamVR을 다시 여는 중… + DroidDeck을 자동으로 다시 시작하지 못했습니다. 앱을 완전히 종료한 후 다시 여세요. DroidDeck 첫 실행 시 Steam을 다운로드합니다. 몇 분 정도 걸릴 수 있습니다. @@ -929,6 +936,8 @@ 키보드 버튼 Select를 눌러 Steam 메뉴 열기 View/Select 대신. + Joy-Con을 VR 컨트롤러로 사용 + SteamVR이 실행되는 동안 각 Joy-Con이 손이 됩니다. 앞을 향하게 한 채 HOME 또는 캡처 버튼을 길게 누르면 다시 가운데로 맞춥니다. 애니메이션 스틱이 터치한 위치에 나타나며 손을 떼면 숨겨집니다 레이아웃 @@ -986,6 +995,7 @@ KDE Plasma, Firefox 및 에뮬레이터 Steam에서 설치한 게임은 세션 종료 후 여기에 표시됩니다. Steam 실행 + SteamVR 실행 실행 설정 실행 게임 파일 @@ -2280,6 +2290,113 @@ GOG에 연결할 수 없습니다 GOG 라이브러리를 읽을 수 없습니다 Steam에 등록 중… + 안경 + VITURE 안경 + 안경 제어 + 이 빌드에는 이 안경용 드라이버가 포함되어 있지 않습니다. + 안경에 연결하지 못했습니다. 케이블을 다시 연결하거나 “허용”을 탭하여 다시 시도하세요. + 허용 + DroidDeck이 안경의 주사율, 3D 모드, 밝기, 볼륨, 선블록을 설정하고 헤드 트래킹을 읽도록 허용합니다. + Android 안내 창에서 허용하기를 기다리는 중… + 제어가 거부되었습니다. 안경 설정을 바꾸려면 허용하세요. + 이 안경은 게임을 표시하지만 여기에서 설정을 바꿀 수는 없습니다. + 출력 + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + 안경에 게임 표시 + 게임을 안경에만 표시합니다. 끄면 휴대폰에 표시되고 Android가 휴대폰 화면을 미러링합니다. + 디스플레이 + 주사율 + 3D (사이드 바이 사이드) + 각 눈이 3840×1080 화면의 절반씩을 봅니다. 화면이 끊기면 안경의 2D/3D 버튼을 길게 누르세요. + 3D 신호를 확인하지 못했습니다. 2D 화면이 돌아오면 다시 시도하세요. + 화면 없음: 안경이 이 휴대폰에서 보낼 수 없는 모드에 있습니다. 2D/3D 버튼을 길게 누르거나 안경을 뺐다가 다시 연결하세요. + 3D 주사율 + VR 게임에서 3D + SteamVR이 실행되는 동안 3D로 전환하고 끝나면 되돌립니다. + 3D 화면 + 자동: SteamVR은 시야 전체를 채우고, 다른 게임은 3D에서는 화면으로 떠 있고 2D에서는 평면으로 보입니다. + 자동 + 가상 화면 + 사이드 바이 사이드 게임 + 화면 너비 + 화면 거리 + 너비와 거리는 게임이 안경에서 가상 화면으로 실행될 때 적용됩니다. + 이 설정은 “안경에 게임 표시”가 켜져 있고 게임이 안경에 표시될 때 적용됩니다. + “자동”에서는 이 2D 게임이 전체 화면으로 유지됩니다. 너비, 거리, 헤드 트래킹을 조정하려면 가상 화면을 사용하세요. + 가상 화면 사용 + SteamVR이 시야를 가득 채웁니다. 아래에서 시야각을 조정하세요. 너비와 거리는 가상 화면에만 적용됩니다. + 사이드 바이 사이드 게임은 양쪽 눈의 시야를 각각 가득 채웁니다. 너비와 거리는 가상 화면에만 적용됩니다. + “자동”에서는 이 2D 게임이 시야에 고정됩니다. 헤드 트래킹을 사용하려면 “디스플레이”에서 “가상 화면”을 선택하세요. + 헤드 트래킹은 가상 화면이나 실행 중인 SteamVR 게임에 적용됩니다. + 안경 + 밝기 + 볼륨 + 선블록 + 앞쪽 렌즈를 어둡게 합니다. + 헤드 트래킹 + 헤드 트래킹 + 안경의 센서가 머리 움직임에 맞춰 가상 화면과 SteamVR을 돌립니다. + 시점 재설정 + 중앙 맞추기 + SteamVR 시야각 + 낮추면 SteamVR이 가까워져 안경을 가득 채우고, 높이면 주변이 더 넓게 보입니다. + %1$s에서 플레이 중 + 트래킹 보정 + 드리프트를 줄이고 수평 기준을 맞춥니다. + 저장된 보정이 이 안경에 적용되어 있습니다. + 보정 + 연결된 안경을 수평한 곳에 뒤집어 놓고 프레임이 고르게 닿도록 하세요. “시작”을 탭한 다음 약 8초 동안 건드리지 마세요. 움직이면 측정이 다시 시작됩니다. 드리프트와 기울기는 보정되지만 좌우 방향은 보정되지 않습니다. + 안경을 뒤집어 둔 채 건드리지 마세요. 안정될 때까지 기다리는 중… + 센서를 측정하는 중입니다. 안경을 움직이지 마세요. + 움직임이 감지되었습니다. 안경을 건드리지 마세요. 측정이 자동으로 다시 시작됩니다. + 안경을 수평한 곳에 뒤집어 놓으세요. 올바른 위치에 놓이면 측정이 시작됩니다. + 보정이 저장되었습니다. 안경을 쓰고 정면을 바라본 다음 “완료”를 탭하여 중앙을 맞추세요. + 보정을 완료하지 못했습니다. 연결을 확인하고 안경을 움직이지 않게 둔 다음 다시 시도하세요. 이전 보정은 그대로 유지됩니다. + 시작 + 완료 + 트래킹 보정 초기화 + 저장된 보정을 삭제하고 모델 기본값으로 되돌릴까요? 안경을 다시 쓴 후 중앙을 맞추세요. + 초기화 + 핸드 트래킹 + 카메라로 손을 트래킹할까요? + 카메라가 손을 볼 수 있습니다. 컨트롤러가 연결되어 있지 않으면 맨손이 그대로 VR 손이 됩니다. 엄지와 검지를 맞대면 클릭, 손을 쥐면 잡기, 손바닥을 자신 쪽으로 향하고 맞대면 메뉴가 열립니다. Joy-Con을 쓸 때는 손이 실제로 있는 위치에 배치합니다. 영상은 이 기기에서 처리되며 저장되거나 전송되지 않습니다. + 후면 카메라 + 권장합니다. 보통 더 선명하고 화각이 넓습니다. + 전면 카메라 + 기기의 뒷면을 당신 쪽으로 향하게 할 수 없을 때 사용합니다. + 카메라 사용 + 나중에 + 카메라 핸드 트래킹 + 컨트롤러가 연결되어 있지 않으면 손이 VR 손이 됩니다. 손가락을 맞대면 클릭, 손바닥을 자신 쪽으로 향하고 맞대면 메뉴입니다. Joy-Con은 카메라가 보는 위치에 배치합니다. 영상은 이 기기 밖으로 나가지 않습니다. + 카메라 + 카메라 권한 + 핸드 트래킹을 시작하려면 카메라 권한을 허용하세요. + 카메라 권한이 꺼져 있습니다. Android 설정에서 켜세요. + 허용 + 설정 열기 + 카메라 위치 + 플레이하기 전에 카메라에 당신이 보이는지 확인하세요. + 확인 + 카메라 위치 + 기기를 가슴 높이쯤에서 1.5~2m 떨어진 곳에 세우고, 후면 카메라가 당신을 향하게 하세요. 그런 다음 손을 벌리고 화면 안으로 들어오세요. + 기기를 가슴 높이쯤에서 1.5~2m 떨어진 곳에 세우고, 화면이 당신을 향하게 하세요. 그런 다음 손을 벌리고 화면 안으로 들어오세요. + 카메라를 시작하는 중… + 기기가 눕혀져 있습니다. 카메라가 당신을 향하도록 세워 주세요. + 카메라가 당신을 향하도록 기기를 더 세워 주세요. + 아직 보이지 않습니다. 머리와 어깨가 보이도록 화면 안으로 들어와 주세요. + 조금 뒤로 물러나 주세요. + 조금 더 가까이 와 주세요. + 카메라가 볼 수 있는 곳에서 양손을 뻗어 보여주세요. + 좋습니다. + 카메라를 시작하지 못했습니다. 카메라를 사용 중인 다른 앱을 닫고 다시 시도하세요. + 카메라 전환 + 건너뛰기 + 완료 + 안경의 USB Ethernet 연결을 기다리는 중입니다. 이 휴대폰은 앱에 이 연결을 제공하지 않을 수 있습니다. + XREAL 연결이 중단되었거나 지원되지 않는 응답을 받았습니다. 다시 시도하는 중… + 1S 지원은 실험 단계입니다. 안경 메뉴에서 Follow 모드를 사용하고 안정화를 끄세요. 트래킹 방향과 디스플레이 모드는 아직 기기에서 테스트가 필요합니다. 개발사 배급사 출시일 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index af735bcfb..f4a7dfa86 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1,5 +1,12 @@ + Grafik SteamVR terhenti + DroidDeck akan dimulakan semula dalam %1$d saat untuk menetapkan semula grafik dan membuka SteamVR sekali lagi. Permainan yang sedang berjalan akan ditutup; kemajuan yang belum disimpan tidak dapat dipulihkan. + Ralat grafik berlaku lagi selepas dimulakan semula baru-baru ini. Pemulihan automatik dijeda untuk mengelakkan gelung. Memulakan semula akan menutup permainan anda; kemajuan yang belum disimpan tidak dapat dipulihkan. + Mulakan semula sekarang + Biarkan terbuka + Menetapkan semula grafik dan membuka SteamVR sekali lagi… + DroidDeck tidak dapat dimulakan semula secara automatik. Tutup apl sepenuhnya, kemudian buka semula. DroidDeck Larian pertama memuat turun Steam. Ini mungkin mengambil beberapa minit. @@ -929,6 +936,8 @@ Butang papan kekunci Tekan Select untuk membuka menu Steam Sebagai ganti View/Select. + Joy-Con sebagai pengawal VR + Semasa SteamVR berjalan, setiap Joy-Con menjadi tangan. Halakan ke hadapan dan tahan Home atau Capture untuk memusatkannya semula. Animasi Stik muncul di tempat anda sentuh dan tersembunyi apabila dilepaskan Susun atur @@ -986,6 +995,7 @@ KDE Plasma, Firefox dan emulator anda Permainan yang anda pasang dalam Steam muncul di sini selepas sesi. Main Steam + Main SteamVR Tetapan pelancaran Lancarkan Fail permainan @@ -2280,6 +2290,113 @@ Tidak dapat menyambung ke GOG Pustaka GOG anda tidak dapat dibaca Mendaftar dengan Steam… + Cermin mata + Cermin mata VITURE + Kawalan cermin mata + Binaan ini tidak menyertakan pemacu untuk cermin mata ini. + Tidak dapat menyambung ke cermin mata. Sambungkan semula kabel atau ketik Benarkan untuk mencuba lagi. + Benarkan + Benarkan DroidDeck menetapkan kadar penyegaran, mod 3D, kecerahan, kelantangan dan peredup kanta cermin mata, serta membaca penjejakan kepalanya. + Menunggu anda membenarkannya dalam dialog Android… + Kawalan ditolak. Benarkannya untuk menukar tetapan cermin mata. + Cermin mata ini memaparkan permainan, tetapi tetapannya tidak boleh ditukar dari sini. + Output + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Permainan pada cermin mata + Paparkan permainan pada cermin mata sahaja. Mati: telefon memaparkannya dan Android mencerminkan skrin telefon. + Paparan + Kadar penyegaran + 3D (sebelah-menyebelah) + Setiap mata mendapat separuh gambar 3840×1080 sendiri. Jika gambar hilang, tahan butang 2D/3D cermin mata. + Isyarat 3D tidak dapat disahkan. Cuba lagi selepas gambar 2D kembali. + Tiada gambar: cermin mata dalam mod yang tidak dapat dihantar oleh telefon ini. Tahan butang 2D/3D-nya, atau cabut dan pasang semula. + Kadar penyegaran 3D + 3D untuk permainan VR + Bertukar kepada 3D semasa SteamVR berjalan, dan kembali selepas itu. + Gambar 3D + Automatik: SteamVR memenuhi pandangan; permainan lain terapung sebagai skrin dalam 3D dan kekal rata dalam 2D. + Automatik + Skrin maya + Permainan 3D sebelah-menyebelah + Lebar skrin + Jarak skrin + Lebar dan jarak digunakan apabila permainan berjalan pada cermin mata sebagai skrin maya. + Tetapan ini digunakan semasa Permainan pada cermin mata didayakan dan permainan dipaparkan di sana. + Automatik mengekalkan permainan 2D ini dalam skrin penuh. Gunakan skrin maya untuk melaraskan lebar, jarak dan penjejakan kepalanya. + Gunakan skrin maya + SteamVR memenuhi pandangan. Laraskan medan pandangannya di bawah; lebar dan jarak hanya digunakan untuk skrin maya. + Permainan sebelah-menyebelah memenuhi setiap mata. Lebar dan jarak hanya digunakan untuk skrin maya. + Automatik mengekalkan permainan 2D ini tetap dalam pandangan. Pilih Skrin maya dalam Paparan untuk menggunakan penjejakan kepala. + Penjejakan kepala digunakan untuk skrin maya atau permainan SteamVR yang sedang berjalan. + Cermin mata + Kecerahan + Kelantangan + Peredup kanta + Menggelapkan kanta hadapan. + Penjejakan kepala + Penjejakan kepala + Penderia cermin mata itu sendiri memutarkan skrin maya dan SteamVR mengikut kepala anda. + Pusatkan semula paparan + Pusatkan semula + Medan pandangan SteamVR + Lebih rendah mendekatkan SteamVR dan memenuhi cermin mata; lebih tinggi menunjukkan lebih banyak di sekeliling anda. + Bermain pada %1$s + Penentukuran penjejakan + Mengurangkan hanyutan dan menjajarkan rujukan mendatar. + Penentukuran yang disimpan digunakan pada cermin mata ini. + Tentukur + Letakkan cermin mata yang disambungkan secara terbalik di atas permukaan rata, dengan bingkainya terletak sekata. Ketik Mula, kemudian jangan sentuh selama kira-kira 8 saat. Pergerakan akan memulakan semula pengukuran. Ini melaraskan hanyutan dan kecondongan, bukan arah haluan. + Biarkan cermin mata terbalik dan jangan sentuh. Menunggu ia stabil… + Mengukur penderia. Pastikan cermin mata tidak bergerak. + Pergerakan dikesan. Jangan sentuh cermin mata; pengukuran akan bermula semula secara automatik. + Letakkan cermin mata secara terbalik di atas permukaan rata. Pengukuran akan bermula apabila ia diletakkan dengan betul. + Penentukuran disimpan. Pakai cermin mata dan pandang lurus ke hadapan, kemudian ketik Selesai untuk memusatkan semula. + Penentukuran tidak dapat diselesaikan. Semak sambungan dan pastikan cermin mata tidak bergerak, kemudian cuba lagi. Penentukuran anda sebelum ini tidak berubah. + Mula + Selesai + Tetapkan semula penentukuran penjejakan + Alih keluar penentukuran yang disimpan dan pulihkan tetapan lalai model? Pusatkan semula selepas memakai cermin mata semula. + Tetapkan semula + Penjejakan tangan + Jejak tangan anda dengan kamera? + Kamera boleh melihat tangan anda. Tanpa pengawal yang disambungkan, tangan kosong anda menjadi tangan VR: rapatkan ibu jari dan jari telunjuk untuk mengklik, genggam tangan untuk memegang, dan hadapkan tapak tangan ke arah anda lalu rapatkan jari untuk membuka menu. Dengan Joy-Con, kamera meletakkannya di tempat tangan anda sebenarnya berada. Gambar diproses pada peranti ini dan tidak pernah disimpan atau dihantar. + Kamera belakang + Disyorkan. Biasanya lebih jelas dan lebih luas. + Kamera depan + Untuk apabila bahagian belakang peranti tidak boleh menghadap anda. + Gunakan kamera + Bukan sekarang + Penjejakan tangan dengan kamera + Tanpa pengawal yang disambungkan, tangan anda menjadi tangan VR: rapatkan jari untuk mengklik, hadapkan tapak tangan ke arah anda dan rapatkan jari untuk menu. Dengan Joy-Con, ia meletakkannya di tempat yang dilihat oleh kamera. Gambar kekal pada peranti ini. + Kamera + Akses kamera + Benarkan akses kamera supaya penjejakan tangan boleh bermula. + Akses kamera dimatikan. Hidupkannya dalam tetapan Android. + Benarkan + Buka tetapan + Kedudukan kamera + Pastikan kamera boleh melihat anda sebelum bermain. + Semak + Kedudukan kamera + Dirikan peranti sejauh 1.5 hingga 2 meter kira-kira pada paras dada, dengan kamera belakang menghadap anda. Kemudian masuk ke pandangan dengan tangan terhulur. + Dirikan peranti sejauh 1.5 hingga 2 meter kira-kira pada paras dada, dengan skrin menghadap anda. Kemudian masuk ke pandangan dengan tangan terhulur. + Memulakan kamera… + Peranti terbaring rata. Dirikannya supaya kamera menghadap anda. + Dirikan peranti dengan lebih tegak supaya kamera menghadap anda. + Belum dapat melihat anda. Masuk ke pandangan supaya kepala dan bahu anda kelihatan. + Undur sedikit. + Dekatkan sedikit lagi. + Hulurkan kedua-dua tangan di tempat yang boleh dilihat oleh kamera. + Kelihatan baik. + Kamera tidak dapat dimulakan. Tutup aplikasi lain yang menggunakannya dan cuba lagi. + Tukar kamera + Langkau + Selesai + Menunggu sambungan USB Ethernet cermin mata. Telefon ini mungkin tidak mendedahkannya kepada apl. + Sambungan XREAL terganggu atau memberikan respons yang tidak disokong. Mencuba semula… + Sokongan eksperimen untuk 1S. Gunakan mod Follow dan matikan penstabilan dalam menu cermin mata. Arah penjejakan dan mod paparan masih perlu diuji pada peranti. Cardboard VR Mod Cardboard Paparan mata kiri dan kanan untuk kaca mata VR telefon. diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index e485909da..03c5cc3ed 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1,5 +1,12 @@ + SteamVR-grafikken stoppet + DroidDeck starter på nytt om %1$d sekunder for å tilbakestille grafikken og åpne SteamVR igjen. Spillet som kjører, lukkes, og ulagret fremdrift kan ikke gjenopprettes. + Grafikkfeilen kom tilbake etter en nylig omstart. Automatisk gjenoppretting er satt på pause for å unngå en løkke. Omstart lukker spillet, og ulagret fremdrift kan ikke gjenopprettes. + Start på nytt nå + La stå åpen + Tilbakestiller grafikken og åpner SteamVR igjen… + DroidDeck kunne ikke starte på nytt automatisk. Lukk appen helt, og åpne den igjen. DroidDeck Første gang lastes Steam ned. Dette kan ta noen minutter. @@ -935,6 +942,8 @@ Tastatur-knapp Trykk på Select for å åpne Steam-menyen I stedet for View/Select. + Joy-Con som VR-kontrollere + Mens SteamVR kjører, blir hver Joy-Con en hånd. Pek den fremover og hold inne Home eller Capture for å sentrere den på nytt. Animasjoner Stikker vises der du trykker og skjules når du slipper Oppsett @@ -992,6 +1001,7 @@ KDE Plasma, Firefox og emulatorene dine Spill du installerer i Steam, vises her etter sesjonen. Spill Steam + Spill SteamVR Startinnstillinger Start Spillfiler @@ -2305,6 +2315,113 @@ Kunne ikke nå GOG Kunne ikke lese GOG-biblioteket ditt Registrerer hos Steam… + Briller + VITURE-briller + Brillestyring + Denne versjonen inneholder ikke driveren for disse brillene. + Kunne ikke koble til brillene. Koble til kabelen på nytt, eller trykk på Tillat for å prøve igjen. + Tillat + La DroidDeck stille inn brillenes oppdateringsfrekvens, 3D-modus, lysstyrke, volum og solskjerming og lese av hodesporingen deres. + Venter på at du tillater det i Android-dialogen … + Styring ble avslått. Tillat den for å endre brillenes innstillinger. + Disse brillene viser spillet, men innstillingene deres kan ikke endres herfra. + Utgang + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Spill i brillene + Vis spillet bare i brillene. Av: telefonen viser det, og Android speiler telefonen. + Skjerm + Oppdateringsfrekvens + 3D (side ved side) + Hvert øye får sin egen halvdel av et bilde på 3840×1080. Hvis bildet faller ut, holder du inne 2D/3D-knappen på brillene. + 3D-signalet ble ikke bekreftet. Prøv igjen når 2D-bildet er tilbake. + Ikke noe bilde: brillene er i en modus denne telefonen ikke kan sende. Hold inne 2D/3D-knappen deres, eller koble dem fra og til igjen. + 3D-oppdateringsfrekvens + 3D for VR-spill + Bytter til 3D mens SteamVR kjører, og tilbake etterpå. + 3D-bilde + Automatisk: SteamVR fyller synsfeltet; andre spill svever som en skjerm i 3D og forblir flate i 2D. + Automatisk + Virtuell skjerm + Side-ved-side-spill + Skjermbredde + Skjermavstand + Bredde og avstand gjelder når et spill kjører i brillene som en virtuell skjerm. + Disse innstillingene gjelder når Spill i brillene er på og spillet vises der. + Automatisk holder dette 2D-spillet i fullskjerm. Bruk en virtuell skjerm for å justere bredde, avstand og hodesporing. + Bruk virtuell skjerm + SteamVR fyller hele bildet. Juster synsfeltet nedenfor; bredde og avstand gjelder bare for en virtuell skjerm. + Side-ved-side-spill fyller hvert øye. Bredde og avstand gjelder bare for en virtuell skjerm. + Automatisk holder dette 2D-spillet fast i synsfeltet. Velg Virtuell skjerm under Skjerm for å bruke hodesporing. + Hodesporing gjelder for en virtuell skjerm eller et SteamVR-spill som kjører. + Briller + Lysstyrke + Volum + Solskjerming + Gjør de fremre glassene mørkere. + Hodesporing + Hodesporing + Brillenes egne sensorer dreier den virtuelle skjermen og SteamVR med hodet ditt. + Sentrer visningen + Sentrer + SteamVR-synsfelt + Lavere bringer SteamVR nærmere og fyller brillene; høyere viser mer rundt deg. + Spiller på %1$s + Sporingskalibrering + Reduserer avdrift og retter inn horisontreferansen. + Lagret kalibrering brukes for disse brillene. + Kalibrer + Legg de tilkoblede brillene opp ned på en plan flate, slik at rammen hviler jevnt. Trykk på Start, og la dem ligge urørt i omtrent 8 sekunder. Bevegelse starter målingen på nytt. Dette justerer avdrift og helning, ikke retning. + La brillene ligge opp ned og urørt. Venter på at de skal falle til ro… + Måler sensorene. Hold brillene i ro. + Bevegelse oppdaget. La brillene ligge urørt; målingen starter på nytt automatisk. + Legg brillene opp ned på en plan flate. Målingen starter når de ligger riktig. + Kalibreringen er lagret. Ta på brillene og se rett frem, og trykk deretter på Ferdig for å sentrere. + Kalibreringen kunne ikke fullføres. Sjekk tilkoblingen, hold brillene i ro og prøv igjen. Den forrige kalibreringen er uendret. + Start + Ferdig + Nullstill sporingskalibrering + Vil du fjerne den lagrede kalibreringen og gå tilbake til modellens standardverdier? Sentrer når du har tatt på deg brillene igjen. + Nullstill + Håndsporing + Spor hendene dine med kameraet? + Kameraet kan se hendene dine. Uten tilkoblet kontroller blir de bare hendene dine VR-hendene: klyp sammen tommel og pekefinger for å klikke, lukk hånden for å gripe, og vend håndflaten mot deg og klyp for å åpne menyen. Med Joy-Con plasserer det dem der hendene dine faktisk er. Bilder behandles på denne enheten og lagres eller sendes aldri. + Bakre kamera + Anbefales. Vanligvis skarpere og bredere. + Frontkamera + For når baksiden av enheten ikke kan vende mot deg. + Bruk kamera + Ikke nå + Håndsporing med kamera + Uten tilkoblet kontroller blir hendene dine VR-hendene: klyp for å klikke, håndflaten mot deg og klyp for menyen. Med Joy-Con plasseres de der kameraet ser dem. Bilder blir på denne enheten. + Kamera + Kameratilgang + Tillat kameratilgang slik at håndsporing kan starte. + Kameratilgang er av. Slå den på i Android-innstillingene. + Tillat + Åpne innstillinger + Kameraplassering + Sjekk at kameraet kan se deg før du spiller. + Sjekk + Kameraplassering + Still enheten opp 1,5 til 2 meter unna i omtrent brysthøyde, med det bakre kameraet vendt mot deg. Tre så inn i bildet med hendene ute. + Still enheten opp 1,5 til 2 meter unna i omtrent brysthøyde, med skjermen vendt mot deg. Tre så inn i bildet med hendene ute. + Starter kameraet… + Enheten ligger flatt. Still den opp slik at kameraet vender mot deg. + Still enheten mer oppreist slik at kameraet vender mot deg. + Ser deg ikke ennå. Tre inn i bildet slik at hode og skuldre vises. + Ta et steg tilbake. + Kom litt nærmere. + Hold begge hendene ute der kameraet kan se dem. + Ser bra ut. + Kameraet kunne ikke starte. Lukk andre apper som bruker det, og prøv igjen. + Bytt kamera + Hopp over + Ferdig + Venter på USB Ethernet-tilkoblingen til brillene. Denne telefonen gjør den kanskje ikke tilgjengelig for apper. + XREAL-tilkoblingen ble avbrutt eller ga et svar som ikke støttes. Prøver igjen… + Eksperimentell støtte for 1S. Bruk Follow-modus og slå av stabilisering i brillemenyen. Sporingsretninger og visningsmoduser må fortsatt testes på enheten. Cardboard VR Cardboard-modus Bilder for venstre og høyre øye til en VR-brille for mobil. diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ead54def2..2b4fcb7d7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,5 +1,12 @@ + Grafische weergave van SteamVR gestopt + DroidDeck start over %1$d seconden opnieuw om de grafische weergave te resetten en SteamVR weer te openen. Je game wordt gesloten; niet-opgeslagen voortgang kan niet worden hersteld. + De grafische fout kwam terug na een recente herstart. Automatisch herstel is gepauzeerd om een lus te voorkomen. Opnieuw starten sluit je game; niet-opgeslagen voortgang kan niet worden hersteld. + Nu opnieuw starten + Open laten + Grafische weergave resetten en SteamVR opnieuw openen… + DroidDeck kon niet automatisch opnieuw starten. Sluit de app volledig af en open hem daarna opnieuw. DroidDeck Bij de eerste start wordt Steam gedownload. Dit kan een paar minuten duren. @@ -935,6 +942,8 @@ Toetsenbordknop Druk op Select om het Steam-menu te openen In plaats van View/Select. + Joy-Cons als VR-controllers + Zolang SteamVR draait, wordt elke Joy-Con een hand. Richt hem naar voren en houd Home of Capture ingedrukt om hem opnieuw te centreren. Animaties Sticks verschijnen waar je aanraakt en verdwijnen als je loslaat Indeling @@ -992,6 +1001,7 @@ KDE Plasma, Firefox en je emulators Games die je in Steam installeert, verschijnen hier na de sessie. Steam spelen + SteamVR spelen Opstartinstellingen Starten Gamebestanden @@ -2305,6 +2315,113 @@ Kan GOG niet bereiken Je GOG-bibliotheek kon niet worden gelezen Registreren bij Steam… + Bril + VITURE-bril + Brilbediening + Deze build bevat geen driver voor deze bril. + Kan geen verbinding maken met de bril. Sluit de kabel opnieuw aan of tik op Toestaan om het opnieuw te proberen. + Toestaan + Laat DroidDeck de verversingssnelheid, 3D-modus, helderheid, het volume en de zonwering van de bril instellen en de hoofdtracking uitlezen. + Wachten tot je het toestaat in het Android-venster… + Bediening is geweigerd. Sta het toe om de instellingen van de bril te wijzigen. + Deze bril toont de game, maar de instellingen ervan kunnen hier niet worden gewijzigd. + Uitvoer + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Game op bril + Toon de game alleen op de bril. Uit: de telefoon toont de game en Android spiegelt de telefoon. + Scherm + Verversingssnelheid + 3D (side-by-side) + Elk oog krijgt een eigen helft van een beeld van 3840×1080. Valt het beeld weg, houd dan de 2D/3D-knop van de bril ingedrukt. + Het 3D-signaal is niet bevestigd. Probeer het opnieuw zodra het 2D-beeld terug is. + Geen beeld: de bril staat in een modus die deze telefoon niet kan versturen. Houd de 2D/3D-knop ingedrukt, of koppel de bril los en sluit hem opnieuw aan. + 3D-verversingssnelheid + 3D voor VR-games + Schakelt naar 3D zolang SteamVR draait, en daarna weer terug. + 3D-beeld + Automatisch: SteamVR vult het beeld; andere games zweven in 3D als scherm en blijven plat in 2D. + Automatisch + Virtueel scherm + Side-by-side game + Schermbreedte + Schermafstand + Breedte en afstand gelden wanneer een game als virtueel scherm op de bril draait. + Deze instellingen gelden zolang Game op bril aanstaat en de game daar wordt getoond. + Automatisch houdt deze 2D-game schermvullend. Gebruik een virtueel scherm om breedte, afstand en hoofdtracking aan te passen. + Virtueel scherm gebruiken + SteamVR vult het beeld. Pas hieronder het gezichtsveld aan; breedte en afstand gelden alleen voor een virtueel scherm. + Side-by-side games vullen elk oog. Breedte en afstand gelden alleen voor een virtueel scherm. + Automatisch houdt deze 2D-game vast in beeld. Kies Virtueel scherm bij Scherm om hoofdtracking te gebruiken. + Hoofdtracking geldt voor een virtueel scherm of een actieve SteamVR-game. + Bril + Helderheid + Volume + Zonwering + Maakt de voorste glazen donkerder. + Hoofdtracking + Hoofdtracking + De eigen sensoren van de bril laten het virtuele scherm en SteamVR met je hoofd meedraaien. + Weergave centreren + Centreren + SteamVR-gezichtsveld + Lager brengt SteamVR dichterbij en vult de bril; hoger toont meer om je heen. + Je speelt op %1$s + Trackingkalibratie + Vermindert drift en lijnt de horizontale referentie uit. + De opgeslagen kalibratie wordt op deze bril toegepast. + Kalibreren + Leg de aangesloten bril ondersteboven op een vlak oppervlak, zodat het montuur gelijkmatig rust. Tik op Starten en laat de bril ongeveer 8 seconden onaangeroerd liggen. Bij beweging begint de meting opnieuw. Dit corrigeert drift en kanteling, niet de kijkrichting. + Laat de bril ondersteboven en onaangeroerd liggen. Wachten tot hij stilligt… + De sensoren worden gemeten. Houd de bril stil. + Beweging gedetecteerd. Raak de bril niet aan; de meting begint automatisch opnieuw. + Leg de bril ondersteboven op een vlak oppervlak. De meting begint zodra hij goed ligt. + Kalibratie opgeslagen. Zet de bril op, kijk recht vooruit en tik dan op Klaar om te centreren. + De kalibratie kon niet worden voltooid. Controleer de verbinding, houd de bril stil en probeer het opnieuw. Je vorige kalibratie is ongewijzigd. + Starten + Klaar + Trackingkalibratie resetten + De opgeslagen kalibratie verwijderen en de standaardwaarden van het model herstellen? Centreer opnieuw nadat je de bril weer hebt opgezet. + Resetten + Handtracking + Je handen volgen met de camera? + De camera kan je handen zien. Zonder verbonden controller worden je blote handen de VR-handen: knijp duim en wijsvinger samen om te klikken, sluit je hand om te pakken, en draai je handpalm naar je toe en knijp om het menu te openen. Met Joy-Cons zet de camera ze waar je handen echt zijn. Beelden worden op dit apparaat verwerkt en nooit opgeslagen of verzonden. + Achtercamera + Aanbevolen. Meestal scherper en breder. + Voorcamera + Voor als de achterkant van het apparaat niet naar je toe kan wijzen. + Camera gebruiken + Niet nu + Handtracking via camera + Zonder verbonden controller worden je handen de VR-handen: knijp om te klikken, handpalm naar je toe en knijpen voor het menu. Met Joy-Cons plaatst de camera ze waar hij ze ziet. Beelden blijven op dit apparaat. + Camera + Cameratoegang + Sta cameratoegang toe zodat handtracking kan starten. + Cameratoegang staat uit. Zet dit aan in de Android-instellingen. + Toestaan + Instellingen openen + Cameraplaatsing + Controleer of de camera je kan zien voordat je gaat spelen. + Controleren + Cameraplaatsing + Zet het apparaat rechtop op 1,5 tot 2 meter afstand, op borsthoogte, met de achtercamera naar je toe. Stap dan in beeld met je handen vooruit. + Zet het apparaat rechtop op 1,5 tot 2 meter afstand, op borsthoogte, met het scherm naar je toe. Stap dan in beeld met je handen vooruit. + Camera wordt gestart… + Het apparaat ligt plat. Zet het rechtop zodat de camera naar je toe wijst. + Zet het apparaat rechter overeind zodat de camera naar je toe wijst. + Kan je nog niet zien. Stap in beeld zodat je hoofd en schouders zichtbaar zijn. + Doe een stapje naar achteren. + Kom wat dichterbij. + Houd beide handen zo dat de camera ze kan zien. + Ziet er goed uit. + De camera kon niet starten. Sluit andere apps die hem gebruiken en probeer het opnieuw. + Camera wisselen + Overslaan + Klaar + Wachten op de USB Ethernet-verbinding van de bril. Deze telefoon maakt die mogelijk niet beschikbaar voor apps. + De XREAL-verbinding is onderbroken of gaf een niet-ondersteund antwoord. Opnieuw proberen… + Experimentele ondersteuning voor 1S. Gebruik de Follow-modus en schakel stabilisatie uit in het menu van de bril. Trackingrichtingen en weergavemodi moeten nog op een apparaat worden getest. Cardboard VR Cardboard-modus Beelden voor linker- en rechteroog voor een VR-bril voor je telefoon. diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index ca223bc97..cf79b13c7 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1,5 +1,12 @@ + Grafika SteamVR przestała działać + DroidDeck uruchomi się ponownie za %1$d s, aby zresetować grafikę i ponownie otworzyć SteamVR. Uruchomiona gra zostanie zamknięta; niezapisanego postępu nie da się przywrócić. + Błąd grafiki powrócił wkrótce po ponownym uruchomieniu. Automatyczne odzyskiwanie wstrzymano, aby uniknąć pętli. Ponowne uruchomienie zamknie grę; niezapisanego postępu nie da się przywrócić. + Uruchom ponownie + Nie zamykaj + Resetowanie grafiki i ponowne otwieranie SteamVR… + Nie udało się automatycznie uruchomić ponownie DroidDeck. Całkowicie zamknij aplikację, a potem otwórz ją ponownie. DroidDeck Przy pierwszym uruchomieniu pobierany jest Steam. Może to potrwać kilka minut. @@ -947,6 +954,8 @@ Przycisk klawiatury Naciśnij Select, aby otworzyć menu Steam Zamiast View/Select. + Joy-Cony jako kontrolery VR + Gdy działa SteamVR, każdy Joy-Con staje się dłonią. Skieruj go przed siebie i przytrzymaj Home lub Capture, aby go wyśrodkować. Animacje Gałki pojawiają się tam, gdzie dotykasz, i chowają się po puszczeniu Układ @@ -1004,6 +1013,7 @@ KDE Plasma, Firefox i Twoje emulatory Gry, które instalujesz w Steam, pojawią się tutaj po sesji. Zagraj w Steam + Zagraj w SteamVR Ustawienia uruchamiania Uruchom Pliki gry @@ -2355,6 +2365,113 @@ Nie udało się połączyć z GOG Nie udało się wczytać Twojej biblioteki GOG Rejestrowanie w Steam… + Okulary + Okulary VITURE + Sterowanie okularami + Ta kompilacja nie zawiera sterownika dla tych okularów. + Nie udało się połączyć z okularami. Podłącz ponownie kabel lub dotknij „Zezwól”, aby spróbować ponownie. + Zezwól + Pozwól DroidDeck ustawiać częstotliwość odświeżania, tryb 3D, jasność, głośność i przyciemnianie okularów oraz odczytywać dane śledzenia głowy. + Oczekiwanie na zgodę w oknie systemu Android… + Nie przyznano zgody na sterowanie. Zezwól, aby zmieniać ustawienia okularów. + Te okulary wyświetlają grę, ale ich ustawień nie można tu zmienić. + Wyjście + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Gra w okularach + Pokazuje grę tylko w okularach. Wyłączone: grę pokazuje telefon, a Android powiela jego ekran. + Wyświetlacz + Częstotliwość odświeżania + 3D (obok siebie) + Każde oko dostaje własną połowę obrazu 3840×1080. Jeśli obraz zniknie, przytrzymaj przycisk 2D/3D na okularach. + Nie udało się potwierdzić sygnału 3D. Spróbuj ponownie, gdy wróci obraz 2D. + Brak obrazu: okulary są w trybie, którego ten telefon nie obsługuje. Przytrzymaj ich przycisk 2D/3D albo odłącz je i podłącz ponownie. + Częstotliwość odświeżania w 3D + 3D w grach VR + Przełącza na 3D, gdy działa SteamVR, a potem wraca. + Obraz 3D + Automatycznie: SteamVR wypełnia widok; inne gry wiszą jako ekran w 3D i pozostają płaskie w 2D. + Automatycznie + Wirtualny ekran + Gra 3D obok siebie + Szerokość ekranu + Odległość ekranu + Szerokość i odległość działają, gdy gra jest wyświetlana w okularach jako wirtualny ekran. + Te ustawienia działają, gdy opcja „Gra w okularach” jest włączona, a gra jest tam wyświetlana. + Tryb „Automatycznie” pokazuje tę grę 2D na pełnym ekranie. Użyj wirtualnego ekranu, aby dostosować jej szerokość, odległość i śledzenie głowy. + Użyj wirtualnego ekranu + SteamVR wypełnia widok. Dostosuj poniżej jego pole widzenia; szerokość i odległość dotyczą tylko wirtualnego ekranu. + Gry 3D obok siebie wypełniają obraz każdego oka. Szerokość i odległość dotyczą tylko wirtualnego ekranu. + Tryb „Automatycznie” utrzymuje tę grę 2D nieruchomo w polu widzenia. Aby używać śledzenia głowy, wybierz „Wirtualny ekran” w sekcji „Wyświetlacz”. + Śledzenie głowy działa z wirtualnym ekranem lub uruchomioną grą SteamVR. + Okulary + Jasność + Głośność + Przyciemnianie + Przyciemnia przednie szkła. + Śledzenie głowy + Śledzenie głowy + Własne czujniki okularów obracają wirtualny ekran i SteamVR zgodnie z ruchem głowy. + Wyśrodkuj widok + Wyśrodkuj + Pole widzenia SteamVR + Mniejsze przybliża SteamVR i wypełnia okulary; większe pokazuje więcej dookoła. + Grasz na: %1$s + Kalibracja śledzenia + Ogranicza dryf i wyrównuje poziom odniesienia. + Dla tych okularów używana jest zapisana kalibracja. + Kalibruj + Połóż podłączone okulary do góry nogami na poziomej powierzchni, tak aby oprawka leżała równo. Dotknij „Rozpocznij” i nie ruszaj ich przez około 8 sekund. Ruch rozpoczyna pomiar od nowa. Kalibracja koryguje dryf i pochylenie, ale nie kierunek. + Zostaw okulary do góry nogami i ich nie dotykaj. Oczekiwanie, aż się ustabilizują… + Pomiar czujników. Nie ruszaj okularów. + Wykryto ruch. Nie dotykaj okularów; pomiar rozpocznie się ponownie automatycznie. + Połóż okulary do góry nogami na poziomej powierzchni. Pomiar rozpocznie się, gdy będą prawidłowo ułożone. + Kalibracja zapisana. Załóż okulary i patrz prosto przed siebie, a potem dotknij „Gotowe”, aby wyśrodkować widok. + Nie udało się ukończyć kalibracji. Sprawdź połączenie i nie ruszaj okularów, a potem spróbuj ponownie. Poprzednia kalibracja pozostała bez zmian. + Rozpocznij + Gotowe + Resetuj kalibrację śledzenia + Usunąć zapisaną kalibrację i przywrócić domyślne ustawienia modelu? Po ponownym założeniu okularów wyśrodkuj widok. + Resetuj + Śledzenie dłoni + Śledzić dłonie za pomocą kamery? + Kamera widzi Twoje dłonie. Gdy nie jest podłączony żaden kontroler, Twoje gołe dłonie stają się dłońmi VR: złącz kciuk i palec wskazujący, aby kliknąć, zaciśnij dłoń, aby chwycić, a żeby otworzyć menu, obróć dłoń wnętrzem do siebie i złącz palce. Z Joy-Conami kamera umieszcza je tam, gdzie naprawdę są Twoje dłonie. Obrazy są przetwarzane na tym urządzeniu i nigdy nie są zapisywane ani wysyłane. + Tylna kamera + Zalecane. Zwykle ostrzejsza i szersza. + Przednia kamera + Na wypadek, gdy tył urządzenia nie może być skierowany w Twoją stronę. + Użyj kamery + Nie teraz + Śledzenie dłoni kamerą + Gdy nie jest podłączony żaden kontroler, Twoje dłonie stają się dłońmi VR: złącz palce, aby kliknąć, a z dłonią zwróconą do siebie, aby otworzyć menu. Z Joy-Conami umieszcza je tam, gdzie widzi je kamera. Obrazy pozostają na tym urządzeniu. + Kamera + Dostęp do kamery + Zezwól na dostęp do kamery, aby rozpocząć śledzenie dłoni. + Dostęp do kamery jest wyłączony. Włącz go w ustawieniach Androida. + Zezwól + Otwórz ustawienia + Położenie kamery + Sprawdź, czy kamera Cię widzi, zanim zaczniesz grać. + Sprawdź + Położenie kamery + Ustaw urządzenie pionowo w odległości od 1,5 do 2 metrów, mniej więcej na wysokości klatki piersiowej, tylną kamerą skierowaną w Twoją stronę. Następnie wejdź w kadr z wyciągniętymi rękami. + Ustaw urządzenie pionowo w odległości od 1,5 do 2 metrów, mniej więcej na wysokości klatki piersiowej, ekranem skierowanym w Twoją stronę. Następnie wejdź w kadr z wyciągniętymi rękami. + Uruchamianie kamery… + Urządzenie leży płasko. Ustaw je pionowo, aby kamera była skierowana w Twoją stronę. + Ustaw urządzenie bardziej pionowo, aby kamera była skierowana w Twoją stronę. + Jeszcze Cię nie widać. Wejdź w kadr tak, aby widoczne były głowa i ramiona. + Cofnij się trochę. + Podejdź trochę bliżej. + Wyciągnij obie ręce tak, aby kamera je widziała. + Wygląda dobrze. + Nie udało się uruchomić kamery. Zamknij inne aplikacje, które jej używają, i spróbuj ponownie. + Zmień kamerę + Pomiń + Gotowe + Oczekiwanie na połączenie USB Ethernet okularów. Ten telefon może nie udostępniać go aplikacjom. + Połączenie z XREAL zostało przerwane lub zwróciło nieobsługiwaną odpowiedź. Ponawianie… + Eksperymentalna obsługa 1S. Użyj trybu Follow i wyłącz stabilizację w menu okularów. Kierunki śledzenia i tryby wyświetlania wymagają jeszcze testów na urządzeniu. Cardboard VR Tryb Cardboard Obraz dla lewego i prawego oka do gogli VR na telefon. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index bbaf4ce34..87eaed937 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -1,5 +1,12 @@ + Os gráficos do SteamVR pararam + O DroidDeck vai reiniciar dentro de %1$d segundos para repor os gráficos e reabrir o SteamVR. O jogo em execução será fechado; o progresso não guardado não pode ser recuperado. + A falha gráfica voltou após um reinício recente. A recuperação automática está em pausa para evitar um ciclo. Reiniciar fecha o jogo; o progresso não guardado não pode ser recuperado. + Reiniciar agora + Manter aberto + A repor os gráficos e a reabrir o SteamVR… + O DroidDeck não conseguiu reiniciar automaticamente. Feche completamente a app e volte a abri-la. DroidDeck A primeira execução transfere o Steam. Isto pode demorar alguns minutos. @@ -930,6 +937,8 @@ Botão do teclado Premir Select para abrir o menu do Steam Em vez de View/Select. + Joy-Con como comandos de RV + Enquanto o SteamVR está a correr, cada Joy-Con torna-se uma mão. Aponte-o para a frente e mantenha Home ou Capture premido para o recentrar. Animações Os analógicos aparecem onde tocar e ocultam-se ao soltar Esquema @@ -987,6 +996,7 @@ KDE Plasma, Firefox e os seus emuladores Os jogos que instalar no Steam aparecem aqui após a sessão. Jogar no Steam + Jogar no SteamVR Definições de arranque Iniciar Ficheiros do jogo @@ -2316,6 +2326,113 @@ Não foi possível contactar a GOG Não foi possível ler a tua biblioteca da GOG A registar na Steam… + Óculos + Óculos VITURE + Controlo dos óculos + Esta versão não inclui o controlador para estes óculos. + Não foi possível ligar aos óculos. Volte a ligar o cabo ou toque em Permitir para tentar novamente. + Permitir + Permite que o DroidDeck defina a taxa de atualização, o modo 3D, o brilho, o volume e o escurecimento dos óculos, e leia o seguimento da cabeça. + A aguardar que permita no pedido do Android… + O controlo foi recusado. Permita-o para alterar as definições dos óculos. + Estes óculos mostram o jogo, mas as definições deles não podem ser alteradas aqui. + Saída + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Jogo nos óculos + Mostra o jogo só nos óculos. Desativado: o telemóvel mostra-o e o Android espelha o ecrã do telemóvel. + Ecrã + Taxa de atualização + 3D (lado a lado) + Cada olho recebe a sua metade de uma imagem de 3840×1080. Se a imagem desaparecer, mantenha premido o botão 2D/3D dos óculos. + O sinal 3D não foi confirmado. Tenta novamente quando a imagem 2D voltar. + Sem imagem: os óculos estão num modo que este telemóvel não consegue enviar. Mantenha premido o botão 2D/3D ou desligue-os e volte a ligá-los. + Taxa de atualização em 3D + 3D para jogos de RV + Muda para 3D enquanto o SteamVR está em execução e volta depois. + Imagem 3D + Automático: o SteamVR preenche a vista; os outros jogos flutuam como um ecrã em 3D e ficam planos em 2D. + Automático + Ecrã virtual + Jogo lado a lado + Largura do ecrã + Distância do ecrã + A largura e a distância aplicam-se quando um jogo é executado nos óculos como ecrã virtual. + Estas definições aplicam-se enquanto Jogo nos óculos estiver ativado e o jogo for mostrado nos óculos. + O modo Automático mantém este jogo 2D em ecrã inteiro. Utilize um ecrã virtual para ajustar a largura, a distância e o seguimento da cabeça. + Utilizar ecrã virtual + O SteamVR preenche a vista. Ajuste o campo de visão abaixo; a largura e a distância aplicam-se apenas a um ecrã virtual. + Os jogos lado a lado preenchem a vista de cada olho. A largura e a distância aplicam-se apenas a um ecrã virtual. + O modo Automático mantém este jogo 2D fixo na vista. Escolha Ecrã virtual na secção Ecrã para utilizar o seguimento da cabeça. + O seguimento da cabeça aplica-se a um ecrã virtual ou a um jogo do SteamVR em execução. + Óculos + Brilho + Volume + Escurecimento + Escurece as lentes frontais. + Seguimento da cabeça + Seguimento da cabeça + Os sensores dos próprios óculos rodam o ecrã virtual e o SteamVR com a sua cabeça. + Recentrar vista + Recentrar + Campo de visão do SteamVR + Mais baixo aproxima o SteamVR e preenche os óculos; mais alto mostra mais à sua volta. + A jogar em %1$s + Calibração do seguimento + Reduz a deriva e alinha a referência de nível. + A calibração guardada é aplicada a estes óculos. + Calibrar + Coloque os óculos ligados de cabeça para baixo numa superfície nivelada, com a armação bem assente. Toque em Iniciar e não mexa neles durante cerca de 8 segundos. Qualquer movimento reinicia a medição. Isto ajusta a deriva e a inclinação, não a direção. + Deixe os óculos de cabeça para baixo, sem lhes tocar. A aguardar que estabilizem… + A medir os sensores. Mantenha os óculos imóveis. + Movimento detetado. Não toque nos óculos; a medição recomeça automaticamente. + Pouse os óculos de cabeça para baixo numa superfície nivelada. A medição começa quando estiverem na posição correta. + Calibração guardada. Coloque os óculos e olhe em frente; depois toque em Concluído para recentrar. + Não foi possível concluir a calibração. Verifique a ligação, mantenha os óculos imóveis e tente novamente. A calibração anterior não foi alterada. + Iniciar + Concluído + Repor calibração do seguimento + Remover a calibração guardada e repor as predefinições do modelo? Recentre depois de voltar a colocar os óculos. + Repor + Seguimento das mãos + Seguir as tuas mãos com a câmara? + A câmara consegue ver as tuas mãos. Sem nenhum comando ligado, as tuas mãos livres passam a ser as mãos de RV: junta o polegar e o indicador para clicar, fecha a mão para agarrar e, para abrir o menu, vira a palma para ti e junta os dedos. Com Joy-Con, coloca-os onde as tuas mãos estão realmente. As imagens são processadas neste dispositivo e nunca são guardadas nem enviadas. + Câmara traseira + Recomendada. Costuma ser mais nítida e ampla. + Câmara frontal + Para quando a parte de trás do dispositivo não puder ficar virada para ti. + Utilizar câmara + Agora não + Seguimento das mãos pela câmara + Sem nenhum comando ligado, as tuas mãos passam a ser as mãos de RV: junta os dedos para clicar e, com a palma virada para ti, junta-os para abrir o menu. Com Joy-Con, coloca-os onde a câmara os vê. As imagens ficam neste dispositivo. + Câmara + Acesso à câmara + Permita o acesso à câmara para que o seguimento das mãos possa começar. + O acesso à câmara está desativado. Ative-o nas definições do Android. + Permitir + Abrir definições + Posição da câmara + Verifique se a câmara te consegue ver antes de jogar. + Verificar + Posição da câmara + Coloque o dispositivo em pé a 1,5-2 metros de distância, à altura do peito, com a câmara traseira voltada para ti. Depois entre no campo de visão com as mãos estendidas. + Coloque o dispositivo em pé a 1,5-2 metros de distância, à altura do peito, com o ecrã voltado para ti. Depois entre no campo de visão com as mãos estendidas. + A iniciar a câmara… + O dispositivo está deitado. Coloque-o em pé para a câmara ficar voltada para ti. + Coloque o dispositivo mais direito para a câmara ficar voltada para ti. + Ainda não consegue ver-te. Entre no campo de visão para mostrar a cabeça e os ombros. + Afaste-se um pouco. + Aproxime-se um pouco mais. + Mostre as duas mãos onde a câmara as consiga ver. + Parece bem. + Não foi possível iniciar a câmara. Feche outras apps que estejam a usar a câmara e tente novamente. + Mudar de câmara + Ignorar + Concluído + A aguardar a ligação Ethernet por USB dos óculos. Este telemóvel pode não a disponibilizar às apps. + A ligação aos óculos XREAL foi interrompida ou devolveu uma resposta não suportada. A tentar novamente… + Suporte experimental para os 1S. Utilize o modo Follow e desative a estabilização no menu dos óculos. As direções de seguimento e os modos de ecrã ainda têm de ser testados no dispositivo. Cardboard VR Modo Cardboard Imagens para o olho esquerdo e o direito, para uns óculos de RV para telemóvel. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ce0da8139..fc1e5fbca 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,5 +1,12 @@ + Os gráficos do SteamVR pararam + O DroidDeck vai reiniciar em %1$d segundos para redefinir os gráficos e reabrir o SteamVR. O jogo em execução será fechado; o progresso não salvo não poderá ser recuperado. + A falha gráfica voltou logo após uma reinicialização recente. A recuperação automática foi pausada para evitar um loop. Reiniciar vai fechar o jogo; o progresso não salvo não poderá ser recuperado. + Reiniciar agora + Manter aberto + Redefinindo os gráficos e reabrindo o SteamVR… + O DroidDeck não conseguiu reiniciar automaticamente. Feche o app completamente e abra-o de novo. DroidDeck Na primeira execução, a Steam é baixada. Isso pode levar alguns minutos. @@ -930,6 +937,8 @@ Botão do teclado Pressione Select para abrir o menu Steam Em vez de View/Select. + Joy-Cons como controles de RV + Enquanto o SteamVR está rodando, cada Joy-Con vira uma mão. Aponte-o para a frente e segure Home ou Capture para recentralizá-lo. Animações Os analógicos aparecem onde você toca e somem ao soltar Layout @@ -987,6 +996,7 @@ KDE Plasma, Firefox e seus emuladores Os jogos que você instala na Steam aparecem aqui após a sessão. Jogar na Steam + Jogar no SteamVR Configurações de inicialização Iniciar Arquivos do jogo @@ -2316,6 +2326,113 @@ Não foi possível conectar à GOG Não foi possível ler sua biblioteca da GOG Registrando na Steam… + Óculos + Óculos VITURE + Controle dos óculos + Esta versão não inclui o driver para estes óculos. + Não foi possível conectar aos óculos. Reconecte o cabo ou toque em Permitir para tentar de novo. + Permitir + Permite que o DroidDeck ajuste a taxa de atualização, o modo 3D, o brilho, o volume e o escurecimento dos óculos, e leia o rastreamento da cabeça deles. + Aguardando você permitir na solicitação do Android… + O controle foi recusado. Permita para alterar as configurações dos óculos. + Estes óculos mostram o jogo, mas as configurações deles não podem ser alteradas por aqui. + Saída + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Jogo nos óculos + Mostra o jogo só nos óculos. Desativado: o celular mostra o jogo e o Android espelha a tela do celular. + Tela + Taxa de atualização + 3D (lado a lado) + Cada olho recebe a própria metade de uma imagem de 3840×1080. Se a imagem sumir, mantenha pressionado o botão 2D/3D dos óculos. + O sinal 3D não foi confirmado. Tente novamente quando a imagem 2D voltar. + Sem imagem: os óculos estão em um modo que este celular não consegue enviar. Mantenha pressionado o botão 2D/3D deles ou desconecte e reconecte os óculos. + Taxa de atualização em 3D + 3D para jogos de RV + Muda para 3D enquanto o SteamVR está em execução e volta depois. + Imagem 3D + Automático: o SteamVR preenche a visão; outros jogos flutuam como uma tela em 3D e ficam planos em 2D. + Automático + Tela virtual + Jogo lado a lado + Largura da tela + Distância da tela + A largura e a distância valem quando um jogo roda nos óculos como tela virtual. + Estas configurações valem enquanto Jogo nos óculos estiver ativado e o jogo for exibido neles. + O modo Automático mantém este jogo 2D em tela cheia. Use uma tela virtual para ajustar a largura, a distância e o rastreamento da cabeça. + Usar tela virtual + O SteamVR preenche a visão. Ajuste o campo de visão abaixo; a largura e a distância valem só para uma tela virtual. + Jogos lado a lado preenchem a visão de cada olho. A largura e a distância valem só para uma tela virtual. + O modo Automático mantém este jogo 2D fixo na visão. Escolha Tela virtual na seção Tela para usar o rastreamento da cabeça. + O rastreamento da cabeça vale para uma tela virtual ou um jogo do SteamVR em execução. + Óculos + Brilho + Volume + Escurecimento + Escurece as lentes frontais. + Rastreamento da cabeça + Rastreamento da cabeça + Os sensores dos próprios óculos giram a tela virtual e o SteamVR com a sua cabeça. + Recentralizar visão + Recentralizar + Campo de visão do SteamVR + Menor aproxima o SteamVR e preenche os óculos; maior mostra mais ao seu redor. + Jogando em %1$s + Calibração do rastreamento + Reduz a deriva e alinha a referência de nível. + A calibração salva é aplicada a estes óculos. + Calibrar + Coloque os óculos conectados de cabeça para baixo em uma superfície nivelada, com a armação apoiada por igual. Toque em Iniciar e não mexa neles por cerca de 8 segundos. Qualquer movimento reinicia a medição. Isso ajusta a deriva e a inclinação, não a direção. + Deixe os óculos de cabeça para baixo, sem tocar neles. Aguardando que fiquem estáveis… + Medindo os sensores. Mantenha os óculos parados. + Movimento detectado. Não toque nos óculos; a medição vai reiniciar automaticamente. + Apoie os óculos de cabeça para baixo em uma superfície nivelada. A medição começa quando eles estiverem na posição correta. + Calibração salva. Coloque os óculos e olhe para a frente; depois toque em Concluído para recentralizar. + Não foi possível concluir a calibração. Verifique a conexão, mantenha os óculos parados e tente novamente. Sua calibração anterior não foi alterada. + Iniciar + Concluído + Redefinir calibração do rastreamento + Remover a calibração salva e restaurar o padrão do modelo? Recentralize depois de colocar os óculos de novo. + Redefinir + Rastreamento das mãos + Rastrear suas mãos com a câmera? + A câmera consegue ver suas mãos. Sem nenhum controle conectado, suas mãos livres viram as mãos de RV: junte o polegar e o indicador para clicar, feche a mão para pegar e, para abrir o menu, vire a palma para você e junte os dedos. Com Joy-Cons, ela os coloca onde suas mãos realmente estão. As imagens são processadas neste dispositivo e nunca são salvas ou enviadas. + Câmera traseira + Recomendada. Costuma ser mais nítida e ampla. + Câmera frontal + Para quando a parte de trás do dispositivo não puder ficar voltada para você. + Usar câmera + Agora não + Rastreamento das mãos pela câmera + Sem nenhum controle conectado, suas mãos viram as mãos de RV: junte os dedos para clicar e, com a palma virada para você, junte-os para abrir o menu. Com Joy-Cons, ela os coloca onde a câmera os vê. As imagens ficam neste dispositivo. + Câmera + Acesso à câmera + Permita o acesso à câmera para que o rastreamento das mãos possa começar. + O acesso à câmera está desativado. Ative-o nas configurações do Android. + Permitir + Abrir configurações + Posição da câmera + Verifique se a câmera consegue ver você antes de jogar. + Verificar + Posição da câmera + Deixe o dispositivo em pé a 1,5-2 metros de distância, na altura do peito, com a câmera traseira voltada para você. Depois entre no campo de visão com as mãos estendidas. + Deixe o dispositivo em pé a 1,5-2 metros de distância, na altura do peito, com a tela voltada para você. Depois entre no campo de visão com as mãos estendidas. + Iniciando a câmera… + O dispositivo está deitado. Deixe-o em pé para a câmera ficar voltada para você. + Deixe o dispositivo mais em pé para a câmera ficar voltada para você. + Ainda não consegue ver você. Entre no campo de visão para mostrar a cabeça e os ombros. + Afaste-se um pouco. + Chegue um pouco mais perto. + Mostre as duas mãos onde a câmera possa vê-las. + Tudo certo. + Não foi possível iniciar a câmera. Feche outros apps que estejam usando a câmera e tente de novo. + Trocar de câmera + Pular + Concluído + Aguardando a conexão Ethernet por USB dos óculos. Este celular pode não disponibilizá-la para os apps. + A conexão com os óculos XREAL foi interrompida ou retornou uma resposta incompatível. Tentando novamente… + Suporte experimental ao 1S. Use o modo Follow e desative a estabilização no menu dos óculos. As direções do rastreamento e os modos de exibição ainda precisam de testes no dispositivo. Cardboard VR Modo Cardboard Imagens para o olho esquerdo e o direito, para um óculos de RV de celular. diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 0ed0c9765..ae0967c32 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1,5 +1,12 @@ + Grafica SteamVR s-a oprit + DroidDeck va reporni în %1$d s pentru a reseta grafica și a redeschide SteamVR. Jocul care rulează se va închide; progresul nesalvat nu poate fi recuperat. + Eroarea grafică a revenit după o repornire recentă. Recuperarea automată este pusă pe pauză pentru a evita o buclă. Repornirea va închide jocul; progresul nesalvat nu poate fi recuperat. + Repornește acum + Lasă deschis + Se resetează grafica și se redeschide SteamVR… + DroidDeck nu a putut reporni automat. Închide complet aplicația, apoi redeschide-o. DroidDeck Prima rulare descarcă Steam. Poate dura câteva minute. @@ -941,6 +948,8 @@ Butonul tastaturii Apasă Select pentru a deschide meniul Steam În loc de View/Select. + Joy-Con ca controllere VR + Cât timp rulează SteamVR, fiecare Joy-Con devine o mână. Îndreaptă-l înainte și ține apăsat Home sau Capture pentru a-l recentra. Animații Manșele apar unde atingi ecranul și se ascund la eliberare Aranjament @@ -998,6 +1007,7 @@ KDE Plasma, Firefox și emulatoarele tale Jocurile pe care le instalezi în Steam apar aici după sesiune. Joacă Steam + Joacă SteamVR Setări de lansare Lansează Fișierele jocului @@ -2330,6 +2340,113 @@ Nu s-a putut contacta GOG Biblioteca ta GOG nu a putut fi citită Se înregistrează la Steam… + Ochelari + Ochelari VITURE + Controlul ochelarilor + Această versiune nu include driverul pentru acești ochelari. + Nu s-a putut realiza conexiunea cu ochelarii. Reconectează cablul sau apasă Permite pentru a reîncerca. + Permite + Permite-i lui DroidDeck să seteze rata de reîmprospătare, modul 3D, luminozitatea, volumul și întunecarea ochelarilor și să le citească urmărirea capului. + Se așteaptă permisiunea ta în solicitarea Android… + Controlul a fost refuzat. Permite-l pentru a schimba setările ochelarilor. + Acești ochelari afișează jocul, dar setările lor nu pot fi schimbate de aici. + Ieșire + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Joc pe ochelari + Afișează jocul doar pe ochelari. Dezactivat: telefonul îl afișează, iar Android oglindește ecranul telefonului. + Afișaj + Rată de reîmprospătare + 3D (alăturat) + Fiecare ochi primește propria jumătate dintr-o imagine de 3840×1080. Dacă imaginea dispare, ține apăsat butonul 2D/3D al ochelarilor. + Semnalul 3D nu a fost confirmat. Încearcă din nou când revine imaginea 2D. + Nicio imagine: ochelarii sunt într-un mod pe care acest telefon nu îl poate trimite. Ține apăsat butonul lor 2D/3D sau deconectează-i și reconectează-i. + Rată de reîmprospătare 3D + 3D pentru jocuri VR + Trece la 3D cât timp rulează SteamVR, apoi revine. + Imagine 3D + Automat: SteamVR umple vederea; celelalte jocuri plutesc ca un ecran în 3D și rămân plate în 2D. + Automat + Ecran virtual + Joc alăturat + Lățimea ecranului + Distanța ecranului + Lățimea și distanța se aplică atunci când un joc rulează pe ochelari ca ecran virtual. + Aceste setări se aplică cât timp Joc pe ochelari este activat și jocul este afișat acolo. + Modul Automat păstrează acest joc 2D pe tot ecranul. Folosește un ecran virtual pentru a-i ajusta lățimea, distanța și urmărirea capului. + Folosește ecranul virtual + SteamVR umple vederea. Ajustează-i câmpul vizual mai jos; lățimea și distanța se aplică doar unui ecran virtual. + Jocurile alăturate umplu vederea fiecărui ochi. Lățimea și distanța se aplică doar unui ecran virtual. + Modul Automat menține acest joc 2D fix în câmpul vizual. Alege Ecran virtual în Afișaj pentru a folosi urmărirea capului. + Urmărirea capului se aplică unui ecran virtual sau unui joc SteamVR care rulează. + Ochelari + Luminozitate + Volum + Întunecare + Întunecă lentilele din față. + Urmărirea capului + Urmărirea capului + Senzorii proprii ai ochelarilor rotesc ecranul virtual și SteamVR odată cu capul tău. + Recentrează vederea + Recentrează + Câmp vizual SteamVR + Mai mic aduce SteamVR mai aproape și umple ochelarii; mai mare arată mai mult din jurul tău. + Se joacă pe %1$s + Calibrarea urmăririi + Reduce deriva și aliniază referința de nivel. + Calibrarea salvată se aplică acestor ochelari. + Calibrează + Așază ochelarii conectați cu susul în jos pe o suprafață orizontală, cu rama sprijinită uniform. Apasă Pornește, apoi lasă-i neatinși circa 8 secunde. Orice mișcare repornește măsurarea. Calibrarea corectează deriva și înclinarea, nu direcția. + Lasă ochelarii cu susul în jos, neatinși. Se așteaptă stabilizarea lor… + Se măsoară senzorii. Ține ochelarii nemișcați. + Mișcare detectată. Nu atinge ochelarii; măsurarea va reporni automat. + Așază ochelarii cu susul în jos pe o suprafață orizontală. Măsurarea va începe când sunt poziționați corect. + Calibrare salvată. Pune-ți ochelarii și privește drept înainte, apoi apasă Gata pentru a recentra. + Calibrarea nu a putut fi finalizată. Verifică conexiunea și ține ochelarii nemișcați, apoi încearcă din nou. Calibrarea anterioară a rămas neschimbată. + Pornește + Gata + Resetează calibrarea urmăririi + Elimini calibrarea salvată și restaurezi valorile implicite ale modelului? Recentrează după ce îți pui ochelarii la loc. + Resetează + Urmărirea mâinilor + Urmărești mâinile cu camera? + Camera îți poate vedea mâinile. Când nu e conectat niciun controller, mâinile tale goale devin mâinile VR: apropie degetul mare de arătător ca să dai clic, strânge mâna ca să apuci, iar pentru meniu întoarce palma spre tine și apropie degetele. Cu Joy-Con, îi așază acolo unde sunt de fapt mâinile tale. Imaginile sunt procesate pe acest dispozitiv și nu sunt niciodată salvate sau trimise. + Camera din spate + Recomandat. De obicei mai clară și mai largă. + Camera frontală + Pentru atunci când spatele dispozitivului nu poate fi îndreptat spre tine. + Folosește camera + Nu acum + Urmărirea mâinilor cu camera + Când nu e conectat niciun controller, mâinile tale devin mâinile VR: apropie degetele ca să dai clic, iar cu palma spre tine apropie-le pentru meniu. Cu Joy-Con, îi așază acolo unde îi vede camera. Imaginile rămân pe acest dispozitiv. + Camera + Acces la cameră + Permite accesul la cameră pentru a putea începe urmărirea mâinilor. + Accesul la cameră este dezactivat. Activează-l din setările Android. + Permite + Deschide setările + Poziționarea camerei + Verifică dacă te vede camera înainte de a juca. + Verifică + Poziționarea camerei + Așază dispozitivul vertical, la 1,5–2 metri distanță, cam la înălțimea pieptului, cu camera din spate îndreptată spre tine. Apoi intră în cadru cu mâinile întinse. + Așază dispozitivul vertical, la 1,5–2 metri distanță, cam la înălțimea pieptului, cu ecranul îndreptat spre tine. Apoi intră în cadru cu mâinile întinse. + Se pornește camera… + Dispozitivul stă culcat. Așază-l vertical ca să fie camera îndreptată spre tine. + Așază dispozitivul mai vertical ca să fie camera îndreptată spre tine. + Încă nu ești vizibil. Intră în cadru astfel încât să se vadă capul și umerii tăi. + Dă-te puțin înapoi. + Apropie-te puțin. + Întinde ambele mâini acolo unde le poate vedea camera. + Arată bine. + Camera nu a putut porni. Închide celelalte aplicații care o folosesc și încearcă din nou. + Schimbă camera + Omite + Gata + Se așteaptă conexiunea USB Ethernet a ochelarilor. Este posibil ca acest telefon să nu o expună aplicațiilor. + Conexiunea XREAL a fost întreruptă sau a returnat un răspuns neacceptat. Se reîncearcă… + Suport experimental pentru 1S. Folosește modul Follow și dezactivează stabilizarea din meniul ochelarilor. Direcțiile de urmărire și modurile de afișare trebuie încă testate pe dispozitiv. Cardboard VR Mod Cardboard Imagini pentru ochiul stâng și cel drept, pentru un vizor VR pentru telefon. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7ff891a44..96fcb545c 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,12 @@ + Графика SteamVR перестала работать + DroidDeck перезапустится через %1$d сек., чтобы сбросить графику и снова открыть SteamVR. Запущенная игра закроется; несохранённый прогресс восстановить не удастся. + Сбой графики повторился вскоре после перезапуска. Автоматическое восстановление приостановлено, чтобы избежать зацикливания. Перезапуск закроет игру; несохранённый прогресс восстановить не удастся. + Перезапустить + Не закрывать + Сброс графики и повторный запуск SteamVR… + Не удалось автоматически перезапустить DroidDeck. Полностью закройте приложение и откройте его снова. DroidDeck При первом запуске будет загружен Steam. Это может занять несколько минут. @@ -944,6 +951,8 @@ Кнопка клавиатуры Нажмите Select, чтобы открыть меню Steam Вместо View/Select. + Joy-Con как VR-контроллеры + Пока работает SteamVR, каждый Joy-Con становится рукой. Направьте его вперёд и удерживайте Home или Capture, чтобы снова его отцентрировать. Анимации Стики появляются в месте касания и исчезают после отпускания Раскладка @@ -1001,6 +1010,7 @@ KDE Plasma, Firefox и ваши эмуляторы Игры, установленные в Steam, появятся здесь после завершения сессии. Запустить Steam + Запустить SteamVR Настройки запуска Запустить Файлы игры @@ -2349,6 +2359,113 @@ Не удалось связаться с GOG Не удалось прочитать вашу библиотеку GOG Регистрация в Steam… + Очки + Очки VITURE + Управление очками + В этой сборке нет драйвера для этих очков. + Не удалось подключиться к очкам. Переподключите кабель или нажмите «Разрешить», чтобы повторить попытку. + Разрешить + Разрешите DroidDeck менять частоту обновления, режим 3D, яркость, громкость и затемнение очков и считывать отслеживание головы. + Ожидание разрешения в окне Android… + Управление отклонено. Разрешите его, чтобы менять настройки очков. + Эти очки показывают игру, но их настройки нельзя изменить отсюда. + Вывод + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Игра на очках + Показывать игру только на очках. Выкл.: игру показывает телефон, а Android дублирует экран телефона. + Экран + Частота обновления + 3D (стереопара) + Каждый глаз получает свою половину изображения 3840×1080. Если изображение пропадёт, удерживайте кнопку 2D/3D на очках. + Сигнал 3D не подтверждён. Повторите попытку, когда вернётся изображение 2D. + Нет изображения: очки в режиме, который этот телефон не может передать. Удерживайте их кнопку 2D/3D или отключите и снова подключите очки. + Частота в 3D + 3D для VR-игр + Включает 3D, пока работает SteamVR, и возвращает обратно после. + Изображение 3D + Автоматически: SteamVR заполняет весь обзор; другие игры висят экраном в 3D и остаются плоскими в 2D. + Автоматически + Виртуальный экран + Игра в стереопаре + Ширина экрана + Расстояние до экрана + Ширина и расстояние применяются, когда игра выводится на очки как виртуальный экран. + Эти настройки действуют, когда включена «Игра на очках» и игра показывается на них. + В режиме «Автоматически» эта 2D-игра занимает весь экран. Чтобы настроить ширину, расстояние и отслеживание головы, используйте виртуальный экран. + Использовать виртуальный экран + SteamVR заполняет весь обзор. Настройте поле зрения ниже; ширина и расстояние применяются только к виртуальному экрану. + Игры в стереопаре заполняют обзор каждого глаза. Ширина и расстояние применяются только к виртуальному экрану. + В режиме «Автоматически» эта 2D-игра неподвижна в поле зрения. Чтобы использовать отслеживание головы, выберите «Виртуальный экран» в разделе «Экран». + Отслеживание головы работает с виртуальным экраном или запущенной игрой SteamVR. + Очки + Яркость + Громкость + Затемнение + Затемняет передние линзы. + Отслеживание головы + Отслеживание головы + Датчики очков поворачивают виртуальный экран и SteamVR вслед за головой. + Сбросить вид + Центрировать + Поле зрения SteamVR + Меньше — SteamVR ближе и заполняет очки; больше — шире обзор вокруг. + Игра на %1$s + Калибровка отслеживания + Уменьшает дрейф и выравнивает опорный горизонт. + Для этих очков применяется сохранённая калибровка. + Калибровать + Положите подключённые очки перевёрнутыми на горизонтальную поверхность так, чтобы оправа опиралась равномерно. Нажмите «Начать» и не трогайте их около 8 секунд. При движении измерение начнётся заново. Калибровка корректирует дрейф и наклон, но не направление. + Оставьте очки перевёрнутыми и не трогайте их. Ожидание стабилизации… + Измерение показаний датчиков. Не двигайте очки. + Обнаружено движение. Не трогайте очки; измерение начнётся заново автоматически. + Положите очки перевёрнутыми на горизонтальную поверхность. Измерение начнётся, когда они будут расположены правильно. + Калибровка сохранена. Наденьте очки, смотрите прямо перед собой и нажмите «Готово», чтобы отцентрировать вид. + Не удалось завершить калибровку. Проверьте подключение, держите очки неподвижно и повторите попытку. Предыдущая калибровка не изменена. + Начать + Готово + Сбросить калибровку отслеживания + Удалить сохранённую калибровку и вернуть настройки модели по умолчанию? Снова надев очки, отцентрируйте вид. + Сбросить + Отслеживание рук + Отслеживать руки с помощью камеры? + Камера видит ваши руки. Если контроллер не подключён, ваши руки сами становятся руками в VR: сведите большой и указательный пальцы, чтобы нажать, сожмите кулак, чтобы схватить, а чтобы открыть меню, поверните ладонь к себе и сведите пальцы. С Joy-Con камера располагает их там, где на самом деле находятся ваши руки. Изображения обрабатываются на этом устройстве и никогда не сохраняются и не передаются. + Задняя камера + Рекомендуется. Обычно резче и шире. + Фронтальная камера + Для случаев, когда заднюю часть устройства нельзя направить на себя. + Использовать камеру + Не сейчас + Отслеживание рук камерой + Если контроллер не подключён, ваши руки становятся руками в VR: сведите пальцы, чтобы нажать, а повернув ладонь к себе, — чтобы открыть меню. С Joy-Con располагает их там, где их видит камера. Изображения остаются на этом устройстве. + Камера + Доступ к камере + Разрешите доступ к камере, чтобы начать отслеживание рук. + Доступ к камере отключён. Включите его в настройках Android. + Разрешить + Открыть настройки + Расположение камеры + Проверьте, что камера видит вас, прежде чем начать игру. + Проверить + Расположение камеры + Поставьте устройство вертикально на расстоянии от 1,5 до 2 метров, примерно на уровне груди, направив заднюю камеру на себя. Затем встаньте в кадр, вытянув руки. + Поставьте устройство вертикально на расстоянии от 1,5 до 2 метров, примерно на уровне груди, направив экран на себя. Затем встаньте в кадр, вытянув руки. + Запуск камеры… + Устройство лежит плашмя. Поставьте его вертикально, чтобы камера была направлена на вас. + Поставьте устройство более вертикально, чтобы камера была направлена на вас. + Пока вас не видно. Встаньте в кадр так, чтобы были видны голова и плечи. + Отойдите немного назад. + Подойдите немного ближе. + Вытяните обе руки так, чтобы камера их видела. + Хорошо. + Не удалось запустить камеру. Закройте другие приложения, использующие её, и попробуйте снова. + Сменить камеру + Пропустить + Готово + Ожидание подключения очков по USB Ethernet. Этот телефон может не предоставлять его приложениям. + Подключение к XREAL прервано или получен неподдерживаемый ответ. Повторная попытка… + Экспериментальная поддержка 1S. Включите режим Follow и отключите стабилизацию в меню очков. Направления отслеживания и режимы отображения ещё нужно проверить на устройстве. Разработчик Издатель Дата выхода diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 227629f42..939a58bb4 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1,5 +1,12 @@ + SteamVR-grafiken slutade fungera + DroidDeck startar om om %1$d sekunder för att återställa grafiken och öppna SteamVR igen. Spelet som körs stängs; osparade framsteg kan inte återställas. + Grafikfelet kom tillbaka strax efter en omstart. Automatisk återställning är pausad för att undvika en loop. En omstart stänger spelet; osparade framsteg kan inte återställas. + Starta om nu + Stäng inte + Återställer grafiken och öppnar SteamVR igen… + DroidDeck kunde inte starta om automatiskt. Stäng appen helt och öppna den sedan igen. DroidDeck Första starten laddar ned Steam. Det kan ta några minuter. @@ -935,6 +942,8 @@ Tangentbordsknappen Tryck på Select för att öppna Steam-menyn I stället för View/Select. + Joy-Con som VR-handkontroller + Medan SteamVR körs blir varje Joy-Con en hand. Rikta den framåt och håll in Home eller Capture för att centrera den igen. Animationer Spakarna visas där du trycker och döljs när du släpper Layout @@ -992,6 +1001,7 @@ KDE Plasma, Firefox och dina emulatorer Spel du installerar i Steam visas här efter sessionen. Spela Steam + Spela SteamVR Startinställningar Starta Spelfiler @@ -2305,6 +2315,113 @@ Det gick inte att nå GOG Det gick inte att läsa ditt GOG-bibliotek Registrerar hos Steam… + Glasögon + VITURE-glasögon + Glasögonstyrning + Det här bygget saknar drivrutinen för de här glasögonen. + Kunde inte ansluta till glasögonen. Anslut kabeln igen eller tryck på Tillåt för att försöka igen. + Tillåt + Låt DroidDeck ställa in glasögonens uppdateringsfrekvens, 3D-läge, ljusstyrka, volym och solskydd och läsa av deras huvudspårning. + Väntar på att du tillåter det i Androids dialogruta… + Styrningen nekades. Tillåt den för att ändra glasögonens inställningar. + Glasögonen visar spelet, men deras inställningar kan inte ändras härifrån. + Utgång + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Spel i glasögonen + Visa spelet bara i glasögonen. Av: telefonen visar det och Android speglar telefonen. + Skärm + Uppdateringsfrekvens + 3D (sida vid sida) + Varje öga får sin egen halva av en bild på 3840×1080. Om bilden försvinner håller du in glasögonens 2D/3D-knapp. + 3D-signalen kunde inte bekräftas. Försök igen när 2D-bilden är tillbaka. + Ingen bild: glasögonen är i ett läge som den här telefonen inte kan skicka. Håll in deras 2D/3D-knapp, eller koppla ur dem och anslut dem igen. + 3D-uppdateringsfrekvens + 3D för VR-spel + Växlar till 3D medan SteamVR körs och tillbaka efteråt. + 3D-bild + Automatiskt: SteamVR fyller synfältet; andra spel svävar som en skärm i 3D och förblir platta i 2D. + Automatiskt + Virtuell skärm + Sida vid sida-spel + Skärmbredd + Skärmavstånd + Bredd och avstånd gäller när ett spel körs i glasögonen som en virtuell skärm. + De här inställningarna gäller när Spel i glasögonen är på och spelet visas där. + Automatiskt visar det här 2D-spelet i helskärm. Använd en virtuell skärm för att justera bredd, avstånd och huvudspårning. + Använd virtuell skärm + SteamVR fyller hela vyn. Justera synfältet nedan; bredd och avstånd gäller bara en virtuell skärm. + Sida vid sida-spel fyller varje ögas bild. Bredd och avstånd gäller bara en virtuell skärm. + Automatiskt håller det här 2D-spelet fast i synfältet. Välj Virtuell skärm under Skärm för att använda huvudspårning. + Huvudspårning gäller en virtuell skärm eller ett SteamVR-spel som körs. + Glasögon + Ljusstyrka + Volym + Solskydd + Gör de främre glasen mörkare. + Huvudspårning + Huvudspårning + Glasögonens egna sensorer vrider den virtuella skärmen och SteamVR med ditt huvud. + Centrera vyn + Centrera + SteamVR-synfält + Lägre för SteamVR närmare och fyller glasögonen; högre visar mer runt dig. + Spelar på %1$s + Spårningskalibrering + Minskar drift och justerar horisontreferensen. + Sparad kalibrering används för de här glasögonen. + Kalibrera + Lägg de anslutna glasögonen upp och ned på en vågrät yta så att bågen vilar jämnt. Tryck på Starta och rör dem sedan inte på cirka 8 sekunder. Rörelse startar om mätningen. Detta justerar drift och lutning, inte riktning. + Låt glasögonen ligga upp och ned utan att röra dem. Väntar på att de ska bli stilla… + Mäter sensorerna. Håll glasögonen stilla. + Rörelse upptäcktes. Rör inte glasögonen; mätningen startar om automatiskt. + Lägg glasögonen upp och ned på en vågrät yta. Mätningen startar när de ligger rätt. + Kalibreringen har sparats. Ta på glasögonen och titta rakt fram, tryck sedan på Klar för att centrera. + Kalibreringen kunde inte slutföras. Kontrollera anslutningen och håll glasögonen stilla, försök sedan igen. Din tidigare kalibrering är oförändrad. + Starta + Klar + Återställ spårningskalibrering + Ta bort den sparade kalibreringen och återställ modellens standardvärden? Centrera vyn när du har tagit på dig glasögonen igen. + Återställ + Handspårning + Spåra dina händer med kameran? + Kameran kan se dina händer. Utan ansluten handkontroll blir dina bara händer VR-händerna: nyp ihop tummen och pekfingret för att klicka, knyt handen för att greppa, och vänd handflatan mot dig och nyp för att öppna menyn. Med Joy-Con placerar den dem där dina händer faktiskt är. Bilderna behandlas på den här enheten och sparas eller skickas aldrig. + Bakre kameran + Rekommenderas. Oftast skarpare och bredare. + Främre kameran + För när enhetens baksida inte kan vara vänd mot dig. + Använd kameran + Inte nu + Handspårning med kamera + Utan ansluten handkontroll blir dina händer VR-händerna: nyp för att klicka, handflatan mot dig och nyp för menyn. Med Joy-Con placeras de där kameran ser dem. Bilderna stannar på den här enheten. + Kamera + Kameraåtkomst + Tillåt kameraåtkomst så att handspårningen kan starta. + Kameraåtkomst är avstängd. Slå på den i Android-inställningarna. + Tillåt + Öppna inställningar + Kameraplacering + Kontrollera att kameran kan se dig innan du spelar. + Kontrollera + Kameraplacering + Ställ enheten upprätt 1,5 till 2 meter bort, på ungefär bröstnivå, med den bakre kameran vänd mot dig. Kliv sedan in i bild med händerna ute. + Ställ enheten upprätt 1,5 till 2 meter bort, på ungefär bröstnivå, med skärmen vänd mot dig. Kliv sedan in i bild med händerna ute. + Startar kameran… + Enheten ligger platt. Ställ upp den så att kameran är vänd mot dig. + Ställ enheten mer upprätt så att kameran är vänd mot dig. + Kan inte se dig än. Kliv in i bild så att huvud och axlar syns. + Ta ett steg bakåt. + Kom lite närmare. + Håll ut båda händerna där kameran kan se dem. + Ser bra ut. + Kameran kunde inte startas. Stäng andra appar som använder den och försök igen. + Byt kamera + Hoppa över + Klar + Väntar på glasögonens USB Ethernet-anslutning. Telefonen kanske inte gör den tillgänglig för appar. + XREAL-anslutningen avbröts eller gav ett svar som inte stöds. Försöker igen… + Experimentellt stöd för 1S. Använd Follow-läget och stäng av stabiliseringen i glasögonens meny. Spårningsriktningar och visningslägen behöver fortfarande testas på enheten. Cardboard VR Cardboard-läge Bilder för vänster och höger öga till ett VR-headset för mobilen. diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 3c2d4f495..445fe9756 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1,5 +1,12 @@ + กราฟิกของ SteamVR หยุดทำงาน + DroidDeck จะรีสตาร์ทใน %1$d วินาทีเพื่อรีเซ็ตกราฟิกและเปิด SteamVR อีกครั้ง เกมที่กำลังเล่นอยู่จะถูกปิด และความคืบหน้าที่ยังไม่ได้บันทึกจะกู้คืนไม่ได้ + ปัญหากราฟิกกลับมาอีกหลังรีสตาร์ทไปไม่นาน จึงหยุดการกู้คืนอัตโนมัติไว้ชั่วคราวเพื่อไม่ให้วนซ้ำ การรีสตาร์ทจะปิดเกมของคุณ และความคืบหน้าที่ยังไม่ได้บันทึกจะกู้คืนไม่ได้ + รีสตาร์ทเลย + เปิดไว้ต่อ + กำลังรีเซ็ตกราฟิกและเปิด SteamVR อีกครั้ง… + DroidDeck รีสตาร์ทโดยอัตโนมัติไม่ได้ ปิดแอปให้สนิทแล้วเปิดใหม่อีกครั้ง DroidDeck การเริ่มใช้งานครั้งแรกจะดาวน์โหลด Steam ซึ่งอาจใช้เวลาสักครู่ @@ -929,6 +936,8 @@ ปุ่มแป้นพิมพ์ กด Select เพื่อเปิดเมนู Steam แทน View/Select + ใช้ Joy-Con เป็นคอนโทรลเลอร์ VR + ขณะที่ SteamVR ทำงาน Joy-Con แต่ละตัวจะกลายเป็นมือ ชี้ไปข้างหน้าแล้วกด Home หรือ Capture ค้างไว้เพื่อจัดให้อยู่ตรงกลางอีกครั้ง ภาพเคลื่อนไหว สติ๊กจะปรากฏที่ตำแหน่งที่คุณแตะและซ่อนเมื่อปล่อย การจัดวาง @@ -986,6 +995,7 @@ KDE Plasma, Firefox และโปรแกรมจำลองของคุณ เกมที่คุณติดตั้งใน Steam จะปรากฏที่นี่หลังจากจบเซสชัน เล่น Steam + เล่น SteamVR การตั้งค่าการเปิดเกม เปิดเกม ไฟล์เกม @@ -2280,6 +2290,113 @@ เชื่อมต่อกับ GOG ไม่ได้ ไม่สามารถอ่านคลังเกม GOG ของคุณได้ กำลังลงทะเบียนกับ Steam… + แว่นตา + แว่นตา VITURE + การควบคุมแว่นตา + บิลด์นี้ไม่มีไดรเวอร์สำหรับแว่นตานี้ + เชื่อมต่อกับแว่นตาไม่ได้ เสียบสายใหม่ หรือแตะ “อนุญาต” เพื่อลองอีกครั้ง + อนุญาต + ให้ DroidDeck ตั้งค่าอัตรารีเฟรช โหมด 3D ความสว่าง ระดับเสียง และการกันแดดของแว่นตา รวมถึงอ่านข้อมูลการติดตามศีรษะ + กำลังรอให้คุณอนุญาตในกล่องโต้ตอบของ Android… + การควบคุมถูกปฏิเสธ โปรดอนุญาตเพื่อเปลี่ยนการตั้งค่าของแว่นตา + แว่นตานี้แสดงเกมได้ แต่เปลี่ยนการตั้งค่าจากที่นี่ไม่ได้ + เอาต์พุต + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + แสดงเกมบนแว่นตา + แสดงเกมบนแว่นตาเท่านั้น เมื่อปิด: เกมจะแสดงบนโทรศัพท์ และ Android จะสะท้อนหน้าจอโทรศัพท์ + หน้าจอ + อัตรารีเฟรช + 3D (แบบเคียงข้าง) + ตาแต่ละข้างจะเห็นภาพคนละครึ่งของภาพขนาด 3840×1080 หากภาพหายไป ให้กดปุ่ม 2D/3D ของแว่นตาค้างไว้ + ยังยืนยันสัญญาณ 3D ไม่ได้ ลองอีกครั้งเมื่อภาพ 2D กลับมา + ไม่มีภาพ: แว่นตาอยู่ในโหมดที่โทรศัพท์นี้ส่งสัญญาณให้ไม่ได้ กดปุ่ม 2D/3D ของแว่นตาค้างไว้ หรือถอดสายแล้วเสียบใหม่ + อัตรารีเฟรช 3D + 3D สำหรับเกม VR + เปลี่ยนเป็น 3D ขณะที่ SteamVR ทำงาน และเปลี่ยนกลับเมื่อเลิกใช้ + ภาพ 3D + อัตโนมัติ: SteamVR แสดงเต็มมุมมอง ส่วนเกมอื่นจะลอยเป็นหน้าจอในโหมด 3D และแสดงภาพแบนในโหมด 2D + อัตโนมัติ + หน้าจอเสมือน + เกมแบบเคียงข้าง + ความกว้างหน้าจอ + ระยะห่างหน้าจอ + ความกว้างและระยะห่างจะมีผลเมื่อเกมแสดงบนแว่นตาเป็นหน้าจอเสมือน + การตั้งค่าเหล่านี้จะมีผลเมื่อเปิด “แสดงเกมบนแว่นตา” และเกมกำลังแสดงอยู่บนแว่นตา + โหมดอัตโนมัติจะแสดงเกม 2D นี้แบบเต็มจอ ใช้หน้าจอเสมือนเพื่อปรับความกว้าง ระยะห่าง และการติดตามศีรษะ + ใช้หน้าจอเสมือน + SteamVR แสดงเต็มมุมมอง ปรับขอบเขตการมองเห็นได้ด้านล่าง ส่วนความกว้างและระยะห่างใช้กับหน้าจอเสมือนเท่านั้น + เกมแบบเคียงข้างจะแสดงเต็มภาพในตาแต่ละข้าง ความกว้างและระยะห่างใช้กับหน้าจอเสมือนเท่านั้น + โหมดอัตโนมัติจะตรึงเกม 2D นี้ไว้ในมุมมอง เลือก “หน้าจอเสมือน” ในส่วน “หน้าจอ” เพื่อใช้การติดตามศีรษะ + การติดตามศีรษะใช้กับหน้าจอเสมือนหรือเกม SteamVR ที่กำลังทำงาน + แว่นตา + ความสว่าง + ระดับเสียง + กันแดด + ทำให้เลนส์ด้านหน้ามืดลง + การติดตามศีรษะ + การติดตามศีรษะ + เซ็นเซอร์ในตัวแว่นตาจะหมุนหน้าจอเสมือนและ SteamVR ตามศีรษะของคุณ + จัดมุมมองให้อยู่ตรงกลาง + จัดกึ่งกลาง + ขอบเขตการมองเห็นของ SteamVR + ค่าต่ำจะดึง SteamVR เข้ามาใกล้และเต็มแว่นตา ค่าสูงจะแสดงสิ่งรอบตัวคุณได้มากขึ้น + กำลังเล่นบน %1$s + การปรับเทียบการติดตาม + ลดการดริฟต์และปรับแนวระดับอ้างอิงให้ตรง + ใช้การปรับเทียบที่บันทึกไว้กับแว่นตานี้แล้ว + ปรับเทียบ + วางแว่นตาที่เชื่อมต่ออยู่แบบกลับหัวบนพื้นผิวที่ได้ระดับ โดยให้กรอบแว่นวางราบเสมอกัน แตะ “เริ่ม” แล้วอย่าแตะต้องแว่นประมาณ 8 วินาที หากมีการขยับ การวัดจะเริ่มใหม่ การปรับเทียบนี้แก้การดริฟต์และการเอียง แต่ไม่แก้ทิศทางที่หัน + วางแว่นตากลับหัวไว้และอย่าแตะต้อง กำลังรอให้แว่นนิ่ง… + กำลังวัดค่าเซ็นเซอร์ อย่าขยับแว่นตา + ตรวจพบการเคลื่อนไหว อย่าแตะต้องแว่นตา การวัดจะเริ่มใหม่โดยอัตโนมัติ + วางแว่นตากลับหัวบนพื้นผิวที่ได้ระดับ การวัดจะเริ่มเมื่อวางแว่นในตำแหน่งที่ถูกต้อง + บันทึกการปรับเทียบแล้ว สวมแว่นตาและมองตรงไปข้างหน้า แล้วแตะ “เสร็จสิ้น” เพื่อจัดกึ่งกลาง + ปรับเทียบไม่สำเร็จ ตรวจสอบการเชื่อมต่อและอย่าขยับแว่นตา แล้วลองอีกครั้ง การปรับเทียบก่อนหน้ายังคงเดิม + เริ่ม + เสร็จสิ้น + รีเซ็ตการปรับเทียบการติดตาม + ลบการปรับเทียบที่บันทึกไว้และคืนค่าเริ่มต้นของรุ่น? สวมแว่นตาอีกครั้งแล้วจัดกึ่งกลางใหม่ + รีเซ็ต + การติดตามมือ + ติดตามมือของคุณด้วยกล้อง? + กล้องมองเห็นมือของคุณได้ เมื่อไม่ได้เชื่อมต่อคอนโทรลเลอร์ มือเปล่าของคุณจะกลายเป็นมือใน VR: แตะนิ้วโป้งกับนิ้วชี้เข้าหากันเพื่อคลิก กำมือเพื่อจับ และหันฝ่ามือเข้าหาตัวแล้วแตะนิ้วเข้าหากันเพื่อเปิดเมนู เมื่อใช้ Joy-Con กล้องจะวางไว้ตรงตำแหน่งที่มือของคุณอยู่จริง ภาพจะถูกประมวลผลบนอุปกรณ์นี้เท่านั้น และจะไม่ถูกบันทึกหรือส่งออกไปที่ใด + กล้องหลัง + แนะนำ โดยทั่วไปจะคมชัดและมีมุมมองกว้างกว่า + กล้องหน้า + ใช้เมื่อด้านหลังของอุปกรณ์ไม่สามารถหันมาทางคุณได้ + ใช้กล้อง + ไม่ใช่ตอนนี้ + การติดตามมือด้วยกล้อง + เมื่อไม่ได้เชื่อมต่อคอนโทรลเลอร์ มือของคุณจะกลายเป็นมือใน VR: แตะนิ้วเข้าหากันเพื่อคลิก หันฝ่ามือเข้าหาตัวแล้วแตะนิ้วเพื่อเปิดเมนู เมื่อใช้ Joy-Con จะจัดวางตามตำแหน่งที่กล้องมองเห็น ภาพจะอยู่ในอุปกรณ์นี้เท่านั้น + กล้อง + สิทธิ์กล้อง + อนุญาตสิทธิ์กล้องเพื่อให้เริ่มการติดตามมือได้ + สิทธิ์กล้องปิดอยู่ กรุณาเปิดในการตั้งค่า Android + อนุญาต + เปิดการตั้งค่า + ตำแหน่งกล้อง + ตรวจสอบว่ากล้องมองเห็นคุณก่อนเริ่มเล่น + ตรวจสอบ + ตำแหน่งกล้อง + ตั้งอุปกรณ์ให้ห่างออกไป 1.5 ถึง 2 เมตร ที่ระดับความสูงประมาณหน้าอก โดยให้กล้องหลังหันมาทางคุณ จากนั้นเดินเข้ามาในมุมมองพร้อมยื่นมือทั้งสองข้างออกมา + ตั้งอุปกรณ์ให้ห่างออกไป 1.5 ถึง 2 เมตร ที่ระดับความสูงประมาณหน้าอก โดยให้หน้าจอหันมาทางคุณ จากนั้นเดินเข้ามาในมุมมองพร้อมยื่นมือทั้งสองข้างออกมา + กำลังเริ่มกล้อง… + อุปกรณ์วางราบอยู่ กรุณาตั้งให้กล้องหันมาทางคุณ + ตั้งอุปกรณ์ให้ตรงขึ้นอีกหน่อยเพื่อให้กล้องหันมาทางคุณ + ยังมองไม่เห็นคุณ กรุณาเข้ามาในมุมมองให้เห็นศีรษะและไหล่ของคุณ + ถอยหลังอีกนิด + เข้ามาใกล้อีกนิด + ยื่นมือทั้งสองข้างออกมาในตำแหน่งที่กล้องมองเห็นได้ + ดูดีแล้ว + กล้องเริ่มทำงานไม่ได้ กรุณาปิดแอปอื่นที่ใช้กล้องอยู่แล้วลองอีกครั้ง + สลับกล้อง + ข้าม + เสร็จสิ้น + กำลังรอการเชื่อมต่อ USB Ethernet ของแว่นตา โทรศัพท์นี้อาจไม่เปิดให้แอปเข้าถึงการเชื่อมต่อนี้ + การเชื่อมต่อ XREAL ขาดหายหรือได้รับการตอบกลับที่ไม่รองรับ กำลังลองใหม่… + รองรับ 1S แบบทดลอง ใช้โหมด Follow และปิดการกันสั่นในเมนูของแว่นตา ทิศทางการติดตามและโหมดการแสดงผลยังต้องทดสอบกับอุปกรณ์จริง Cardboard VR โหมด Cardboard ภาพสำหรับตาซ้ายและตาขวาสำหรับแว่น VR ของโทรศัพท์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 72c4d890a..0879104a3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,5 +1,12 @@ + SteamVR grafikleri durdu + DroidDeck, grafikleri sıfırlamak ve SteamVR\'ı yeniden açmak için %1$d saniye içinde yeniden başlayacak. Çalışan oyununuz kapanacak; kaydedilmemiş ilerleme geri yüklenemez. + Grafik hatası, kısa süre önceki yeniden başlatmanın ardından yine oluştu. Döngüye girmemek için otomatik kurtarma duraklatıldı. Yeniden başlatma oyununuzu kapatır; kaydedilmemiş ilerleme geri yüklenemez. + Şimdi yeniden başlat + Açık kalsın + Grafikler sıfırlanıyor ve SteamVR yeniden açılıyor… + DroidDeck otomatik olarak yeniden başlatılamadı. Uygulamayı tamamen kapatıp yeniden açın. DroidDeck İlk çalıştırmada Steam indirilir. Bu işlem birkaç dakika sürebilir. @@ -924,6 +931,8 @@ Klavye düğmesi Steam menüsünü açmak için Select\'e basın Görünüm/Select yerine. + VR kumandası olarak Joy-Con\'lar + SteamVR çalışırken her Joy-Con bir el olur. Öne doğrultun ve yeniden ortalamak için Home veya Capture düğmesini basılı tutun. Animasyonlar Çubuklar dokunduğunuz yerde görünür, bıraktığınızda gizlenir Düzen @@ -981,6 +990,7 @@ KDE Plasma, Firefox ve emülatörleriniz Steam\'de yüklediğiniz oyunlar oturumdan sonra burada görünür. Steam\'de oyna + SteamVR\'da oyna Başlatma ayarları Başlat Oyun dosyaları @@ -2291,6 +2301,113 @@ GOG\'a bağlanılamadı GOG kitaplığın okunamadı Steam\'e kaydediliyor… + Gözlük + VITURE gözlüğü + Gözlük kontrolü + Bu derleme, bu gözlüğün sürücüsünü içermiyor. + Gözlüğe bağlanılamadı. Kabloyu yeniden takın veya yeniden denemek için İzin ver\'e dokunun. + İzin ver + DroidDeck\'in gözlüğün yenileme hızını, 3D modunu, parlaklığını, ses düzeyini ve güneş korumasını ayarlamasına ve kafa takibini okumasına izin verir. + Android penceresinde izin vermeniz bekleniyor… + Kontrol reddedildi. Gözlüğün ayarlarını değiştirmek için izin verin. + Bu gözlük oyunu gösterir ama ayarları buradan değiştirilemez. + Çıkış + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Oyun gözlükte + Oyunu yalnızca gözlükte gösterir. Kapalı: oyunu telefon gösterir ve Android telefon ekranını yansıtır. + Ekran + Yenileme hızı + 3D (yan yana) + Her göz 3840×1080 görüntünün kendi yarısını alır. Görüntü kesilirse gözlüğün 2D/3D düğmesini basılı tutun. + 3D sinyali doğrulanamadı. 2D görüntü geri geldiğinde tekrar deneyin. + Görüntü yok: gözlük bu telefonun gönderemediği bir modda. Gözlüğün 2D/3D düğmesini basılı tutun ya da kablosunu çıkarıp yeniden takın. + 3D yenileme hızı + VR oyunlarında 3D + SteamVR çalışırken 3D\'ye geçer, sonra geri döner. + 3D görüntü + Otomatik: SteamVR görüşü doldurur; diğer oyunlar 3D\'de bir ekran olarak havada durur, 2D\'de düz kalır. + Otomatik + Sanal ekran + Yan yana oyun + Ekran genişliği + Ekran uzaklığı + Genişlik ve uzaklık, oyun gözlükte sanal ekran olarak çalıştığında uygulanır. + Bu ayarlar, “Oyun gözlükte” açıkken ve oyun orada gösterilirken geçerlidir. + Otomatik, bu 2D oyunu tam ekran tutar. Genişliğini, uzaklığını ve kafa takibini ayarlamak için sanal ekran kullanın. + Sanal ekranı kullan + SteamVR görüşü doldurur. Görüş alanını aşağıdan ayarlayın; genişlik ve uzaklık yalnızca sanal ekranda geçerlidir. + Yan yana oyunlar her gözü doldurur. Genişlik ve uzaklık yalnızca sanal ekranda geçerlidir. + Otomatik, bu 2D oyunu görüşte sabit tutar. Kafa takibini kullanmak için “Ekran” bölümünde “Sanal ekran” seçeneğine geçin. + Kafa takibi, sanal ekranda veya çalışan bir SteamVR oyununda geçerlidir. + Gözlük + Parlaklık + Ses düzeyi + Güneş koruması + Ön camları karartır. + Kafa takibi + Kafa takibi + Gözlüğün kendi sensörleri sanal ekranı ve SteamVR\'ı başınızla birlikte döndürür. + Görünümü ortala + Ortala + SteamVR görüş alanı + Düşük değer SteamVR\'ı yaklaştırır ve gözlüğü doldurur; yüksek değer çevrenizi daha çok gösterir. + %1$s ile oynanıyor + Takip kalibrasyonu + Kaymayı azaltır ve yatay düzlem referansını hizalar. + Kayıtlı kalibrasyon bu gözlüğe uygulanır. + Kalibre et + Bağlı gözlüğü, çerçevesi düzgünce oturacak şekilde düz bir yüzeye ters çevirerek koyun. Başlat\'a dokunun, sonra gözlüğe yaklaşık 8 saniye dokunmayın. Hareket ölçümü yeniden başlatır. Bu işlem kaymayı ve eğimi düzeltir, bakış yönünü değil. + Gözlüğü ters çevrili bırakın ve dokunmayın. Hareketsiz kalması bekleniyor… + Sensörler ölçülüyor. Gözlüğü sabit tutun. + Hareket algılandı. Gözlüğe dokunmayın; ölçüm otomatik olarak yeniden başlayacak. + Gözlüğü düz bir yüzeye ters çevirerek koyun. Doğru konumlandığında ölçüm başlayacak. + Kalibrasyon kaydedildi. Gözlüğü takıp tam karşıya bakın, ardından ortalamak için Bitti\'ye dokunun. + Kalibrasyon tamamlanamadı. Bağlantıyı kontrol edin, gözlüğü sabit tutup tekrar deneyin. Önceki kalibrasyonunuz değişmedi. + Başlat + Bitti + Takip kalibrasyonunu sıfırla + Kayıtlı kalibrasyon kaldırılsın ve model varsayılanları geri yüklensin mi? Gözlüğü yeniden taktıktan sonra ortalayın. + Sıfırla + El takibi + Elleriniz kamerayla takip edilsin mi? + Kamera ellerinizi görebilir. Bağlı bir kumanda yokken çıplak elleriniz VR elleri olur: tıklamak için başparmağınızla işaret parmağınızı birleştirin, tutmak için elinizi yumruk yapın, menüyü açmak için de avucunuzu kendinize çevirip parmaklarınızı birleştirin. Joy-Con\'larla kamera onları ellerinizin gerçekten bulunduğu yere yerleştirir. Görüntüler bu cihazda işlenir, asla kaydedilmez veya gönderilmez. + Arka kamera + Önerilir. Genellikle daha net ve daha geniş. + Ön kamera + Cihazın arkası size dönük olamadığında kullanın. + Kamerayı kullan + Şimdi değil + Kamerayla el takibi + Bağlı bir kumanda yokken elleriniz VR elleri olur: tıklamak için parmaklarınızı birleştirin, menü için avucunuzu kendinize çevirip birleştirin. Joy-Con\'larla onları kameranın gördüğü yere yerleştirir. Görüntüler bu cihazda kalır. + Kamera + Kamera erişimi + El takibinin başlayabilmesi için kamera erişimine izin verin. + Kamera erişimi kapalı. Android ayarlarından açın. + İzin ver + Ayarları aç + Kamera konumu + Oynamadan önce kameranın sizi görebildiğini kontrol edin. + Kontrol et + Kamera konumu + Cihazı yaklaşık göğüs hizasında, 1,5 ila 2 metre uzağa, arka kamera size dönük olacak şekilde dik durdurun. Ardından ellerinizi açarak görüntüye girin. + Cihazı yaklaşık göğüs hizasında, 1,5 ila 2 metre uzağa, ekran size dönük olacak şekilde dik durdurun. Ardından ellerinizi açarak görüntüye girin. + Kamera başlatılıyor… + Cihaz düz yatıyor. Kamera size dönük olacak şekilde dik durdurun. + Kamera size dönük olacak şekilde cihazı daha dik durdurun. + Henüz sizi göremiyor. Baş ve omuzlarınız görünecek şekilde görüntüye girin. + Biraz geri gidin. + Biraz daha yaklaşın. + Kameranın görebileceği şekilde iki elinizi de açın. + İyi görünüyor. + Kamera başlatılamadı. Kamerayı kullanan diğer uygulamaları kapatıp tekrar deneyin. + Kamerayı değiştir + Atla + Bitti + Gözlüğün USB Ethernet bağlantısı bekleniyor. Bu telefon bu bağlantıyı uygulamalara açmıyor olabilir. + XREAL bağlantısı kesildi veya desteklenmeyen bir yanıt döndü. Yeniden deneniyor… + Deneysel 1S desteği. Gözlük menüsünde Follow modunu kullanın ve görüntü sabitlemeyi kapatın. Takip yönlerinin ve ekran modlarının hâlâ cihazda test edilmesi gerekiyor. Cardboard VR Cardboard modu Telefon VR gözlüğü için sol ve sağ göz görüntüleri. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9d493bf14..d047d0101 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -1,5 +1,12 @@ + Графіка SteamVR зупинилася + DroidDeck перезапуститься через %1$d с, щоб скинути графіку й знову відкрити SteamVR. Запущену гру буде закрито; незбережений прогрес не вдасться відновити. + Збій графіки повторився після нещодавнього перезапуску. Автоматичне відновлення призупинено, щоб уникнути циклу. Перезапуск закриє гру; незбережений прогрес не вдасться відновити. + Перезапустити зараз + Не закривати + Скидання графіки й повторне відкриття SteamVR… + DroidDeck не вдалося перезапуститися автоматично. Повністю закрийте додаток, а потім відкрийте його знову. DroidDeck Під час першого запуску завантажується Steam. Це може зайняти кілька хвилин. @@ -947,6 +954,8 @@ Кнопка клавіатури Натисніть Select, щоб відкрити меню Steam Замість View/Select. + Joy-Con як VR-контролери + Поки працює SteamVR, кожен Joy-Con стає рукою. Спрямуйте його вперед і утримуйте Home або Capture, щоб знову його відцентрувати. Анімації Стики з’являються там, де ви торкаєтеся, і зникають після відпускання Розкладка @@ -1004,6 +1013,7 @@ KDE Plasma, Firefox і ваші емулятори Ігри, які ви встановите в Steam, з’являться тут після сеансу. Грати в Steam + Грати в SteamVR Налаштування запуску Запустити Файли гри @@ -2355,6 +2365,113 @@ Не вдалося з\'єднатися з GOG Не вдалося прочитати вашу бібліотеку GOG Реєстрація в Steam… + Окуляри + Окуляри VITURE + Керування окулярами + Ця збірка не містить драйвера для цих окулярів. + Не вдалося під’єднатися до окулярів. Під’єднайте кабель повторно або торкніться «Дозволити», щоб спробувати ще раз. + Дозволити + Дозвольте DroidDeck змінювати частоту оновлення, режим 3D, яскравість, гучність і затемнення окулярів, а також зчитувати дані відстеження голови. + Очікування вашого дозволу у вікні Android… + Керування відхилено. Дозвольте його, щоб змінювати налаштування окулярів. + Ці окуляри показують гру, але їхні налаштування не можна змінити звідси. + Вихід + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Гра на окулярах + Показує гру лише на окулярах. Вимкнено: гру показує телефон, а Android дублює його екран. + Екран + Частота оновлення + 3D (стереопара) + Кожне око отримує свою половину зображення 3840×1080. Якщо зображення зникне, утримуйте кнопку 2D/3D на окулярах. + Сигнал 3D не підтверджено. Спробуйте знову, коли повернеться зображення 2D. + Немає зображення: окуляри в режимі, який цей телефон не може передати. Утримуйте їхню кнопку 2D/3D або від’єднайте й знову під’єднайте їх. + Частота оновлення в 3D + 3D для VR-ігор + Перемикає на 3D, поки працює SteamVR, а потім назад. + Зображення 3D + Автоматично: SteamVR заповнює поле зору; інші ігри висять як екран у 3D і лишаються пласкими у 2D. + Автоматично + Віртуальний екран + Стереопара з гри + Ширина екрана + Відстань до екрана + Ширина й відстань застосовуються, коли гра працює на окулярах як віртуальний екран. + Ці налаштування діють, коли ввімкнено «Гра на окулярах» і гра показується на них. + «Автоматично» показує цю 2D-гру на весь екран. Щоб налаштувати її ширину, відстань і відстеження голови, використайте віртуальний екран. + Використати віртуальний екран + SteamVR заповнює все зображення. Налаштуйте поле зору нижче; ширина й відстань стосуються лише віртуального екрана. + Ігри зі стереопарою займають усе поле зору кожного ока. Ширина й відстань стосуються лише віртуального екрана. + «Автоматично» тримає цю 2D-гру нерухомою в полі зору. Щоб використовувати відстеження голови, виберіть «Віртуальний екран» у розділі «Екран». + Відстеження голови діє для віртуального екрана або запущеної гри SteamVR. + Окуляри + Яскравість + Гучність + Затемнення + Затемнює передні лінзи. + Відстеження голови + Відстеження голови + Власні датчики окулярів повертають віртуальний екран і SteamVR услід за головою. + Відцентрувати вигляд + Відцентрувати + Поле зору SteamVR + Нижче значення наближає SteamVR і заповнює окуляри; вище — показує більше навколо вас. + Гра на %1$s + Калібрування відстеження + Зменшує дрейф і вирівнює опорний горизонт. + До цих окулярів застосовано збережене калібрування. + Калібрувати + Покладіть під’єднані окуляри догори дном на рівну горизонтальну поверхню, щоб оправа лежала рівно. Торкніться «Почати» й не чіпайте їх приблизно 8 секунд. Рух перезапускає вимірювання. Це виправляє дрейф і нахил, але не напрямок погляду. + Залиште окуляри догори дном і не чіпайте їх. Очікування, поки вони стабілізуються… + Вимірювання показань датчиків. Не рухайте окуляри. + Виявлено рух. Не чіпайте окуляри; вимірювання перезапуститься автоматично. + Покладіть окуляри догори дном на рівну горизонтальну поверхню. Вимірювання почнеться, коли їх буде розміщено правильно. + Калібрування збережено. Надіньте окуляри, подивіться прямо перед собою, а потім торкніться «Готово», щоб відцентрувати. + Не вдалося завершити калібрування. Перевірте з’єднання, тримайте окуляри нерухомо й спробуйте ще раз. Попереднє калібрування не змінено. + Почати + Готово + Скинути калібрування відстеження + Видалити збережене калібрування й відновити стандартні значення моделі? Відцентруйте вигляд, коли знову надінете окуляри. + Скинути + Відстеження рук + Відстежувати руки за допомогою камери? + Камера бачить ваші руки. Якщо контролер не підключено, ваші руки самі стають руками у VR: зведіть великий і вказівний пальці, щоб натиснути, стисніть кулак, щоб схопити, а щоб відкрити меню, поверніть долоню до себе й зведіть пальці. З Joy-Con камера розташовує їх там, де насправді перебувають ваші руки. Зображення обробляються на цьому пристрої і ніколи не зберігаються та не надсилаються. + Задня камера + Рекомендовано. Зазвичай різкіша і ширша. + Фронтальна камера + Для випадків, коли задню частину пристрою не можна спрямувати на себе. + Використати камеру + Не зараз + Відстеження рук камерою + Якщо контролер не підключено, ваші руки стають руками у VR: зведіть пальці, щоб натиснути, а повернувши долоню до себе, — щоб відкрити меню. З Joy-Con розташовує їх там, де їх бачить камера. Зображення залишаються на цьому пристрої. + Камера + Доступ до камери + Дозвольте доступ до камери, щоб розпочати відстеження рук. + Доступ до камери вимкнено. Увімкніть його в налаштуваннях Android. + Дозволити + Відкрити налаштування + Розташування камери + Перевірте, що камера бачить вас, перш ніж почати гру. + Перевірити + Розташування камери + Поставте пристрій вертикально на відстані від 1,5 до 2 метрів, приблизно на рівні грудей, спрямувавши задню камеру на себе. Потім станьте в кадр, витягнувши руки. + Поставте пристрій вертикально на відстані від 1,5 до 2 метрів, приблизно на рівні грудей, спрямувавши екран на себе. Потім станьте в кадр, витягнувши руки. + Запуск камери… + Пристрій лежить плиском. Поставте його вертикально, щоб камера була спрямована на вас. + Поставте пристрій більш вертикально, щоб камера була спрямована на вас. + Поки вас не видно. Станьте в кадр так, щоб було видно голову і плечі. + Відійдіть трохи назад. + Підійдіть трохи ближче. + Витягніть обидві руки так, щоб камера їх бачила. + Добре. + Не вдалося запустити камеру. Закрийте інші додатки, які її використовують, і спробуйте ще раз. + Змінити камеру + Пропустити + Готово + Очікування з’єднання USB Ethernet від окулярів. Цей телефон може не надавати його додаткам. + З’єднання з XREAL перервалося або повернуло непідтримувану відповідь. Повторна спроба… + Експериментальна підтримка 1S. Увімкніть режим Follow і вимкніть стабілізацію в меню окулярів. Напрямки відстеження й режими відображення ще потрібно перевірити на пристрої. Cardboard VR Режим Cardboard Зображення для лівого й правого ока для VR-окулярів під телефон. diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 359800a26..58befae92 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1,5 +1,12 @@ + Đồ họa SteamVR đã dừng + DroidDeck sẽ khởi động lại sau %1$d giây để đặt lại đồ họa và mở lại SteamVR. Game đang chạy sẽ bị đóng; tiến trình chưa lưu sẽ không thể khôi phục. + Lỗi đồ họa lại xuất hiện sau lần khởi động lại gần đây. Tự động khôi phục đã tạm dừng để tránh lặp vô hạn. Khởi động lại sẽ đóng game của bạn; tiến trình chưa lưu sẽ không thể khôi phục. + Khởi động lại ngay + Giữ mở + Đang đặt lại đồ họa và mở lại SteamVR… + DroidDeck không thể tự khởi động lại. Hãy đóng hẳn ứng dụng rồi mở lại. DroidDeck Lần chạy đầu tải Steam. Quá trình này có thể mất vài phút. @@ -929,6 +936,8 @@ Nút bàn phím Nhấn Select để mở menu Steam Thay cho View/Select. + Dùng Joy-Con làm tay cầm VR + Khi SteamVR đang chạy, mỗi Joy-Con trở thành một bàn tay. Hướng nó về phía trước rồi giữ Home hoặc Capture để căn giữa lại. Hiệu ứng chuyển động Cần xuất hiện ở nơi bạn chạm và ẩn khi thả ra Bố cục @@ -986,6 +995,7 @@ KDE Plasma, Firefox và các trình giả lập của bạn Game bạn cài trong Steam sẽ xuất hiện ở đây sau phiên. Chơi trên Steam + Chơi trên SteamVR Thiết lập khởi chạy Khởi chạy Tệp game @@ -2280,6 +2290,113 @@ Không thể kết nối với GOG Không thể đọc thư viện GOG của bạn Đang đăng ký với Steam… + Kính + Kính VITURE + Điều khiển kính + Bản build này không có driver cho kính này. + Không thể kết nối với kính. Hãy cắm lại cáp hoặc nhấn “Cho phép” để thử lại. + Cho phép + Để DroidDeck đặt tốc độ làm mới, chế độ 3D, độ sáng, âm lượng và chống nắng của kính, đồng thời đọc dữ liệu theo dõi đầu của kính. + Đang chờ bạn cho phép trong hộp thoại của Android… + Quyền điều khiển đã bị từ chối. Hãy cho phép để thay đổi thiết lập của kính. + Kính này hiển thị game, nhưng không thể thay đổi thiết lập của kính từ đây. + Đầu ra + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Game trên kính + Chỉ hiển thị game trên kính. Khi tắt: điện thoại hiển thị game và Android phản chiếu màn hình điện thoại. + Hiển thị + Tốc độ làm mới + 3D (cạnh nhau) + Mỗi mắt nhận một nửa riêng của hình 3840×1080. Nếu mất hình, hãy giữ nút 2D/3D của kính. + Chưa xác nhận được tín hiệu 3D. Hãy thử lại khi hình ảnh 2D trở lại. + Không có hình: kính đang ở chế độ mà điện thoại này không thể xuất. Hãy giữ nút 2D/3D của kính, hoặc rút ra rồi cắm lại. + Tốc độ làm mới 3D + 3D cho game VR + Chuyển sang 3D khi SteamVR chạy, rồi chuyển lại sau đó. + Hình ảnh 3D + Tự động: SteamVR lấp đầy tầm nhìn; các game khác hiện như một màn hình lơ lửng ở 3D và giữ phẳng ở 2D. + Tự động + Màn hình ảo + Game 3D cạnh nhau + Chiều rộng màn hình + Khoảng cách màn hình + Chiều rộng và khoảng cách được áp dụng khi game chạy trên kính dưới dạng màn hình ảo. + Các thiết lập này áp dụng khi “Game trên kính” đang bật và game đang hiển thị trên kính. + Tự động giữ game 2D này ở chế độ toàn màn hình. Dùng màn hình ảo để chỉnh chiều rộng, khoảng cách và theo dõi đầu. + Dùng màn hình ảo + SteamVR lấp đầy tầm nhìn. Chỉnh trường nhìn ở bên dưới; chiều rộng và khoảng cách chỉ áp dụng cho màn hình ảo. + Game 3D cạnh nhau lấp đầy từng mắt. Chiều rộng và khoảng cách chỉ áp dụng cho màn hình ảo. + Tự động giữ game 2D này cố định trong tầm nhìn. Chọn “Màn hình ảo” trong “Hiển thị” để dùng theo dõi đầu. + Theo dõi đầu áp dụng cho màn hình ảo hoặc game SteamVR đang chạy. + Kính + Độ sáng + Âm lượng + Chống nắng + Làm tối tròng kính phía trước. + Theo dõi đầu + Theo dõi đầu + Cảm biến riêng của kính xoay màn hình ảo và SteamVR theo đầu bạn. + Căn giữa lại góc nhìn + Căn giữa lại + Trường nhìn của SteamVR + Thấp hơn sẽ đưa SteamVR lại gần và lấp đầy kính; cao hơn cho thấy nhiều hơn xung quanh bạn. + Đang chơi trên %1$s + Hiệu chỉnh theo dõi + Giảm hiện tượng trôi và căn lại mốc thăng bằng. + Hiệu chỉnh đã lưu đang được áp dụng cho kính này. + Hiệu chỉnh + Đặt kính đang kết nối lật úp trên mặt phẳng nằm ngang, sao cho gọng kính tựa đều. Nhấn “Bắt đầu”, rồi để yên kính khoảng 8 giây. Chuyển động sẽ khiến phép đo bắt đầu lại. Thao tác này chỉnh độ trôi và độ nghiêng, không chỉnh hướng nhìn. + Để kính lật úp và không chạm vào. Đang chờ kính ổn định… + Đang đo cảm biến. Hãy giữ yên kính. + Phát hiện chuyển động. Đừng chạm vào kính; phép đo sẽ tự động bắt đầu lại. + Đặt kính lật úp trên mặt phẳng nằm ngang. Phép đo sẽ bắt đầu khi kính được đặt đúng vị trí. + Đã lưu hiệu chỉnh. Đeo kính vào và nhìn thẳng về phía trước, rồi nhấn “Xong” để căn giữa lại. + Không thể hoàn tất hiệu chỉnh. Hãy kiểm tra kết nối và giữ yên kính, rồi thử lại. Hiệu chỉnh trước đó vẫn được giữ nguyên. + Bắt đầu + Xong + Đặt lại hiệu chỉnh theo dõi + Xóa hiệu chỉnh đã lưu và khôi phục mặc định của mẫu kính? Hãy căn giữa lại sau khi đeo kính vào. + Đặt lại + Theo dõi tay + Theo dõi tay của bạn bằng camera? + Camera có thể thấy tay bạn. Khi không kết nối tay cầm nào, đôi tay trần của bạn sẽ trở thành tay trong VR: chụm ngón cái và ngón trỏ để nhấp, nắm tay lại để cầm, và xoay lòng bàn tay về phía bạn rồi chụm ngón tay để mở menu. Khi dùng Joy-Con, camera đặt chúng đúng nơi tay bạn thực sự ở. Hình ảnh được xử lý ngay trên thiết bị này và không bao giờ được lưu hay gửi đi. + Camera sau + Nên dùng. Thường sắc nét và có góc nhìn rộng hơn. + Camera trước + Dùng khi không thể hướng mặt sau thiết bị về phía bạn. + Dùng camera + Không phải bây giờ + Theo dõi tay bằng camera + Khi không kết nối tay cầm nào, tay bạn sẽ trở thành tay trong VR: chụm ngón tay để nhấp, lòng bàn tay hướng về bạn và chụm ngón để mở menu. Khi dùng Joy-Con, chúng được đặt đúng vị trí camera nhìn thấy. Hình ảnh luôn ở lại trên thiết bị này. + Camera + Quyền camera + Hãy cho phép quyền camera để bắt đầu theo dõi tay. + Quyền camera đang tắt. Hãy bật trong thiết lập Android. + Cho phép + Mở thiết lập + Vị trí camera + Hãy kiểm tra xem camera có thấy bạn không trước khi chơi. + Kiểm tra + Vị trí camera + Dựng thiết bị cách xa khoảng 1,5 đến 2 mét, ở độ cao ngang ngực, với camera sau hướng về phía bạn. Sau đó bước vào khung hình và đưa cả hai tay ra. + Dựng thiết bị cách xa khoảng 1,5 đến 2 mét, ở độ cao ngang ngực, với màn hình hướng về phía bạn. Sau đó bước vào khung hình và đưa cả hai tay ra. + Đang khởi động camera… + Thiết bị đang nằm ngang. Hãy dựng đứng để camera hướng về phía bạn. + Hãy dựng thiết bị thẳng đứng hơn để camera hướng về phía bạn. + Chưa thấy bạn. Hãy bước vào khung hình để thấy rõ đầu và vai bạn. + Hãy lùi lại một chút. + Hãy lại gần hơn một chút. + Hãy đưa cả hai tay ra nơi camera có thể nhìn thấy. + Trông ổn rồi. + Không thể khởi động camera. Hãy đóng các ứng dụng khác đang dùng camera rồi thử lại. + Đổi camera + Bỏ qua + Xong + Đang chờ kết nối USB Ethernet của kính. Điện thoại này có thể không cho ứng dụng truy cập kết nối đó. + Kết nối XREAL bị gián đoạn hoặc trả về phản hồi không được hỗ trợ. Đang thử lại… + Hỗ trợ 1S thử nghiệm. Dùng chế độ Follow và tắt ổn định hình ảnh trong menu của kính. Hướng theo dõi và các chế độ hiển thị vẫn cần thử nghiệm trên thiết bị. Cardboard VR Chế độ Cardboard Hình cho mắt trái và mắt phải dành cho kính VR điện thoại. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 29b498a73..7358b987a 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,12 @@ + SteamVR 图形已停止工作 + DroidDeck 将在 %1$d 秒后重启,以重置图形并重新打开 SteamVR。正在运行的游戏将被关闭,未保存的进度无法恢复。 + 最近一次重启后,图形故障再次出现。为避免循环,自动恢复已暂停。重启会关闭你的游戏,未保存的进度无法恢复。 + 立即重启 + 保持打开 + 正在重置图形并重新打开 SteamVR… + DroidDeck 无法自动重启。请完全关闭应用,然后重新打开。 DroidDeck 首次运行会下载 Steam,这可能需要几分钟。 @@ -1011,6 +1018,8 @@ 键盘按钮 按 Select 打开 Steam 菜单 代替 View/Select。 + 将 Joy-Con 用作 VR 手柄 + SteamVR 运行时,每个 Joy-Con 都会成为一只手。将其指向前方并按住 HOME 键或截图键即可重新居中。 动画 摇杆出现在触摸处,松手后隐藏 布局 @@ -1068,6 +1077,7 @@ KDE Plasma、Firefox 和你的模拟器 你在 Steam 安装的游戏会在会话结束后显示在这里。 游玩 Steam + 游玩 SteamVR 启动设置 启动 游戏文件 @@ -2280,6 +2290,113 @@ 无法连接到 GOG 无法读取你的 GOG 游戏库 正在向 Steam 注册… + 眼镜 + VITURE 眼镜 + 眼镜控制 + 此版本未包含这副眼镜的驱动。 + 无法连接眼镜。请重新插好线缆,或点按“允许”重试。 + 允许 + 允许 DroidDeck 设置眼镜的刷新率、3D 模式、亮度、音量和遮光,并读取其头部追踪。 + 正在等待你在 Android 提示中允许… + 控制已被拒绝。允许后才能更改眼镜设置。 + 这副眼镜可以显示游戏,但无法在此更改其设置。 + 输出 + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + 在眼镜上显示游戏 + 只在眼镜上显示游戏。关闭:由手机显示,Android 会镜像手机画面。 + 显示 + 刷新率 + 3D(左右分屏) + 每只眼睛各看 3840×1080 画面的一半。如果画面消失,请长按眼镜上的 2D/3D 按钮。 + 未能确认 3D 信号。请在 2D 画面恢复后重试。 + 无画面:眼镜处于这部手机无法输出的模式。请长按眼镜的 2D/3D 按钮,或拔下后重新连接。 + 3D 刷新率 + VR 游戏使用 3D + SteamVR 运行时切换到 3D,结束后切回。 + 3D 画面 + 自动:SteamVR 填满视野;其他游戏在 3D 下以屏幕形式悬浮,在 2D 下保持平面。 + 自动 + 虚拟屏幕 + 左右分屏游戏 + 屏幕宽度 + 屏幕距离 + 当游戏以虚拟屏幕形式在眼镜上运行时,宽度和距离才会生效。 + 这些设置在开启“在眼镜上显示游戏”且游戏显示在眼镜上时生效。 + “自动”会让这款 2D 游戏全屏显示。使用虚拟屏幕可调整其宽度、距离和头部追踪。 + 使用虚拟屏幕 + SteamVR 会填满视野。可在下方调整其视场角;宽度和距离仅适用于虚拟屏幕。 + 左右分屏游戏会填满每只眼睛的视野。宽度和距离仅适用于虚拟屏幕。 + “自动”会让这款 2D 游戏固定在视野中。要使用头部追踪,请在“显示”中选择“虚拟屏幕”。 + 头部追踪适用于虚拟屏幕或正在运行的 SteamVR 游戏。 + 眼镜 + 亮度 + 音量 + 遮光 + 让前镜片变暗。 + 头部追踪 + 头部追踪 + 眼镜自带的传感器会随头部转动虚拟屏幕和 SteamVR。 + 重新居中视角 + 重新居中 + SteamVR 视场角 + 调低让 SteamVR 更近并填满眼镜;调高可看到更多周围内容。 + 正在 %1$s 上游玩 + 追踪校准 + 减少漂移并对齐水平基准。 + 这副眼镜已应用保存的校准。 + 校准 + 将已连接的眼镜倒置放在水平面上,让镜框平稳贴合。点按“开始”,然后约 8 秒内不要触碰。一旦移动,测量会重新开始。此操作校正漂移和倾斜,不校正朝向。 + 保持眼镜倒置,不要触碰。正在等待其静止… + 正在测量传感器。请保持眼镜静止。 + 检测到移动。请勿触碰眼镜;测量将自动重新开始。 + 请将眼镜倒置放在水平面上。放置正确后将开始测量。 + 校准已保存。戴上眼镜并直视前方,然后点按“完成”以重新居中。 + 校准未能完成。请检查连接并保持眼镜静止,然后重试。之前的校准保持不变。 + 开始 + 完成 + 重置追踪校准 + 删除已保存的校准并恢复该型号的默认值?重新戴上眼镜后请重新居中。 + 重置 + 手部追踪 + 要用相机追踪你的双手吗? + 相机能看到你的双手。未连接手柄时,你的双手会直接成为 VR 中的手:拇指和食指捏合即可点击,握拳即可抓取,掌心朝向自己再捏合即可打开菜单。使用 Joy-Con 时,会把它们放在你双手的实际位置。画面在本设备上处理,绝不会被保存或发送。 + 后置相机 + 推荐使用。通常更清晰、视野更广。 + 前置相机 + 适用于设备背面无法朝向你的情况。 + 使用相机 + 暂不 + 相机手部追踪 + 未连接手柄时,你的双手会成为 VR 中的手:捏合即可点击,掌心朝向自己再捏合即可打开菜单。使用 Joy-Con 时,会将其放在相机看到的位置。画面不会离开本设备。 + 相机 + 相机权限 + 请允许相机权限,以便开始手部追踪。 + 相机权限已关闭。请在 Android 设置中开启。 + 允许 + 打开设置 + 相机位置 + 开始游戏前,请确认相机能看到你。 + 检查 + 相机位置 + 将设备立在距离 1.5 到 2 米、约胸口高度的地方,让后置相机对着你。然后张开双手走进画面。 + 将设备立在距离 1.5 到 2 米、约胸口高度的地方,让屏幕对着你。然后张开双手走进画面。 + 正在启动相机… + 设备平放着。请将它立起来,让相机对着你。 + 请把设备立得更直一些,让相机对着你。 + 还看不到你。请走进画面,让头和肩膀露出来。 + 请稍微往后退一点。 + 请再靠近一点。 + 请在相机能看到的位置伸出双手。 + 看起来不错。 + 相机无法启动。请关闭正在使用相机的其他应用,然后重试。 + 切换相机 + 跳过 + 完成 + 正在等待眼镜的 USB Ethernet 连接。这部手机可能不向应用开放此连接。 + XREAL 连接中断或返回了不受支持的响应。正在重试… + 实验性 1S 支持。请在眼镜菜单中使用跟随模式并关闭防抖。追踪方向和显示模式仍需在设备上测试。 开发商 发行商 发行日期 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b664ab1f7..cdcc363a1 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1,5 +1,12 @@ + SteamVR 圖形已停止運作 + DroidDeck 將於 %1$d 秒後重新啟動,以重設圖形並重新開啟 SteamVR。正在運行的遊戲會被關閉,未儲存的進度無法還原。 + 最近一次重新啟動後,圖形故障再次出現。為避免不斷循環,自動復原已暫停。重新啟動會關閉你的遊戲,未儲存的進度無法還原。 + 立即重新啟動 + 保持開啟 + 正在重設圖形並重新開啟 SteamVR… + DroidDeck 無法自動重新啟動。請完全關閉應用程式,然後重新開啟。 DroidDeck 首次執行會下載 Steam,可能需要幾分鐘。 @@ -929,6 +936,8 @@ 鍵盤按鈕 按 Select 開啟 Steam 選單 代替 View/Select。 + 將 Joy-Con 用作 VR 控制器 + SteamVR 執行時,每個 Joy-Con 都會變成一隻手。將它指向前方並按住 HOME 鍵或擷取鍵,即可重新置中。 動畫 搖桿會在你觸控的位置出現,放開後隱藏 佈局 @@ -986,6 +995,7 @@ KDE Plasma、Firefox 和你的模擬器 你在 Steam 安裝的遊戲會在會話結束後顯示在這裡。 開啟 Steam + 開啟 SteamVR 啟動設定 啟動 遊戲檔案 @@ -2280,6 +2290,113 @@ 無法連線至 GOG 無法讀取你的 GOG 遊戲庫 正在向 Steam 註冊… + 眼鏡 + VITURE 眼鏡 + 眼鏡控制 + 此版本不包含這副眼鏡的驅動程式。 + 無法連接眼鏡。請重新插上連接線,或輕按「允許」重試。 + 允許 + 允許 DroidDeck 設定眼鏡的更新率、3D 模式、亮度、音量和遮光,並讀取其頭部追蹤。 + 正在等候你在 Android 提示中允許… + 控制已被拒絕。允許後才可更改眼鏡設定。 + 這副眼鏡可以顯示遊戲,但無法在此更改其設定。 + 輸出 + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + 在眼鏡上顯示遊戲 + 只在眼鏡上顯示遊戲。關閉:由手機顯示,Android 會鏡像手機畫面。 + 顯示 + 更新率 + 3D(左右並排) + 每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。 + 未能確認 3D 訊號。請在 2D 畫面恢復後重試。 + 沒有畫面:眼鏡處於這部手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。 + 3D 更新率 + VR 遊戲使用 3D + SteamVR 運行時切換到 3D,結束後切回。 + 3D 畫面 + 自動:SteamVR 填滿視野;其他遊戲在 3D 下以螢幕形式懸浮,在 2D 下保持平面。 + 自動 + 虛擬螢幕 + 左右並排遊戲 + 螢幕寬度 + 螢幕距離 + 當遊戲以虛擬螢幕形式在眼鏡上運行時,寬度和距離才會生效。 + 這些設定在開啟「在眼鏡上顯示遊戲」且遊戲顯示於眼鏡上時生效。 + 「自動」會令這款 2D 遊戲全螢幕顯示。使用虛擬螢幕可調整其寬度、距離和頭部追蹤。 + 使用虛擬螢幕 + SteamVR 會填滿視野。可在下方調整其視場角;寬度和距離只適用於虛擬螢幕。 + 左右並排遊戲會填滿每隻眼睛的視野。寬度和距離只適用於虛擬螢幕。 + 「自動」會令這款 2D 遊戲固定在視野中。如要使用頭部追蹤,請在「顯示」中選擇「虛擬螢幕」。 + 頭部追蹤適用於虛擬螢幕或正在運行的 SteamVR 遊戲。 + 眼鏡 + 亮度 + 音量 + 遮光 + 令前鏡片變暗。 + 頭部追蹤 + 頭部追蹤 + 眼鏡自帶的感應器會隨頭部轉動虛擬螢幕和 SteamVR。 + 重新置中視角 + 重新置中 + SteamVR 視場角 + 調低令 SteamVR 更近並填滿眼鏡;調高可看到更多周圍內容。 + 正在 %1$s 上遊玩 + 追蹤校準 + 減少漂移並對齊水平基準。 + 這副眼鏡已套用儲存的校準。 + 校準 + 將已連接的眼鏡倒轉放在水平面上,使鏡框平穩貼合。輕按「開始」,然後約 8 秒內不要觸碰。一旦移動,測量會重新開始。此操作校正漂移和傾斜,但不校正朝向。 + 保持眼鏡倒轉,不要觸碰。正在等候其靜止… + 正在測量感應器。請保持眼鏡靜止。 + 偵測到移動。請勿觸碰眼鏡;測量會自動重新開始。 + 請將眼鏡倒轉放在水平面上。放置正確後便會開始測量。 + 校準已儲存。戴上眼鏡並直視前方,然後輕按「完成」以重新置中。 + 未能完成校準。請檢查連接並保持眼鏡靜止,然後再試一次。之前的校準保持不變。 + 開始 + 完成 + 重設追蹤校準 + 要移除已儲存的校準並還原此型號的預設值嗎?重新戴上眼鏡後請重新置中。 + 重設 + 手部追蹤 + 要用相機追蹤你的雙手嗎? + 相機能看到你的雙手。未連接控制器時,你的雙手會直接成為 VR 中的手:拇指和食指捏合即可點擊,握拳即可抓取,掌心朝向自己再捏合即可打開選單。使用 Joy-Con 時,會把它們放在你雙手的實際位置。畫面會在此裝置上處理,絕不會被儲存或傳送。 + 後置相機 + 建議使用。通常更清晰、視野更廣。 + 前置相機 + 適用於裝置背面無法朝向你的情況。 + 使用相機 + 稍後 + 相機手部追蹤 + 未連接控制器時,你的雙手會成為 VR 中的手:捏合即可點擊,掌心朝向自己再捏合即可打開選單。使用 Joy-Con 時,會將其放在相機看到的位置。畫面不會離開此裝置。 + 相機 + 相機權限 + 請允許相機權限,才能開始手部追蹤。 + 相機權限已關閉。請在 Android 設定中開啟。 + 允許 + 開啟設定 + 相機位置 + 開始玩之前,請確認相機能看到你。 + 檢查 + 相機位置 + 將裝置立在距離 1.5 到 2 米、約胸口高度的地方,讓後置相機對著你。然後張開雙手走進畫面。 + 將裝置立在距離 1.5 到 2 米、約胸口高度的地方,讓螢幕對著你。然後張開雙手走進畫面。 + 正在啟動相機… + 裝置平放著。請將它立起來,讓相機對著你。 + 請把裝置立得更直一點,讓相機對著你。 + 還看不到你。請走進畫面,讓頭和肩膀露出來。 + 請稍微往後退一點。 + 請再靠近一點。 + 請在相機能看到的位置伸出雙手。 + 看起來不錯。 + 相機無法啟動。請關閉正在使用相機的其他應用程式,然後再試一次。 + 切換相機 + 略過 + 完成 + 正在等候眼鏡的 USB Ethernet 連線。這部手機可能不會向應用程式開放此連線。 + XREAL 連線中斷或傳回不支援的回應。正在重試… + 實驗性 1S 支援。請在眼鏡選單中使用跟隨模式並關閉畫面穩定。追蹤方向和顯示模式仍需在裝置上測試。 開發商 發行商 發行日期 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 380f92382..a3412487b 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,5 +1,12 @@ + SteamVR 圖形已停止運作 + DroidDeck 將在 %1$d 秒後重新啟動,以重設圖形並重新開啟 SteamVR。執行中的遊戲會被關閉,未儲存的進度將無法復原。 + 最近一次重新啟動後,圖形故障再次發生。為避免無限循環,已暫停自動復原。重新啟動會關閉你的遊戲,未儲存的進度將無法復原。 + 立即重新啟動 + 保持開啟 + 正在重設圖形並重新開啟 SteamVR… + DroidDeck 無法自動重新啟動。請完全關閉應用程式,然後重新開啟。 DroidDeck 首次執行會下載 Steam。這可能需要幾分鐘。 @@ -929,6 +936,8 @@ 鍵盤按鈕 按 Select 開啟 Steam 功能表 代替 View/Select。 + 將 Joy-Con 當作 VR 控制器 + SteamVR 執行時,每個 Joy-Con 都會變成一隻手。將它指向前方並按住 HOME 鍵或擷取鍵,即可重新置中。 動畫 搖桿會出現在你觸控的位置,並在放開後隱藏 版面配置 @@ -986,6 +995,7 @@ KDE Plasma、Firefox 和你的模擬器 在 Steam 中安裝的遊戲會在工作階段結束後顯示在這裡。 執行 Steam + 執行 SteamVR 啟動設定 啟動 遊戲檔案 @@ -2280,6 +2290,113 @@ 無法連線到 GOG 無法讀取你的 GOG 遊戲庫 正在向 Steam 註冊… + 眼鏡 + VITURE 眼鏡 + 眼鏡控制 + 這個版本未包含這副眼鏡的驅動程式。 + 無法連線到眼鏡。請重新插上傳輸線,或點擊「允許」重試。 + 允許 + 允許 DroidDeck 設定眼鏡的更新率、3D 模式、亮度、音量和遮光,並讀取其頭部追蹤。 + 正在等待你在 Android 提示中允許… + 控制已被拒絕。允許後才能變更眼鏡設定。 + 這副眼鏡可以顯示遊戲,但無法在此變更其設定。 + 輸出 + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + 在眼鏡上顯示遊戲 + 只在眼鏡上顯示遊戲。關閉:由手機顯示,Android 會鏡像手機畫面。 + 顯示 + 更新率 + 3D(左右並排) + 每隻眼睛各看 3840×1080 畫面的一半。如果畫面消失,請長按眼鏡上的 2D/3D 按鈕。 + 未能確認 3D 訊號。請在 2D 畫面恢復後再試。 + 沒有畫面:眼鏡處於這支手機無法輸出的模式。請長按眼鏡的 2D/3D 按鈕,或拔除後重新連接。 + 3D 更新率 + VR 遊戲使用 3D + SteamVR 執行時切換到 3D,結束後切回。 + 3D 畫面 + 自動:SteamVR 填滿視野;其他遊戲在 3D 下以螢幕形式懸浮,在 2D 下保持平面。 + 自動 + 虛擬螢幕 + 左右並排遊戲 + 螢幕寬度 + 螢幕距離 + 當遊戲以虛擬螢幕形式在眼鏡上執行時,寬度和距離才會生效。 + 這些設定會在開啟「在眼鏡上顯示遊戲」且遊戲顯示於眼鏡上時生效。 + 「自動」會讓這款 2D 遊戲全螢幕顯示。使用虛擬螢幕即可調整其寬度、距離和頭部追蹤。 + 使用虛擬螢幕 + SteamVR 會填滿視野。可在下方調整其視野角度;寬度和距離只適用於虛擬螢幕。 + 左右並排遊戲會填滿每隻眼睛的視野。寬度和距離只適用於虛擬螢幕。 + 「自動」會讓這款 2D 遊戲固定在視野中。如要使用頭部追蹤,請在「顯示」中選擇「虛擬螢幕」。 + 頭部追蹤適用於虛擬螢幕或執行中的 SteamVR 遊戲。 + 眼鏡 + 亮度 + 音量 + 遮光 + 讓前鏡片變暗。 + 頭部追蹤 + 頭部追蹤 + 眼鏡內建的感測器會隨頭部轉動虛擬螢幕和 SteamVR。 + 重新置中視角 + 重新置中 + SteamVR 視野角度 + 調低讓 SteamVR 更近並填滿眼鏡;調高可看到更多周圍內容。 + 正在 %1$s 上遊玩 + 追蹤校正 + 減少漂移並對齊水平基準。 + 這副眼鏡已套用儲存的校正資料。 + 校正 + 將已連接的眼鏡上下顛倒放在水平面上,讓鏡框平穩貼合。點擊「開始」,然後約 8 秒內不要碰觸。只要移動,測量就會重新開始。這項操作會校正漂移和傾斜,但不校正朝向。 + 請保持眼鏡上下顛倒且不要碰觸。正在等待眼鏡靜止… + 正在測量感測器。請保持眼鏡靜止。 + 偵測到移動。請勿碰觸眼鏡;測量會自動重新開始。 + 請將眼鏡上下顛倒放在水平面上。放置正確後就會開始測量。 + 校正已儲存。戴上眼鏡並直視前方,然後點擊「完成」以重新置中。 + 無法完成校正。請檢查連線並保持眼鏡靜止,然後再試一次。先前的校正不受影響。 + 開始 + 完成 + 重設追蹤校正 + 要移除已儲存的校正並還原此型號的預設值嗎?重新戴上眼鏡後請重新置中。 + 重設 + 手部追蹤 + 要用相機追蹤你的雙手嗎? + 相機能看到你的雙手。未連接控制器時,你的雙手會直接成為 VR 中的手:拇指和食指捏合即可點選,握拳即可抓取,掌心朝向自己再捏合即可開啟選單。使用 Joy-Con 時,會把它們放在你雙手的實際位置。畫面會在此裝置上處理,絕不會被儲存或傳送。 + 後置相機 + 建議使用。通常更清晰、視野更廣。 + 前置相機 + 適用於裝置背面無法朝向你的情況。 + 使用相機 + 暫時不要 + 相機手部追蹤 + 未連接控制器時,你的雙手會成為 VR 中的手:捏合即可點選,掌心朝向自己再捏合即可開啟選單。使用 Joy-Con 時,會將其放在相機看到的位置。畫面不會離開此裝置。 + 相機 + 相機權限 + 請允許相機權限,才能開始手部追蹤。 + 相機權限已關閉。請在 Android 設定中開啟。 + 允許 + 開啟設定 + 相機位置 + 開始遊玩前,請確認相機能看到你。 + 檢查 + 相機位置 + 將裝置立在距離 1.5 到 2 公尺、約胸口高度的地方,讓後置相機對著你。然後張開雙手走進畫面。 + 將裝置立在距離 1.5 到 2 公尺、約胸口高度的地方,讓螢幕對著你。然後張開雙手走進畫面。 + 正在啟動相機… + 裝置平放著。請將它立起來,讓相機對著你。 + 請把裝置立得更直一點,讓相機對著你。 + 還看不到你。請走進畫面,讓頭和肩膀露出來。 + 請稍微往後退一點。 + 請再靠近一點。 + 請在相機能看到的位置伸出雙手。 + 看起來不錯。 + 相機無法啟動。請關閉正在使用相機的其他應用程式,然後再試一次。 + 切換相機 + 略過 + 完成 + 正在等待眼鏡的 USB Ethernet 連線。這支手機可能不會向應用程式開放此連線。 + XREAL 連線中斷或傳回不支援的回應。正在重試… + 實驗性 1S 支援。請在眼鏡選單中使用跟隨模式並關閉畫面穩定。追蹤方向和顯示模式仍需在實機上測試。 開發商 發行商 發行日期 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bdddb1802..bafbf14ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,12 @@ + SteamVR graphics stopped + DroidDeck will restart in %1$d seconds to reset graphics and reopen SteamVR. Your running game will close; unsaved progress cannot be restored. + The graphics fault returned after a recent restart. Automatic recovery is paused to prevent a loop. Restarting will close your game; unsaved progress cannot be restored. + Restart now + Keep open + Resetting graphics and reopening SteamVR… + DroidDeck could not restart automatically. Close the app completely, then reopen it. DroidDeck First run downloads Steam. This may take a few minutes. @@ -962,6 +969,8 @@ Keyboard button Press Select to open the Steam menu Instead of View/Select. + Joy-Cons as VR controllers + While SteamVR runs, each Joy-Con becomes a hand. Point it ahead and hold Home or Capture to recenter it. Animations Sticks appear where you touch and hide on release Layout @@ -1019,6 +1028,7 @@ KDE Plasma, Firefox and your emulators Games you install in Steam show up here after the session. Play Steam + Play SteamVR Launch settings Launch Game files @@ -2354,6 +2364,113 @@ Couldn\'t reach GOG Could not read your GOG library Registering with Steam… + Glasses + VITURE glasses + Glasses control + This build does not include the driver for these glasses. + Could not connect to the glasses. Reconnect the cable or tap Allow to retry. + Allow + Let DroidDeck set the glasses\' refresh rate, 3D mode, brightness, volume and sunblock, and read their head tracking. + Waiting for you to allow it in Android\'s prompt… + Control was declined. Allow it to change the glasses\' settings. + These glasses show the game, but their settings can\'t be changed from here. + Output + %1$d×%2$d · %3$d Hz · %4$s + 2D + 3D + Game on glasses + Show the game only on the glasses. Off: the phone shows it and Android mirrors the phone. + Display + Refresh rate + 3D (side-by-side) + Each eye gets its own half of a 3840×1080 picture. If the picture drops out, hold the glasses\' 2D/3D button. + The 3D signal was not confirmed. Try again once the 2D picture returns. + No picture: the glasses are in a mode this phone can\'t send. Hold their 2D/3D button, or unplug and replug them. + 3D refresh rate + 3D for VR games + Switches to 3D while SteamVR runs, and back after. + 3D picture + Automatic: SteamVR fills the view; other games hang as a screen in 3D and stay flat in 2D. + Automatic + Virtual screen + Side-by-side game + Screen width + Screen distance + Width and distance apply when a game runs on the glasses as a virtual screen. + These settings apply while Game on glasses is enabled and the game is displayed there. + Automatic keeps this 2D game full-screen. Use a virtual screen to adjust its width, distance and head tracking. + Use virtual screen + SteamVR fills the view. Adjust its field of view below; width and distance apply only to a virtual screen. + Side-by-side games fill each eye. Width and distance apply only to a virtual screen. + Automatic keeps this 2D game fixed in view. Choose Virtual screen in Display to use head tracking. + Head tracking applies to a virtual screen or a running SteamVR game. + Glasses + Brightness + Volume + Sunblock + Darkens the front lenses. + Head tracking + Head tracking + The glasses\' own sensors turn the virtual screen and SteamVR with your head. + Recenter view + Recenter + SteamVR field of view + Lower brings SteamVR closer and fills the glasses; higher shows more around you. + Playing on %1$s + Tracking calibration + Reduce drift and align the level reference. + Saved calibration is applied to these glasses. + Calibrate + Place the connected glasses upside down on a level surface, with the frame resting evenly. Tap Start, then leave them untouched for about 8 seconds. Movement restarts the measurement. This adjusts drift and tilt, not heading. + Leave the glasses upside down and untouched. Waiting for them to settle… + Measuring the sensors. Keep the glasses still. + Movement detected. Leave the glasses untouched; measurement will restart automatically. + Rest the glasses upside down on a level surface. Measurement will start when they are positioned correctly. + Calibration saved. Put the glasses on and look straight ahead, then tap Done to recenter. + Calibration could not finish. Check the connection and keep the glasses still, then try again. Your previous calibration is unchanged. + Start + Done + Reset tracking calibration + Remove the saved calibration and restore the model defaults? Recenter after putting the glasses back on. + Reset + Hand tracking + Track your hands with the camera? + The camera can see your hands. With no controller connected, your bare hands become the VR hands: pinch your thumb and index finger to click, close your hand to grab, and turn your palm toward you and pinch to open the menu. With Joy-Cons, it puts them where your hands really are. Pictures are processed on this device and never saved or sent. + Back camera + Recommended. Usually sharper and wider. + Front camera + For when the back of the device can\'t face you. + Use camera + Not now + Camera hand tracking + With no controller connected, your hands become the VR hands: pinch to click, palm toward you and pinch for the menu. With Joy-Cons, it places them where the camera sees them. Pictures stay on this device. + Camera + Camera access + Allow camera access so hand tracking can start. + Camera access is off. Turn it on in Android settings. + Allow + Open settings + Camera placement + Check that the camera can see you before you play. + Check + Camera placement + Stand the device up 1.5 to 2 metres away at about chest height, with the back camera facing you. Then step into view with your hands out. + Stand the device up 1.5 to 2 metres away at about chest height, with the screen facing you. Then step into view with your hands out. + Starting the camera… + The device is lying flat. Stand it up so the camera faces you. + Stand the device more upright so the camera faces you. + Can\'t see you yet. Step into view so your head and shoulders show. + Step back a little. + Come a little closer. + Hold both hands out where the camera can see them. + Looks good. + The camera couldn\'t start. Close other apps that use it and try again. + Switch camera + Skip + Done + Waiting for the glasses’ USB Ethernet connection. This phone may not expose it to apps. + The XREAL connection was interrupted or returned an unsupported response. Retrying… + Experimental 1S support. Use Follow mode and disable stabilization in the glasses menu. Tracking directions and display modes still need device testing. Picture position Top Bottom diff --git a/app/src/test/cpp/viture_display_modes_test.cpp b/app/src/test/cpp/viture_display_modes_test.cpp new file mode 100644 index 000000000..ea6ea5462 --- /dev/null +++ b/app/src/test/cpp/viture_display_modes_test.cpp @@ -0,0 +1,21 @@ +// Compile with the installed SDK's headers to verify the two mode vocabularies. +#include "viture_protocol_public.h" +#include "../../main/cpp/viture/display_modes.h" +using namespace droiddeck::viture; + +#define CHECK_MODE(standard, native) \ + static_assert(to_native_mode(standard) == native); \ + static_assert(from_native_mode(native) == standard) +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1080_60HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1080_60HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1080_90HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1080_90HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1080_120HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1080_120HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_3840_1080_60HZ, VITURE_NATIVE_DISPLAY_MODE_3D_SBS_3840_1080_60HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_3840_1080_90HZ, VITURE_NATIVE_DISPLAY_MODE_3D_SBS_3840_1080_90HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1200_60HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1200_60HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1200_90HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1200_90HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_1920_1200_120HZ, VITURE_NATIVE_DISPLAY_MODE_1920_1200_120HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_3840_1200_60HZ, VITURE_NATIVE_DISPLAY_MODE_3D_SBS_3840_1200_60HZ); +CHECK_MODE(VITURE_DISPLAY_MODE_3840_1200_90HZ, VITURE_NATIVE_DISPLAY_MODE_3D_SBS_3840_1200_90HZ); +static_assert(from_native_mode(VITURE_NATIVE_DISPLAY_MODE_ULTRAWIDE_3840_1080_60HZ) < 0); +static_assert(from_native_mode(VITURE_NATIVE_DISPLAY_MODE_ULTRAWIDE_3840_1200_90HZ) < 0); +static_assert(to_native_mode(0) < 0); diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt new file mode 100644 index 000000000..47f6b7a7c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesCallbacksTest.kt @@ -0,0 +1,235 @@ +package com.droiddeck.launcher.glasses + +import android.os.Handler +import android.os.Looper +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesCallbacksTest { + private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true } + private fun generation(value: Long) = field("linkGeneration").setLong(null, value) + private fun listener(generation: Long): VitureLink.Listener = + Glasses::class.java.getDeclaredMethod("linkListener", Long::class.javaPrimitiveType).apply { isAccessible = true } + .invoke(Glasses, generation) as VitureLink.Listener + + @Before fun prepare() { + field("handler").set(null, Handler(Looper.getMainLooper())) + generation(1) + @Suppress("UNCHECKED_CAST") + val state = field("_state").get(null) as MutableStateFlow + state.value = GlassesState(connected = true, brightness = 2, displayId = 2, width = 640, height = 480, noSignal = true) + field("imuPackets").setLong(null, 0) + field("probe").set(null, null) + } + + private fun brightness(value: Int) = VitureProtocol.Frame( + VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_BRIGHTNESS, byteArrayOf(value.toByte()), true, + ) + + @Test fun statusIncludesCalibrationAndTransportChecksumDiagnostics() { + val previousLink = field("link").get(null) + @Suppress("UNCHECKED_CAST") + val calibration = field("_calibration").get(null) as MutableStateFlow + val previousCalibration = calibration.value + try { + field("link").set(null, object : GlassesLink { + override val hasImu = true + override val badFrames = 7L + override fun send(msgId: Int, payload: ByteArray) = false + override fun close() {} + }) + calibration.value = GlassesCalibrationState(supported = true, saved = true, + phase = CalibrationPhase.SAVED, progress = 100) + val status = Glasses.status() + assertEquals(7L, status.getLong("badFrames")) + val reportedCalibration = status.getJSONObject("calibration") + assertTrue(reportedCalibration.getBoolean("supported")) + assertTrue(reportedCalibration.getBoolean("saved")) + assertEquals("SAVED", reportedCalibration.getString("phase")) + assertEquals(100, reportedCalibration.getInt("progress")) + field("link").set(null, null) + assertEquals(0L, Glasses.status().getLong("badFrames")) + } finally { + field("link").set(null, previousLink) + calibration.value = previousCalibration + } + } + + @Test fun queuedDisconnectCannotCloseAReplacementLink() { + val old = listener(1) + old.onLost() + old.onLost() + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(Glasses.state.value.connected) + } + + @Test fun queuedReportCannotOverwriteReplacementState() { + listener(1).onFrame(brightness(7)) + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(2, Glasses.state.value.brightness) + listener(2).onFrame(brightness(4)) + listener(1).onFrame(brightness(8)) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(4, Glasses.state.value.brightness) + } + + @Test fun oldImuPacketsCannotReachTheNewProbe() { + var samples = 0 + val probe: (ByteArray?, FloatArray?) -> Unit = { _, _ -> samples++ } + field("probe").set(null, probe) + generation(2) + listener(1).onImu(ByteArray(12), 0) + assertEquals(0, samples) + assertEquals(0L, field("imuPackets").getLong(null)) + listener(2).onImu(ByteArray(12), 0) + assertEquals(1, samples) + assertEquals(1L, field("imuPackets").getLong(null)) + } + + @Test fun rayNeoPoseUsesTheExistingSinkAndRejectsAnOldConnection() { + fun rayneo(gen: Long) = Glasses::class.java.getDeclaredMethod("rayNeoListener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, gen) as RayNeoLink.Listener + var delivered: FloatArray? = null + var timestamp = 0L + val sink = Glasses.PoseSink { x, y, z, w, time -> + delivered = floatArrayOf(x, y, z, w) + timestamp = time + } + field("sink").set(null, sink) + try { + val q = floatArrayOf(0f, 0f, 0.6f, 0.8f) + rayneo(0).onPose(ByteArray(64), q, 99) + assertEquals(null, delivered) + rayneo(1).onPose(ByteArray(64), q, 123) + org.junit.Assert.assertArrayEquals(q, delivered, 0f) + assertEquals(123L, timestamp) + assertEquals(1L, field("imuPackets").getLong(null)) + rayneo(0).onLost() + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertTrue(Glasses.state.value.connected) + } finally { + field("sink").set(null, null) + } + } + + @Test fun deviceModeReportsCannotConfirmThePhysicalStereoSignal() { + listener(1).onFrame(VitureProtocol.Frame( + VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_DISPLAY, byteArrayOf(0x32), true, + )) + listener(1).onFrame(VitureProtocol.Frame( + VitureProtocol.MAGIC_REPORT, VitureProtocol.MSG_GET_DISPLAY, byteArrayOf(0, 0x32), true, + )) + shadowOf(Looper.getMainLooper()).idle() + assertFalse(Glasses.state.value.sbs) + assertTrue(Glasses.state.value.noSignal) + assertEquals(640, Glasses.state.value.width) + } + + @Test fun xrealCallbacksUseSharedPoseAndIgnoreDetachedDeviceValues() { + fun xreal(gen: Long) = Glasses::class.java.getDeclaredMethod("xrealListener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, gen) as XrealLink.Listener + var delivered: FloatArray? = null + field("sink").set(null, Glasses.PoseSink { x, y, z, w, _ -> delivered = floatArrayOf(x, y, z, w) }) + try { + val q = floatArrayOf(0f, 0f, 0.6f, 0.8f) + xreal(0).onPose(ByteArray(128), q, 1) + assertEquals(null, delivered) + xreal(1).onPose(ByteArray(128), q, 2) + org.junit.Assert.assertArrayEquals(q, delivered, 0f) + xreal(1).onValue(VitureProtocol.MSG_BRIGHTNESS, 8) + xreal(1).onUnavailable(true) + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(2, Glasses.state.value.brightness) + assertTrue(Glasses.state.value.connected) + } finally { field("sink").set(null, null) } + } + + @Test fun cancelledCalibrationCallbacksCannotResurrectOrOverwriteTheProfile() { + val rayneo = Glasses::class.java.getDeclaredMethod("rayNeoListener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, 1L) as RayNeoLink.Listener + @Suppress("UNCHECKED_CAST") + val calibration = field("_calibration").get(null) as MutableStateFlow + calibration.value = GlassesCalibrationState(supported = true, saved = true, phase = CalibrationPhase.SETTLING) + field("calibrationRevision").setLong(null, 1) + rayneo.onCalibrationSaved(1, RayNeoCalibration(Vec3(0f, 0f, 0f), Quaternion.IDENTITY)) + rayneo.onCalibration(1, CalibrationPhase.SAVED, 100) + field("calibrationRevision").setLong(null, 2) + calibration.value = calibration.value.copy(phase = CalibrationPhase.IDLE) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(CalibrationPhase.IDLE, calibration.value.phase) + assertTrue(calibration.value.saved) + } + + @Test fun disconnectedRayNeoBoardCannotChangeTheCurrentCalibrationKey() { + val rayneo = Glasses::class.java.getDeclaredMethod("rayNeoListener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, 1L) as RayNeoLink.Listener + val previous = field("calibrationKey").get(null) + try { + field("calibrationKey").set(null, "current-profile") + rayneo.onDeviceInfo(0x40, true) + generation(2) + shadowOf(Looper.getMainLooper()).idle() + assertEquals("current-profile", field("calibrationKey").get(null)) + } finally { field("calibrationKey").set(null, previous) } + } + + @Test fun sdkCallbacksCannotCrossConnectionGenerationsOrFakeStereoConfirmation() { + fun sdk(gen: Long) = Glasses::class.java.getDeclaredMethod("sdkListener", Long::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, gen) as VitureSdkLink.Listener + var samples = 0 + val probe: (ByteArray?, FloatArray?) -> Unit = { raw, pose -> + assertEquals(null, raw) + assertEquals(4, pose!!.size) + samples++ + } + field("probe").set(null, probe) + try { + sdk(0).onPose(floatArrayOf(0f, 0f, 0f, 1f), 100) + sdk(1).onPose(floatArrayOf(0f, 0f, 0f, 1f), 101) + assertEquals(1, samples) + sdk(0).onValue(VitureProtocol.MSG_GET_BRIGHTNESS, 8) + sdk(1).onValue(VitureProtocol.MSG_GET_DISPLAY, 0x42) + sdk(0).onLost() + shadowOf(Looper.getMainLooper()).idle() + assertEquals(2, Glasses.state.value.brightness) + assertFalse(Glasses.state.value.sbs) + assertTrue(Glasses.state.value.connected) + } finally { field("probe").set(null, null) } + } + + @Test fun rejectedTrackingStartDoesNotAdvertiseTracking() { + @Suppress("UNCHECKED_CAST") + val state = field("_state").get(null) as MutableStateFlow + state.value = state.value.copy(tracking = false) + val rejected = object : GlassesLink { + override val hasImu = true + override fun send(msgId: Int, payload: ByteArray) = false + override fun close() {} + } + field("link").set(null, rejected) + field("sink").set(null, Glasses.PoseSink { _, _, _, _, _ -> }) + try { + Glasses::class.java.getDeclaredMethod("updateImu").apply { isAccessible = true }.invoke(Glasses) + assertFalse(Glasses.state.value.tracking) + } finally { + field("link").set(null, null) + field("sink").set(null, null) + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt new file mode 100644 index 000000000..923f98848 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesDisplayTest.kt @@ -0,0 +1,137 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.display.DeviceProductInfo +import android.hardware.display.DisplayManager +import android.view.Display +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31, 34], manifest = Config.NONE, shadows = [GlassesDisplayTest.ProductMetadataDisplay::class]) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesDisplayTest { + @Test fun connectedGlassesUseTheOnlyExternalPanelWhenProductMetadataIsMissing() { + val panel = display("External Display") + assertNull(panel.deviceProductInfo) + + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = true)?.displayId) + } + + @Test fun missingProductMetadataDoesNotIdentifyGlassesWithoutUsb() { + val panel = display("External Display") + + assertNull(Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)) + } + + @Test fun connectedGlassesDoNotChooseBetweenMultipleUnidentifiedPanels() { + val first = display("External Display 1") + val second = display("External Display 2") + + assertNull(Glasses.selectGlassesDisplay(listOf(first, second), usbPresent = true)) + } + + @Test fun primaryAndNonPresentationDisplaysCannotBecomeTheGlassesOutput() { + val primary = displayManager().getDisplay(Display.DEFAULT_DISPLAY) + shadowOf(primary).setName("RayNeo Air 4 Pro") + shadowOf(primary).setFlags(Display.FLAG_PRESENTATION) + val nonPresentation = display("RayNeo Air 4 Pro", flags = 0) + + assertNull(Glasses.selectGlassesDisplay(listOf(primary, nonPresentation), usbPresent = true)) + } + + @Test fun rayNeoDisplayNameIsRecognizedEvenWhenProductMetadataHasAGenericName() { + val panel = display("RayNeo Air 4 Pro", productName = "External Monitor") + + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)?.displayId) + } + + @Test fun rayNeoProductMetadataIsRecognizedWhenAndroidUsesAGenericDisplayName() { + val panel = display("External Display", productName = "RayNeo Air 3") + + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)?.displayId) + } + + @Test fun anUnrelatedNamedMonitorIsNotClaimedByConnectedGlasses() { + val monitor = display("External Display", productName = "TCL TV") + + assertNull(Glasses.selectGlassesDisplay(listOf(monitor), usbPresent = true)) + } + + @Test fun namedRayNeoDisplayWorksWithoutUsbControlPermission() { + val panel = display("RayNeo GT") + + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)?.displayId) + } + + @Test fun blankProductNameRetainsTheSinglePanelUsbFallback() { + val panel = display("External Display", productName = " ") + + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = true)?.displayId) + } + + @Test fun brandedGlassesWinOverAnUnidentifiedSecondDisplay() { + val monitor = display("External Display") + val glasses = display("RayNeo Air 2s") + + assertEquals(glasses.displayId, Glasses.selectGlassesDisplay(listOf(monitor, glasses), usbPresent = true)?.displayId) + } + + @Test fun gtMaxSmartGlassesEdidUsesTheSameOutputSelectionAsViture() { + val panel = display("SmartGlasses", productName = "SmartGlasses", productId = "980") + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = true)?.displayId) + // The display remains usable while USB control reconnects during a mode switch. + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(panel), usbPresent = false)?.displayId) + } + + @Test fun gtMaxMetadataIsRecognizedBehindGenericAndroidNameAndOtherPanels() { + val monitor = display("External Display", productName = "TCL TV") + val panel = display("External Display", productName = "SmartGlasses", productId = "980") + assertEquals(panel.displayId, Glasses.selectGlassesDisplay(listOf(monitor, panel), usbPresent = true)?.displayId) + } + + @Test fun genericSmartGlassesNameDoesNotClaimUnverifiedProducts() { + val unknown = display("SmartGlasses", productName = "SmartGlasses") + assertNull(Glasses.selectGlassesDisplay(listOf(unknown), usbPresent = true)) + assertFalse(RayNeoModel.isDisplayProduct("SmartGlasses", "OTHER", "980")) + assertFalse(RayNeoModel.isDisplayProduct("TCL TV", "TCL", "980")) + } + + private fun displayManager(): DisplayManager = + RuntimeEnvironment.getApplication().getSystemService(DisplayManager::class.java) + + private fun display(name: String, productName: String? = null, flags: Int = Display.FLAG_PRESENTATION, + productId: String = "test-panel"): Display { + val external = ReflectionHelpers.getStaticField(Display::class.java, "TYPE_EXTERNAL") + val id = ShadowDisplayManager.addDisplay("w1920dp-h1080dp", external) + return displayManager().getDisplay(id).also { panel -> + shadowOf(panel).setName(name) + shadowOf(panel).setFlags(flags) + Shadow.extract(panel).productMetadata = productName?.let { + DeviceProductInfo(it, "TCL", productId, 2024, DeviceProductInfo.CONNECTION_TO_SINK_DIRECT) + } + } + } + + // Robolectric exposes display names and flags, but has no product-metadata setter. + @Implements(Display::class) + class ProductMetadataDisplay : ShadowDisplay() { + var productMetadata: DeviceProductInfo? = null + + @Implementation(minSdk = 31) + protected fun getDeviceProductInfo(): DeviceProductInfo? = productMetadata + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesFovTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesFovTest.kt new file mode 100644 index 000000000..6880a41ed --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesFovTest.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.* +import org.junit.Test + +class GlassesFovTest { + @Test fun native1sAspectUsesThePerEyeResolution() { + val native = GlassesState(width = 1920, height = 1200, diagonalFovDeg = 52f) + val stereo = native.copy(width = 3840, sbs = true) + val widescreen = native.copy(height = 1080) + assertEquals(native.verticalFovDeg, stereo.verticalFovDeg, 0.001f) + assertTrue(native.verticalFovDeg > widescreen.verticalFovDeg) + assertEquals(widescreen.verticalFovDeg, native.copy(width = 0, height = 0).verticalFovDeg, 0.001f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt new file mode 100644 index 000000000..359fcfe30 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesMirrorPacingTest.kt @@ -0,0 +1,87 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.display.DisplayManager +import android.view.Display +import androidx.compose.runtime.MutableState +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.session.SessionState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31, 34], manifest = Config.NONE, shadows = [GlassesMirrorPacingTest.ModeDisplay::class]) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesMirrorPacingTest { + private lateinit var activity: SessionActivity + private val connected = GlassesState(present = true, displayId = 2, width = 1920, height = 1080) + private val settings = GlassesSettings(gameOnGlasses = false, refreshHz = 60) + + @Before fun setup() { + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + ReflectionHelpers.setField(activity, "mBase", activity.baseContext.createDisplayContext(display)) + val panel = Shadow.extract(activity.display) + panel.supported = arrayOf(mode(1, 60f), mode(2, 90f), mode(3, 120f)) + panel.current = panel.supported.last() + SessionState.fpsLimit = 0 + } + + @After fun cleanup() { SessionState.fpsLimit = 0 } + + @Test fun connectingChangingRateAndDisconnectingUpdatePhonePacingWithoutSavingPreferences() { + apply(connected, settings) + expect(1, 60f, 60) + apply(connected, settings.copy(refreshHz = 90)) + expect(2, 90f, 90) + apply(GlassesState(), settings) + expect(3, 120f, 0) + assertEquals(0, SessionState.fpsLimit) + } + + @Test fun lowerSavedLimitSurvivesMirroringAndStalePhoneRefreshReports() { + SessionState.fpsLimit = 30 + apply(connected, settings) + expect(1, 60f, 30) + apply(connected.copy(hz = 120f), settings) + expect(1, 60f, 30) + apply(GlassesState(), settings) + expect(3, 120f, 30) + assertEquals(30, SessionState.fpsLimit) + } + + private fun apply(state: GlassesState, config: GlassesSettings) { + ReflectionHelpers.getField>(activity, "glasses\$delegate").value = state + ReflectionHelpers.getField>(activity, "glassesSettings\$delegate").value = config + ReflectionHelpers.callInstanceMethod(activity, "applyOutputPacing") + } + + private fun expect(mode: Int, hz: Float, fps: Int) { + assertEquals(mode, activity.window.attributes.preferredDisplayModeId) + assertEquals(hz, ReflectionHelpers.callInstanceMethod(activity, "refreshHz"), 0.01f) + assertEquals(fps, ReflectionHelpers.callInstanceMethod(activity, "outputFpsLimit").toInt()) + } + + private fun mode(id: Int, hz: Float): Display.Mode = ShadowDisplayManager.ModeBuilder.modeBuilder(id) + .setWidth(1080).setHeight(2520).setRefreshRate(hz).build() + + @Implements(Display::class) + class ModeDisplay : ShadowDisplay() { + lateinit var current: Display.Mode + var supported = emptyArray() + @Implementation protected fun getMode(): Display.Mode = current + @Implementation protected fun getSupportedModes(): Array = supported + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt new file mode 100644 index 000000000..0ed562983 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesModeStateTest.kt @@ -0,0 +1,162 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.glasses.GlassesModeState.Confirmation +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GlassesModeStateTest { + private val device = GlassesModeState.Device(0x101d, "pro") + private val state = GlassesModeState(20_000).apply { updateDevice(device, 0) } + + @Test fun transientFallbackDoesNotRejectAWorkingStereoTransition() { + state.sent(true, 100) + assertEquals(Confirmation.WAITING, state.observe(false, 2_000)) + assertEquals(Confirmation.WAITING, state.observe(false, 19_000)) + assertFalse(state.restorePending) + assertEquals(Confirmation.CONFIRMED, state.observe(true, 19_500)) + assertFalse(state.pendingStereo) + assertEquals(Confirmation.NONE, state.observe(false, 20_100)) + } + + @Test fun missingSignalAtDeadlineRequestsOneFallback() { + state.sent(true, 100) + assertEquals(Confirmation.WAITING, state.observe(false, 20_099)) + assertEquals(Confirmation.FAILED, state.observe(false, 20_100)) + assertTrue(state.restorePending) + assertEquals(Confirmation.NONE, state.observe(false, 40_000)) + state.sent(false, 40_001) + assertFalse(state.restorePending) + assertFalse(state.stopVr()) + } + + @Test fun stoppingVrBeforeStereoAppearsStillRestores2d() { + state.sent(true, 100) + state.startVr() + assertTrue(state.stopVr()) + assertTrue(state.restorePending) + state.sent(false, 200) + assertFalse(state.pendingStereo) + assertFalse(state.restorePending) + assertFalse(state.stopVr()) + assertEquals(Confirmation.NONE, state.observe(true, 400)) + } + + @Test fun failedRevertRemainsPendingUntilAWriteSucceeds() { + state.sent(true, 100) + state.startVr() + state.observe(true, 200) + state.stopVr() + state.cancelPending() + assertTrue(state.restorePending) + assertTrue(state.stopVr()) + state.sent(false, 300) + assertFalse(state.restorePending) + } + + @Test fun manualModeTakesOwnershipFromVr() { + state.sent(true, 100) + state.startVr() + state.manual() + state.sent(true, 200) + assertFalse(state.stopVr()) + assertFalse(state.restorePending) + assertEquals(Confirmation.CONFIRMED, state.observe(true, 300)) + } + + @Test fun manualRetryGetsItsOwnConfirmationWindow() { + state.sent(true, 100) + assertEquals(Confirmation.FAILED, state.observe(false, 20_100)) + state.sent(false, 20_101) + state.manual() + state.sent(true, 30_000) + assertEquals(Confirmation.WAITING, state.observe(false, 49_999)) + assertEquals(Confirmation.CONFIRMED, state.observe(true, 50_000)) + assertFalse(state.restorePending) + } + + @Test fun cancelledOrDisconnectedRequestCannotFailLater() { + state.sent(true, 100) + state.cancelPending() + assertFalse(state.pendingStereo) + assertEquals(Confirmation.NONE, state.observe(false, 20_100)) + assertFalse(state.restorePending) + } + + @Test fun manual2dCancelsThePendingStereoDeadline() { + state.sent(true, 100) + state.manual() + state.sent(false, 200) + assertFalse(state.pendingStereo) + assertEquals(Confirmation.NONE, state.observe(false, 20_100)) + } + + @Test fun modeReenumerationPreservesDeadlineAndVrOwnership() { + state.sent(true, 100) + state.startVr() + assertFalse(state.updateDevice(null, 1_000)) + assertEquals(Confirmation.WAITING, state.observe(false, 1_000)) + assertFalse(state.updateDevice(device, 2_000)) + assertEquals(20_100L, state.nextCheckAt) + assertEquals(Confirmation.CONFIRMED, state.observe(true, 3_000)) + assertTrue(state.stopVr()) + } + + @Test fun absentAtDeadlineCancelsWithoutFailureOrLateRestore() { + state.sent(true, 100) + state.startVr() + state.updateDevice(null, 1_000) + state.stopVr() + assertEquals(Confirmation.NONE, state.observe(false, 20_100)) + assertFalse(state.restorePending) + assertNull(state.nextCheckAt) + assertFalse(state.updateDevice(device, 40_000)) + assertFalse(state.stopVr()) + } + + @Test fun differentModelOrKnownSerialCannotInheritARequest() { + listOf(GlassesModeState.Device(0x1131, "pro"), device.copy(serial = "other")).forEach { other -> + state.updateDevice(device, 0) + state.sent(true, 100) + state.startVr() + state.updateDevice(null, 1_000) + assertTrue(state.updateDevice(other, 2_000)) + assertFalse(state.pendingStereo) + assertFalse(state.stopVr()) + } + } + + @Test fun permissionPendingReconnectKeepsTheKnownIdentity() { + state.sent(true, 100) + state.startVr() + state.updateDevice(null, 1_000) + assertFalse(state.updateDevice(device.copy(serial = null), 2_000)) + assertTrue(state.pendingStereo) + assertTrue(state.updateDevice(device.copy(serial = "other"), 3_000)) + assertFalse(state.pendingStereo) + assertFalse(state.stopVr()) + } + + @Test fun sameDeviceReconnectDoesNotResetFailureStatus() { + state.sent(true, 100) + assertEquals(Confirmation.FAILED, state.observe(false, 20_100)) + state.sent(false, 20_101) + assertFalse(state.updateDevice(null, 21_000)) + assertFalse(state.updateDevice(device, 22_000)) + } + + @Test fun confirmedVrCannotRestoreAfterAProlongedUnplug() { + state.sent(true, 100) + state.startVr() + state.observe(true, 200) + state.updateDevice(null, 1_000) + assertTrue(state.stopVr()) + assertEquals(21_000L, state.nextCheckAt) + state.updateDevice(device, 22_000) + assertFalse(state.restorePending) + assertFalse(state.stopVr()) + assertNull(state.nextCheckAt) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt new file mode 100644 index 000000000..e1258f489 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesOutputHandoffTest.kt @@ -0,0 +1,99 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import androidx.compose.runtime.MutableState +import com.droiddeck.launcher.SessionActivity +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowDisplayManager +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31, 34], manifest = Config.NONE) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesOutputHandoffTest { + private lateinit var activity: SessionActivity + private lateinit var phone: SurfaceView + private lateinit var screen: GlassesScreen + + @Before fun setup() { + // Exercise the output lifecycle without starting the native Linux runtime. + activity = Robolectric.buildActivity(SessionActivity::class.java).get() + phone = SurfaceView(activity) + ReflectionHelpers.setField(activity, "surfaceView", phone) + ReflectionHelpers.setField(activity, "ended", true) + val app = RuntimeEnvironment.getApplication() + val id = ShadowDisplayManager.addDisplay("w1920dp-h1080dp") + val display = app.getSystemService(DisplayManager::class.java).getDisplay(id) + shadowOf(display).setFlags(Display.FLAG_PRESENTATION) + screen = GlassesScreen(activity, display, 60, activity) + ReflectionHelpers.setField(activity, "glassesScreen", screen) + } + + @Test fun selectingGlassesDoesNotHidePhoneOrClaimOutputBeforeAttachment() { + assertFalse(active()) + assertEquals(View.VISIBLE, phone.visibility) + attach(phone.holder) // A late phone callback must not complete the handoff. + assertFalse(active()) + assertEquals(View.VISIBLE, phone.visibility) + } + + @Test fun attachingGlassesRemovesPhoneSurfaceButPreservesItsInputLayout() { + phone.layout(0, 0, 1200, 800) + attach(screen.holder) + + assertTrue(active()) + assertEquals(View.INVISIBLE, phone.visibility) + assertEquals(1200, phone.width) + assertEquals(800, phone.height) + } + + @Test fun latePhoneDestructionDoesNotResetTheGlassesOutput() { + attach(screen.holder) + ReflectionHelpers.setField(activity, "surfaceW", 3840) + ReflectionHelpers.setField(activity, "surfaceH", 1080) + + activity.surfaceDestroyed(phone.holder) + + assertTrue(active()) + assertEquals(3840, ReflectionHelpers.getField(activity, "surfaceW")) + assertEquals(1080, ReflectionHelpers.getField(activity, "surfaceH")) + } + + @Test fun disconnectRestoresPhoneAndRejectsLateGlassesAttachment() { + attach(screen.holder) + ReflectionHelpers.callInstanceMethod(activity, "restorePhoneOutput", from(GlassesScreen::class.java, screen)) + + assertFalse(active()) + assertEquals(View.VISIBLE, phone.visibility) + attach(screen.holder) + assertFalse(active()) + assertEquals(View.VISIBLE, phone.visibility) + } + + @Test fun losingGlassesSurfaceClearsThePlayingHint() { + attach(screen.holder) + activity.surfaceDestroyed(screen.holder) + assertFalse(active()) + } + + private fun attach(holder: SurfaceHolder) { + ReflectionHelpers.callInstanceMethod(activity, "onOutputAttached", from(SurfaceHolder::class.java, holder)) + } + + private fun active(): Boolean = + ReflectionHelpers.getField>(activity, "glassesOutputActive\$delegate").value +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt new file mode 100644 index 000000000..993a1799a --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesRenderingTest.kt @@ -0,0 +1,159 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.gpu.Cardboard +import org.junit.Assert.* +import org.junit.Test + +class GlassesRenderingTest { + private val ultra = GlassesState( + model = "VITURE Luma Ultra", connected = true, controllable = true, + displayId = 2, width = 1920, height = 1200, + ) + private val base = Cardboard(ipdMm = 67) + + @Test fun mirroredRefreshUsesTheSelectedGlassesRateEvenBeforeTheDisplaySwitches() { + val settings = GlassesSettings(gameOnGlasses = false, refreshHz = 60) + assertEquals(60, GlassesRendering.mirrorRefreshLimit(settings, ultra.copy(hz = 120f), false)) + assertEquals(90, GlassesRendering.mirrorRefreshLimit(settings.copy(refreshHz = 90), ultra, false)) + assertEquals(60, GlassesRendering.mirrorRefreshLimit(settings, ultra.copy(connected = false), false)) + } + + @Test fun mirroredStereoUsesTheStereoRefreshAndItsHardwareLimit() { + val settings = GlassesSettings(refreshHz = 120, sbsHz = 90) + assertEquals(60, GlassesRendering.mirrorRefreshLimit(settings, ultra.copy(sbs = true), false)) + assertEquals(90, GlassesRendering.mirrorRefreshLimit(settings, ultra.copy(sbs = true, sbs90 = true), false)) + } + + @Test fun disconnectOrDedicatedOutputRemovesTheMirrorCap() { + val settings = GlassesSettings(refreshHz = 60) + assertEquals(0, GlassesRendering.mirrorRefreshLimit(settings, GlassesState(), false)) + assertEquals(0, GlassesRendering.mirrorRefreshLimit(settings, ultra.copy(noSignal = true), false)) + assertEquals(0, GlassesRendering.mirrorRefreshLimit(settings, ultra, true)) + } + + @Test fun mirrorCapPreservesLowerLimitsAndRestoresTheRequestedLimitAfterDisconnect() { + for (requested in listOf(0, 30, 60, 90, 120)) { + assertEquals(if (requested == 30) 30 else 60, GlassesRendering.fpsLimit(requested, 60)) + assertEquals(requested, GlassesRendering.fpsLimit(requested, 0)) + } + } + + private fun render(settings: GlassesSettings, state: GlassesState = ultra, steamVr: Boolean = false) = + GlassesRendering.cardboard(base, settings, state, outputActive = true, steamVrRunning = steamVr) + + @Test fun steamVrDefaultsToOneTrackedEyeWithoutRequestingStereo() { + val settings = GlassesSettings() + assertFalse(settings.auto3d) + val projection = render(settings, steamVr = true) + assertTrue(projection.enabled) + assertTrue(projection.mono) + assertTrue(projection.headTracking) + assertEquals(Cardboard.SOURCE_SBS, projection.source) + } + + @Test fun automatic2dIsFlatSoVirtualScreenSettingsDoNotApply() { + val settings = GlassesSettings(screenWidthDm = 40, screenDistanceDm = 80) + assertEquals(GlassesRendering.Picture.FLAT, GlassesRendering.picture(settings, false, false)) + assertEquals(Cardboard.OFF, render(settings)) + assertEquals(Cardboard.OFF, render(settings.copy(screenWidthDm = 10, screenDistanceDm = 10))) + } + + @Test fun selectingVirtualScreenMakesSizeDistanceAndTrackingReachTheRendererIn2d() { + val settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN, + screenWidthDm = 40, screenDistanceDm = 80) + val projection = render(settings) + assertTrue(projection.enabled) + assertTrue(projection.mono) + assertTrue(projection.headTracking) + assertEquals(Cardboard.SOURCE_SCREEN, projection.source) + assertEquals(40, projection.screenWidthDm) + assertEquals(80, projection.screenDistanceDm) + assertEquals(67, projection.ipdMm) + val changed = render(settings.copy(screenWidthDm = 15, screenDistanceDm = 20, headTracking = false)) + assertEquals(15, changed.screenWidthDm) + assertEquals(20, changed.screenDistanceDm) + assertFalse(changed.headTracking) + } + + @Test fun automatic3dGamesUseAVirtualScreenButSteamVrUsesDirectStereo() { + val settings = GlassesSettings() + val stereo = ultra.copy(sbs = true, width = 3840) + assertEquals(GlassesRendering.Picture.VIRTUAL_SCREEN, GlassesRendering.picture(settings, true, false)) + assertEquals(Cardboard.SOURCE_SCREEN, render(settings, stereo).source) + assertFalse(render(settings, stereo).mono) + assertEquals(GlassesRendering.Picture.STEREO, GlassesRendering.picture(settings, true, true)) + assertEquals(Cardboard.SOURCE_SBS, render(settings, stereo, steamVr = true).source) + } + + @Test fun explicitPictureChoiceOverridesAutomaticResolution() { + val virtual = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN) + val direct = GlassesSettings(picture3d = GlassesSettings.PICTURE_SBS) + for (stereo in listOf(false, true)) { + for (vr in listOf(false, true)) { + assertEquals(GlassesRendering.Picture.VIRTUAL_SCREEN, GlassesRendering.picture(virtual, stereo, vr)) + assertEquals(GlassesRendering.Picture.STEREO, GlassesRendering.picture(direct, stereo, vr)) + } + } + } + + @Test fun mirrorModeKeepsThePhoneProjectionInsteadOfApplyingDormantGlassesSettings() { + val phone = base.copy(enabled = true, screenWidthDm = 60, screenDistanceDm = 20) + val settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN, + gameOnGlasses = false, screenWidthDm = 10, screenDistanceDm = 50) + assertSame(phone, GlassesRendering.cardboard(phone, settings, ultra, false, false)) + } + + @Test fun steamVrOnPhoneKeepsHeadsetActiveWithoutEnablingTheSavedCardboardSetting() { + val settings = GlassesSettings(gameOnGlasses = false, headTracking = false, + picture3d = GlassesSettings.PICTURE_SCREEN) + for (state in listOf(ultra.copy(displayId = -1), GlassesState())) { + val preview = GlassesRendering.cardboard(base, settings, state, false, true) + assertTrue(preview.enabled) // Published as headset proximity, preventing standby. + assertTrue(preview.mono) + assertEquals(Cardboard.SOURCE_SBS, preview.source) + assertEquals(Cardboard.Viewer.NONE.id, preview.viewer) + assertTrue(preview.headTracking) // Phone preference, not dormant glasses settings. + assertFalse(base.enabled) + assertSame(base, GlassesRendering.cardboard(base, settings, state, false, false)) + } + val noTracking = base.copy(headTracking = false) + assertFalse(GlassesRendering.cardboard(noTracking, settings, GlassesState(), false, true).headTracking) + } + + @Test fun steamVrKeepsAnEnabledPhoneViewerAndSwitchesBackToGlassesRenderingOnHandoff() { + val phone = base.copy(enabled = true, source = Cardboard.SOURCE_SBS) + val settings = GlassesSettings() + assertSame(phone, GlassesRendering.cardboard(phone, settings, GlassesState(), false, true)) + val glasses = GlassesRendering.cardboard(base, settings, ultra.copy(sbs = true), true, true) + assertTrue(glasses.enabled) + assertFalse(glasses.mono) + assertEquals(Cardboard.Viewer.VITURE.id, glasses.viewer) + assertEquals(Cardboard.SOURCE_SBS, glasses.source) + } + + @Test fun steamVrMirroringKeepsTheSameGlassesPictureAndSensorSource() { + for (stereo in listOf(false, true)) { + val state = ultra.copy(sbs = stereo) + for (picture in listOf(GlassesSettings.PICTURE_AUTO, GlassesSettings.PICTURE_SCREEN, GlassesSettings.PICTURE_SBS)) { + val settings = GlassesSettings(picture3d = picture) + val dedicated = GlassesRendering.cardboard(base, settings, state, true, true) + val mirrored = GlassesRendering.cardboard(base, settings.copy(gameOnGlasses = false), state, false, true) + assertEquals(dedicated, mirrored) + assertTrue(mirrored.enabled) + assertTrue(mirrored.headTracking) + assertTrue(GlassesRendering.usesGlasses(state, false, true)) + } + } + assertFalse(GlassesRendering.usesGlasses(ultra, false, false)) + assertFalse(GlassesRendering.usesGlasses(GlassesState(), false, true)) + assertTrue(GlassesRendering.usesGlasses(ultra, true, false)) + } + + @Test fun missingUsbControlOnlyDisablesTrackingNotScreenSize() { + val settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN, screenWidthDm = 35) + val projection = render(settings, ultra.copy(connected = false)) + assertTrue(projection.enabled) + assertFalse(projection.headTracking) + assertEquals(35, projection.screenWidthDm) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesScreenTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesScreenTest.kt new file mode 100644 index 000000000..f092b8413 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesScreenTest.kt @@ -0,0 +1,86 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.SurfaceHolder +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31, 34], manifest = Config.NONE, shadows = [GlassesScreenTest.ModeDisplay::class]) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesScreenTest { + @Test fun switchingBetween2dAndStereoRefreshesTheModePreferenceEvenAtTheSameHz() { + val flat = mode(1, 1920, 1200, 60f) + val stereo = mode(2, 3840, 1200, 60f) + val (screen, panel) = screen(flat, arrayOf(flat, stereo), 60) + assertEquals(flat.modeId, screen.window!!.attributes.preferredDisplayModeId) + + panel.current = stereo + screen.prefer(60) + assertEquals(stereo.modeId, screen.window!!.attributes.preferredDisplayModeId) + + panel.current = flat + screen.prefer(60) + assertEquals(flat.modeId, screen.window!!.attributes.preferredDisplayModeId) + } + + @Test fun refreshPreferenceKeepsBothDimensionsOfTheActiveStereoMode() { + val stereo1080 = mode(1, 3840, 1080, 90f) + val stereo1200 = mode(2, 3840, 1200, 60f) + val (screen, _) = screen(stereo1200, arrayOf(stereo1080, stereo1200), 90) + + assertEquals(stereo1200.modeId, screen.window!!.attributes.preferredDisplayModeId) + } + + @Test fun refreshChangesStillChooseTheNearestRateAtTheCurrentResolution() { + val stereo60 = mode(1, 3840, 1200, 60f) + val stereo90 = mode(2, 3840, 1200, 90f) + val flat120 = mode(3, 1920, 1200, 120f) + val (screen, _) = screen(stereo60, arrayOf(stereo60, stereo90, flat120), 60) + + screen.prefer(120) + assertEquals(stereo90.modeId, screen.window!!.attributes.preferredDisplayModeId) + } + + private fun mode(id: Int, width: Int, height: Int, hz: Float): Display.Mode = + ShadowDisplayManager.ModeBuilder.modeBuilder(id) + .setWidth(width).setHeight(height).setRefreshRate(hz).build() + + private fun screen(current: Display.Mode, modes: Array, hz: Int): Pair { + val context = RuntimeEnvironment.getApplication() + val id = ShadowDisplayManager.addDisplay("w1920dp-h1200dp") + val display = context.getSystemService(DisplayManager::class.java).getDisplay(id) + shadowOf(display).setFlags(Display.FLAG_PRESENTATION) + val panel = Shadow.extract(display).apply { + this.current = current + supported = modes + } + val callback = object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) = Unit + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit + override fun surfaceDestroyed(holder: SurfaceHolder) = Unit + } + return GlassesScreen(context, display, hz, callback).apply { create() } to panel + } + + @Implements(Display::class) + class ModeDisplay : ShadowDisplay() { + lateinit var current: Display.Mode + var supported = emptyArray() + + @Implementation protected fun getMode(): Display.Mode = current + @Implementation protected fun getSupportedModes(): Array = supported + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt new file mode 100644 index 000000000..471aa804d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesSteamVrTest.kt @@ -0,0 +1,179 @@ +package com.droiddeck.launcher.glasses + +import android.app.Application +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.gpu.SteamVr +import com.droiddeck.launcher.session.LoadingState +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.* +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ThreadPoolExecutor +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], application = Application::class) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesSteamVrTest { + private val commands = mutableListOf() + private val handler = Handler(Looper.getMainLooper()) + private lateinit var restore: () -> Unit + private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true } + + @Before fun prepare() { + val fields = listOf("handler", "app", "model").associateWith { field(it).get(null) } + @Suppress("UNCHECKED_CAST") + val flows = listOf("_state", "_settings").map { field(it).get(null) as MutableStateFlow } + val values = flows.map { it.value } + val mode = SessionState.mode + restore = { + fields.forEach { (name, value) -> field(name).set(null, value) } + flows.zip(values).forEach { (flow, value) -> flow.value = value } + SessionState.mode = mode + } + field("handler").set(null, handler) + field("app").set(null, RuntimeEnvironment.getApplication()) + field("model").set(null, VitureProtocol.model(0x1019, null)) + field("link").set(null, object : GlassesLink { + override val hasImu = false + override fun send(msgId: Int, payload: ByteArray): Boolean { + if (msgId == VitureProtocol.MSG_DISPLAY) commands.add(payload[0].toInt() and 255) + return true + } + override fun close() {} + }) + @Suppress("UNCHECKED_CAST") + val state = field("_state").get(null) as MutableStateFlow + state.value = GlassesState(present = true, connected = true, controllable = true, + displayId = 2, width = 1920, height = 1080) + } + + @After fun cleanUp() { + handler.removeCallbacksAndMessages(null) + field("link").set(null, null) + (field("modeState").get(null) as GlassesModeState).apply { manual(); sent(false, 0) } + SessionState.steamVr = false + restore() + } + + @Test fun defaultLaunchAndRunningStatusKeepTheDisplayIn2d() = withLaunch(auto3d = false) { activity -> + status(activity, SteamVr.Status.READY) + status(activity, SteamVr.Status.RUNNING) + status(activity, SteamVr.Status.READY) + assertEquals(emptyList(), commands) + assertFalse(Glasses.state.value.switching3d) + } + + @Test fun optedInLaunchWaitsForRunningAndRestoresOnlyAfterStopping() = withLaunch(auto3d = true) { activity -> + status(activity, SteamVr.Status.READY) + status(activity, SteamVr.Status.READY) + assertEquals("Startup must not change the display mode", emptyList(), commands) + status(activity, SteamVr.Status.RUNNING) + status(activity, SteamVr.Status.RUNNING) + assertEquals(listOf(0x32), commands) + assertTrue(Glasses.state.value.switching3d) + status(activity, SteamVr.Status.UNAVAILABLE) + assertEquals("A failed status poll is not a stop", listOf(0x32), commands) + status(activity, SteamVr.Status.READY) + assertEquals(listOf(0x32, 0x34), commands) + assertFalse(Glasses.state.value.switching3d) + } + + @Test fun ignoredManualSwitchKeepsThePending2dRestore() { + val mode = field("modeState").get(null) as GlassesModeState + mode.startVr() + mode.stopVr() + @Suppress("UNCHECKED_CAST") + val state = field("_state").get(null) as MutableStateFlow + state.value = state.value.copy(width = 640, height = 480, noSignal = true) + Glasses.set3d(true) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(emptyList(), commands) + assertTrue(mode.restorePending) + state.value = state.value.copy(width = 1920, height = 1080, noSignal = false) + Glasses.set3d(false) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(listOf(0x34), commands) + assertFalse(mode.restorePending) + } + + @Test fun absentPreferenceDefaultsTo2dAndAnExplicitOptInSurvivesReload() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences("glasses", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + fun load() = Glasses::class.java.getDeclaredMethod("load").apply { isAccessible = true } + .invoke(Glasses) as GlassesSettings + assertFalse(load().auto3d) + prefs.edit().putBoolean("auto3d", true).commit() + assertTrue(load().auto3d) + prefs.edit().putBoolean("auto3d", false).commit() + assertFalse(load().auto3d) + } + + @Test fun manualStartOnDisplayOnlyGlassesRequestsPollingWithoutDuplicatingLaunch() { + @Suppress("UNCHECKED_CAST") + val state = field("_state").get(null) as MutableStateFlow + state.value = GlassesState(present = true, displayId = 2, width = 1920, height = 1080) + withLaunch(auto3d = false) { activity -> + SessionState.steamVr = false + fun action(name: String) = SessionActivity::class.java.getDeclaredMethod("steamVrAction", String::class.java) + .apply { isAccessible = true }.invoke(activity, name) + action("start") + assertTrue("A manual start must keep status polling alive", SessionState.steamVr) + val worker = SessionActivity::class.java.getDeclaredField("steamVrWorker") + .apply { isAccessible = true }.get(null) as ThreadPoolExecutor + assertEquals(1, worker.queue.size) + status(activity, SteamVr.Status.READY) + assertEquals("Polling must not launch a duplicate instance", 1, worker.queue.size) + status(activity, SteamVr.Status.RUNNING) + status(activity, SteamVr.Status.UNAVAILABLE) + assertTrue(SessionState.steamVr) + action("stop") + assertFalse(SessionState.steamVr) + } + } + + private fun status(activity: SessionActivity, status: SteamVr.Status) { + SessionActivity::class.java.getDeclaredMethod("onSteamVrStatus", SteamVr.Status::class.java) + .apply { isAccessible = true }.invoke(activity, status) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun withLaunch(auto3d: Boolean, check: (SessionActivity) -> Unit) { + @Suppress("UNCHECKED_CAST") + val settings = field("_settings").get(null) as MutableStateFlow + settings.value = GlassesSettings(auto3d = auto3d) + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + SessionActivity::class.java.getDeclaredField("loading").apply { isAccessible = true } + .set(activity, LoadingState(activity).apply { visible = false }) + SessionState.steamVr = true + SessionState.mode = SessionService.MODE_STEAM + // Hold the external worker so status transitions can be exercised + // without launching a Linux runtime from the host test process. + val worker = SessionActivity::class.java.getDeclaredField("steamVrWorker") + .apply { isAccessible = true }.get(null) as ThreadPoolExecutor + val blocked = CountDownLatch(1) + val release = CountDownLatch(1) + worker.execute { blocked.countDown(); release.await(30, TimeUnit.SECONDS) } + try { + assertTrue(blocked.await(5, TimeUnit.SECONDS)) + check(activity) + } finally { + worker.queue.clear() + release.countDown() + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt new file mode 100644 index 000000000..d627353cb --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/GlassesUsbTest.kt @@ -0,0 +1,206 @@ +package com.droiddeck.launcher.glasses + +import android.app.PendingIntent +import android.content.Context +import android.hardware.display.DisplayManager +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.view.Display +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowDisplayManager +import org.robolectric.shadows.ShadowSystemClock +import org.robolectric.shadows.ShadowUsbManager +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, shadows = [GlassesUsbTest.RecordingUsbManager::class]) +@LooperMode(LooperMode.Mode.PAUSED) +class GlassesUsbTest { + private lateinit var manager: RecordingUsbManager + private lateinit var state: MutableStateFlow + private fun field(name: String) = Glasses::class.java.getDeclaredField(name).apply { isAccessible = true } + + @Before fun prepare() { + val context = RuntimeEnvironment.getApplication() + val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager + manager = Shadow.extract(usb) + field("app").set(null, context) + field("usb").set(null, usb) + field("displays").set(null, context.getSystemService(Context.DISPLAY_SERVICE)) + field("handler").set(null, Handler(Looper.getMainLooper())) + field("asked").setBoolean(null, false) + @Suppress("UNCHECKED_CAST") + state = field("_state").get(null) as MutableStateFlow + state.value = GlassesState() + } + + @Test fun controllableGlassesTakePriorityOverDockAndUnsupportedGlasses() { + val dock = device(0x3011) + val beast = device(0x1201) + val pro = device(0x101d) + manager.devices.putAll(listOf(dock, beast, pro).associateBy { it.deviceName }) + refresh() + assertEquals(listOf(pro), manager.requests) + assertTrue(state.value.controllable) + assertEquals(Glasses.Permission.ASKING, state.value.permission) + } + + @Test fun unsupportedDevicesNeverRequestOrAdvertiseUsbPermission() { + for (pid in listOf(0x3011, 0xffff, 0x1201)) { + val device = device(pid) + manager.devices.clear() + manager.devices[device.deviceName] = device + state.value = GlassesState() + refresh(ask = true) + assertTrue(manager.requests.isEmpty()) + assertEquals(Glasses.Permission.NONE, state.value.permission) + manager.granted.add(device.deviceName) + state.value = GlassesState(permission = Glasses.Permission.ASKING) + refresh(ask = true) + assertTrue(manager.requests.isEmpty()) + assertEquals(Glasses.Permission.NONE, state.value.permission) + assertEquals(pid == 0x1201, state.value.present) + assertFalse(state.value.controllable) + assertFalse(state.value.connected) + } + } + + @Test fun dockPresenceStillIdentifiesAnUnnamedPresentationDisplay() { + val hdmi = ReflectionHelpers.getStaticField(Display::class.java, "TYPE_HDMI") + val displayId = ShadowDisplayManager.addDisplay("w1920dp-h1080dp", hdmi) + val displays = RuntimeEnvironment.getApplication().getSystemService(DisplayManager::class.java) + val display = displays.getDisplay(displayId) + shadowOf(display).setFlags(Display.FLAG_PRESENTATION) + assertEquals(displayId, displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION).single().displayId) + assertFalse(display.name.contains("VITURE", ignoreCase = true)) + val dock = device(0x3011) + manager.devices[dock.deviceName] = dock + refresh() + assertEquals(displayId, state.value.displayId) + assertTrue(manager.requests.isEmpty()) + assertEquals(Glasses.Permission.NONE, state.value.permission) + } + + @Test fun noSignalStereoFailureIsRememberedPerModel() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences("glasses", Context.MODE_PRIVATE) + prefs.edit().clear().commit() + val hdmi = ReflectionHelpers.getStaticField(Display::class.java, "TYPE_HDMI") + val displayId = ShadowDisplayManager.addDisplay("w640dp-h480dp", hdmi) + val displays = RuntimeEnvironment.getApplication().getSystemService(DisplayManager::class.java) + shadowOf(displays.getDisplay(displayId)).setFlags(Display.FLAG_PRESENTATION) + val pro = device(0x1019) + manager.devices[pro.deviceName] = pro + refresh() + assertTrue(state.value.noSignal) + (field("modeState").get(null) as GlassesModeState).sent(true, SystemClock.uptimeMillis()) + ShadowSystemClock.advanceBy(Duration.ofSeconds(21)) + refresh() + assertEquals(GlassesState.SBS_FAILS, state.value.sbsSupport) + assertEquals(setOf("1019"), prefs.getStringSet("sbsFailed", null)) + ShadowDisplayManager.removeDisplay(displayId) + + val xr = device(0x101d) + manager.devices.clear() + manager.devices[xr.deviceName] = xr + refresh() + assertEquals(GlassesState.SBS_UNKNOWN, state.value.sbsSupport) + manager.devices.clear() + manager.devices[pro.deviceName] = pro + refresh() + assertEquals(GlassesState.SBS_FAILS, state.value.sbsSupport) + assertFalse(state.value.can3d) + } + + @Test fun rayNeoRequestsPermissionWithoutClaimingUnrelatedTclDevices() { + val air = device(0xaf50, 0x1bbb, "RayNeo Air 4 Pro") + manager.devices[air.deviceName] = air + refresh(ask = true) + assertEquals("RayNeo Air 4 Pro", state.value.model) + assertTrue(state.value.controllable) + assertFalse(state.value.hardwareControls) + assertEquals(listOf(air), manager.requests) + + manager.devices.clear() + manager.devices["phone"] = device(0x1234, 0x1bbb, "TCL Phone") + manager.requests.clear() + refresh(ask = true) + assertFalse(state.value.present) + assertTrue(manager.requests.isEmpty()) + } + + @Test fun alternateBridgeRequiresRayNeoModelIdentity() { + val gt = device(0x1234, 0x22d9, "RayNeo GT") + manager.devices[gt.deviceName] = gt + refresh(ask = true) + assertEquals("RayNeo GT", state.value.model) + assertEquals(listOf(gt), manager.requests) + } + + @Test fun gtVendorRequestsPermissionAndRetainsMaxProductName() { + val gt = device(0xaf50, 0x3941, "RayNeo GT MAX") + manager.devices[gt.deviceName] = gt + refresh(ask = true) + assertEquals("RayNeo GT MAX", state.value.model) + assertTrue(state.value.controllable) + assertFalse(state.value.hardwareControls) + assertEquals(listOf(gt), manager.requests) + } + + private fun refresh(ask: Boolean = false) { + Glasses::class.java.getDeclaredMethod("refresh", Boolean::class.javaPrimitiveType) + .apply { isAccessible = true }.invoke(Glasses, ask) + } + + @Test fun xreal1SUsesExactIdentityAndExcludesAirOneProAndRecoveryProducts() { + assertEquals(XrealModel, Glasses.deviceModel(device(0x043e, 0x3318, "XREAL 1S"))) + assertTrue(Glasses.isGlassesDisplayName("XREAL 1S")) + for (pid in listOf(0x043d, 0x0435, 0x0436, 0x0424, 0xffff)) { + assertEquals(null, Glasses.deviceModel(device(pid, 0x3318, "XREAL 1S"))) + } + assertEquals(null, Glasses.deviceModel(device(0x043e, 0x1234, "XREAL 1S"))) + } + + private fun device(pid: Int, vid: Int = VitureProtocol.VENDOR_ID, name: String = "VITURE device"): UsbDevice = ReflectionHelpers.callConstructor( + UsbDevice::class.java, + from(String::class.java, "/dev/bus/usb/$pid"), + from(Int::class.javaPrimitiveType, vid), + from(Int::class.javaPrimitiveType, pid), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 0), + from(String::class.java, "VITURE"), + from(String::class.java, name), + from(String::class.java, "1.0"), + from(String::class.java, "test-$pid"), + ) + + @Implements(UsbManager::class) + class RecordingUsbManager : ShadowUsbManager() { + val devices = linkedMapOf() + val granted = mutableSetOf() + val requests = mutableListOf() + + @Implementation override fun getDeviceList(): HashMap = LinkedHashMap(devices) + @Implementation override fun hasPermission(device: UsbDevice): Boolean = device.deviceName in granted + @Implementation fun requestPermission(device: UsbDevice, intent: PendingIntent) { requests += device } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt new file mode 100644 index 000000000..996b8fa8c --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoCalibrationTest.kt @@ -0,0 +1,137 @@ +package com.droiddeck.launcher.glasses + +import android.content.Context +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class RayNeoCalibrationTest { + private val axes = RayNeoSensorAxes(0f, false) + private fun sample(a: FloatArray = floatArrayOf(0f, -9.81f, 0f), g: FloatArray = floatArrayOf(0.5f, -0.3f, 0.2f)) = + RayNeoSensorSample(a, g, 0f, FloatArray(3), 0f, 0f, 0) + + private fun finish(c: RayNeoCalibrationCollector, start: Long = 0, sample: RayNeoSensorSample = sample()): RayNeoCalibration? { + var result: RayNeoCalibration? = null + for (ms in start..(start + 8100) step 10) c.update(sample, ms)?.let { result = it } + return result + } + + @Test fun upsideDownStillPoseMeasuresBiasAfterSettlingAndFiveSeconds() { + val c = RayNeoCalibrationCollector(axes, 0) + for (ms in 0L..7990 step 10) assertNull(c.update(sample(), ms)) + val result = c.update(sample(), 8000)!! + assertEquals(CalibrationPhase.SAVED, c.phase) + assertEquals(100, c.progress) + assertEquals(Vec3(0.5f, -0.3f, 0.2f), result.gyroBiasDps) + assertEquals(Quaternion.IDENTITY, result.level) + } + + @Test fun uprightOrSidewaysCannotSave() { + for (a in listOf(floatArrayOf(0f, 9.81f, 0f), floatArrayOf(9.81f, 0f, 0f))) { + val c = RayNeoCalibrationCollector(axes, 0) + assertNull(finish(c, sample = sample(a))) + assertEquals(CalibrationPhase.TURN_OVER, c.phase) + } + } + + @Test fun movementRestartsTheWindowAndAllowsRecovery() { + val c = RayNeoCalibrationCollector(axes, 0) + for (ms in 0L..6000 step 10) c.update(sample(), ms) + c.update(sample(g = floatArrayOf(20f, 0f, 0f)), 6010) + assertEquals(CalibrationPhase.MOVING, c.phase) + assertEquals(0, c.progress) + assertNotNull(finish(c, 6020)) + } + + @Test fun VibrationIsRejectedEvenBelowTheInstantSpeedLimit() { + val c = RayNeoCalibrationCollector(axes, 0) + var result: RayNeoCalibration? = null + for (ms in 0L..12000 step 10) { + val gyro = if (ms % 20 == 0L) 4f else -4f + c.update(sample(g = floatArrayOf(gyro, 0f, 0f)), ms)?.let { result = it } + } + assertNull(result) + } + + @Test fun missingSamplesCannotCompleteAStationaryWindow() { + val c = RayNeoCalibrationCollector(axes, 0) + for (ms in 0L..6000 step 10) c.update(sample(), ms) + assertNull(c.update(sample(), 9000)) + assertEquals(CalibrationPhase.MOVING, c.phase) + assertNull(c.update(sample(), 30_000)) + assertEquals(CalibrationPhase.FAILED, c.phase) + } + + @Test fun nonFiniteOrInvalidGravityCannotSave() { + for (a in listOf(floatArrayOf(Float.NaN, -9.81f, 0f), FloatArray(3), floatArrayOf(0f, -20f, 0f))) { + assertNull(finish(RayNeoCalibrationCollector(axes, 0), sample = sample(a))) + } + } + + @Test fun levelCorrectionAlignsBothInvertedAndUprightGravity() { + val c = RayNeoCalibrationCollector(axes, 0) + val result = finish(c, sample = sample(floatArrayOf(0.85f, -9.735f, 0.85f)))!! + val up = result.level.rotate(Vec3(-0.85f, 9.735f, -0.85f).normalized()) + assertEquals(0f, up.x, 0.001f); assertEquals(1f, up.y, 0.001f); assertEquals(0f, up.z, 0.001f) + val filter = RayNeoOrientationFilter(0f, calibration = result) + var q = FloatArray(4) + repeat(1000) { q = filter.update(sample(floatArrayOf(-0.85f, 9.735f, -0.85f)).copy(deviceTick100us = it * 100L)) } + assertArrayEquals(floatArrayOf(1f, 0f, 0f, 0f), q, 0.001f) + } + + @Test fun profilePersistenceIsIsolatedByVendorProductAndSerialAndCanReset() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences("cal-test", Context.MODE_PRIVATE) + val store = RayNeoCalibrationStore(prefs) + val value = RayNeoCalibration(Vec3(0.1f, 0.2f, 0.3f), Quaternion.IDENTITY) + val key = store.key(0x3941, 0xaf50, "first") + store.save(key, value) + assertEquals(value, store.load(key)) + for (other in listOf(store.key(0x3941, 0xaf50, "second"), store.key(0x1bbb, 0xaf50, "first"), store.key(0x3941, 1, "first"))) { + assertNull(store.load(other)) + } + store.save(key, null) + assertNull(store.load(key)) + } + + @Test fun corruptOrExcessiveProfilesFallBackToDefaults() { + assertNull(RayNeoCalibration.decode("bad")) + assertNull(RayNeoCalibration.decode("{\"version\":2}")) + assertNull(RayNeoCalibration.decode(RayNeoCalibration(Vec3(10f, 0f, 0f), Quaternion.IDENTITY).encode())) + assertNull(RayNeoCalibration.decode(RayNeoCalibration(Vec3(0f, 0f, 0f), Quaternion(0f, 1f, 0f, 0f)).encode())) + } + + @Test fun recordedNonMaxRestingPoseCalibratesWithItsOwnProfile() { + val profile = RayNeoSensorProfile.forDevice(true, 0x40) + val collector = RayNeoCalibrationCollector(RayNeoSensorAxes(profile.rotationXDeg, profile.gtMaxAxes), 0) + val reading = sample(floatArrayOf(0.02f, -8.819f, -4.106f), floatArrayOf(-0.1f, 0.35f, -0.038f)) + val saved = finish(collector, sample = reading)!! + assertEquals(CalibrationPhase.SAVED, collector.phase) + assertEquals(-0.1f, saved.gyroBiasDps.x, 0.0001f) + val upright = RayNeoOrientationFilter(profile.rotationXDeg, profile.gtMaxAxes, saved) + .update(sample(floatArrayOf(-0.02f, 8.819f, 4.106f), reading.gyroDps)) + assertArrayEquals(floatArrayOf(1f, 0f, 0f, 0f), upright, 0.001f) + // This was the reported failure: the same physical pose cannot calibrate + // through the Max's permutation and 35-degree mounting correction. + assertNull(finish(RayNeoCalibrationCollector(RayNeoSensorAxes(35f, true), 0), sample = reading)) + } + + @Test fun nonMaxProfileCannotOverwriteLegacyMaxCalibrationWithTheSameUsbIdentity() { + val prefs = RuntimeEnvironment.getApplication().getSharedPreferences("cal-profile-test", Context.MODE_PRIVATE) + val store = RayNeoCalibrationStore(prefs) + val base = store.key(0x3941, 0xaf50, "00000001") + val standard = RayNeoCalibrationStore.profileKey(base, true, 0x40) + val legacy = RayNeoCalibration(Vec3(1f, 0f, 0f), Quaternion.IDENTITY) + val current = RayNeoCalibration(Vec3(0f, 1f, 0f), Quaternion.IDENTITY) + store.save(base, legacy); store.save(standard, current) + assertEquals(legacy, store.load(base)) + assertEquals(current, store.load(standard)) + assertEquals(base, RayNeoCalibrationStore.profileKey(base, false, 0x40)) + assertEquals(base, RayNeoCalibrationStore.profileKey(base, true, 0x7f)) + assertEquals(base, RayNeoCalibrationStore.profileKey(base, true, 0x41)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoEndpointsTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoEndpointsTest.kt new file mode 100644 index 000000000..933df6220 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoEndpointsTest.kt @@ -0,0 +1,62 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class RayNeoEndpointsTest { + @Test fun gtPrefersInterfaceNumberFiveOverEarlierPairs() { + val other = iface(1) + val control = iface(5) + val selected = RayNeoProtocol.selectEndpoints(device(0x3941, other, control))!! + assertSame(control, selected.usbInterface) + assertEquals(0x81, selected.inEndpoint.address) + assertEquals(0x01, selected.outEndpoint.address) + } + + @Test fun gtAcceptsUniqueRenumberedPairAndIgnoresSmallButtonReports() { + val control = iface(0) + assertSame(control, RayNeoProtocol.selectEndpoints( + device(0x3941, iface(5, packetSize = 8), control))?.usbInterface) + } + + @Test fun gtDoesNotGuessBetweenAmbiguousPairsOrUseInputOnlyInterface() { + assertNull(RayNeoProtocol.selectEndpoints(device(0x3941, iface(0), iface(1)))) + assertNull(RayNeoProtocol.selectEndpoints(device(0x3941, iface(5, output = false)))) + assertNull(RayNeoProtocol.selectEndpoints(device(0x3941, iface(5, packetSize = 8)))) + } + + @Test fun airRetainsItsExistingFirstInterruptPairSelection() { + val first = iface(1) + assertSame(first, RayNeoProtocol.selectEndpoints(device(0x1bbb, first, iface(5)))?.usbInterface) + } + + private fun device(vid: Int, vararg interfaces: UsbInterface): UsbDevice = + Shadow.newInstanceOf(UsbDevice::class.java).also { + ReflectionHelpers.setField(it, "mVendorId", vid) + ReflectionHelpers.setField(it, "mProductId", 0xaf50) + ReflectionHelpers.setField(it, "mInterfaces", interfaces) + } + + private fun iface(id: Int, packetSize: Int = 64, output: Boolean = true): UsbInterface = + Shadow.newInstanceOf(UsbInterface::class.java).also { + ReflectionHelpers.setField(it, "mId", id) + val endpoints = if (output) arrayOf(endpoint(0x81, packetSize), endpoint(1, packetSize)) + else arrayOf(endpoint(0x81, packetSize)) + ReflectionHelpers.setField(it, "mEndpoints", endpoints) + } + + private fun endpoint(address: Int, size: Int) = ReflectionHelpers.callConstructor(UsbEndpoint::class.java, + from(Int::class.javaPrimitiveType, address), from(Int::class.javaPrimitiveType, 3), + from(Int::class.javaPrimitiveType, size), from(Int::class.javaPrimitiveType, 1)) +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt new file mode 100644 index 000000000..832d220c7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoLinkTest.kt @@ -0,0 +1,150 @@ +package com.droiddeck.launcher.glasses + +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.Duration +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowSystemClock +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, shadows = [RayNeoLinkTest.Connection::class]) +@LooperMode(LooperMode.Mode.PAUSED) +class RayNeoLinkTest { + @Test fun handshakeWarmupPoseAndShutdownUseOnlyRayNeoWireCommands() = exerciseLink(false) + + @Test fun gtControlTrafficDoesNotBreakHandshakeOrTracking() = exerciseLink(true) + @Test fun nonMaxBoardSelectsItsProfileBeforeTrackingStarts() = exerciseLink(true, true) + + private fun exerciseLink(gtTraffic: Boolean, nonMax: Boolean = false) { + fun endpoint(address: Int) = ReflectionHelpers.callConstructor(UsbEndpoint::class.java, + from(Int::class.javaPrimitiveType, address), from(Int::class.javaPrimitiveType, 3), + from(Int::class.javaPrimitiveType, 64), from(Int::class.javaPrimitiveType, 1)) + val iface = Shadow.newInstanceOf(UsbInterface::class.java) + val conn = Shadow.newInstanceOf(UsbDeviceConnection::class.java) + val io = Shadow.extract(conn) + io.gtTraffic = gtTraffic + io.boardId = if (nonMax) 0x40 else if (gtTraffic) 0x41 else 0 + lateinit var link: RayNeoLink + var ready = false + var pose: FloatArray? = null + var lost = false + var loadedBoard = -1 + var reportedBoard = -1 + val listener = object : RayNeoLink.Listener { + override fun onDeviceInfo(boardId: Int, calibrationSaved: Boolean) { + reportedBoard = boardId + assertFalse(calibrationSaved) + } + override fun onReady() { + assertEquals(io.boardId, loadedBoard) + assertEquals(io.boardId, reportedBoard) + ready = true + if (gtTraffic) { + assertTrue(link.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(6))) + assertTrue(link.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(7))) + } + } + override fun onPose(raw: ByteArray, xyzw: FloatArray, timeNs: Long) { + pose = xyzw + link.close() + } + override fun onLost() { lost = true } + } + val ctor = RayNeoLink::class.java.declaredConstructors.first { it.parameterCount == 5 }.apply { isAccessible = true } + val loadCalibration: (Int) -> RayNeoCalibration? = { board -> loadedBoard = board; null } + link = ctor.newInstance(conn, RayNeoEndpointSelection(iface, endpoint(0x81), endpoint(0x01)), listener, gtTraffic, loadCalibration) as RayNeoLink + ReflectionHelpers.setField(link, "worker", Thread.currentThread()) + assertFalse(link.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(8))) + assertFalse(link.send(VitureProtocol.MSG_FILM, byteArrayOf(1))) + assertFalse(link.send(VitureProtocol.MSG_IMU_RATE, byteArrayOf(3))) + assertTrue(io.commands.isEmpty()) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + RayNeoLink::class.java.getDeclaredMethod("run").apply { isAccessible = true }.invoke(link) + assertTrue(ready) + assertFalse(lost) + assertEquals(if (gtTraffic) listOf(0, 6, 7, 1, 2) else listOf(0, 1, 2), io.commands) + assertTrue(io.samples >= 200) + assertTrue(io.closed) + assertTrue(io.released) + assertNotNull(pose) + assertEquals(0f, pose!![0], 1e-5f) + assertEquals(0f, pose!![1], 1e-5f) + assertTrue(pose!![2] > 0) + } + + @Implements(UsbDeviceConnection::class) + class Connection { + val commands = mutableListOf() + private val responses = ArrayDeque() + var samples = 0 + var closed = false + var released = false + var gtTraffic = false + var boardId = 0 + private var streaming = false + + @Implementation fun bulkTransfer(ep: UsbEndpoint, bytes: ByteArray, count: Int, timeout: Int): Int { + if (ep.direction == 0) { + check(bytes[0] == 0x66.toByte() && count == 64) + val cmd = bytes[1].toInt() and 0xff + commands += cmd + if (gtTraffic && cmd in listOf(0, 1)) { + // Notifications and unrelated ACKs can precede the requested response. + for (other in listOf(0x08, 6, 7, 0x50)) { + responses += ByteArray(64).apply { + this[0] = 0x99.toByte(); this[1] = 0xc8.toByte(); this[2] = 64 + this[8] = other.toByte() + } + } + } + responses += ByteArray(64).apply { + this[0] = 0x99.toByte(); this[1] = 0xc8.toByte(); this[2] = 64 + this[8] = cmd.toByte() + if (cmd == 0) this[21] = boardId.toByte() + } + if (cmd == 1) streaming = true + if (cmd == 2) streaming = false + return count + } + ShadowSystemClock.advanceBy(Duration.ofMillis(10)) + val packet = if (responses.isNotEmpty()) responses.removeFirst() else { + check(streaming) + check(++samples < 500) { "Warmup never delivered a pose" } + ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN).apply { + put(0, 0x99.toByte()); put(1, 0x65); put(2, 64) + if (gtTraffic && boardId == 0x40) { + val s = kotlin.math.sin(Math.toRadians(25.0)).toFloat() + val c = kotlin.math.cos(Math.toRadians(25.0)).toFloat() + putFloat(8, 9.81f * c); putFloat(12, 9.81f * s) + putFloat(20, 90f * c); putFloat(24, 90f * s) + } else if (gtTraffic) { + val s = kotlin.math.sin(Math.toRadians(35.0)).toFloat() + val c = kotlin.math.cos(Math.toRadians(35.0)).toFloat() + putFloat(4, 9.81f * s); putFloat(12, 9.81f * c) + putFloat(16, 90f * s); putFloat(24, 90f * c) + } else { + putFloat(8, 9.81f); putFloat(20, 90f) + } + putInt(40, samples * 100) + }.array() + } + packet.copyInto(bytes) + return packet.size + } + @Implementation fun releaseInterface(iface: UsbInterface): Boolean { released = true; return true } + @Implementation fun close() { closed = true } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt new file mode 100644 index 000000000..c05b7eb19 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoOrientationFilterTest.kt @@ -0,0 +1,115 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.gpu.HeadPose +import kotlin.math.cos +import kotlin.math.sin +import org.junit.Assert.* +import org.junit.Test + +class RayNeoOrientationFilterTest { + private fun sample(tick: Long, accel: FloatArray, gyro: FloatArray = FloatArray(3)) = + RayNeoSensorSample(accel, gyro, 0f, FloatArray(3), 0f, 0f, tick) + + private fun screen(q: FloatArray): FloatArray { + val p = RayNeoOrientationFilter.glassesPose(q) + return HeadPose.fromGlasses(p[0], p[1], p[2], p[3]) + } + + @Test fun gravityCorrectionSettlesTowardTiltInsteadOfAwayFromIt() { + val filter = RayNeoOrientationFilter(0f) + filter.update(sample(0, floatArrayOf(0f, 9.81f, 0f))) + val angle = Math.toRadians(20.0) + val accel = floatArrayOf(0f, 9.81f * cos(angle).toFloat(), -9.81f * sin(angle).toFloat()) + var q = FloatArray(4) + repeat(1000) { q = filter.update(sample((it + 1) * 100L, accel)) } + assertArrayEquals(floatArrayOf(cos(angle / 2).toFloat(), sin(angle / 2).toFloat(), 0f, 0f), q, 0.002f) + } + + @Test fun initialGravityDoesNotRotateAfterTheFirstRecenter() { + val filter = RayNeoOrientationFilter(0f) + val accel = floatArrayOf(4.905f, 8.4957f, 0f) + val first = filter.update(sample(0, accel)) + var last = first + repeat(1000) { last = filter.update(sample((it + 1) * 100L, accel)) } + assertArrayEquals(first, last, 0.0001f) + assertEquals(sin(Math.toRadians(15.0)).toFloat(), first[3], 0.001f) + } + + @Test fun recordedGtLevelGravityStartsWithAnUprightHorizon() { + val q = RayNeoOrientationFilter(0f, gt = true) + .update(sample(0, floatArrayOf(5.697f, 0.001f, 7.947f))) + val m = screen(q) + val relative = HeadPose.relative(HeadPose.recenter(m), m) + assertArrayEquals(HeadPose.IDENTITY, relative, 0.02f) + } + + @Test fun gtLeftTurnLooksLeftWithoutPitchOrRoll() = checkGtMotion(0) + @Test fun gtLookingUpLooksUpWithoutYawOrRoll() = checkGtMotion(1) + @Test fun gtRollTiltsTheHorizonWithoutTurningTheView() = checkGtMotion(2) + + @Test fun nonMaxMountingKeepsYawAndPitchSeparateThroughTheSharedPosePath() { + val profile = RayNeoSensorProfile.forDevice(true, 0x40) + assertFalse(profile.gtMaxAxes) + val mount = Math.toRadians(25.0) + val s = sin(mount).toFloat(); val c = cos(mount).toFloat() + for (pitch in listOf(false, true)) { + val filter = RayNeoOrientationFilter(profile.rotationXDeg, profile.gtMaxAxes) + val level = floatArrayOf(0f, 9.81f * c, 9.81f * s) + val ref = HeadPose.recenter(screen(filter.update(sample(0, level)))) + var q = FloatArray(4) + repeat(100) { i -> + val angle = Math.toRadians((i + 1) * 0.3) + val accel = if (pitch) floatArrayOf(0f, 9.81f * cos(mount - angle).toFloat(), 9.81f * sin(mount - angle).toFloat()) else level + val gyro = if (pitch) floatArrayOf(30f, 0f, 0f) else floatArrayOf(0f, 30f * c, 30f * s) + q = filter.update(sample((i + 1) * 100L, accel, gyro)) + } + val forward = HeadPose.apply(HeadPose.relative(ref, screen(q)), floatArrayOf(0f, 0f, -1f)) + assertArrayEquals(if (pitch) floatArrayOf(0f, 0.5f, -0.8660254f) else floatArrayOf(-0.5f, 0f, -0.8660254f), forward, 0.01f) + } + assertTrue(RayNeoSensorProfile.forDevice(true, 0x7f).gtMaxAxes) + assertTrue(RayNeoSensorProfile.forDevice(true, 0x41).gtMaxAxes) + assertEquals(-20f, RayNeoSensorProfile.forDevice(false, 0x3a).rotationXDeg, 0f) + } + + private fun checkGtMotion(axis: Int) { + val mount = Math.toRadians(35.0) + val s = sin(mount).toFloat(); val c = cos(mount).toFloat() + val filter = RayNeoOrientationFilter(0f, gt = true) + val level = floatArrayOf(9.81f * s, 0f, 9.81f * c) + val first = screen(filter.update(sample(0, level))) + val ref = HeadPose.recenter(first) + var q = FloatArray(4) + // Physical 30-degree turn over one second, through the complete shared + // VITURE conversion and recenter pipeline. GT signs match device captures. + repeat(100) { i -> + val a = Math.toRadians((i + 1) * 0.3) + val accel = when (axis) { + 1 -> floatArrayOf(9.81f * sin(mount + a).toFloat(), 0f, 9.81f * cos(mount + a).toFloat()) + 2 -> floatArrayOf(9.81f * s * cos(a).toFloat(), -9.81f * sin(a).toFloat(), 9.81f * c * cos(a).toFloat()) + else -> level + } + val gyro = when (axis) { + 0 -> floatArrayOf(30f * s, 0f, 30f * c) + 1 -> floatArrayOf(0f, -30f, 0f) + else -> floatArrayOf(-30f * c, 0f, 30f * s) + } + q = filter.update(sample((i + 1) * 100L, accel, gyro)) + } + val view = HeadPose.relative(ref, screen(q)) + val forward = HeadPose.apply(view, floatArrayOf(0f, 0f, -1f)) + val up = HeadPose.apply(view, floatArrayOf(0f, 1f, 0f)) + val c30 = cos(Math.toRadians(30.0)).toFloat() + val expectedForward = when (axis) { + 0 -> floatArrayOf(-0.5f, 0f, -c30) + 1 -> floatArrayOf(0f, 0.5f, -c30) + else -> floatArrayOf(0f, 0f, -1f) + } + val expectedUp = when (axis) { + 0 -> floatArrayOf(0f, 1f, 0f) + 1 -> floatArrayOf(0f, c30, 0.5f) + else -> floatArrayOf(-0.5f, c30, 0f) + } + assertArrayEquals(expectedForward, forward, 0.01f) + assertArrayEquals(expectedUp, up, 0.01f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt new file mode 100644 index 000000000..0cf666308 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/RayNeoProtocolTest.kt @@ -0,0 +1,122 @@ +package com.droiddeck.launcher.glasses + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import org.junit.Assert.* +import org.junit.Test +import kotlin.math.sqrt + +class RayNeoProtocolTest { + private fun sensor(tick: Long = 1234L): ByteArray = ByteBuffer.allocate(64).order(ByteOrder.LITTLE_ENDIAN).apply { + put(0, 0x99.toByte()); put(1, 0x65); put(2, 64) + putFloat(4, 1f); putFloat(8, 9.81f); putFloat(12, -2f) + putFloat(16, 30f); putFloat(20, -60f); putFloat(24, 90f) + putFloat(28, 25f); putFloat(32, 2f); putFloat(36, 3f) + putInt(40, tick.toInt()); putFloat(44, 1f); putFloat(48, 42f); putFloat(52, 4f) + }.array() + + @Test fun decodesScaffoldLayoutAndUnsignedTick() { + val p = RayNeoProtocol.classifyPacket(sensor(0xfffffff0L)) as RayNeoPacket.Sensor + assertArrayEquals(floatArrayOf(1f, 9.81f, -2f), p.sample.accelMps2, 0f) + assertArrayEquals(floatArrayOf(30f, -60f, 90f), p.sample.gyroDps, 0f) + assertArrayEquals(floatArrayOf(2f, 3f, 4f), p.sample.magnet, 0f) + assertEquals(0xfffffff0L, p.sample.deviceTick100us) + assertEquals(25f, p.sample.temperatureC, 0f) + assertEquals(42f, p.sample.light, 0f) + } + + @Test fun rejectsTruncationBadHeaderAndNonfiniteMotion() { + assertSame(RayNeoPacket.Unknown, RayNeoProtocol.classifyPacket(sensor().copyOf(40))) + assertSame(RayNeoPacket.Unknown, RayNeoProtocol.classifyPacket(sensor().apply { this[0] = 0 })) + assertSame(RayNeoPacket.Unknown, RayNeoProtocol.classifyPacket(sensor().apply { this[2] = 56 })) + val nan = sensor().apply { ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).putFloat(16, Float.NaN) } + assertSame(RayNeoPacket.Unknown, RayNeoProtocol.classifyPacket(nan)) + } + + @Test fun assemblesFragmentedAndCoalescedFramesAfterNoise() { + val a = RayNeoPacketAssembler() + val packet = sensor() + val bytes = byteArrayOf(1, 2, 0x99.toByte(), 0, 0) + packet + packet + a.append(bytes.copyOf(20), 20) + assertNull(a.nextPacket()) + val rest = bytes.copyOfRange(20, bytes.size) + a.append(rest, rest.size) + assertArrayEquals(packet, a.nextPacket()) + assertArrayEquals(packet, a.nextPacket()) + assertNull(a.nextPacket()) + } + + @Test fun commandAndDeviceInfoMatchScaffold() { + assertArrayEquals(byteArrayOf(0x66, 1, 0) + ByteArray(61), RayNeoProtocol.buildCommandPacket(1, 0, 64)) + val info = ByteArray(64).apply { + this[0] = 0x99.toByte(); this[1] = 0xc8.toByte(); this[2] = 64 + this[8] = 0; this[21] = 0x3a; this[43] = 1 + } + assertEquals(RayNeoDeviceInfo(0x3a, true), RayNeoProtocol.parseDeviceInfo(info)) + assertNull(RayNeoProtocol.parseDeviceInfo(info.copyOf(21))) + info[8] = 1 + assertNull(RayNeoProtocol.parseDeviceInfo(info)) + } + + @Test fun discoveryDoesNotClaimEveryTclOrBridgeDevice() { + assertNotNull(RayNeoModel.model(0x1bbb, 0xaf50, null)) + for (name in listOf("RayNeo Air 2", "RayNeo Air 2s", "RayNeo Air 3", "RayNeo Air 4 Pro", "RayNeo GT")) { + assertEquals(name, RayNeoModel.model(0x22d9, 1, name)?.name) + assertTrue(Glasses.isGlassesDisplayName(name)) + } + assertNull(RayNeoModel.model(0x1bbb, 1, "TCL Phone")) + assertNull(RayNeoModel.model(0x22d9, 1, "USB bridge")) + assertNull(RayNeoModel.model(0x1234, 0xaf50, "RayNeo Air 3")) + assertFalse(Glasses.isGlassesDisplayName("TCL TV")) + assertTrue(Glasses.isGlassesDisplayName("VITURE Pro")) + } + + @Test fun gtIdentityWorksWithoutProductNameAndPreservesMaxName() { + assertEquals("RayNeo GT series", RayNeoModel.model(0x3941, 0xaf50, null)?.name) + assertEquals("RayNeo GT series", RayNeoModel.model(0x3941, 0xaf50, "USB device")?.name) + for (name in listOf("RayNeo GT", "RayNeo GT MAX")) { + val model = RayNeoModel.model(0x3941, 0xaf50, name)!! + assertEquals(name, model.name) + assertEquals(6, model.mode(60, true)) + assertEquals(7, model.mode(120, false)) + assertTrue(Glasses.isGlassesDisplayName(name)) + } + assertNull(RayNeoModel.model(0x3941, 0x1234, "RayNeo GT")) + assertNull(RayNeoModel.model(0x1234, 0xaf50, "RayNeo GT MAX")) + } + + @Test fun gtNotificationsAndControlAcksAreNotDeviceInfo() { + for (cmd in listOf(0x08, 6, 7, 0x50)) { + val raw = ByteArray(64).apply { + this[0] = 0x99.toByte(); this[1] = 0xc8.toByte(); this[2] = 64 + this[8] = cmd.toByte() + } + assertEquals(cmd, (RayNeoProtocol.classifyPacket(raw) as RayNeoPacket.Response).cmd) + assertNull(RayNeoProtocol.parseDeviceInfo(raw)) + } + } + + private fun sample(tick: Long, yawDps: Float = 0f) = RayNeoSensorSample( + floatArrayOf(0f, 9.81f, 0f), floatArrayOf(0f, yawDps, 0f), 0f, + FloatArray(3), 0f, 0f, tick, + ) + + @Test fun stationaryPoseIsFiniteAndYawUsesVitureUpAxis() { + val stationary = RayNeoOrientationFilter(0f) + repeat(100) { assertArrayEquals(floatArrayOf(1f, 0f, 0f, 0f), stationary.update(sample(it * 100L)), 1e-6f) } + val filter = RayNeoOrientationFilter(0f) + var q = FloatArray(4) + repeat(100) { q = filter.update(sample(it * 100L, 90f)) } + assertEquals(1f, sqrt(q.sumOf { (it * it).toDouble() }).toFloat(), 1e-5f) + val pose = RayNeoOrientationFilter.glassesPose(q) + assertEquals(0f, pose[0], 1e-5f); assertEquals(0f, pose[1], 1e-5f) + assertEquals(sqrt(0.5f), pose[2], 0.01f); assertEquals(sqrt(0.5f), pose[3], 0.01f) + } + + @Test fun tickWrapDoesNotIntroduceOrientationJump() { + val filter = RayNeoOrientationFilter(0f) + filter.update(sample(0xfffffff0L, 90f)) + val q = filter.update(sample(84, 90f)) // 100 ticks = 10ms across uint32 rollover. + assertEquals(kotlin.math.sin(Math.PI * 0.005).toFloat(), q[2], 1e-4f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureLinkTest.kt new file mode 100644 index 000000000..18a374052 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureLinkTest.kt @@ -0,0 +1,192 @@ +package com.droiddeck.launcher.glasses + +import android.content.Context +import android.hardware.usb.UsbConfiguration +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Parcelable +import android.util.Log +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowLog +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE, shadows = [VitureLinkTest.QueuedConnection::class]) +class VitureLinkTest { + private val frames = LinkedBlockingQueue() + private val imu = LinkedBlockingQueue() + private var link: VitureLink? = null + + private val listener = object : VitureLink.Listener { + override fun onFrame(frame: VitureProtocol.Frame) { frames += frame } + override fun onImu(payload: ByteArray, timeNs: Long) { imu += payload } + override fun onLost() {} + } + + @Before fun prepare() { + incoming.clear() + ShadowLog.clear() + } + + @After fun close() { link?.close() } + + @Test fun framesWithABadChecksumAreDroppedAndCounted() { + val usb = RuntimeEnvironment.getApplication().getSystemService(Context.USB_SERVICE) as UsbManager + val l = VitureLink.open(usb, glasses(), listener) + assertNotNull(l) + link = l + val report = packet(VitureProtocol.MAGIC_REPORT, VitureProtocol.EVENT_BRIGHTNESS, byteArrayOf(6)) + val pose = ByteArray(36) { it.toByte() } + val imuPacket = packet(VitureProtocol.MAGIC_IMU, 0, pose) + feed(MCU_IN, corrupt(report), report) + feed(IMU_IN, corrupt(imuPacket), imuPacket) + + val frame = frames.poll(2, TimeUnit.SECONDS) + assertNotNull(frame) + assertEquals(VitureProtocol.EVENT_BRIGHTNESS, frame!!.msgId) + assertArrayEquals(byteArrayOf(6), frame.payload) + assertArrayEquals(pose, imu.poll(2, TimeUnit.SECONDS)) + assertNull(frames.poll(200, TimeUnit.MILLISECONDS)) + assertNull(imu.poll(0, TimeUnit.MILLISECONDS)) + val transport: GlassesLink = l!! + assertEquals(2L, transport.badFrames) + assertEquals(1, ShadowLog.getLogsForTag("VitureLink").count { it.type == Log.WARN }) + } + + @Test fun bothReadersDiscardCorruptReportsAndAcceptTheNextValidPacket() { + for (imuEndpoint in listOf(false, true)) { + checkPackets(VitureProtocol.MAGIC_REPORT, imuEndpoint, expectImu = false) + } + } + + @Test fun imuReaderDiscardsCorruptPoseAndAcceptsTheNextValidPacket() { + checkPackets(VitureProtocol.MAGIC_IMU, imuEndpoint = true, expectImu = true) + } + + private fun checkPackets(magic: Int, imuEndpoint: Boolean, expectImu: Boolean) { + val frames = mutableListOf() + val poses = mutableListOf() + val listener = object : VitureLink.Listener { + override fun onFrame(frame: VitureProtocol.Frame) { frames += frame } + override fun onImu(payload: ByteArray, timeNs: Long) { poses += payload } + override fun onLost() { fail("A corrupt packet must not disconnect the link") } + } + val ctor = VitureLink::class.java.getDeclaredConstructor(UsbDeviceConnection::class.java, + UsbInterface::class.java, UsbEndpoint::class.java, UsbEndpoint::class.java, + UsbInterface::class.java, UsbEndpoint::class.java, VitureLink.Listener::class.java) + .apply { isAccessible = true } + val link = ctor.newInstance(Shadow.newInstanceOf(UsbDeviceConnection::class.java), + Shadow.newInstanceOf(UsbInterface::class.java), Shadow.newInstanceOf(UsbEndpoint::class.java), + null, null, null, listener) + val receive = VitureLink::class.java.getDeclaredMethod("onPacket", ByteArray::class.java, + Int::class.javaPrimitiveType, Boolean::class.javaPrimitiveType).apply { isAccessible = true } + val payload = byteArrayOf(0, 0x32) + val valid = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, payload).also { it[1] = magic.toByte() } + val corrupt = valid.copyOf().also { it[18] = 1 } + assertFalse(VitureProtocol.parse(corrupt, corrupt.size)!!.crcOk) + receive.invoke(link, corrupt, corrupt.size, imuEndpoint) + assertTrue(frames.isEmpty()) + assertTrue(poses.isEmpty()) + receive.invoke(link, valid, valid.size, imuEndpoint) + assertEquals(if (expectImu) 0 else 1, frames.size) + assertEquals(if (expectImu) 1 else 0, poses.size) + assertArrayEquals(payload, if (expectImu) poses.single() else frames.single().payload) + } + + private fun packet(magic: Int, msgId: Int, payload: ByteArray) = + VitureProtocol.packet(msgId, payload).also { it[1] = magic.toByte() } + + private fun corrupt(packet: ByteArray) = packet.copyOf().also { it[18] = (it[18].toInt() xor 0x01).toByte() } + + private fun feed(address: Int, vararg packets: ByteArray) { + val queue = incoming.getOrPut(address) { LinkedBlockingQueue() } + packets.forEach(queue::put) + } + + private fun glasses(): UsbDevice { + fun endpoint(address: Int): UsbEndpoint = ReflectionHelpers.callConstructor( + UsbEndpoint::class.java, + from(Int::class.javaPrimitiveType, address), + from(Int::class.javaPrimitiveType, UsbConstants.USB_ENDPOINT_XFER_INT), + from(Int::class.javaPrimitiveType, VitureProtocol.PACKET_SIZE), + from(Int::class.javaPrimitiveType, 1), + ) + fun hid(id: Int, vararg endpoints: UsbEndpoint): UsbInterface = ReflectionHelpers.callConstructor( + UsbInterface::class.java, + from(Int::class.javaPrimitiveType, id), + from(Int::class.javaPrimitiveType, 0), + from(String::class.java, "hid$id"), + from(Int::class.javaPrimitiveType, UsbConstants.USB_CLASS_HID), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 0), + ).also { hidden(it, "setEndpoints", *endpoints) } + val config: UsbConfiguration = ReflectionHelpers.callConstructor( + UsbConfiguration::class.java, + from(Int::class.javaPrimitiveType, 1), + from(String::class.java, "default"), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 250), + ) + hidden(config, "setInterfaces", hid(0, endpoint(IMU_IN)), hid(1, endpoint(MCU_IN), endpoint(MCU_OUT))) + return ReflectionHelpers.callConstructor( + UsbDevice::class.java, + from(String::class.java, "/dev/bus/usb/001/002"), + from(Int::class.javaPrimitiveType, VitureProtocol.VENDOR_ID), + from(Int::class.javaPrimitiveType, 0x101d), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 0), + from(Int::class.javaPrimitiveType, 0), + from(String::class.java, "VITURE"), + from(String::class.java, "VITURE Pro XR"), + from(String::class.java, "1.0"), + from(String::class.java, "test"), + ).also { hidden(it, "setConfigurations", config) } + } + + private fun hidden(target: Any, method: String, vararg items: Parcelable) { + ReflectionHelpers.callInstanceMethod(target, method, from(Array::class.java, arrayOf(*items))) + } + + @Implements(UsbDeviceConnection::class) + class QueuedConnection { + @Implementation fun claimInterface(intf: UsbInterface, force: Boolean) = true + @Implementation fun releaseInterface(intf: UsbInterface) = true + @Implementation fun bulkTransfer(endpoint: UsbEndpoint, buffer: ByteArray, length: Int, timeout: Int): Int { + if (endpoint.direction == UsbConstants.USB_DIR_OUT) return length + val packet = incoming[endpoint.address]?.poll(timeout.toLong(), TimeUnit.MILLISECONDS) ?: return 0 + packet.copyInto(buffer, endIndex = minOf(packet.size, length)) + return minOf(packet.size, length) + } + } + + companion object { + private const val IMU_IN = 0x81 + private const val MCU_IN = 0x82 + private const val MCU_OUT = 0x02 + private val incoming = ConcurrentHashMap>() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt new file mode 100644 index 000000000..565e8b614 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureProtocolTest.kt @@ -0,0 +1,142 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import kotlin.math.cos +import kotlin.math.sin + +class VitureProtocolTest { + private fun u8(b: Byte) = b.toInt() and 0xFF + private fun u16(b: ByteArray, at: Int) = u8(b[at]) or (u8(b[at + 1]) shl 8) + + @Test fun crcIsXmodem() { + val check = "123456789".toByteArray() + assertEquals(0x31C3, VitureProtocol.crc16(check, 0, check.size)) + } + + @Test fun packetLayout() { + val p = VitureProtocol.packet(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(5, 0)) + assertEquals(VitureProtocol.PACKET_SIZE, p.size) + assertEquals(0xFF, u8(p[0])) + assertEquals(VitureProtocol.MAGIC_COMMAND, u8(p[1])) + assertEquals(2 + 0x0C, u16(p, 4)) + assertEquals(VitureProtocol.MSG_BRIGHTNESS, u16(p, 14)) + assertEquals(5, u8(p[18])) + assertEquals(VitureProtocol.crc16(p, 4, u16(p, 4) + 2), u16(p, 2)) + for (i in 6 until 14) assertEquals(0, u8(p[i])) + } + + @Test fun parseRoundTripsAndChecksCrc() { + val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0x34)) + val f = VitureProtocol.parse(p, p.size) + assertNotNull(f) + assertEquals(VitureProtocol.MAGIC_COMMAND, f!!.magic) + assertEquals(VitureProtocol.MSG_DISPLAY, f.msgId) + assertArrayEquals(byteArrayOf(0x34), f.payload) + assertTrue(f.crcOk) + p[18] = 0x31 + val badCrc = VitureProtocol.parse(p, p.size)!! + assertFalse(badCrc.crcOk) + assertArrayEquals(byteArrayOf(0x31), badCrc.payload) + } + + @Test fun parseAcceptsPaddingOutsideDeclaredFrame() { + val payload = byteArrayOf(0, 0x32) + val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, payload) + val end = 18 + payload.size + p.fill(0x5A, end) + for (size in listOf(end, p.size)) { + val frame = VitureProtocol.parse(p, size)!! + assertArrayEquals(payload, frame.payload) + assertTrue(frame.crcOk) + } + } + + @Test fun parseRejectsTruncatedDeclaredFrame() { + val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0, 0x32)) + val received = 19 + assertNull(VitureProtocol.parse(p, received)) + assertNull(VitureProtocol.parse(p.copyOf(received), received)) + p[4] = 0xFF.toByte() + p[5] = 0xFF.toByte() + assertNull(VitureProtocol.parse(p, p.size)) + } + + @Test fun parseRejectsInvalidReceiveSize() { + val p = VitureProtocol.packet(VitureProtocol.MSG_DISPLAY, byteArrayOf(0, 0x32)) + assertNull(VitureProtocol.parse(p, p.size + 1)) + assertNull(VitureProtocol.parse(p, 17)) + assertNull(VitureProtocol.parse(ByteArray(0), 18)) + assertNull(VitureProtocol.parse(ByteArray(0), 0)) + assertNull(VitureProtocol.parse(p, -1)) + } + + @Test fun parseReadsRepliesAndRejectsJunk() { + val reply = VitureProtocol.packet(VitureProtocol.MSG_GET_VOLUME, byteArrayOf(0, 5)) + reply[1] = VitureProtocol.MAGIC_REPORT.toByte() + val f = VitureProtocol.parse(reply, reply.size)!! + assertEquals(VitureProtocol.MAGIC_REPORT, f.magic) + assertArrayEquals(byteArrayOf(0, 5), f.payload) + assertNull(VitureProtocol.parse(ByteArray(64), 64)) + assertNull(VitureProtocol.parse(reply, 8)) + val wrongMagic = reply.copyOf().also { it[1] = 0x10 } + assertNull(VitureProtocol.parse(wrongMagic, wrongMagic.size)) + } + + @Test fun modelsAndModes() { + val pro = VitureProtocol.model(0x101d, "VITURE Pro XR Glasses")!! + assertEquals("VITURE Pro XR", pro.name) + assertEquals(VitureProtocol.Family.PRO, pro.family) + assertTrue(pro.controllable) + assertFalse(pro.sbs90) + assertEquals(0x34, pro.mode(120, false)) + assertEquals(0x33, pro.mode(90, false)) + assertEquals(0x31, pro.mode(60, false)) + assertEquals(0x32, pro.mode(90, true)) + assertEquals(0x32, pro.mode(60, true)) + + val luma = VitureProtocol.model(0x1131, null)!! + assertEquals("VITURE Luma", luma.name) + assertFalse(luma.sbs90) + assertEquals(0x44, luma.mode(120, false)) + assertEquals(0x42, luma.mode(90, true)) + assertEquals(0x42, luma.mode(60, true)) + + val ultra = VitureProtocol.model(0x1101, null)!! + assertTrue(ultra.sbs90) + assertEquals(0x45, ultra.mode(90, true)) + assertEquals(0x42, ultra.mode(60, true)) + + assertTrue(VitureProtocol.model(0x1201, null)!!.controllable) + assertTrue(VitureProtocol.model(0x1201, null)!!.needsSdk) + assertNull(VitureProtocol.model(0x9999, "VITURE")) + assertEquals("VITURE One", VitureProtocol.model(0x1011, "Something else")!!.name) + } + + @Test fun imuPrefersTheQuaternion() { + val half = Math.toRadians(30.0) / 2 + val payload = ByteBuffer.allocate(40) + .putFloat(1f).putFloat(2f).putFloat(3f) + .putFloat(0f).putFloat(0f) + .putFloat(cos(half).toFloat()).putFloat(0f).putFloat(0f).putFloat(sin(half).toFloat()) + .array() + val q = VitureProtocol.imuQuaternion(payload)!! + assertArrayEquals(floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()), q, 1e-5f) + } + + @Test fun imuFallsBackToEuler() { + val payload = ByteBuffer.allocate(12).putFloat(0f).putFloat(0f).putFloat(30f).array() + val q = VitureProtocol.imuQuaternion(payload)!! + val half = Math.toRadians(30.0) / 2 + assertArrayEquals(floatArrayOf(0f, 0f, sin(half).toFloat(), cos(half).toFloat()), q, 1e-5f) + assertNull(VitureProtocol.imuQuaternion(ByteArray(8))) + val nan = ByteBuffer.allocate(12).putFloat(Float.NaN).putFloat(0f).putFloat(0f).array() + assertNull(VitureProtocol.imuQuaternion(nan)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt new file mode 100644 index 000000000..3143cf70f --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/VitureSdkTest.kt @@ -0,0 +1,95 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.* +import org.junit.Test +import kotlin.math.sqrt +import com.droiddeck.launcher.gpu.HeadPose + +class VitureSdkTest { + @Test fun pro2UsesItsOwnSdkTransportAndDisplayLimits() { + val m = VitureProtocol.sdkModel("Pro 2")!! + assertTrue(m.needsSdk) + assertEquals(VitureProtocol.Family.PRO2, m.family) + assertEquals(0x34, m.mode(120, false)) + assertEquals(0x32, m.mode(90, true)) + assertFalse(m.sbs90) + assertFalse(m.hasSunblock) + assertEquals(8, m.volumeMax) + assertEquals(50f, m.diagonalFovDeg, 0f) + } + + @Test fun ultraUsesSdkWith1200pAnd90HzStereo() { + val m = VitureProtocol.sdkModel("Luma Ultra")!! + assertTrue(m.needsSdk) + assertEquals(0x44, m.mode(120, false)) + assertEquals(0x42, m.mode(60, true)) + assertEquals(0x45, m.mode(90, true)) + assertTrue(m.hasSunblock) + } + + @Test fun gen2DoesNotImplyBeastCapabilities() { + val beast = VitureProtocol.sdkModel("Beast")!! + assertTrue(beast.needsSdk) + assertEquals(15, beast.volumeMax) + assertTrue(beast.hasSunblock) + assertFalse(VitureProtocol.sdkModel("Pro")!!.needsSdk) + assertNull(VitureProtocol.sdkModel("Unknown")) + assertNull(VitureProtocol.sdkModel("Mobile Dock")) + } + + @Test fun otherGen2DevicesPreserveSdkNwuAxesAndChangeComponentOrder() { + val a = sqrt(0.5f) + assertArrayEquals(floatArrayOf(0f, 0f, a, a), + VitureSdkPose.toGlasses(floatArrayOf(a, 0f, 0f, a), false), 0.00001f) + } + + @Test fun pro2AndCarinaSelectOpenGlButOtherGen2ModelsKeepTheirExistingBasis() { + assertTrue(VitureSdkPose.usesOpenGlAxes(false, VitureProtocol.Family.PRO2)) + assertTrue(VitureSdkPose.usesOpenGlAxes(true, VitureProtocol.Family.ULTRA)) + assertFalse(VitureSdkPose.usesOpenGlAxes(false, VitureProtocol.Family.BEAST)) + assertFalse(VitureSdkPose.usesOpenGlAxes(false, VitureProtocol.Family.PRO)) + assertFalse(VitureSdkPose.usesOpenGlAxes(false, null)) + } + + @Test fun pro2YawPitchAndRollReachTheCorrectRendererAxes() { + val openGl = VitureSdkPose.usesOpenGlAxes(false, VitureProtocol.sdkModel("Pro 2")!!.family) + fun screen(wxyz: FloatArray): FloatArray { + val q = VitureSdkPose.toGlasses(wxyz, openGl)!! + return HeadPose.fromGlasses(q[0], q[1], q[2], q[3]) + } + val ref = HeadPose.recenter(screen(floatArrayOf(1f, 0f, 0f, 0f))) + val s = kotlin.math.sin(Math.PI / 12).toFloat() + val c = kotlin.math.cos(Math.PI / 12).toFloat() + for ((axis, expected) in listOf( + 2 to floatArrayOf(-0.5f, 0f, -0.8660254f), // Observed left turn: positive Y. + 1 to floatArrayOf(0f, 0.5f, -0.8660254f), // Observed upward look: positive X. + 3 to floatArrayOf(-0.5f, 0.8660254f, 0f), + )) { + val q = floatArrayOf(c, 0f, 0f, 0f).apply { this[axis] = s } + val view = HeadPose.relative(ref, screen(q)) + val direction = if (axis == 3) floatArrayOf(0f, 1f, 0f) else floatArrayOf(0f, 0f, -1f) + assertArrayEquals(expected, HeadPose.apply(view, direction), 0.001f) + } + } + + @Test fun ultraOpenGlAxesMatchExistingGlassesHeadTracker() { + val a = sqrt(0.5f) + // OpenGL Y yaw maps to NWU Z; OpenGL X pitch maps to negative NWU Y. + assertArrayEquals(floatArrayOf(0f, 0f, a, a), + VitureSdkPose.toGlasses(floatArrayOf(a, 0f, a, 0f), true), 0.00001f) + assertArrayEquals(floatArrayOf(0f, -a, 0f, a), + VitureSdkPose.toGlasses(floatArrayOf(a, a, 0f, 0f), true), 0.00001f) + assertArrayEquals(floatArrayOf(-a, 0f, 0f, a), + VitureSdkPose.toGlasses(floatArrayOf(a, 0f, 0f, a), true), 0.00001f) + } + + @Test fun invalidOrUninitializedSdkPosesNeverReachTheRenderer() { + for (q in listOf(FloatArray(4), floatArrayOf(Float.NaN, 0f, 0f, 0f), + floatArrayOf(Float.POSITIVE_INFINITY, 0f, 0f, 0f), floatArrayOf(3f, 0f, 0f, 0f), FloatArray(7))) { + assertNull(VitureSdkPose.toGlasses(q, false)) + assertNull(VitureSdkPose.toGlasses(q, true)) + } + assertArrayEquals(floatArrayOf(0f, 0f, 0f, 1f), + VitureSdkPose.toGlasses(floatArrayOf(1.1f, 0f, 0f, 0f), true), 0.00001f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealLinkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealLinkTest.kt new file mode 100644 index 000000000..9273dfb4b --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealLinkTest.kt @@ -0,0 +1,149 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class XrealLinkTest { + private class Input : InputStream() { + private val bytes = LinkedBlockingQueue() + @Volatile private var closed = false + fun add(value: ByteArray) { value.forEach { bytes.add(it.toInt() and 255) } } + override fun read(): Int = if (closed && bytes.isEmpty()) -1 else bytes.take() + override fun read(b: ByteArray, off: Int, len: Int): Int { + if (len == 0) return 0 + val first = read() + if (first < 0) return -1 + b[off] = first.toByte() + var count = 1 + while (count < len) { + val value = bytes.poll() ?: break + if (value < 0) break + b[off + count++] = value.toByte() + } + return count + } + override fun close() { closed = true; bytes.clear(); bytes.offer(-1) } + } + + private class Channels : XrealChannels { + override val controlInput = Input() + override val reports = Input() + val wrote = LinkedBlockingQueue() + val closed = CountDownLatch(1) + @Volatile var acknowledge = true + override val controlOutput = object : OutputStream() { + private val buffer = ByteArrayOutputStream() + override fun write(b: Int) { buffer.write(b) } + override fun flush() { + val data = buffer.toByteArray(); buffer.reset() + val request = XrealProtocol.read(data.inputStream()) + val id = ByteBuffer.wrap(request.body).int and Int.MAX_VALUE + wrote.offer(request.command) + if (acknowledge) controlInput.add(XrealProtocolTest.response(request.command, id, + if (request.command == XrealProtocol.GET_DISPLAY) byteArrayOf(0x22, 2, 0x10, 2) else byteArrayOf(0x22, 0))) + } + } + override fun close() { controlInput.close(); reports.close(); closed.countDown() } + } + + private class Listener : XrealLink.Listener { + val ready = CountDownLatch(1) + val poses = LinkedBlockingQueue() + val values = LinkedBlockingQueue>() + val errors = LinkedBlockingQueue() + override fun onReady() { ready.countDown() } + override fun onValue(message: Int, value: Int) { values.offer(message to value) } + override fun onPose(raw: ByteArray, pose: FloatArray, timeNs: Long) { poses.offer(pose) } + override fun onUnavailable(networkMissing: Boolean) { errors.offer(networkMissing) } + } + + @Test fun trackingUsesSharedPoseAndCommandsAreAcknowledgedBeforePublishing() { + val channels = Channels() + val listener = Listener() + val connector = object : XrealConnector { + override fun connect() = channels + override fun close() = channels.close() + } + val link = XrealLink(connector, listener) + try { + assertTrue(listener.ready.await(2, TimeUnit.SECONDS)) + assertEquals(VitureProtocol.MSG_GET_DISPLAY to 2, listener.values.poll(2, TimeUnit.SECONDS)) + assertTrue(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + channels.reports.add(XrealProtocolTest.frame(XrealProtocol.REPORT, XrealProtocolTest.sensor())) + assertNotNull(listener.poses.poll(2, TimeUnit.SECONDS)) + assertFalse(link.send(VitureProtocol.MSG_VOLUME, byteArrayOf(5))) + assertFalse(link.send(VitureProtocol.MSG_DISPLAY, byteArrayOf(0x32))) + assertTrue(link.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(6))) + assertEquals(VitureProtocol.MSG_BRIGHTNESS to 6, listener.values.poll(2, TimeUnit.SECONDS)) + } finally { link.close() } + assertFalse(link.ready) + assertTrue(channels.closed.await(1, TimeUnit.SECONDS)) + assertFalse(link.send(VitureProtocol.MSG_IMU, byteArrayOf(1))) + } + + @Test fun failedControlReadDisconnectsWithoutPublishingRequestedBrightness() { + val channels = Channels() + val listener = Listener() + val link = XrealLink(object : XrealConnector { + override fun connect() = channels + override fun close() = channels.close() + }, listener) + try { + assertTrue(listener.ready.await(2, TimeUnit.SECONDS)) + listener.values.clear() + channels.acknowledge = false + assertTrue(link.send(VitureProtocol.MSG_BRIGHTNESS, byteArrayOf(8))) + channels.controlInput.close() + assertEquals(false, listener.errors.poll(2, TimeUnit.SECONDS)) + assertFalse(link.ready) + assertNull(listener.values.poll()) + } finally { link.close() } + } + + @Test fun missingNetworkIsReportedAndCloseCancelsRetry() { + val listener = Listener() + val closed = AtomicBoolean() + val link = XrealLink(object : XrealConnector { + override fun connect(): XrealChannels = throw XrealNetworkMissing() + override fun close() { closed.set(true) } + }, listener) + try { + assertEquals(true, listener.errors.poll(2, TimeUnit.SECONDS)) + assertFalse(link.ready) + } finally { link.close() } + assertTrue(closed.get()) + } + + @Test fun closeDuringConnectCannotPublishReadyOrLeakSockets() { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val channels = Channels() + val listener = Listener() + val link = XrealLink(object : XrealConnector { + override fun connect(): XrealChannels { + entered.countDown() + release.await() + return channels + } + override fun close() { release.countDown(); channels.close() } + }, listener) + assertTrue(entered.await(2, TimeUnit.SECONDS)) + link.close() + assertTrue(channels.closed.await(1, TimeUnit.SECONDS)) + assertFalse(listener.ready.await(100, TimeUnit.MILLISECONDS)) + assertTrue(listener.values.isEmpty()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealNetworkTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealNetworkTest.kt new file mode 100644 index 000000000..348ba36d3 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealNetworkTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.glasses + +import android.net.IpPrefix +import android.net.LinkProperties +import android.net.RouteInfo +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import org.robolectric.util.ReflectionHelpers.ClassParameter.from +import java.net.InetAddress + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class XrealNetworkTest { + private val target = InetAddress.getByName(XrealProtocol.HOST) + private fun properties(address: String, prefix: Int, gateway: String? = null) = LinkProperties().apply { + interfaceName = "eth0" + val route = ReflectionHelpers.callConstructor(RouteInfo::class.java, + from(IpPrefix::class.java, IpPrefix(InetAddress.getByName(address), prefix)), + from(InetAddress::class.java, InetAddress.getByName(gateway ?: "0.0.0.0")), + from(String::class.java, "eth0")) + addRoute(route) + } + @Test fun onlyAnOnLinkGlassesRouteQualifies() { + val direct = properties("169.254.2.0", 24) + val default = properties("0.0.0.0", 0) + val gateway = properties("169.254.2.0", 24, "192.168.1.1") + val unrelated = properties("192.168.1.0", 24) + assertTrue(AndroidXrealConnector.hasDirectRoute(direct, target)) + assertFalse(AndroidXrealConnector.hasDirectRoute(default, target)) + assertFalse(AndroidXrealConnector.hasDirectRoute(gateway, target)) + assertFalse(AndroidXrealConnector.hasDirectRoute(unrelated, target)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt new file mode 100644 index 000000000..578d334a6 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealOrientationFilterTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.glasses + +import com.droiddeck.launcher.gpu.HeadPose +import org.junit.Assert.* +import org.junit.Test +import kotlin.math.cos +import kotlin.math.sin + +class XrealOrientationFilterTest { + private fun sample(time: Long, gyro: Vec3 = Vec3(0f, 0f, 0f), accel: Vec3 = Vec3(0f, -9.81f, 0f)) = + XrealProtocol.Sample(time, gyro, accel) + private fun matrix(q: FloatArray) = HeadPose.fromGlasses(q[0], q[1], q[2], q[3]) + + @Test fun firstPoseAlignsGravityAndDuplicateOrReversedTimeIsDiscarded() { + val filter = XrealOrientationFilter() + val tilted = Vec3(4.905f, -8.4957f, 0f) + val first = filter.update(sample(1000000, accel = tilted))!! + assertNull(filter.update(sample(1000000))) + assertNull(filter.update(sample(999999))) + assertArrayEquals(first, filter.update(sample(2000000, accel = tilted)), 0.0001f) + } + + @Test fun reconnectGapDoesNotIntegrateSecondsOfGyro() { + val filter = XrealOrientationFilter() + val first = filter.update(sample(1000000))!! + assertArrayEquals(first, filter.update(sample(5000000000, Vec3(2f, 2f, 2f))), 0.0001f) + } + + @Test fun invalidStartupGravityWaitsForAStationarySample() { + val filter = XrealOrientationFilter() + assertNull(filter.update(sample(1000000, accel = Vec3(0f, 0f, 0f)))) + assertNotNull(filter.update(sample(2000000))) + } + + @Test fun observedNegativeYawTurnsLeftThirtyDegreesAtOneKhzThroughSharedPosePath() { + val filter = XrealOrientationFilter() + val first = matrix(filter.update(sample(1000000))!!) + var last = FloatArray(4) + repeat(1000) { last = filter.update(sample((it + 2) * 1000000L, Vec3(0f, -(Math.PI / 6).toFloat(), 0f)))!! } + val view = HeadPose.relative(HeadPose.recenter(first), matrix(last)) + assertArrayEquals(floatArrayOf(-0.5f, 0f, -0.8660254f), HeadPose.apply(view, floatArrayOf(0f, 0f, -1f)), 0.001f) + } + + @Test fun observedPositivePitchAndRollLookUpAndTiltRightWithoutCrossingAxes() { + for (roll in listOf(false, true)) { + val filter = XrealOrientationFilter() + val first = matrix(filter.update(sample(1000000))!!) + var last = FloatArray(4) + repeat(1000) { + val angle = (it + 1) * Math.PI / 6000 + val accel = if (roll) Vec3(-9.81f * sin(angle).toFloat(), -9.81f * cos(angle).toFloat(), 0f) + else Vec3(0f, -9.81f * cos(angle).toFloat(), 9.81f * sin(angle).toFloat()) + val rate = (Math.PI / 6).toFloat() + last = filter.update(sample((it + 2) * 1000000L, + if (roll) Vec3(0f, 0f, rate) else Vec3(rate, 0f, 0f), accel))!! + } + val view = HeadPose.relative(HeadPose.recenter(first), matrix(last)) + val actual = HeadPose.apply(view, if (roll) floatArrayOf(0f, 1f, 0f) else floatArrayOf(0f, 0f, -1f)) + assertArrayEquals(if (roll) floatArrayOf(0.5f, 0.8660254f, 0f) else floatArrayOf(0f, 0.5f, -0.8660254f), actual, 0.002f) + } + } + + @Test fun capturedNeutralGravityStartsUprightBeforeRecenter() { + val pose = XrealOrientationFilter().update(sample(1000000, + accel = Vec3(-0.0909f, -9.6529f, -1.0731f)))!! + // An upright start must not contain the previous approximately 180-degree + // pitch correction. Check before recenter can hide a mounting error. + assertTrue(kotlin.math.abs(pose[3]) > 0.99f) + val screen = matrix(pose) + assertTrue(screen[7] > 0.99f) // Screen up points toward shared world +Z. + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt b/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt new file mode 100644 index 000000000..0deee75dd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/glasses/XrealProtocolTest.kt @@ -0,0 +1,127 @@ +package com.droiddeck.launcher.glasses + +import org.junit.Assert.* +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class XrealProtocolTest { + @Test fun fragmentedAndCoalescedFramesRetainBoundariesAndDeviceUnits() { + val bytes = frame(XrealProtocol.REPORT, sensor()) + frame(0x272e, byteArrayOf(1, 2)) + val input = object : ByteArrayInputStream(bytes) { + override fun read(b: ByteArray, off: Int, len: Int) = super.read(b, off, minOf(3, len)) + } + val sample = XrealProtocol.sample(XrealProtocol.read(input))!! + assertEquals(123456789L, sample.timeNs) + assertEquals(0.5f, sample.gyro.y, 0f) + assertEquals(9.81f, sample.accel.y, 0f) + assertArrayEquals(byteArrayOf(1, 2), XrealProtocol.read(input).body) + } + + @Test fun invalidLengthsAndTruncationFailWithoutUnboundedAllocation() { + for (size in listOf(-1, Int.MAX_VALUE, XrealProtocol.MAX_BODY + 1)) { + val bytes = ByteBuffer.allocate(6).putShort(0x2836).putInt(size).array() + assertThrows(IOException::class.java) { XrealProtocol.read(bytes.inputStream()) } + } + assertThrows(IOException::class.java) { XrealProtocol.read(byteArrayOf(0x28).inputStream()) } + assertThrows(IOException::class.java) { XrealProtocol.read(frame(0x2836, sensor()).dropLast(1).toByteArray().inputStream()) } + } + + @Test fun onlyCompleteFiniteImuReportsAreAccepted() { + assertNull(XrealProtocol.sample(XrealProtocol.Frame(0x2836, ByteArray(84)))) + assertNull(XrealProtocol.sample(XrealProtocol.Frame(0x272e, sensor()))) + for ((offset, value) in listOf(24 to 4, 28 to Float.NaN.toRawBits(), 40 to Float.POSITIVE_INFINITY.toRawBits())) { + val bytes = sensor() + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putInt(offset, value) + assertNull(XrealProtocol.sample(XrealProtocol.Frame(0x2836, bytes))) + } + } + + @Test fun commandAndTransactionMustBothMatchBeforeParsingAcknowledgement() { + val body = ByteBuffer.allocate(6).putInt(7).put(byteArrayOf(0x22, 0)).array() + val reply = XrealProtocol.Frame(0x271c, body) + assertNull(XrealProtocol.response(reply, 0x271c, 8)) + assertNull(XrealProtocol.response(reply, 0x275f, 7)) + assertTrue(XrealProtocol.acknowledged(XrealProtocol.response(reply, 0x271c, 7)!!)) + assertFalse(XrealProtocol.acknowledged(byteArrayOf(0x22, 1, 0))) + assertEquals(5, XrealProtocol.numeric(byteArrayOf(0x22, 2, 0x10, 5))) + assertNull(XrealProtocol.numeric(byteArrayOf(0x22, 2, 8, 5))) + } + + @Test fun getAndSetCommandsUseBigEndianHeaderAndProtobufBody() { + assertArrayEquals(byteArrayOf(0x27, 0x5e, 0, 0, 0, 6, 0x80.toByte(), 0, 0, 1, 0x18, 0), + XrealProtocol.request(XrealProtocol.GET_DISPLAY, 1)) + assertArrayEquals(byteArrayOf(0x27, 0x1c, 0, 0, 0, 8, 0x80.toByte(), 0, 0, 2, 0x1a, 2, 8, 9), + XrealProtocol.request(XrealProtocol.SET_BRIGHTNESS, 2, 9)) + } + + @Test fun controlIgnoresUnsolicitedAndStaleRepliesAndRejectsFailure() { + val output = ByteArrayOutputStream() + val replies = frame(0x272e, ByteArray(8)) + response(0x275e, 99, byteArrayOf(0x22, 2, 0x10, 4)) + + response(0x275e, 1, byteArrayOf(0x22, 2, 0x10, 2)) + response(0x271c, 2, byteArrayOf(0x22, 1, 1)) + val control = XrealControl(channels(replies.inputStream(), output)) + assertEquals(2, control.display()) + assertThrows(IOException::class.java) { control.set(0x271c, 5) } + assertEquals(26, output.size()) + } + + @Test fun unrelatedResponsesCannotKeepControlWaitingForever() { + val replies = ByteArrayOutputStream().apply { repeat(32) { write(frame(0x272e, ByteArray(8))) } } + val control = XrealControl(channels(replies.toByteArray().inputStream(), ByteArrayOutputStream())) + assertThrows(IOException::class.java) { control.display() } + } + + @Test fun captured1sNativeDisplayResponsePreserves1200LineMode() { + val reply = byteArrayOf(0x22, 2, 0x10, 0x1d) + val control = XrealControl(channels(response(0x275e, 1, reply).inputStream(), ByteArrayOutputStream())) + assertEquals(29, control.display()) + assertEquals(29, XrealModel.mode(60, false)) + } + + @Test fun stereoWaitsForEdidChangeWithoutSendingASecondModeCommandWhileBusy() { + val output = ByteArrayOutputStream() + val replies = response(0x2822, 1, byteArrayOf(0x22, 0)) + + response(0x275e, 2, byteArrayOf(0x22, 2, 0x10, 29)) + + response(0x275e, 3, byteArrayOf(0x22, 0)) + + response(0x275e, 4, byteArrayOf(0x22, 2, 0x10, 5)) + assertEquals(5, XrealControl(channels(replies.inputStream(), output), 0).changeDisplay(5)) + val requests = output.toByteArray().inputStream() + assertEquals(0x2822, XrealProtocol.read(requests).command) + repeat(3) { assertEquals(0x275e, XrealProtocol.read(requests).command) } + assertEquals(0, requests.available()) + // Actual 1S busy response remains an error, never a successful ack. + assertFalse(XrealProtocol.acknowledged(byteArrayOf(0x22, 3, 8, 0x89.toByte(), 0x27))) + } + + @Test fun stereoCanReturnToNative1200LinesAndAnUnfinishedTransitionIsBounded() { + val replies = response(0x2822, 1, byteArrayOf(0x22, 0)) + + response(0x275e, 2, byteArrayOf(0x22, 0)) + + response(0x275e, 3, byteArrayOf(0x22, 2, 0x10, 29)) + assertEquals(29, XrealControl(channels(replies.inputStream(), ByteArrayOutputStream()), 0).changeDisplay(29)) + val stalled = ByteArrayOutputStream().apply { + write(response(0x2822, 1, byteArrayOf(0x22, 0))) + repeat(40) { write(response(0x275e, it + 2, byteArrayOf(0x22, 0))) } + } + val control = XrealControl(channels(stalled.toByteArray().inputStream(), ByteArrayOutputStream()), 0) + assertThrows(IOException::class.java) { control.changeDisplay(5) } + } + + companion object { + fun sensor(time: Long = 123456789L): ByteArray = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN) + .putLong(8, time).putInt(24, 11).putFloat(32, 0.5f).putFloat(44, 9.81f).array() + fun frame(command: Int, body: ByteArray): ByteArray = ByteBuffer.allocate(6 + body.size) + .putShort(command.toShort()).putInt(body.size).put(body).array() + fun response(command: Int, transaction: Int, body: ByteArray) = frame(command, + ByteBuffer.allocate(4 + body.size).putInt(transaction).put(body).array()) + internal fun channels(input: InputStream, output: ByteArrayOutputStream) = object : XrealChannels { + override val controlInput = input + override val controlOutput = output + override val reports = ByteArrayInputStream(ByteArray(0)) + override fun close() = Unit + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt index a54bc216f..40646d6b0 100644 --- a/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/gpu/CardboardTest.kt @@ -153,4 +153,37 @@ class CardboardTest { assertVec(HeadPose.apply(r, floatArrayOf(0f, 1f, 0f)), HeadPose.apply(s270, floatArrayOf(1f, 0f, 0f))) assertVec(r, HeadPose.toScreen(HeadPose.toScreen(HeadPose.toScreen(HeadPose.toScreen(r, 1), 1), 1), 1)) } + + @Test fun glassesLevelAndAheadIsStraightAhead() { + val s2w = HeadPose.fromGlasses(0f, 0f, 0f, 1f) + assertVec(floatArrayOf(1f, 0f, 0f), HeadPose.apply(s2w, floatArrayOf(0f, 0f, -1f))) + assertVec(floatArrayOf(0f, 0f, 1f), HeadPose.apply(s2w, floatArrayOf(0f, 1f, 0f))) + assertVec(HeadPose.IDENTITY, HeadPose.relative(HeadPose.recenter(s2w), s2w)) + } + + @Test fun glassesTurnedLeftLookLeft() { + val ref = HeadPose.recenter(HeadPose.fromGlasses(0f, 0f, 0f, 1f)) + val q = quat(0f, 0f, 1f, 30.0) + val view = HeadPose.relative(ref, HeadPose.fromGlasses(q[0], q[1], q[2], q[3])) + val s = sin(PI / 6).toFloat(); val c = cos(PI / 6).toFloat() + assertVec(floatArrayOf(-s, 0f, -c), HeadPose.apply(view, floatArrayOf(0f, 0f, -1f))) + assertVec(floatArrayOf(0f, 1f, 0f), HeadPose.apply(view, floatArrayOf(0f, 1f, 0f))) + } + + @Test fun glassesRecenterKeepsTheHorizon() { + val q = quat(0f, 0f, 1f, 75.0) + val ref = HeadPose.recenter(HeadPose.fromGlasses(q[0], q[1], q[2], q[3])) + assertVec(HeadPose.IDENTITY, HeadPose.relative(ref, HeadPose.fromGlasses(q[0], q[1], q[2], q[3]))) + val tilt = quat(0f, 1f, 0f, 20.0) + val both = HeadPose.multiply(HeadPose.fromQuaternion(q[0], q[1], q[2], q[3]), HeadPose.fromQuaternion(tilt[0], tilt[1], tilt[2], tilt[3])) + val view = HeadPose.multiply(HeadPose.transpose(ref.basis), HeadPose.multiply(both, floatArrayOf(0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f))) + val ahead = HeadPose.apply(view, floatArrayOf(0f, 0f, -1f)) + assertEquals(0f, ahead[0], eps) + assertEquals(-sin(PI / 9).toFloat(), ahead[1], eps) + } + + @Test fun monoIsNotPartOfTheSavedSettings() { + val c = Cardboard(enabled = true, mono = true) + assertEquals(false, Cardboard.decode(c.encode()).mono) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt new file mode 100644 index 000000000..e54564dba --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrDeviceLossLogTest.kt @@ -0,0 +1,72 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SteamVrDeviceLossLogTest { + @get:Rule val temp = TemporaryFolder() + + @Test fun oldErrorsAreIgnoredButFreshSplitLinesAreDetected() { + val file = temp.newFile().apply { writeText("old vkerror=-4\n") } + val log = SteamVrDeviceLossLog(file) + assertFalse(log.poll()) + file.appendText("WaitForAcquire timed out\nFailed GetDeltas(Compositor): vkerror=") + assertFalse(log.poll()) + file.appendText("-4\n") + assertTrue(log.poll()) + assertTrue(log.poll()) // Keep pending until the activity is foreground. + } + + @Test fun onlyExplicitDeviceLossTriggersRecovery() { + assertTrue(SteamVrDeviceLossLog.isDeviceLoss("Failed GetDeltas: vkerror=-4\n")) + assertTrue(SteamVrDeviceLossLog.isDeviceLoss("Submit returned VK_ERROR_DEVICE_LOST\n")) + for (text in listOf("vkerror=-40", "vkerror=-1", "headset taken off", "WaitForAcquire timed out", + "Unexpected failure when checking for timeline signal", "Failed to submit to queue")) { + assertFalse(text, SteamVrDeviceLossLog.isDeviceLoss(text)) + } + } + + @Test fun handlesMissingLogCreationAndTruncation() { + val file = File(temp.root, "vrcompositor.txt") + val log = SteamVrDeviceLossLog(file) + assertFalse(log.poll()) + file.writeText("healthy line\n".repeat(100)) + assertFalse(log.poll()) + file.writeText("vkerror=-4\n") + assertTrue(log.poll()) + } + + @Test fun detectsReplacementEvenWhenNewFileIsLargerThanTheOldOffset() { + val file = temp.newFile().apply { writeText("old\n".repeat(50)) } + val log = SteamVrDeviceLossLog(file) + assertTrue(file.renameTo(File(temp.root, "previous.txt"))) + file.writeText("VK_ERROR_DEVICE_LOST\n" + "new\n".repeat(100)) + assertTrue(log.poll()) + } + + @Test fun scansBoundedTailOfRapidlyGrowingFaultLogs() { + val file = temp.newFile() + val log = SteamVrDeviceLossLog(file) + file.writeText("ordinary line\n".repeat(100_000) + "vkerror=-4\n") + assertTrue(log.poll()) + } + + @Test fun catchesTruncateAndRegrowBetweenPollsWithoutAnInodeChange() { + val file = temp.newFile().apply { writeText("previous run\n".repeat(100)) } + val log = SteamVrDeviceLossLog(file) + file.writeText("new run vkerror=-4\n" + "new output\n".repeat(200)) + assertTrue(log.poll()) + } + + @Test fun cooldownSurvivesRepeatedFailuresAndClockRollback() { + val now = 1_000_000L + assertTrue(SteamVrRecoveryPolicy.canRestartAutomatically(0, now)) + assertFalse(SteamVrRecoveryPolicy.canRestartAutomatically(now, now)) + assertFalse(SteamVrRecoveryPolicy.canRestartAutomatically(now, now - 1)) + assertFalse(SteamVrRecoveryPolicy.canRestartAutomatically(now, now + 599_999)) + assertTrue(SteamVrRecoveryPolicy.canRestartAutomatically(now, now + 600_000)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrLaunchTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrLaunchTest.kt new file mode 100644 index 000000000..b097ba263 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrLaunchTest.kt @@ -0,0 +1,50 @@ +package com.droiddeck.launcher.gpu + +import org.junit.Assert.* +import org.junit.Test + +class SteamVrLaunchTest { + @Test fun manualRequestKeepsPollingWithoutGlassesControlOrCardboard() { + for (status in listOf(null, SteamVr.Status.READY, SteamVr.Status.UNAVAILABLE, SteamVr.Status.RUNNING)) { + assertTrue(SteamVr.shouldPoll(status, false, false, false, true)) + } + } + + @Test fun observedActivityIsPolledUntilItStopsEvenWithoutARequest() { + assertTrue(SteamVr.shouldPoll(SteamVr.Status.RUNNING, false, false, false, false)) + assertTrue(SteamVr.shouldPoll(SteamVr.Status.INSTALLING, false, false, false, false)) + assertFalse(SteamVr.shouldPoll(SteamVr.Status.READY, false, false, false, false)) + assertFalse(SteamVr.shouldPoll(null, false, false, false, false)) + } + + @Test fun explicitPlayRequestStartsWhenRuntimeIsReadyWithoutWaitingForGlassesControlOrOutput() { + assertEquals("start", action(SteamVr.Status.READY)) + } + + @Test fun pendingRequestWaitsForGuestAndLoadingThenStarts() { + assertNull(action(SteamVr.Status.UNAVAILABLE)) + assertNull(action(SteamVr.Status.READY, loading = true)) + assertEquals("start", action(SteamVr.Status.READY)) + } + + @Test fun missingRuntimeIsInstalledOnceAndThenStarted() { + assertEquals("install", action(SteamVr.Status.NOT_INSTALLED)) + assertNull(action(SteamVr.Status.NOT_INSTALLED, installTried = true)) + assertNull(action(SteamVr.Status.INSTALLING, installTried = true)) + assertEquals("start", action(SteamVr.Status.READY, installTried = true)) + } + + @Test fun runningRuntimeOrAttemptedStartDoesNotLaunchAnotherInstance() { + assertNull(action(SteamVr.Status.RUNNING)) + assertNull(action(SteamVr.Status.READY, startTried = true)) + } + + @Test fun ordinarySessionsAndCancelledRequestsDoNotAutostart() { + assertNull(SteamVr.autoAction(SteamVr.Status.READY, false, false, true, false, false)) + assertNull(SteamVr.autoAction(SteamVr.Status.READY, true, false, false, false, false)) + } + + private fun action(status: SteamVr.Status, loading: Boolean = false, + startTried: Boolean = false, installTried: Boolean = false) = + SteamVr.autoAction(status, true, loading, true, startTried, installTried) +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettingsTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettingsTest.kt new file mode 100644 index 000000000..1f14a9ef7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoverySettingsTest.kt @@ -0,0 +1,52 @@ +package com.droiddeck.launcher.gpu + +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SteamVrRecoverySettingsTest { + @get:Rule val temp = TemporaryFolder() + + @Test fun recoveryUnblocksOnlyOurHeadsetAndKeepsUserSettings() { + val file = temp.newFile().apply { writeText("""{ + "driver_droiddeck":{"blocked_by_safe_mode":true,"other":42}, + "driver_other":{"blocked_by_safe_mode":true}, + "steamvr":{"enableSafeMode":true,"environmentMode":0}, + "GpuSpeed":{"gpuSpeed0":450} + }""") } + assertTrue(SteamVrRecoverySettings.unblockHeadset(file)) + val after = JSONObject(file.readText()) + assertFalse(after.getJSONObject("driver_droiddeck").getBoolean("blocked_by_safe_mode")) + assertEquals(42, after.getJSONObject("driver_droiddeck").getInt("other")) + assertTrue(after.getJSONObject("driver_other").getBoolean("blocked_by_safe_mode")) + assertTrue(after.getJSONObject("steamvr").getBoolean("enableSafeMode")) + assertEquals(0, after.getJSONObject("steamvr").getInt("environmentMode")) + assertEquals(450, after.getJSONObject("GpuSpeed").getInt("gpuSpeed0")) + val unchanged = file.readText() + assertFalse(SteamVrRecoverySettings.unblockHeadset(file)) + assertEquals(unchanged, file.readText()) + } + + @Test fun missingOrUnblockedConfigIsNotRewritten() { + assertFalse(SteamVrRecoverySettings.unblockHeadset(File(temp.root, "missing"))) + for (text in listOf("{}", "{\"driver_droiddeck\":{\"blocked_by_safe_mode\":false}}")) { + val file = temp.newFile().apply { writeText(text) } + assertFalse(SteamVrRecoverySettings.unblockHeadset(file)) + assertEquals(text, file.readText()) + } + } + + @Test fun malformedConfigIsPreservedAndRecoveryFailsInsteadOfReplacingIt() { + val file = temp.newFile().apply { writeText("broken JSON") } + assertTrue(runCatching { SteamVrRecoverySettings.unblockHeadset(file) }.isFailure) + assertEquals("broken JSON", file.readText()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoveryTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoveryTest.kt new file mode 100644 index 000000000..8407df8f8 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrRecoveryTest.kt @@ -0,0 +1,84 @@ +package com.droiddeck.launcher.gpu + +import android.app.Activity +import android.content.Context +import android.os.Looper +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowAlertDialog +import java.time.Duration + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@LooperMode(LooperMode.Mode.PAUSED) +class SteamVrRecoveryTest { + private lateinit var activity: Activity + + @Before fun setup() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + activity.getSharedPreferences(SteamVrRecovery.PREFS, Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun countdownLaunchesPrivateRestartActivityAndPersistsLoopGuard() { + val recovery = SteamVrRecovery(activity) { true } + recovery.detected() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(7)) + assertNull(shadowOf(activity).nextStartedActivity) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + assertEquals(SteamVrRestartActivity::class.java.name, shadowOf(activity).nextStartedActivity.component?.className) + assertTrue(activity.getSharedPreferences(SteamVrRecovery.PREFS, Context.MODE_PRIVATE) + .getLong(SteamVrRecovery.LAST_ATTEMPT, 0) > 0) + recovery.close() + } + + @Test fun cancellationPreventsRestartAndRepeatedPrompts() { + val recovery = SteamVrRecovery(activity) { true } + recovery.detected() + ShadowAlertDialog.getLatestAlertDialog().getButton(android.app.AlertDialog.BUTTON_NEGATIVE).performClick() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(20)) + recovery.detected() + assertNull(shadowOf(activity).nextStartedActivity) + assertFalse(ShadowAlertDialog.getLatestAlertDialog().isShowing) + } + + @Test fun recentRecoveryDoesNotAutomaticallyRestartAgain() { + activity.getSharedPreferences(SteamVrRecovery.PREFS, Context.MODE_PRIVATE).edit() + .putLong(SteamVrRecovery.LAST_ATTEMPT, System.currentTimeMillis()).commit() + val recovery = SteamVrRecovery(activity) { true } + recovery.detected() + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(20)) + assertNull(shadowOf(activity).nextStartedActivity) + assertTrue(ShadowAlertDialog.getLatestAlertDialog().isShowing) + val outside = android.view.MotionEvent.obtain(0, 0, android.view.MotionEvent.ACTION_OUTSIDE, -10f, -10f, 0) + try { + assertFalse(ShadowAlertDialog.getLatestAlertDialog().onTouchEvent(outside)) + assertTrue(ShadowAlertDialog.getLatestAlertDialog().isShowing) + } finally { outside.recycle() } + recovery.close() + } + + @Test fun backgroundingCancelsTheCountdownUntilTheSessionReturns() { + var foreground = true + val recovery = SteamVrRecovery(activity) { foreground } + recovery.detected() + foreground = false + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(20)) + assertNull(shadowOf(activity).nextStartedActivity) + assertFalse(ShadowAlertDialog.getLatestAlertDialog().isShowing) + recovery.detected() + assertFalse(ShadowAlertDialog.getLatestAlertDialog().isShowing) + foreground = true + recovery.detected() + assertTrue(ShadowAlertDialog.getLatestAlertDialog().isShowing) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(8)) + assertEquals(SteamVrRestartActivity::class.java.name, shadowOf(activity).nextStartedActivity.component?.className) + recovery.close() + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrShutdownTest.kt b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrShutdownTest.kt new file mode 100644 index 000000000..2e4f58c29 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/gpu/SteamVrShutdownTest.kt @@ -0,0 +1,55 @@ +package com.droiddeck.launcher.gpu + +import com.droiddeck.launcher.gpu.SteamVrRecoveryPolicy.Shutdown +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class SteamVrShutdownTest { + private var clock = 0L + + private fun session(phase: String, logs: Boolean = true, collected: Boolean = false) = + JSONObject().put("phase", phase).put("artifactsAvailable", logs).put("artifactsComplete", collected) + + private fun await(state: (Long) -> JSONObject?) = + SteamVrRecoveryPolicy.awaitShutdown({ state(clock) }, { clock }, { clock += it }) + + @Test fun waitsForTheLogsOnceTheSessionHasStopped() { + val result = await { t -> + when { + t < 4_000 -> session("STOPPING") + t < 24_000 -> session("IDLE") + else -> session("IDLE", collected = true) + } + } + assertEquals(Shutdown.COMPLETE, result) + assertEquals(24_000L, clock) + } + + @Test fun aStuckTeardownGivesUpAfterTheStopDeadline() { + assertEquals(Shutdown.STUCK, await { session("STOPPING") }) + assertEquals(SteamVrRecoveryPolicy.STOP_MS, clock) + } + + @Test fun slowLogCollectionIsBounded() { + assertEquals(Shutdown.LOGS_PENDING, await { if (it < 2_000) null else session("FAILED") }) + assertEquals(2_000L + SteamVrRecoveryPolicy.LOGS_MS, clock) + } + + @Test fun aSessionWithoutLogsNeedsNoWait() { + assertEquals(Shutdown.COMPLETE, await { session("IDLE", logs = false) }) + assertEquals(0L, clock) + } + + @Test fun unreadableStateIsRetriedUntilTheDeadline() { + var reads = 0 + assertEquals(Shutdown.STUCK, await { reads++; null }) + assertTrue(reads > 1) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/BodyPoseTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/BodyPoseTest.kt new file mode 100644 index 000000000..154f5dde4 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/BodyPoseTest.kt @@ -0,0 +1,165 @@ +package com.droiddeck.launcher.handtracking + +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.sin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class BodyPoseTest { + private val k = Intrinsics(800f, 800f, 480f, 360f, 960, 720) + private val body = mapOf( + BodyPose.NOSE to Vec3(0f, 0.62f, 0.10f), + BodyPose.LEFT_EYE to Vec3(0.035f, 0.66f, 0.08f), BodyPose.RIGHT_EYE to Vec3(-0.035f, 0.66f, 0.08f), + BodyPose.LEFT_EAR to Vec3(0.075f, 0.64f, 0f), BodyPose.RIGHT_EAR to Vec3(-0.075f, 0.64f, 0f), + BodyPose.LEFT_SHOULDER to Vec3(0.19f, 0.48f, 0f), BodyPose.RIGHT_SHOULDER to Vec3(-0.19f, 0.48f, 0f), + BodyPose.LEFT_ELBOW to Vec3(0.3f, 0.3f, 0.1f), BodyPose.RIGHT_ELBOW to Vec3(-0.28f, 0.25f, 0.05f), + BodyPose.LEFT_WRIST to Vec3(0.35f, 0.45f, 0.35f), BodyPose.RIGHT_WRIST to Vec3(-0.25f, 0.6f, 0.3f), + BodyPose.LEFT_PINKY to Vec3(0.37f, 0.47f, 0.42f), BodyPose.RIGHT_PINKY to Vec3(-0.27f, 0.63f, 0.37f), + BodyPose.LEFT_INDEX to Vec3(0.34f, 0.5f, 0.43f), BodyPose.RIGHT_INDEX to Vec3(-0.24f, 0.66f, 0.37f), + BodyPose.LEFT_HIP to Vec3(0.1f, 0f, 0f), BodyPose.RIGHT_HIP to Vec3(-0.1f, 0f, 0f), + ) + private val hips = Vec3(0.1f, -0.3f, -2.2f) + + @Test fun handsComeOutRelativeToTheEyesInALevelFrame() { + val frame = solve(turn = 0.0, pitch = 0.0)!! + assertTrue(frame.seen) + assertEquals(2.2f, frame.distance, 1e-3f) + for (side in 0..1) assertClose(expectedHand(side, 0.0), frame.hands[side]!!) + assertEquals(PI, abs(frame.faceYaw!!.toDouble()), 1e-4) + } + + @Test fun aCameraPointingDownStillGivesLevelHands() { + for (pitch in listOf(-0.25, 0.1, 0.15)) { + val frame = solve(turn = 0.0, pitch = pitch)!! + for (side in 0..1) assertClose(expectedHand(side, 0.0), frame.hands[side]!!) + } + } + + @Test fun aTurnedPersonGivesTheirFaceHeading() { + for (turn in listOf(-0.6, 0.4)) { + val frame = solve(turn = turn, pitch = 0.1)!! + assertEquals(wrap(PI + turn), wrap(frame.faceYaw!!.toDouble()), 1e-3) + for (side in 0..1) assertClose(expectedHand(side, turn), frame.hands[side]!!) + } + } + + @Test fun shoulderSpanIsAShareOfTheImageWidth() { + val frame = solve(turn = 0.0, pitch = 0.0)!! + assertEquals(800f * 0.38f / 2.2f / 960f, frame.shoulderSpan, 1e-3f) + } + + @Test fun anUnseenWristLeavesThatHandOut() { + val frame = solve(turn = 0.0, pitch = 0.0, hidden = setOf(BodyPose.RIGHT_WRIST))!! + assertNotNull(frame.hands[0]) + assertNull(frame.hands[1]) + } + + @Test fun tooLittleOfThePersonOrAnUnlevelableCameraGivesNothing() { + assertNull(solve(turn = 0.0, pitch = 0.0, hidden = body.keys - setOf(BodyPose.NOSE, BodyPose.LEFT_EYE, BodyPose.RIGHT_EYE))) + assertNull(solve(turn = 0.0, pitch = PI / 2)) + } + + @Test fun aHandModelFixIsPlacedByTheHandsOwnSize() { + val palmAt = Vec3(0.15f, 0.1f, 0.6f) + val shape = List(BodyPose.HAND_POINTS) { i -> WorldPoint(((i * 37) % 11 - 5) * 0.01f, ((i * 53) % 13 - 6) * 0.01f, ((i * 29) % 7 - 3) * 0.01f) } + val points = FloatArray(BodyPose.HAND_POINTS * 2) { j -> + val w = shape[j / 2] + val x = palmAt.x + w.x + val y = palmAt.y + w.y + val z = palmAt.z + w.z + if (j % 2 == 0) k.fx * x / z + k.cx else k.fy * y / z + k.cy + } + val fix = BodyPose.handFix(points, shape, k)!! + val palm = intArrayOf(0, 5, 9, 13, 17) + val expectedPalm = Vec3(palmAt.x + palm.map { shape[it].x }.average().toFloat(), palmAt.y + palm.map { shape[it].y }.average().toFloat(), + palmAt.z + palm.map { shape[it].z }.average().toFloat()) + assertClose(expectedPalm, fix.palm) + val frame = solve(turn = 0.0, pitch = 0.0, fixes = arrayOf(fix, null))!! + val eyes = toCamera(hips + (body[BodyPose.LEFT_EYE]!! + body[BodyPose.RIGHT_EYE]!!) * 0.5f) + val d = fix.palm - eyes + assertClose(Vec3(d.x, -d.y, -d.z), frame.hands[0]!!) + assertNull("no fix and no held depth leaves the hand out", frame.hands[1]) + assertTrue(!frame.estimated[0]) + val joints = frame.joints[0]!! + val mean = Vec3(palm.map { shape[it].x }.average().toFloat(), palm.map { shape[it].y }.average().toFloat(), + palm.map { shape[it].z }.average().toFloat()) + for (i in 0 until BodyPose.HAND_POINTS) { + assertClose(Vec3(shape[i].x - mean.x, -(shape[i].y - mean.y), -(shape[i].z - mean.z)), + Vec3(joints[i * 3], joints[i * 3 + 1], joints[i * 3 + 2])) + } + assertNull(frame.joints[1]) + } + + @Test fun aHandTheModelMissedKeepsItsLastDepth() { + val frame = solve(turn = 0.0, pitch = 0.0, fixes = arrayOfNulls(2), held = floatArrayOf(Float.NaN, 1.8f))!! + assertNull(frame.hands[0]) + assertTrue(frame.estimated[1]) + assertNull("a guessed hand has no finger joints", frame.joints[1]) + val eyes = toCamera(hips + (body[BodyPose.LEFT_EYE]!! + body[BodyPose.RIGHT_EYE]!!) * 0.5f) + assertEquals(-(1.8f - eyes.z), frame.hands[1]!!.z, 1e-3f) + } + + @Test fun jointsNearTheEdgeOfThePictureShowAsLittleRoom() { + val frame = solve(turn = 0.0, pitch = 0.0)!! + assertTrue(frame.edgeRoom > 0.05f && frame.edgeRoom < 0.5f) + val near = solve(turn = 0.0, pitch = 0.0, shift = 0.75f)!! + assertTrue("${near.edgeRoom}", near.edgeRoom < frame.edgeRoom) + } + + private fun solve(turn: Double, pitch: Double, hidden: Set = emptySet(), fixes: Array? = null, + held: FloatArray? = null, shift: Float = 0f): BodyFrame? { + val image = ArrayList() + val world = ArrayList() + for (i in 0 until 33) { + val rel = body[i]?.let { rotateY(it, turn) } + if (rel == null) { + image += Landmark(0.5f, 0.5f, 0f) + world += WorldPoint(0f, 0f, 0f) + continue + } + val camera = tilt(toCamera(hips + rel), pitch) + image += Landmark((k.fx * (camera.x + shift) / camera.z + k.cx) / k.width, (k.fy * camera.y / camera.z + k.cy) / k.height, + if (i in hidden) 0.1f else 0.95f) + val w = tilt(toCamera(rel), pitch) + world += WorldPoint(w.x, w.y, w.z) + } + val up = tilt(toCamera(Vec3(0f, 1f, 0f)), pitch) + return BodyPose.solve(image, world, k, CameraLevel(up), fixes, held) + } + + private fun expectedHand(side: Int, turn: Double): Vec3 { + val (wrist, pinky, index) = if (side == 0) Triple(BodyPose.LEFT_WRIST, BodyPose.LEFT_PINKY, BodyPose.LEFT_INDEX) + else Triple(BodyPose.RIGHT_WRIST, BodyPose.RIGHT_PINKY, BodyPose.RIGHT_INDEX) + val grip = (rotateY(body[wrist]!!, turn) + rotateY(body[pinky]!!, turn) + rotateY(body[index]!!, turn)) * (1f / 3f) + val head = (rotateY(body[BodyPose.LEFT_EYE]!!, turn) + rotateY(body[BodyPose.RIGHT_EYE]!!, turn)) * 0.5f + return grip - head + } + + private fun toCamera(v: Vec3) = Vec3(v.x, -v.y, -v.z) + + private fun tilt(v: Vec3, pitch: Double): Vec3 { + val c = cos(pitch).toFloat() + val s = sin(pitch).toFloat() + return Vec3(v.x, v.y * c - v.z * s, v.y * s + v.z * c) + } + + private fun rotateY(v: Vec3, angle: Double): Vec3 { + val c = cos(angle).toFloat() + val s = sin(angle).toFloat() + return Vec3(c * v.x + s * v.z, v.y, -s * v.x + c * v.z) + } + + private fun wrap(a: Double): Double = Math.IEEEremainder(a, 2 * PI) + + private fun assertClose(want: Vec3, got: Vec3) { + assertEquals("x", want.x, got.x, 2e-4f) + assertEquals("y", want.y, got.y, 2e-4f) + assertEquals("z", want.z, got.z, 2e-4f) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/HandCameraShmTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/HandCameraShmTest.kt new file mode 100644 index 000000000..fd7fcfa98 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/HandCameraShmTest.kt @@ -0,0 +1,92 @@ +package com.droiddeck.launcher.handtracking + +import com.droiddeck.launcher.glasses.Vec3 +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test + +class HandCameraShmTest { + private lateinit var dir: File + private lateinit var file: File + private var fences = 0 + + @Before fun setUp() { + dir = Files.createTempDirectory("handcam-shm").toFile() + file = File(dir, "run/${HandCameraShm.FILE}") + } + + @After fun tearDown() { dir.deleteRecursively() } + + @Test fun anOpenedFileCarriesTheHeaderAndNothingSeen() { + assertNotNull(HandCameraShm.open(file) { fences++ }) + val bytes = read() + assertEquals(HandCameraShm.SIZE, bytes.capacity()) + assertEquals(568, HandCameraShm.SIZE) + assertEquals(0x4D434844, bytes.getInt(0)) + assertEquals(2, bytes.getInt(4)) + assertEquals(2, bytes.getInt(8)) + for (at in 12 until HandCameraShm.SIZE step 4) assertEquals("byte $at", 0, bytes.getInt(at)) + assertEquals(2, fences) + } + + @Test fun aFrameLandsWhereTheDriverReadsIt() { + val shm = HandCameraShm.open(file) { fences++ }!! + shm.write(123_456_789_012L, BodyFrame(arrayOf(Vec3(0.2f, -0.45f, -0.3f), null), 2.5f, 1.8f, 0.2f, true)) + val bytes = read() + assertEquals(4, bytes.getInt(8)) + assertEquals(3, bytes.getInt(12)) + assertEquals(123_456_789_012L, bytes.getLong(16)) + assertEquals(1, bytes.getInt(24)) + assertEquals(2.5f, bytes.getFloat(28), 0f) + assertEquals(1, bytes.getInt(32)) + assertEquals(0.2f, bytes.getFloat(36), 0f) + assertEquals(-0.45f, bytes.getFloat(40), 0f) + assertEquals(-0.3f, bytes.getFloat(44), 0f) + assertEquals(0f, bytes.getFloat(48), 0f) + assertEquals(0, bytes.getInt(300)) + assertEquals(0f, bytes.getFloat(304), 0f) + } + + @Test fun fingerJointsAndTheBareHandsFlagLandWhereTheDriverReadsThem() { + val shm = HandCameraShm.open(file) { fences++ }!! + val joints = FloatArray(BodyPose.HAND_POINTS * 3) { it * 0.001f - 0.03f } + shm.write(7L, BodyFrame(arrayOf(null, Vec3(0.1f, -0.4f, -0.35f)), 0f, 2f, 0.2f, true, joints = arrayOf(null, joints)), bareHands = true) + val bytes = read() + assertEquals(1 or 2 or 4, bytes.getInt(12)) + assertEquals(0, bytes.getInt(32)) + assertEquals(1 or 4, bytes.getInt(300)) + assertEquals(0.1f, bytes.getFloat(304), 0f) + for (i in joints.indices) assertEquals("joint float $i", joints[i], bytes.getFloat(316 + i * 4), 0f) + assertEquals(HandCameraShm.SIZE, 316 + joints.size * 4) + } + + @Test fun aFrameWithoutAPersonOnlySaysTheCameraRuns() { + val shm = HandCameraShm.open(file) { fences++ }!! + shm.write(5L, BodyFrame(arrayOf(Vec3(1f, 1f, 1f), Vec3(1f, 1f, 1f)), null, 2f, 0.2f, true)) + shm.write(6L, null) + val bytes = read() + assertEquals(1, bytes.getInt(12)) + assertEquals(6L, bytes.getLong(16)) + assertEquals(2, bytes.getInt(24)) + assertEquals(0, bytes.getInt(32)) + assertEquals(0, bytes.getInt(300)) + } + + @Test fun closingClearsTheFrame() { + val shm = HandCameraShm.open(file) { fences++ }!! + shm.write(5L, BodyFrame(arrayOf(Vec3(1f, 1f, 1f), null), 0f, 2f, 0.2f, true)) + shm.close() + val bytes = read() + assertEquals(0x4D434844, bytes.getInt(0)) + assertEquals(0, bytes.getInt(8) and 1) + for (at in 12 until HandCameraShm.SIZE step 4) assertEquals("byte $at", 0, bytes.getInt(at)) + } + + private fun read(): ByteBuffer = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) +} diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt new file mode 100644 index 000000000..3456857d7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/HandTrackingTest.kt @@ -0,0 +1,57 @@ +package com.droiddeck.launcher.handtracking + +import android.Manifest +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class HandTrackingTest { + private val both = HandTracking.Settings(sides = setOf(TrackingCameras.REAR, TrackingCameras.FRONT)) + + @Test fun theQuestionComesOnceWhenAHeadsetConnects() { + assertTrue(HandTracking.shouldAsk(both, headset = true)) + assertFalse(HandTracking.shouldAsk(both, headset = false)) + assertFalse(HandTracking.shouldAsk(both.copy(asked = true), headset = true)) + assertFalse(HandTracking.shouldAsk(HandTracking.Settings(), headset = true)) + } + + @Test fun answersAreRemembered() { + val context = RuntimeEnvironment.getApplication() + HandTracking.allow(context, TrackingCameras.FRONT) + var s = HandTracking.load(context) + assertTrue(s.asked) + assertTrue(s.enabled) + assertEquals(TrackingCameras.FRONT, s.side) + assertFalse(s.permitted) + HandTracking.allow(context, null) + assertEquals(TrackingCameras.FRONT, HandTracking.load(context).side) + HandTracking.decline(context) + s = HandTracking.load(context) + assertTrue(s.asked) + assertFalse(s.enabled) + HandTracking.setEnabled(context, true) + HandTracking.setSide(context, TrackingCameras.REAR) + s = HandTracking.load(context) + assertTrue(s.enabled) + assertEquals(TrackingCameras.REAR, s.side) + } + + @Test fun trackingNeedsThePermissionAndACamera() { + val context = RuntimeEnvironment.getApplication() + assertFalse(both.copy(enabled = true).ready) + assertTrue(both.copy(enabled = true, permitted = true).ready) + assertFalse(HandTracking.Settings(enabled = true, permitted = true).ready) + shadowOf(context).grantPermissions(Manifest.permission.CAMERA) + HandTracking.refreshPermission(context) + assertTrue(HandTracking.settings.value.permitted) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/PlacementAdviceTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/PlacementAdviceTest.kt new file mode 100644 index 000000000..ad31149ef --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/PlacementAdviceTest.kt @@ -0,0 +1,31 @@ +package com.droiddeck.launcher.handtracking + +import com.droiddeck.launcher.glasses.Vec3 +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlacementAdviceTest { + private val hand = Vec3(0.2f, -0.4f, -0.3f) + private fun body(span: Float = 0.2f, left: Vec3? = hand, right: Vec3? = hand, seen: Boolean = true, room: Float = 0.2f) = + BodyFrame(arrayOf(left, right), 3f, 2f, span, seen, edgeRoom = room) + + @Test fun aFlatOrTiltedDeviceIsFixedFirst() { + assertEquals(Advice.STAND_UP, PlacementAdvice.of(Placement.FACE_UP, body())) + assertEquals(Advice.STAND_UP, PlacementAdvice.of(Placement.FACE_DOWN, body())) + assertEquals(Advice.UPRIGHT, PlacementAdvice.of(Placement.POINTS_UP, null)) + assertEquals(Advice.UPRIGHT, PlacementAdvice.of(Placement.POINTS_DOWN, body())) + } + + @Test fun thenThePersonDistanceAndHands() { + assertEquals(Advice.NOT_SEEN, PlacementAdvice.of(Placement.STANDING, null)) + assertEquals(Advice.NOT_SEEN, PlacementAdvice.of(Placement.STANDING, body(seen = false))) + assertEquals(Advice.MOVE_BACK, PlacementAdvice.of(Placement.STANDING, body(span = 0.5f))) + assertEquals("a person 0.65 m from a wide camera is too close", Advice.MOVE_BACK, PlacementAdvice.of(Placement.STANDING, body(span = 0.3f))) + assertEquals("hands at the edge of the picture", Advice.MOVE_BACK, PlacementAdvice.of(Placement.STANDING, body(room = 0.02f))) + assertEquals(Advice.MOVE_CLOSER, PlacementAdvice.of(Placement.STANDING, body(span = 0.05f))) + assertEquals(Advice.MOVE_CLOSER, PlacementAdvice.of(Placement.STANDING, body(span = 0.1f))) + assertEquals(Advice.SHOW_HANDS, PlacementAdvice.of(Placement.STANDING, body(right = null))) + assertEquals(Advice.GOOD, PlacementAdvice.of(Placement.STANDING, body())) + assertEquals(Advice.GOOD, PlacementAdvice.of(Placement.UNKNOWN, body())) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/handtracking/TrackingCamerasTest.kt b/app/src/test/java/com/droiddeck/launcher/handtracking/TrackingCamerasTest.kt new file mode 100644 index 000000000..b3a6ba54d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/handtracking/TrackingCamerasTest.kt @@ -0,0 +1,92 @@ +package com.droiddeck.launcher.handtracking + +import com.droiddeck.launcher.glasses.Vec3 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class TrackingCamerasTest { + private val wide = TrackingCamera("0", front = false, orientation = 90, focalPixels = 3000f, activeWidth = 4000, activeHeight = 3000, minZoom = 1f, realtime = true) + private val ultra = TrackingCamera("2", front = false, orientation = 90, focalPixels = 1000f, activeWidth = 4000, activeHeight = 3000, minZoom = 1f, realtime = true) + private val selfie = TrackingCamera("1", front = true, orientation = 270, focalPixels = 2000f, activeWidth = 4000, activeHeight = 3000, minZoom = 1f, realtime = true) + + @Test fun theBackCameraIsPreferredAndTheWidestBackLensWins() { + assertSame(ultra, TrackingCameras.pick(listOf(selfie, wide, ultra), null)) + assertSame(ultra, TrackingCameras.pick(listOf(selfie, wide, ultra), TrackingCameras.REAR)) + assertSame(selfie, TrackingCameras.pick(listOf(selfie, wide, ultra), TrackingCameras.FRONT)) + } + + @Test fun aMissingSideFallsBackToWhatTheDeviceHas() { + assertSame(wide, TrackingCameras.pick(listOf(wide), TrackingCameras.FRONT)) + assertSame(selfie, TrackingCameras.pick(listOf(selfie), null)) + assertNull(TrackingCameras.pick(emptyList(), null)) + assertEquals(setOf(TrackingCameras.REAR), TrackingCameras.sides(listOf(wide, ultra))) + assertEquals(setOf(TrackingCameras.FRONT, TrackingCameras.REAR), TrackingCameras.sides(listOf(selfie, wide))) + } + + @Test fun theCameraIsHeldAtItsFastestSteadyFrameRate() { + val front = listOf(10 to 10, 15 to 15, 10 to 24, 24 to 24, 10 to 30, 30 to 30) + assertEquals(30 to 30, TrackingCameras.steadyFps(front)) + assertEquals(24 to 24, TrackingCameras.steadyFps(listOf(15 to 15, 10 to 30, 24 to 24, 60 to 60))) + assertEquals(15 to 30, TrackingCameras.steadyFps(listOf(7 to 30, 15 to 30))) + assertNull(TrackingCameras.steadyFps(emptyList())) + } + + @Test fun zoomingOutOnlyHappensWhenTheLensAllowsIt() { + assertEquals(1f, wide.zoom(), 0f) + val logical = TrackingCamera("0", false, 90, 3000f, 4000, 3000, minZoom = 0.6f, realtime = true) + assertEquals(0.6f, logical.zoom(), 1e-6f) + val roomy = TrackingCamera("0", false, 90, 3000f, 4000, 3000, minZoom = 0.3f, realtime = true) + assertEquals((Math.tan(Math.toRadians(logical.fovDegrees / 2.0)) / Math.tan(Math.toRadians(50.0))).toFloat(), roomy.zoom(), 1e-5f) + assertEquals(100f, Math.toDegrees(2 * Math.atan(Math.tan(Math.toRadians(roomy.fovDegrees / 2.0)) / roomy.zoom())).toFloat(), 1e-3f) + } + + @Test fun intrinsicsFollowTheBufferZoomAndTurns() { + val k = wide.intrinsics(960, 720, 1f, 0) + assertEquals(720f, k.fx, 1e-3f) + assertEquals(480f, k.cx, 0f) + assertEquals(360f, k.cy, 0f) + val turned = wide.intrinsics(960, 720, 1f, 1) + assertEquals(720, turned.width) + assertEquals(960, turned.height) + assertEquals(360f, turned.cx, 0f) + assertEquals(360f, wide.intrinsics(960, 720, 0.5f, 0).fx, 1e-3f) + } + + @Test fun framesAreTurnedUprightFromGravity() { + val portrait = Vec3(0f, 9.8f, 0f) + assertEquals(1, CameraGeometry.uprightTurns(false, 90, portrait)) + assertEquals(3, CameraGeometry.uprightTurns(true, 270, portrait)) + assertEquals(0, CameraGeometry.uprightTurns(false, 0, portrait)) + val landscape = Vec3(9.8f, 0f, 0f) + assertEquals(0, CameraGeometry.uprightTurns(false, 90, landscape)) + assertEquals(0, CameraGeometry.uprightTurns(true, 270, landscape)) + assertEquals(2, CameraGeometry.uprightTurns(false, 90, Vec3(-9.8f, 0f, 0f))) + for ((front, orientation) in listOf(false to 90, true to 270, false to 270, true to 90)) { + for (up in listOf(portrait, landscape, Vec3(0f, -9.8f, 0f), Vec3(-9.8f, 0f, 0f))) { + val turns = CameraGeometry.uprightTurns(front, orientation, up) + val seen = CameraGeometry.upInCamera(front, orientation, turns, up) + assertEquals("front=$front $orientation° up=$up", -1f, seen.y, 1e-5f) + } + } + } + + @Test fun theCameraLooksOutOfItsOwnSide() { + val back = CameraGeometry.upInCamera(false, 90, 1, Vec3(0f, 0f, -9.8f)) + assertEquals(1f, back.z, 1e-5f) + val front = CameraGeometry.upInCamera(true, 270, 3, Vec3(0f, 0f, 9.8f)) + assertEquals(1f, front.z, 1e-5f) + } + + @Test fun placementTellsFlatFromStandingAndTilted() { + assertEquals(Placement.FACE_UP, CameraGeometry.placement(false, Vec3(0f, 0.5f, 9.7f))) + assertEquals(Placement.FACE_DOWN, CameraGeometry.placement(true, Vec3(0f, 0.5f, -9.7f))) + assertEquals(Placement.STANDING, CameraGeometry.placement(false, Vec3(0f, 9.8f, 0f))) + assertEquals(Placement.STANDING, CameraGeometry.placement(true, Vec3(9.2f, 0f, 3f))) + val leanBack = Vec3(0f, Math.cos(Math.toRadians(60.0)).toFloat(), -Math.sin(Math.toRadians(60.0)).toFloat()) + assertEquals(Placement.POINTS_UP, CameraGeometry.placement(false, leanBack)) + assertEquals(Placement.POINTS_DOWN, CameraGeometry.placement(true, leanBack)) + assertEquals(Placement.UNKNOWN, CameraGeometry.placement(false, Vec3(0f, 0f, 0f))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConMotionTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConMotionTest.kt new file mode 100644 index 000000000..1ba5c23b0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConMotionTest.kt @@ -0,0 +1,153 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.glasses.Quaternion +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.PI +import kotlin.math.roundToInt +import org.junit.Assert.* +import org.junit.Test + +class JoyConMotionTest { + private val bias = Vec3(0.3f, -0.25f, 0.2f) + + @Test fun restingInSeveralPositionsLearnsTheAccelerometerOffset() { + val calibration = JoyConAccelCalibration() + assertFalse(calibration.ready) + var learned = false + for (up in directions) repeat(200) { learned = calibration.addResting(up * G + bias) || learned } + assertTrue(learned) + assertTrue(calibration.ready) + assertVec(bias, calibration.bias, 0.01f) + assertEquals(1f, calibration.scale, 1e-3f) + assertVec(Vec3(0f, G, 0f), calibration.correct(Vec3(0f, G, 0f) + bias), 0.01f) + } + + @Test fun restingInOnePositionTeachesNothing() { + val calibration = JoyConAccelCalibration() + repeat(2000) { assertFalse(calibration.addResting(Vec3(0f, G, 0f) + bias)) } + assertFalse(calibration.ready) + assertVec(Vec3(1f, 2f, 3f), calibration.correct(Vec3(1f, 2f, 3f))) + } + + @Test fun aSavedOffsetComesBackAndAnImplausibleOneIsIgnored() { + val first = JoyConAccelCalibration() + for (up in directions) repeat(200) { first.addResting(up * G + bias) } + val restored = JoyConAccelCalibration().apply { restore(first.saved()) } + assertTrue(restored.ready) + assertVec(first.bias, restored.bias, 0f) + assertEquals(first.scale, restored.scale, 0f) + for (bad in listOf(floatArrayOf(3f, 0f, 0f, 1f), floatArrayOf(0f, 0f, 0f, 1.5f), floatArrayOf(Float.NaN, 0f, 0f, 1f), floatArrayOf(0f, 0f, 1f))) { + val c = JoyConAccelCalibration().apply { restore(bad) } + assertFalse(bad.contentToString(), c.ready) + assertNull(c.saved()) + } + } + + @Test fun aLiftIsMeasuredAndTheHandStopsWhenItDoes() { + val motion = JoyConMotion() + still(motion, 1f) + val lift = 0.25f + val seconds = 0.5f + val steps = (seconds / DT).roundToInt() + for (i in 1..steps) { + val s = (i - 0.5f) / steps + val a = lift / (seconds * seconds) * (60 * s - 180 * s * s + 120 * s * s * s) + motion.update(Quaternion.IDENTITY, Vec3(0f, G + a, 0f), Vec3(0f, 0f, 0f), true, DT) + } + assertEquals(lift, motion.displacement.y, 0.01f) + assertTrue("moving up at ${motion.velocity}", motion.velocity.length() < 0.05f) + still(motion, 2f) + assertEquals(lift, motion.displacement.y, 0.015f) + assertEquals(0f, motion.velocity.length(), 1e-3f) + assertEquals(0f, motion.displacement.x, 1e-3f) + assertEquals(0f, motion.displacement.z, 1e-3f) + } + + @Test fun theInertialVelocityKeepsAccelerationThatStillnessWouldDrop() { + val motion = JoyConMotion() + still(motion, 1f) + repeat((2f / DT).roundToInt()) { motion.update(Quaternion.IDENTITY, Vec3(0f, G + 0.05f, 0f), Vec3(0f, 0f, 0f), true, DT) } + assertEquals(0f, motion.velocity.y, 1e-3f) + assertEquals(0.1f, motion.inertial.y, 0.005f) + val before = motion.inertial.y + val seconds = 0.25f + repeat((seconds / DT).roundToInt()) { motion.update(Quaternion.IDENTITY, Vec3(2f, G, 0f), Vec3(0f, 0f, 0f), true, DT) } + assertEquals(2f * seconds, motion.inertial.x, 0.01f) + assertEquals(before, motion.inertial.y, 0.005f) + } + + @Test fun aSidewaysPushIsMeasuredInTheWorldFrame() { + val motion = JoyConMotion() + val turned = Quaternion.fromAxisAngle(Vec3(0f, 0f, 1f), (PI / 2).toFloat()) + val down = turned.conjugate().rotate(Vec3(0f, G, 0f)) + repeat((1f / DT).toInt()) { motion.update(turned, down, Vec3(0f, 0f, 0f), true, DT) } + val steps = (0.4f / DT).roundToInt() + for (i in 1..steps) { + val s = (i - 0.5f) / steps + val a = 0.3f / (0.4f * 0.4f) * (60 * s - 180 * s * s + 120 * s * s * s) + motion.update(turned, down + turned.conjugate().rotate(Vec3(a, 0f, 0f)), Vec3(0f, 0f, 0f), true, DT) + } + assertEquals(0.3f, motion.displacement.x, 0.015f) + assertEquals(0f, motion.displacement.y, 0.01f) + } + + @Test fun turningInPlaceDoesNotMoveTheHand() { + val motion = JoyConMotion() + still(motion, 1f) + val rate = 2f + var q = Quaternion.IDENTITY + repeat((1f / DT).toInt()) { + q = q.integrateBodyRate(Vec3(0f, 0f, rate), DT).normalized() + motion.update(q, q.conjugate().rotate(Vec3(0f, G, 0f)), Vec3(0f, 0f, rate), true, DT) + } + repeat((1f / DT).toInt()) { motion.update(q, q.conjugate().rotate(Vec3(0f, G, 0f)), Vec3(0f, 0f, 0f), true, DT) } + assertEquals(0f, motion.displacement.length(), 0.005f) + } + + @Test fun anUncalibratedJoyConAtRestDoesNotDrift() { + val motion = JoyConMotion() + val filter = JoyConOrientation() + val tilted = Vec3(0.3f, G + 0.35f, -0.2f) + repeat((30f / DT).toInt()) { + filter.update(tilted, Vec3(0f, 0f, 0f), DT) + if (filter.ready) motion.update(filter.rotation, tilted, filter.rate, false, DT) + } + assertTrue("drifted ${motion.displacement}", motion.displacement.length() < 0.05f) + assertEquals(0f, motion.velocity.length(), 1e-3f) + } + + @Test fun longHandlingWithATiltErrorCannotRunAway() { + val motion = JoyConMotion() + still(motion, 1f) + val leaking = Vec3(0.5f, G, 0f) + repeat((10f / DT).toInt()) { motion.update(Quaternion.IDENTITY, leaking, Vec3(0f, 0f, 1f), true, DT) } + assertTrue("velocity ${motion.velocity}", motion.velocity.length() < 0.15f) + assertTrue("moved ${motion.displacement}", motion.displacement.length() < 1.5f) + } + + @Test fun onlyAStillJoyConCountsAsResting() { + val motion = JoyConMotion() + still(motion, 0.2f) + assertFalse(motion.resting) + still(motion, 0.2f) + assertTrue(motion.resting) + motion.update(Quaternion.IDENTITY, Vec3(0f, G, 0f), Vec3(0.3f, 0f, 0f), true, DT) + assertFalse(motion.resting) + } + + private fun still(motion: JoyConMotion, seconds: Float) { + repeat((seconds / DT).toInt()) { motion.update(Quaternion.IDENTITY, Vec3(0f, G, 0f), Vec3(0f, 0f, 0f), true, DT) } + } + + private fun assertVec(want: Vec3, got: Vec3, tolerance: Float = 1e-4f) { + assertEquals("x of $got", want.x, got.x, tolerance) + assertEquals("y of $got", want.y, got.y, tolerance) + assertEquals("z of $got", want.z, got.z, tolerance) + } + + private companion object { + const val G = 9.80665f + const val DT = 0.005f + val directions = listOf(Vec3(0f, 1f, 0f), Vec3(0f, -1f, 0f), Vec3(1f, 0f, 0f), Vec3(-1f, 0f, 0f), Vec3(0f, 0f, 1f), Vec3(0f, 0f, -1f)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConOrientationTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConOrientationTest.kt new file mode 100644 index 000000000..550031648 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConOrientationTest.kt @@ -0,0 +1,117 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.glasses.Vec3 +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import org.junit.Assert.* +import org.junit.Test + +class JoyConOrientationTest { + private val faceUp = floatArrayOf(0f, 0f, G) + private val still = floatArrayOf(0f, 0f, 0f) + + @Test fun aJoyConLyingFaceUpPointsStraightAhead() { + val filter = JoyConOrientation() + filter.update(faceUp, still, 0f) + assertTrue(filter.ready) + assertVec(Vec3(0f, 0f, -1f), forward(filter)) + assertVec(Vec3(0f, 1f, 0f), filter.rotation.rotate(Vec3(0f, 1f, 0f))) + } + + @Test fun standingOnItsBaseAimsUp() { + val filter = JoyConOrientation() + filter.update(floatArrayOf(G, 0f, 0f), still, 0f) + assertVec(Vec3(0f, 1f, 0f), forward(filter)) + } + + @Test fun nothingIsReportedUntilGravityIsPlausible() { + val filter = JoyConOrientation() + filter.update(floatArrayOf(0f, 0f, 3f), still, 0f) + assertFalse(filter.ready) + filter.update(floatArrayOf(0f, 0f, 30f), still, 0f) + assertFalse(filter.ready) + filter.update(faceUp, still, 0f) + assertTrue(filter.ready) + } + + @Test fun turningAboutTheFaceTurnsTheAimLeft() { + val filter = JoyConOrientation() + filter.update(faceUp, still, 0f) + repeat(100) { filter.update(faceUp, floatArrayOf(0f, 0f, 1f), 0.01f) } + assertVec(Vec3(-sin(1f), 0f, -cos(1f)), forward(filter), 2e-3f) + assertVec(Vec3(0f, 1f, 0f), filter.rate) + } + + @Test fun gravityPullsATiltedEstimateBackLevel() { + val filter = JoyConOrientation() + filter.update(faceUp, still, 0f) + repeat(50) { filter.update(faceUp, floatArrayOf(1f, 0f, 0f), 0.01f) } + assertTrue(filter.rotation.rotate(Vec3(0f, 1f, 0f)).y < 0.97f) + repeat(600) { filter.update(faceUp, still, 0.01f) } + assertEquals(1f, filter.rotation.rotate(Vec3(0f, 1f, 0f)).y, 1e-3f) + } + + @Test fun aSteadyGyroOffsetIsLearnedWhileTheJoyConRests() { + val filter = JoyConOrientation() + filter.update(faceUp, still, 0f) + val drifting = floatArrayOf(0f, 0f, 0.02f) + repeat(1000) { filter.update(faceUp, drifting, 0.01f) } + assertEquals(0f, filter.rate.length(), 1e-3f) + val heading = heading(filter) + repeat(1000) { filter.update(faceUp, drifting, 0.01f) } + assertEquals(heading, heading(filter), 2e-3f) + } + + @Test fun movingTheJoyConDoesNotTeachTheBias() { + val filter = JoyConOrientation() + filter.update(faceUp, still, 0f) + repeat(300) { filter.update(faceUp, floatArrayOf(0f, 0f, 0.5f), 0.01f) } + assertEquals(0.5f, filter.rate.length(), 1e-4f) + } + + @Test fun reportsArriveNewestFirstAndTheRepeatedSampleIsDropped() { + val reports = JoyConReports() + fun sample(n: Int) = floatArrayOf(n.toFloat(), 0f, G) to floatArrayOf(n * 0.1f, 0f, 0f) + fun send(at: Long, vararg newestFirst: Int): List { + for (n in newestFirst) sample(n).let { reports.add(it.first, it.second, at) } + return reports.flush() + } + assertEquals(listOf(1f, 2f, 3f), send(1_000_000_000L, 3, 2, 1).map { it.accel[0] }) + val second = send(1_010_000_000L, 5, 4, 3) + assertEquals(listOf(4f, 5f), second.map { it.accel[0] }) + assertEquals("the report interval is shared by its new samples", 0.005f, second[0].dt, 1e-6f) + assertEquals(listOf(6f, 7f, 8f), send(1_025_000_000L, 8, 7, 6).map { it.accel[0] }) + val still = send(1_035_000_000L, 8, 8, 8) + assertEquals("a still report still moves time on", 1, still.size) + assertEquals(0.01f, still[0].dt, 1e-6f) + assertEquals("a long gap is capped", 0.05f, send(2_000_000_000L, 11, 10, 9)[0].dt, 1e-6f) + } + + @Test fun reportsInOldestFirstOrderAreRecognised() { + val reports = JoyConReports() + var at = 1_000_000_000L + var next = 1 + val seen = ArrayList() + repeat(12) { + val samples = listOf(next - 1, next, next + 1).filter { it > 0 } + for (n in samples) reports.add(floatArrayOf(n.toFloat(), 0f, G), floatArrayOf(0f, 0f, 0f), at) + seen += reports.flush().map { it.accel[0] } + next += 2 + at += 10_000_000L + } + assertEquals((10..24).map { it.toFloat() }, seen.takeLast(15)) + } + + private fun forward(filter: JoyConOrientation) = filter.rotation.rotate(Vec3(0f, 0f, -1f)) + + private fun heading(filter: JoyConOrientation): Float = forward(filter).let { atan2(-it.x, -it.z) } + + private fun assertVec(want: Vec3, got: Vec3, tolerance: Float = 1e-4f) { + assertEquals("x of $got", want.x, got.x, tolerance) + assertEquals("y of $got", want.y, got.y, tolerance) + assertEquals("z of $got", want.z, got.z, tolerance) + } + + private companion object { const val G = 9.80665f } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt new file mode 100644 index 000000000..43f0a8d82 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConPadTest.kt @@ -0,0 +1,213 @@ +package com.droiddeck.launcher.input + +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.MotionEvent +import java.io.File +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.InputDeviceBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, instrumentedPackages = ["com.droiddeck.launcher.input.FakeInputWriter"], + shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class, PadBridgeRoutingTest.RegisteredMotionEvent::class, + JoyConPadTest.RecordingWriter::class]) +class JoyConPadTest { + private lateinit var dir: File + private lateinit var bridge: PadBridge + private lateinit var left: InputDevice + private lateinit var right: InputDevice + + @Before fun setUp() { + RecordingWriter.pads.clear() + RecordingWriter.grips = 0 + dir = Files.createTempDirectory("joycon-pad").toFile() + bridge = PadBridge(dir) + left = joyCon(LEFT_PRODUCT, MotionEvent.AXIS_X, MotionEvent.AXIS_Y) + right = joyCon(RIGHT_PRODUCT, MotionEvent.AXIS_RX, MotionEvent.AXIS_RY) + } + + @After fun tearDown() { + bridge.stop() + dir.deleteRecursively() + } + + @Test fun twoJoyConsBecomeOnePlayerWithBothSticks() { + pair() + assertEquals(left.id, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + assertEquals(right.id, PadBridge.joyConForSlot(0, JoyCon.RIGHT)) + assertTrue(press(left, KeyEvent.KEYCODE_UNKNOWN, 0x220)) + assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_X, BTN_NORTH)) + assertTrue(pad(0).up) + assertTrue(pad(0).isDown(PadState.Y)) + assertTrue(bridge.onMotionEvent(motion(left, MotionEvent.AXIS_X to 0.75f, MotionEvent.AXIS_Y to -0.5f))) + assertTrue(bridge.onMotionEvent(motion(right, MotionEvent.AXIS_RX to -0.6f, MotionEvent.AXIS_X to 0f))) + assertEquals(0.75f, pad(0).leftX) + assertEquals(-0.5f, pad(0).leftY) + assertEquals(-0.6f, pad(0).rightX) + assertNull("a second player appeared", RecordingWriter.pads[1]) + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(1, JoyCon.LEFT)) + } + + @Test fun railButtonsAreShouldersOnlyForALoneJoyCon() { + assertTrue(press(left, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR)) + assertTrue(pad(0).isDown(PadState.RB)) + tap(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH) + assertFalse("a rail button held while pairing stayed down", pad(0).isDown(PadState.RB)) + assertTrue(bridge.onKeyEvent(JoyCon.normalize(key(left, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR, KeyEvent.ACTION_UP)))) + assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR)) + assertTrue(press(left, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR)) + assertTrue(bridge.onKeyEvent(JoyCon.normalize(key(left, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR, KeyEvent.ACTION_UP)))) + assertTrue("the left rail released the right shoulder", pad(0).isDown(PadState.RB)) + assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_L1, BTN_TL)) + assertFalse(pad(0).isDown(PadState.LB)) + } + + @Test fun captureOnTheFirstPlayerIsTheLeftGripUntilPairingReleasesIt() { + assertTrue(press(left, KeyEvent.KEYCODE_BUTTON_Z, BTN_Z)) + assertEquals(DeckControls.L4, RecordingWriter.grips and DeckControls.L4) + tap(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH) + assertEquals(0, RecordingWriter.grips) + } + + @Test fun whenOneJoyConLeavesTheOtherKeepsThePlayer() { + pair() + assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH)) + bridge.onDeviceRemoved(left.id) + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(0, JoyCon.RIGHT)) + assertFalse("a held button survived the split", pad(0).isDown(PadState.A)) + assertEquals(right.id, PadBridge.controllerForSlot(0)) + tap(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH) + assertNull(RecordingWriter.pads[1]) + val another = joyCon(LEFT_PRODUCT, MotionEvent.AXIS_X, MotionEvent.AXIS_Y) + tap(another, KeyEvent.KEYCODE_UNKNOWN, 0x221) + assertEquals(another.id, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + assertEquals(right.id, PadBridge.joyConForSlot(0, JoyCon.RIGHT)) + bridge.onDeviceRemoved(right.id) + assertEquals(another.id, PadBridge.controllerForSlot(0)) + tap(another, KeyEvent.KEYCODE_UNKNOWN, 0x221) + assertNull(RecordingWriter.pads[1]) + } + + @Test fun aSecondPlayerCanBeAJoyConPair() { + val xbox = InputDeviceBuilder.newBuilder().setId(90).setName("pad").setDescriptor("pad-90").setExternal(true) + .setVendorId(0x045e).setProductId(0x0b13).setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC) + .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)).build() + register(xbox) + tap(xbox, KeyEvent.KEYCODE_BUTTON_A, 0x130) + pair() + assertEquals(left.id, PadBridge.joyConForSlot(1, JoyCon.LEFT)) + assertEquals(right.id, PadBridge.joyConForSlot(1, JoyCon.RIGHT)) + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + assertTrue(press(right, KeyEvent.KEYCODE_BUTTON_B, 0x131)) + assertTrue(pad(1).isDown(PadState.B)) + assertNull(RecordingWriter.pads[2]) + } + + @Test fun joyConsHeldForVrAreLeftAlone() { + bridge.setExcluded { it == left.id } + assertFalse(bridge.onKeyEvent(key(left, KeyEvent.KEYCODE_BUTTON_L1, BTN_TL))) + assertFalse(bridge.onMotionEvent(motion(left, MotionEvent.AXIS_X to 1f))) + bridge.syncConnectedControllers() + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + tap(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH) + assertEquals(right.id, PadBridge.controllerForSlot(0)) + assertEquals(PadBridge.NO_CONTROLLER, PadBridge.joyConForSlot(0, JoyCon.RIGHT)) + bridge.setExcluded { false } + bridge.syncConnectedControllers() + assertEquals(left.id, PadBridge.joyConForSlot(0, JoyCon.LEFT)) + assertEquals(right.id, PadBridge.joyConForSlot(0, JoyCon.RIGHT)) + } + + private fun pair() { + tap(left, KeyEvent.KEYCODE_BUTTON_L1, BTN_TL) + tap(right, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR) + } + + private fun pad(slot: Int): PadState = RecordingWriter.pads[slot] ?: PadState() + + private fun press(device: InputDevice, code: Int, scanCode: Int): Boolean = + bridge.onKeyEvent(JoyCon.normalize(key(device, code, scanCode))) + + private fun tap(device: InputDevice, code: Int, scanCode: Int) { + assertTrue(press(device, code, scanCode)) + assertTrue(bridge.onKeyEvent(JoyCon.normalize(key(device, code, scanCode, KeyEvent.ACTION_UP)))) + } + + private fun key(device: InputDevice, code: Int, scanCode: Int, action: Int = KeyEvent.ACTION_DOWN): KeyEvent { + val event = KeyEvent(0L, 0L, action, code, 0, 0, device.id, scanCode, 0, InputDevice.SOURCE_GAMEPAD) + assertSame(device, event.device) + return event + } + + private fun motion(device: InputDevice, vararg values: Pair): MotionEvent { + val coords = MotionEvent.PointerCoords().apply { for ((axis, value) in values) setAxisValue(axis, value) } + return MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 1, arrayOf(MotionEvent.PointerProperties().apply { id = 0 }), + arrayOf(coords), 0, 0, 1f, 1f, device.id, 0, InputDevice.SOURCE_JOYSTICK, 0) + } + + private var nextId = 60 + + private fun joyCon(product: Int, vararg axes: Int): InputDevice { + val id = nextId++ + val builder = InputDeviceBuilder.newBuilder().setId(id).setName("Joy-Con $id").setDescriptor("joycon-$id") + .setExternal(true).setVendorId(NINTENDO).setProductId(product) + .setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK or InputDevice.SOURCE_KEYBOARD) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC) + .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)) + for (axis in axes) builder.addMotionRange(axis, InputDevice.SOURCE_JOYSTICK, -1f, 1f, 0f, 0f, 0f) + return builder.build().also(::register) + } + + private fun register(device: InputDevice) { + val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java) + shadowOf(manager).addInputDevice(device) + assertSame(device, InputDevice.getDevice(device.id)) + } + + private companion object { + const val NINTENDO = 0x057e + const val LEFT_PRODUCT = 0x2006 + const val RIGHT_PRODUCT = 0x2007 + const val BTN_SOUTH = 0x130 + const val BTN_NORTH = 0x133 + const val BTN_Z = 0x135 + const val BTN_TL = 0x136 + const val BTN_TR = 0x137 + } + + @Implements(FakeInputWriter::class) + class RecordingWriter { + private var slot = 0 + + @Implementation fun __constructor__(fakeInputPath: String, slot: Int) { this.slot = slot } + @Implementation fun open(): Boolean = true + @Implementation fun close() = Unit + @Implementation fun destroy() = Unit + @Implementation fun writePad(state: PadState) { pads[slot] = PadState().apply { copyFrom(state) } } + + companion object { + val pads = HashMap() + var grips = 0 + + @JvmStatic @Implementation fun __staticInitializer__() = Unit + + @JvmStatic @Implementation + fun writeDeckControls(slot: Int, controls: Int, pads: ShortArray, pressure: ShortArray) { grips = controls } + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConShmTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConShmTest.kt new file mode 100644 index 000000000..a96368c91 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConShmTest.kt @@ -0,0 +1,88 @@ +package com.droiddeck.launcher.input + +import com.droiddeck.launcher.glasses.Quaternion +import com.droiddeck.launcher.glasses.Vec3 +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class JoyConShmTest { + private lateinit var dir: File + private lateinit var file: File + private var fences = 0 + + @Before fun setUp() { + dir = Files.createTempDirectory("joycon-shm").toFile() + file = File(dir, "run/${JoyConShm.FILE}") + } + + @After fun tearDown() { dir.deleteRecursively() } + + @Test fun anOpenedFileCarriesTheHeaderAndNoHands() { + assertNotNull(JoyConShm.open(file) { fences++ }) + val bytes = read() + assertEquals(JoyConShm.SIZE, bytes.capacity()) + assertEquals(0x4E434A44, bytes.getInt(0)) + assertEquals(3, bytes.getInt(4)) + assertEquals(2, bytes.getInt(8)) + for (at in 16 until JoyConShm.SIZE step 4) assertEquals("byte $at", 0, bytes.getInt(at)) + assertEquals(2, fences) + } + + @Test fun aHandLandsWhereTheDriverReadsIt() { + val shm = JoyConShm.open(file) { fences++ }!! + shm.write(JoyCon.RIGHT, JoyConShm.Hand(connected = true, charging = true, + buttons = JoyCon.VR_TRIGGER or JoyCon.VR_A, rotation = Quaternion(0.5f, -0.5f, 0.25f, 0.75f), + rate = Vec3(1f, -2f, 3f), displacement = Vec3(0.1f, 0.2f, -0.3f), velocity = Vec3(-1.5f, 0.5f, 2f), inertial = Vec3(4f, -5f, 6f), stickX = 0.25f, stickY = -0.5f, battery = 0.75f, recenter = 3, timeNs = 1_234_567_890_123L)) + val bytes = read() + assertEquals(4, bytes.getInt(8)) + val at = 16 + 104 + assertEquals(3, bytes.getInt(at)) + assertEquals(JoyCon.VR_TRIGGER or JoyCon.VR_A, bytes.getInt(at + 4)) + assertArrayEquals(floatArrayOf(0.5f, -0.5f, 0.25f, 0.75f, 1f, -2f, 3f, 0.25f, -0.5f, 0.75f), + FloatArray(10) { bytes.getFloat(at + 8 + it * 4) }, 0f) + assertEquals(3, bytes.getInt(at + 48)) + assertEquals(1_234_567_890_123L, bytes.getLong(at + 56)) + assertArrayEquals(floatArrayOf(0.1f, 0.2f, -0.3f, -1.5f, 0.5f, 2f, 4f, -5f, 6f), FloatArray(9) { bytes.getFloat(at + 64 + it * 4) }, 0f) + assertEquals(224, JoyConShm.SIZE) + assertEquals("the left hand was touched", 0, bytes.getInt(16)) + assertEquals(4, fences) + } + + @Test fun closingDisconnectsBothHands() { + val shm = JoyConShm.open(file) {}!! + shm.write(JoyCon.LEFT, JoyConShm.Hand(connected = true)) + shm.write(JoyCon.RIGHT, JoyConShm.Hand(connected = true)) + shm.close() + val bytes = read() + assertEquals(0, bytes.getInt(16)) + assertEquals(0, bytes.getInt(16 + 104)) + assertEquals(0, bytes.getInt(8) and 1) + } + + @Test fun reopeningKeepsTheSequenceMovingForward() { + JoyConShm.open(file) {}!!.write(JoyCon.LEFT, JoyConShm.Hand(connected = true)) + assertEquals(4, read().getInt(8)) + file.outputStream().use { } + assertEquals(0L, file.length()) + assertNotNull(JoyConShm.open(file) {}) + assertEquals(2, read().getInt(8)) + java.io.RandomAccessFile(file, "rw").use { it.seek(8); it.write(byteArrayOf(7, 0, 0, 0)) } + JoyConShm.open(file) {}!! + val after = read().getInt(8) + assertEquals(0, after and 1) + assertTrue("$after after a torn write", after > 7) + } + + @Test fun anUnwritableLocationIsReportedAsMissing() { + val blocker = File(dir, "blocker").apply { writeText("") } + assertNull(JoyConShm.open(File(blocker, JoyConShm.FILE)) {}) + } + + private fun read(): ByteBuffer = ByteBuffer.wrap(file.readBytes()).order(ByteOrder.LITTLE_ENDIAN) +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/JoyConTest.kt b/app/src/test/java/com/droiddeck/launcher/input/JoyConTest.kt new file mode 100644 index 000000000..a47d1c0c7 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/JoyConTest.kt @@ -0,0 +1,147 @@ +package com.droiddeck.launcher.input + +import android.hardware.input.InputManager +import android.view.InputDevice +import android.view.KeyCharacterMap +import android.view.KeyEvent +import android.view.MotionEvent +import org.junit.After +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.shadows.InputDeviceBuilder + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, shadows = [PadBridgeRoutingTest.RegisteredKeyEvent::class]) +class JoyConTest { + @After fun clearVrHands() { JoyCon.clearVrDevices(this) } + + @Test fun onlyNintendoJoyConsHaveASide() { + assertEquals(JoyCon.LEFT, JoyCon.side(device(LEFT_PRODUCT))) + assertEquals(JoyCon.RIGHT, JoyCon.side(device(RIGHT_PRODUCT))) + assertEquals(-1, JoyCon.side(device(PRO_CONTROLLER))) + assertEquals(-1, JoyCon.side(device(LEFT_PRODUCT, vendor = 0x045e))) + assertEquals(-1, JoyCon.side(null)) + } + + @Test fun theRightJoyConFaceButtonsKeepTheirPositions() { + val right = device(RIGHT_PRODUCT) + assertEquals(KeyEvent.KEYCODE_BUTTON_Y, JoyCon.normalize(key(right, KeyEvent.KEYCODE_BUTTON_X, BTN_NORTH)).keyCode) + assertEquals(KeyEvent.KEYCODE_BUTTON_X, JoyCon.normalize(key(right, KeyEvent.KEYCODE_BUTTON_Y, BTN_WEST)).keyCode) + val south = key(right, KeyEvent.KEYCODE_BUTTON_A, BTN_SOUTH) + assertSame(south, JoyCon.normalize(south)) + val remapped = JoyCon.normalize(key(right, KeyEvent.KEYCODE_BUTTON_X, BTN_NORTH, KeyEvent.ACTION_UP)) + assertEquals(KeyEvent.ACTION_UP, remapped.action) + assertEquals(right.id, remapped.deviceId) + assertEquals(BTN_NORTH, remapped.scanCode) + assertEquals(InputDevice.SOURCE_GAMEPAD, remapped.source) + } + + @Test fun theLeftJoyConArrowsBecomeADpad() { + val left = device(LEFT_PRODUCT) + val arrows = mapOf(0x220 to KeyEvent.KEYCODE_DPAD_UP, 0x221 to KeyEvent.KEYCODE_DPAD_DOWN, + 0x222 to KeyEvent.KEYCODE_DPAD_LEFT, 0x223 to KeyEvent.KEYCODE_DPAD_RIGHT) + for ((scanCode, code) in arrows) { + assertEquals(code, JoyCon.normalize(key(left, KeyEvent.KEYCODE_UNKNOWN, scanCode)).keyCode) + } + } + + @Test fun railButtonsAndOtherPadsPassThroughUntouched() { + val left = device(LEFT_PRODUCT) + val right = device(RIGHT_PRODUCT) + for (event in listOf(key(left, KeyEvent.KEYCODE_BUTTON_R1, BTN_TR), key(left, KeyEvent.KEYCODE_BUTTON_R2, BTN_TR2), + key(right, KeyEvent.KEYCODE_BUTTON_L1, BTN_TL), key(right, KeyEvent.KEYCODE_BUTTON_L2, BTN_TL2), + key(device(PRO_CONTROLLER), KeyEvent.KEYCODE_BUTTON_X, BTN_NORTH))) { + assertSame(event, JoyCon.normalize(event)) + } + assertTrue(JoyCon.isRail(JoyCon.LEFT, BTN_TR)) + assertTrue(JoyCon.isRail(JoyCon.RIGHT, BTN_TL2)) + assertFalse(JoyCon.isRail(JoyCon.LEFT, BTN_TL)) + assertFalse(JoyCon.isRail(JoyCon.RIGHT, BTN_TR)) + assertFalse(JoyCon.isRail(-1, BTN_TR)) + } + + @Test fun eachJoyConReadsItsOwnStick() { + val left = device(LEFT_PRODUCT) + val right = device(RIGHT_PRODUCT, axes = intArrayOf(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY)) + val oldRight = device(RIGHT_PRODUCT, axes = intArrayOf(MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)) + val source = InputDevice.SOURCE_JOYSTICK + assertArrayEquals(intArrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y), JoyCon.stickAxes(JoyCon.LEFT, left, source)) + assertArrayEquals(intArrayOf(MotionEvent.AXIS_RX, MotionEvent.AXIS_RY), JoyCon.stickAxes(JoyCon.RIGHT, right, source)) + assertArrayEquals(intArrayOf(MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ), JoyCon.stickAxes(JoyCon.RIGHT, oldRight, source)) + } + + @Test fun vrButtonsFollowTheIndexLayoutOnEitherHand() { + val left = mapOf(BTN_TL2 to JoyCon.VR_TRIGGER, BTN_TL to JoyCon.VR_GRIP, BTN_TR to JoyCon.VR_GRIP, + BTN_TR2 to JoyCon.VR_GRIP, 0x221 to JoyCon.VR_A, 0x223 to JoyCon.VR_A, 0x220 to JoyCon.VR_B, + 0x222 to JoyCon.VR_B, BTN_SELECT to JoyCon.VR_B, BTN_THUMBL to JoyCon.VR_STICK, BTN_Z to JoyCon.VR_SYSTEM) + val right = mapOf(BTN_TR2 to JoyCon.VR_TRIGGER, BTN_TR to JoyCon.VR_GRIP, BTN_TL to JoyCon.VR_GRIP, + BTN_TL2 to JoyCon.VR_GRIP, BTN_SOUTH to JoyCon.VR_A, BTN_EAST to JoyCon.VR_A, BTN_NORTH to JoyCon.VR_B, + BTN_WEST to JoyCon.VR_B, BTN_START to JoyCon.VR_B, BTN_THUMBR to JoyCon.VR_STICK, BTN_MODE to JoyCon.VR_SYSTEM) + for ((scanCode, bit) in left) assertEquals("left 0x${scanCode.toString(16)}", bit, JoyCon.vrButton(JoyCon.LEFT, scanCode)) + for ((scanCode, bit) in right) assertEquals("right 0x${scanCode.toString(16)}", bit, JoyCon.vrButton(JoyCon.RIGHT, scanCode)) + assertEquals(0, JoyCon.vrButton(JoyCon.LEFT, BTN_SOUTH)) + assertEquals(0, JoyCon.vrButton(JoyCon.RIGHT, 0x220)) + assertEquals(0, JoyCon.vrButton(-1, BTN_TR2)) + } + + @Test fun vrRumbleSlotsNameTheHandsOfTheCurrentOwnerOnly() { + for (slot in listOf(0, 1, PadBridge.SLOTS, JoyCon.VR_RUMBLE_SLOT + 2)) assertNull(JoyCon.vrRumbleDevice(slot)) + assertEquals(PadBridge.NO_CONTROLLER, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT)) + JoyCon.setVrDevices(this, intArrayOf(31, 32)) + assertEquals(31, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT + JoyCon.LEFT)) + assertEquals(32, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT + JoyCon.RIGHT)) + JoyCon.clearVrDevices(Any()) + assertEquals(31, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT)) + JoyCon.clearVrDevices(this) + assertEquals(PadBridge.NO_CONTROLLER, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT)) + assertEquals(PadBridge.NO_CONTROLLER, JoyCon.vrRumbleDevice(JoyCon.VR_RUMBLE_SLOT + 1)) + } + + private var nextId = 40 + + private fun device(product: Int, vendor: Int = NINTENDO, axes: IntArray = intArrayOf(MotionEvent.AXIS_X, MotionEvent.AXIS_Y)): InputDevice { + val id = nextId++ + val builder = InputDeviceBuilder.newBuilder().setId(id).setName("Joy-Con fixture $id").setDescriptor("joycon-$id") + .setExternal(true).setVendorId(vendor).setProductId(product) + .setSources(InputDevice.SOURCE_GAMEPAD or InputDevice.SOURCE_JOYSTICK or InputDevice.SOURCE_KEYBOARD) + .setKeyboardType(InputDevice.KEYBOARD_TYPE_NON_ALPHABETIC) + .setKeyCharacterMap(KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD)) + for (axis in axes) builder.addMotionRange(axis, InputDevice.SOURCE_JOYSTICK, -1f, 1f, 0f, 0f, 0f) + val device = builder.build() + val manager = RuntimeEnvironment.getApplication().getSystemService(InputManager::class.java) + shadowOf(manager).addInputDevice(device) + return device + } + + private fun key(device: InputDevice, code: Int, scanCode: Int, action: Int = KeyEvent.ACTION_DOWN): KeyEvent { + val event = KeyEvent(0L, 0L, action, code, 0, 0, device.id, scanCode, 0, InputDevice.SOURCE_GAMEPAD) + assertSame(device, event.device) + return event + } + + private companion object { + const val NINTENDO = 0x057e + const val LEFT_PRODUCT = 0x2006 + const val RIGHT_PRODUCT = 0x2007 + const val PRO_CONTROLLER = 0x2009 + const val BTN_SOUTH = 0x130 + const val BTN_EAST = 0x131 + const val BTN_NORTH = 0x133 + const val BTN_WEST = 0x134 + const val BTN_Z = 0x135 + const val BTN_TL = 0x136 + const val BTN_TR = 0x137 + const val BTN_TL2 = 0x138 + const val BTN_TR2 = 0x139 + const val BTN_SELECT = 0x13a + const val BTN_START = 0x13b + const val BTN_MODE = 0x13c + const val BTN_THUMBL = 0x13d + const val BTN_THUMBR = 0x13e + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/PerfModeTest.kt b/app/src/test/java/com/droiddeck/launcher/session/PerfModeTest.kt new file mode 100644 index 000000000..d4afbbfdd --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/PerfModeTest.kt @@ -0,0 +1,37 @@ +package com.droiddeck.launcher.session + +import android.view.Display +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowDisplayManager + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31, 34], manifest = Config.NONE) +class PerfModeTest { + private fun mode(id: Int, hz: Float, width: Int = 1080): Display.Mode = + ShadowDisplayManager.ModeBuilder.modeBuilder(id) + .setWidth(width).setHeight(2520).setRefreshRate(hz).build() + + @Test fun sixtyHzMirrorChoosesSixtyEvenThoughOneTwentyHasMatchingCadence() { + val low = mode(1, 60f) + val high = mode(2, 120f) + assertEquals(low, PerfMode.selectMode(arrayOf(low, high), high, 60, 60f)) + assertEquals(high, PerfMode.selectMode(arrayOf(low, high), low, 0, Float.POSITIVE_INFINITY)) + } + + @Test fun aLowerGameLimitDoesNotOverrideTheGlassesRefreshCeiling() { + val modes = arrayOf(mode(1, 60f), mode(2, 90f), mode(3, 120f)) + assertEquals(modes[0], PerfMode.selectMode(modes, modes[2], 30, 60f)) + assertEquals(modes[1], PerfMode.selectMode(modes, modes[2], 45, 90f)) + } + + @Test fun selectionPreservesResolutionAndUsesTheLowestModeIfTheCapIsUnsupported() { + val low = mode(1, 90f) + val high = mode(2, 120f) + val otherResolution = mode(3, 60f, 720) + assertEquals(low, PerfMode.selectMode(arrayOf(low, high, otherResolution), high, 60, 60f)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt index 9a1e2d55e..8c5361aec 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/RumbleComponentTest.kt @@ -8,6 +8,7 @@ import android.os.Looper import android.os.Vibrator import android.os.VibratorManager import com.droiddeck.launcher.input.ControllerPrefs +import com.droiddeck.launcher.input.JoyCon import com.droiddeck.launcher.input.PadBridge import java.io.IOException import org.junit.After @@ -202,8 +203,53 @@ class RumbleComponentTest { assertFalse(shadowOf(vibrator).isVibrating) } + @Test fun eachVrHandRumblesItsOwnJoyConAndNeverThePhone() { + val left = FakeMotors() + val right = FakeMotors() + rumble.controllerMotors = { id -> if (id == PAD_ID) left else if (id == SECOND_PAD_ID) right else null } + JoyCon.setVrDevices(this, intArrayOf(PAD_ID, SECOND_PAD_ID)) + effect(strong = 30000, weak = 30000, slot = JoyCon.VR_RUMBLE_SLOT + JoyCon.LEFT) + effect(strong = 20000, weak = 20000, slot = JoyCon.VR_RUMBLE_SLOT + JoyCon.RIGHT) + assertEquals(listOf(Triple(30000, 30000, 5000L)), left.played) + assertEquals(listOf(Triple(20000, 20000, 5000L)), right.played) + JoyCon.clearVrDevices(this) + effect(strong = 10000, weak = 10000, slot = JoyCon.VR_RUMBLE_SLOT + JoyCon.LEFT) + assertFalse("the hand kept rumbling after SteamVR let it go", left.vibrating) + assertFalse(shadowOf(vibrator).isVibrating) + } + + @Test fun aJoyConPairSplitsTheMotorsLikeOneController() { + val left = FakeMotors() + val right = FakeMotors() + rumble.controllerMotors = { id -> if (id == PAD_ID) left else if (id == SECOND_PAD_ID) right else null } + activeController(SECOND_PAD_ID) + joyCons(0, PAD_ID, SECOND_PAD_ID) + effect(strong = 40000, weak = 1000) + assertEquals(listOf(Triple(40000, 0, 5000L)), left.played) + assertEquals(listOf(Triple(0, 1000, 5000L)), right.played) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(RumbleComponent.MIN_GAP_MS)) + effect(strong = 20000, weak = 0) + assertTrue(left.vibrating) + assertFalse(right.vibrating) + joyCons(0, PadBridge.NO_CONTROLLER, PadBridge.NO_CONTROLLER) + effect(strong = 10000, weak = 10000) + assertEquals("a lone Joy-Con missed the effect", Triple(10000, 10000, 5000L), right.played.last()) + assertFalse("the departed Joy-Con kept rumbling", left.vibrating) + assertFalse(shadowOf(vibrator).isVibrating) + } + @After fun clearActiveController() { - for (slot in 0 until PadBridge.SLOTS) activeController(PadBridge.NO_CONTROLLER, slot) + for (slot in 0 until PadBridge.SLOTS) { + activeController(PadBridge.NO_CONTROLLER, slot) + joyCons(slot, PadBridge.NO_CONTROLLER, PadBridge.NO_CONTROLLER) + } + JoyCon.clearVrDevices(this) + } + + private fun joyCons(slot: Int, left: Int, right: Int) { + val pairs = PadBridge::class.java.getDeclaredField("slotJoyCons").apply { isAccessible = true }.get(null) + (pairs as java.util.concurrent.atomic.AtomicIntegerArray).set(slot * 2 + JoyCon.LEFT, left) + pairs.set(slot * 2 + JoyCon.RIGHT, right) } private fun activeController(id: Int, slot: Int = 0) { diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GlassesCalibrationControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GlassesCalibrationControlsTest.kt new file mode 100644 index 000000000..67dba5062 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GlassesCalibrationControlsTest.kt @@ -0,0 +1,65 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import com.droiddeck.launcher.glasses.CalibrationPhase +import com.droiddeck.launcher.glasses.GlassesCalibrationState +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class GlassesCalibrationControlsTest { + @get:Rule val compose = createRobolectricComposeRule() + private fun text(id: Int) = RuntimeEnvironment.getApplication().getString(id) + + @Test fun startRequiresInstructionsAndDoneRecentersAfterSuccessfulCapture() { + val state = mutableStateOf(GlassesCalibrationState(supported = true)) + var starts = 0; var cancels = 0; var recentered = 0 + compose.setContent { + Column { + GlassesCalibrationControls(state.value, + onStart = { starts++; state.value = state.value.copy(phase = CalibrationPhase.SETTLING) }, + onCancel = { cancels++ }, onReset = {}, onDone = { recentered++ }) + } + } + compose.onNodeWithText(text(R.string.glasses_calibration_open)).performClick() + compose.onNodeWithText(text(R.string.glasses_calibration_instructions)).assertExists() + assertEquals(0, starts) + compose.onNodeWithText(text(R.string.glasses_calibration_start)).performClick() + assertEquals(1, starts) + compose.onNodeWithText(text(R.string.glasses_calibration_settling)).assertExists() + compose.onNodeWithText(text(R.string.glasses_calibration_start)).assertDoesNotExist() + compose.runOnIdle { state.value = state.value.copy(saved = true, phase = CalibrationPhase.SAVED, progress = 100) } + compose.onNodeWithText(text(R.string.glasses_calibration_complete)).assertExists() + compose.onNodeWithText(text(R.string.glasses_calibration_done)).performClick() + assertEquals(1, recentered) + } + + @Test fun cancelAndResetDoNotSilentlyStartOrOverwriteCalibration() { + var starts = 0; var cancels = 0; var resets = 0 + compose.setContent { + Column { + GlassesCalibrationControls(GlassesCalibrationState(supported = true, saved = true), + onStart = { starts++ }, onCancel = { cancels++ }, onReset = { resets++ }, onDone = {}) + } + } + compose.onNodeWithText(text(R.string.glasses_calibration_open)).performClick() + compose.onNodeWithText(text(R.string.common_cancel)).performClick() + assertEquals(0, starts) + assertTrue(cancels > 0) + compose.onNodeWithText(text(R.string.glasses_calibration_reset_button)).performClick() + compose.onNodeWithText(text(R.string.glasses_calibration_reset_hint)).assertExists() + assertEquals(0, resets) + compose.onNodeWithText(text(R.string.common_cancel)).performClick() + assertEquals(0, resets) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt new file mode 100644 index 000000000..1b3ee6f95 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/GlassesPanelTest.kt @@ -0,0 +1,175 @@ +package com.droiddeck.launcher.ui + +import android.view.Display +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.droiddeck.launcher.R +import com.droiddeck.launcher.glasses.GlassesSettings +import com.droiddeck.launcher.glasses.GlassesState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class GlassesPanelTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun automaticFlatGameOffersVirtualScreenInsteadOfIneffectiveSliders() { + show() + compose.onNodeWithTag("glasses-width").assertDoesNotExist() + compose.onNodeWithTag("glasses-distance").assertDoesNotExist() + compose.onNodeWithTag("glasses-vr-fov").assertDoesNotExist() + compose.onNodeWithText(text(R.string.glasses_screen_use)).assertExists() + compose.onNodeWithText(text(R.string.glasses_screen_flat_hint)).assertExists() + compose.onNodeWithTag("setting-glasses-hz").assertExists() + compose.onNodeWithTag("setting-glasses-3d-hz").assertDoesNotExist() + } + + @Test fun virtualScreenEnablesDimensionsInTwoDimensionalOutput() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN)) + compose.onNodeWithTag("glasses-width").assertIsEnabled() + compose.onNodeWithTag("glasses-distance").assertIsEnabled() + compose.onNodeWithTag("glasses-vr-fov").assertDoesNotExist() + compose.onNodeWithText(text(R.string.glasses_screen_flat_hint)).assertDoesNotExist() + } + + @Test fun automaticSteamVrShowsFieldOfViewAndStereoRefresh() { + show(stereo = true, steamVrRunning = true) + compose.onNodeWithTag("glasses-width").assertDoesNotExist() + compose.onNodeWithTag("glasses-distance").assertDoesNotExist() + compose.onNodeWithTag("glasses-vr-fov").assertIsEnabled() + compose.onNodeWithTag("setting-glasses-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d-hz").assertExists() + compose.onNodeWithText(text(R.string.glasses_screen_direct_hint)).assertExists() + } + + @Test fun mirroredSteamVrIn2dKeepsFieldOfViewAndRecenter() { + show(steamVrRunning = true, outputActive = false, tab = null) + assertMirroredVrControls() + } + + @Test fun mirroredSteamVrIn3dKeepsFieldOfViewAndRecenter() { + show(stereo = true, steamVrRunning = true, outputActive = false, tab = null) + assertMirroredVrControls() + } + + @Test fun mirroredSteamVrVirtualScreenKeepsGeometryAndRecenter() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN), + steamVrRunning = true, outputActive = false, tab = null) + compose.onNodeWithTag("glasses-width").assertIsEnabled() + compose.onNodeWithTag("glasses-distance").assertIsEnabled() + compose.onNodeWithText(text(R.string.cardboard_recenter_button)).assertExists() + compose.onNodeWithText(text(R.string.glasses_screen_no_output)).assertDoesNotExist() + } + + private fun assertMirroredVrControls() { + compose.onNodeWithTag("glasses-vr-fov").assertIsEnabled() + compose.onNodeWithText(text(R.string.cardboard_recenter_button)).assertExists() + compose.onNodeWithText(text(R.string.glasses_screen_no_output)).assertDoesNotExist() + } + + @Test fun automaticStereoOutputWithoutSteamVrUsesVirtualScreenDimensions() { + show(stereo = true) + compose.onNodeWithTag("glasses-width").assertIsEnabled() + compose.onNodeWithTag("glasses-distance").assertIsEnabled() + compose.onNodeWithTag("glasses-vr-fov").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d-hz").assertExists() + } + + @Test fun gameWithoutGlassesOutputExplainsWhyGeometryIsUnavailable() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN), outputActive = false) + compose.onNodeWithTag("glasses-width").assertDoesNotExist() + compose.onNodeWithTag("glasses-distance").assertDoesNotExist() + compose.onNodeWithTag("glasses-vr-fov").assertDoesNotExist() + compose.onNodeWithText(text(R.string.glasses_screen_no_output)).assertExists() + } + + @Test fun homeCanConfigureTheNextSessionWithAnExplanation() { + show(inSession = false, outputActive = false) + compose.onNodeWithTag("glasses-width").assertIsEnabled() + compose.onNodeWithTag("glasses-distance").assertIsEnabled() + compose.onNodeWithTag("glasses-vr-fov").assertIsEnabled() + compose.onNodeWithText(text(R.string.glasses_screen_next_session)).assertExists() + } + + @Test fun flatTrackingExplainsTheModeAndHidesInactiveRecenter() { + show(tab = GlassesTab.TRACKING) + compose.onNodeWithText(text(R.string.glasses_tracking_flat_hint)).assertExists() + compose.onNodeWithText(text(R.string.cardboard_recenter_button)).assertDoesNotExist() + } + + @Test fun virtualScreenGeometryRemainsAvailableWhenUsbControlDisconnects() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SCREEN), controlAvailable = false) + compose.onNodeWithTag("setting-glasses-picture").assertIsEnabled() + compose.onNodeWithTag("glasses-width").assertIsEnabled() + compose.onNodeWithTag("glasses-distance").assertIsEnabled() + compose.onNodeWithTag("setting-glasses-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d").assertDoesNotExist() + } + + @Test fun runningSteamVrFieldOfViewRemainsAvailableWhenUsbControlDisconnects() { + show(stereo = true, steamVrRunning = true, controlAvailable = false) + compose.onNodeWithTag("glasses-vr-fov").assertIsEnabled() + compose.onNodeWithTag("setting-glasses-picture").assertIsEnabled() + compose.onNodeWithTag("setting-glasses-3d-hz").assertDoesNotExist() + compose.onNodeWithTag("setting-glasses-3d").assertDoesNotExist() + } + + @Test fun sideBySideGameWithoutSteamVrDoesNotOfferIneffectiveFieldOfView() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SBS), stereo = true) + compose.onNodeWithTag("glasses-width").assertDoesNotExist() + compose.onNodeWithTag("glasses-distance").assertDoesNotExist() + compose.onNodeWithTag("glasses-vr-fov").assertDoesNotExist() + compose.onNodeWithText(text(R.string.glasses_screen_sbs_hint)).assertExists() + compose.onNodeWithText(text(R.string.glasses_screen_direct_hint)).assertDoesNotExist() + } + + @Test fun sideBySideTrackingWithoutSteamVrExplainsTheScopeAndHidesRecenter() { + show(settings = GlassesSettings(picture3d = GlassesSettings.PICTURE_SBS), stereo = true, + tab = GlassesTab.TRACKING) + compose.onNodeWithText(text(R.string.glasses_tracking_sbs_hint)).assertExists() + compose.onNodeWithText(text(R.string.cardboard_recenter_button)).assertDoesNotExist() + } + + private fun show( + settings: GlassesSettings = GlassesSettings(), + stereo: Boolean = false, + steamVrRunning: Boolean = false, + inSession: Boolean = true, + outputActive: Boolean = inSession, + controlAvailable: Boolean = true, + tab: String? = GlassesTab.DISPLAY, + ) { + val state = GlassesState(present = true, connected = controlAvailable, controllable = true, + displayId = Display.DEFAULT_DISPLAY + 1, width = if (stereo) 3840 else 1920, height = 1200, + model = "VITURE Luma Ultra", sbs = stereo, sbs90 = true) + compose.setContent { + DroidDeckTheme { + Column(Modifier.verticalScroll(rememberScrollState())) { + GlassesGroups(rememberMenuHost(), state, settings, + track = { key -> + when (key) { + "glasses-width", "glasses-distance", "glasses-vr-fov" -> Modifier.testTag(key) + else -> Modifier + } + }, onRecenter = {}, + inSession = inSession, outputActive = outputActive, + steamVrRunning = steamVrRunning, tab = tab) + } + } + } + } + + private fun text(id: Int) = RuntimeEnvironment.getApplication().getString(id) +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/HandCameraPromptTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/HandCameraPromptTest.kt new file mode 100644 index 000000000..38f258530 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/HandCameraPromptTest.kt @@ -0,0 +1,59 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.handtracking.TrackingCameras +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], qualifiers = "w960dp-h540dp-land", application = android.app.Application::class) +class HandCameraPromptTest { + @get:Rule val compose = createRobolectricComposeRule() + + @Test fun bothCamerasOfferAChoiceWithTheBackOnePicked() { + var allowed: String? = "unset" + compose.setContent { + DroidDeckTheme { HandCameraPrompt(setOf(TrackingCameras.REAR, TrackingCameras.FRONT), onAllow = { allowed = it }, onNotNow = {}) } + } + compose.onNodeWithTag("hand-camera-rear").assertExists() + compose.onNodeWithTag("hand-camera-allow").performClick() + assertEquals(TrackingCameras.REAR, allowed) + } + + @Test fun theFrontCameraCanBeChosen() { + var allowed: String? = "unset" + compose.setContent { + DroidDeckTheme { HandCameraPrompt(setOf(TrackingCameras.REAR, TrackingCameras.FRONT), onAllow = { allowed = it }, onNotNow = {}) } + } + compose.onNodeWithTag("hand-camera-front").performClick() + compose.onNodeWithTag("hand-camera-allow").performClick() + assertEquals(TrackingCameras.FRONT, allowed) + } + + @Test fun aSingleCameraIsUsedWithoutAsking() { + var allowed: String? = "unset" + compose.setContent { + DroidDeckTheme { HandCameraPrompt(setOf(TrackingCameras.REAR), onAllow = { allowed = it }, onNotNow = {}) } + } + compose.onAllNodesWithTag("hand-camera-rear").assertCountEquals(0) + compose.onAllNodesWithTag("hand-camera-front").assertCountEquals(0) + compose.onNodeWithTag("hand-camera-allow").performClick() + assertEquals(null, allowed) + } + + @Test fun notNowDeclines() { + var declined = false + compose.setContent { + DroidDeckTheme { HandCameraPrompt(setOf(TrackingCameras.FRONT), onAllow = {}, onNotNow = { declined = true }) } + } + compose.onNodeWithTag("hand-camera-not-now").performClick() + assertEquals(true, declined) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SteamHomeHeadsetTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SteamHomeHeadsetTest.kt new file mode 100644 index 000000000..14aed407e --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SteamHomeHeadsetTest.kt @@ -0,0 +1,113 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.glasses.Glasses +import com.droiddeck.launcher.glasses.GlassesState +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w1200dp-h752dp-land", application = android.app.Application::class) +class SteamHomeHeadsetTest { + @get:Rule val compose = createRobolectricComposeRule() + private var vrStarts = 0 + private val connected = GlassesState(present = true, model = "VITURE Pro XR", controllable = true, connected = true, + displayId = 2, width = 1920, height = 1080, hz = 60f) + + @After fun unplug() { headset(GlassesState()) } + + @Test fun nothingShowsUntilAHeadsetIsConnected() { + show() + for (state in listOf(GlassesState(), GlassesState(present = true, displayId = 2, width = 1920, height = 1080), + connected.copy(connected = false, permission = Glasses.Permission.ASKING))) { + headset(state) + settle() + compose.onAllNodesWithTag("play-steamvr").assertCountEquals(0) + compose.onAllNodesWithTag("drop-open").assertCountEquals(0) + } + compose.onAllNodesWithTag("glasses-open").assertCountEquals(0) + } + + @Test fun aConnectedHeadsetGetsPlaySteamVrWithItsSettingsBadge() { + headset(connected) + show() + compose.onAllNodesWithTag("glasses-open").assertCountEquals(0) + compose.onNodeWithTag("play-steamvr").assertIsEnabled().performClick() + assertEquals(1, vrStarts) + compose.onAllNodesWithText("VITURE PRO XR").assertCountEquals(0) + compose.onNodeWithTag("drop-open").performClick() + settle() + compose.onNodeWithTag("drop-close").assertExists() + compose.onAllNodesWithText("VITURE PRO XR").assertCountEquals(1) + compose.onNodeWithTag("drop-close").performClick() + settle() + compose.onAllNodesWithTag("drop-close").assertCountEquals(0) + } + + @Test fun unpluggingClosesTheSheetAndHidesTheButton() { + headset(connected) + show() + compose.onNodeWithTag("drop-open").performClick() + settle() + headset(GlassesState()) + settle() + compose.onAllNodesWithTag("drop-close").assertCountEquals(0) + compose.onAllNodesWithTag("play-steamvr").assertCountEquals(0) + } + + @Test fun aMomentWithoutAPictureOnlyPausesPlaySteamVr() { + headset(connected.copy(displayId = android.view.Display.INVALID_DISPLAY)) + show() + compose.onNodeWithTag("play-steamvr").assertIsNotEnabled() + compose.onNodeWithTag("drop-open").assertExists() + } + + private fun headset(state: GlassesState) { + val field = Glasses::class.java.getDeclaredField("_state").apply { isAccessible = true } + @Suppress("UNCHECKED_CAST") + (field.get(Glasses) as MutableStateFlow).value = state + } + + private fun settle() = compose.mainClock.advanceTimeBy(2_000) + + private fun show() { + compose.mainClock.autoAdvance = false + compose.setContent { + DroidDeckTheme { + CompositionLocalProvider(LocalFrontFocus provides FrontFocus()) { + FocusGlideHost { + SteamHome(FrontEndState( + installed = "r9", ready = true, available = null, busy = false, stage = "", percent = -1, + desktopInstalled = true, offlineAccount = null, offline = false, frameGenLabel = "", + romsDir = null, logsEnabled = false, steamGames = emptyList(), emulators = emptyList(), running = null, + ), FrontEndActions( + onPlay = {}, onPlaySteamVr = { vrStarts++ }, onPlayDesktopUi = {}, onSteamGame = {}, onDesktop = {}, onEmulator = {}, + onRom = {}, onResume = {}, onSteamSettings = {}, onDesktopSettings = {}, + onInstallPackage = {}, onRemovePackage = {}, onRuntime = {}, onFrameGenPick = {}, + onImportLossless = {}, onProtons = {}, onComponents = {}, onPerformance = {}, + onRoms = {}, onFiles = {}, onLogs = {}, onOffline = {}, + onWirelessAdbPair = { _, _, _, _ -> }, onWirelessAdbApply = { _, _, _, _ -> }, + onSetPhantomProcessLimit = { _, _ -> }, + ), Modifier.fillMaxSize()) + } + } + } + } + settle() + } +} diff --git a/docs/agent-control.md b/docs/agent-control.md index a8ca67fe8..1941e3a17 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -26,14 +26,14 @@ and reports every run. Player controls and observations work over authorized ADB on any build: `state`, `start`, `stop`, `resume`, `wait`, `launch`, `quit`, `focus`, `input`, `ui`, `logs`, `screenshot`, `record`, `pacing`, -`displays`, `wake`, and `access`. +`displays`, `wake`, `glasses` (except `glasses raw`), and `access`. **Setup > Diagnostics > Debugging tools** is off by default in every build, including debug builds. -It enables `run`, `guest`, `cdp`, all `env` operations, `override`, and `prefs`. The toggle switches -directly without a confirmation dialog. The bridge refuses to change it through `ui` or preference -writes. The existing preference key, `setting-agent-commands` test tag, JSON command names, and -`AGENT_COMMANDS_DISABLED` error code are unchanged. Refusals exit 7; `droiddeckctl access` reports -whether commands are enabled. +It enables `run`, `guest`, `cdp`, all `env` operations, `override`, `prefs`, and `glasses raw`. The +toggle switches directly without a confirmation dialog. The bridge refuses to change it through `ui` +or preference writes. The existing preference key, `setting-agent-commands` test tag, JSON command +names, and `AGENT_COMMANDS_DISABLED` error code are unchanged. Refusals exit 7; `droiddeckctl access` +reports whether commands are enabled. Disabling the toggle clears queued and persistent agent environment settings and installed binary overrides. Disabled settings cannot apply at the next session start. Already running commands and @@ -80,6 +80,7 @@ artifact or file error, 7 debugging tools disabled. tools/droiddeckctl state tools/droiddeckctl start steam --wait tools/droiddeckctl start steam --wait --reuse +tools/droiddeckctl start steam --steamvr --wait tools/droiddeckctl start desktop tools/droiddeckctl run /usr/bin/foo -- arg1 arg2 tools/droiddeckctl wait ready|idle|failed @@ -122,8 +123,21 @@ tools/droiddeckctl record ./clip.mp4 --seconds 8 --display 0 tools/droiddeckctl pacing --seconds 30 tools/droiddeckctl displays tools/droiddeckctl wake + +tools/droiddeckctl glasses status +tools/droiddeckctl glasses set refreshHz 120 +tools/droiddeckctl glasses set 3d on +tools/droiddeckctl glasses simulate --seconds 6 --yaw 15 +tools/droiddeckctl glasses stop-simulate +tools/droiddeckctl glasses mode 0x34 +tools/droiddeckctl glasses raw 0x0007 ``` +Glasses status reports `deviceMode` from the MCU separately from `sbs`, which +requires a physical stereo display mode. `switching3d` marks the bounded +confirmation window. A successful mode-command reply alone does not confirm 3D. +`badFrames` counts reports and IMU packets dropped for a bad checksum. + `xr-frame` captures both app eye textures while SteamVR is open, saving `left.png`, `right.png` and `capture.json` with the matching source frame, projection poses, FOV and dimensions. It works over wired or wireless authorized ADB. @@ -154,7 +168,7 @@ mode except the desktop). `state` returns `schema`, `build`, `appVersion`, `runtime {installed, version}`, `agent {guest, guestFocus, commands}` and `session`: -- `id`, `phase`, `running`, `mode`, `program`, `steamUi`, `steamUrl`, `suspended`, `pip`, +- `id`, `phase`, `running`, `mode`, `program`, `steamUi`, `steamUrl`, `steamVr`, `suspended`, `pip`, `firstFrame`, `output` [w, h], `refreshHz`, `lastTransitionAt`, `guestPid`, `installing` - `logDir`, `eventsFile`, `artifactsAvailable`, `artifactsComplete`, `failure {code, message, status}` - `focus`: gamescope's focus as the guest agent last published it, or null: diff --git a/docs/cardboard-vr.md b/docs/cardboard-vr.md index 780226ccb..a7edd0a15 100644 --- a/docs/cardboard-vr.md +++ b/docs/cardboard-vr.md @@ -61,7 +61,8 @@ switches the picture to side-by-side and runs SteamVR with three DroidDeck piece 1. **`driver_droiddeck.so`**, an OpenVR driver with one HMD: - pose: the shared head pose file (3DoF, standing height); - projection: exactly the frustum the Cardboard pass samples, with identity distortion (the app does - the lens); + the lens). A new frustum mid-run (the glasses' SteamVR field of view) goes out with + `SetDisplayProjectionRaw` and `VREvent_LensDistortionChanged`; - a proximity sensor that reads "worn" while Cardboard mode is on. Without it SteamVR takes a still headset (a phone on a table) for an unused one, enters standby after a few seconds, and the compositor stops submitting frames; @@ -96,15 +97,20 @@ switches the picture to side-by-side and runs SteamVR with three DroidDeck piece Turnip/KGSL cannot export. The layer creates it unshared and hands out a memfd holding a 64-bit counter instead. Every process with the semaphore keeps a real local one, and a poller thread carries values both ways (reached locally: up into the counter; seen in the counter: signalled - locally from the host), so GPU waits work as written, a poll period late. Waits on an imported - binary semaphore, which cannot be bridged that way, are dropped. With the semaphores dropped + locally from the host), so GPU waits work as written, a poll period late. An imported binary + semaphore cannot be bridged that way, and its import is refused. With the semaphores dropped outright (the first version) the dashboard showed torn, half-drawn tiles; - Turnip on KGSL does not defer a submit that waits on a timeline value nobody has signalled yet: `vkQueueSubmit` blocks until it is. SteamVR's client waits on a compositor value that the compositor signals only after the client has submitted and said so, which deadlocked every app before its first frame (`helloxr`'s swapchain acquire, DXVK's submit thread in a Proton game). A submit's wait on an imported shared value that is neither reached nor queued in that process - gets 3 ms for the other process to reach it, then is dropped; + gets 3 ms for the other process to reach it, then is dropped. On a counter that has not moved for + 100 ms it is dropped at once: Angry Birds VR waits on two such values a frame, and the grace cost + its submit thread 6 ms a frame and held it at 60 fps. Keeping these waits stops a Proton game + before its first frame (Angry Birds VR stayed on SteamVR's loading card). While a Quest + session's frame socket (`/run/droiddeck/droiddeck-xr-frames`) exists, every wait is kept, as the + Quest path was built and tested with them; - Steam's web helper has the semaphore bridge whenever SteamVR is installed: its GPU process outlives SteamVR runs, and one started without the layer crashed when SteamVR showed Steam's overlays, until Steam quit; @@ -118,7 +124,13 @@ switches the picture to side-by-side and runs SteamVR with three DroidDeck piece (values up to 3) submitted on them. 3. **`droiddeck-steamvr`**, the guest helper (`status | install | start | stop`), which the app drives through the guest agent's fixed `vr` request (no agent commands needed). It also supplies an `xset` - stand-in, because the dashboard's web helper checks the X server with it. + stand-in, because the dashboard's web helper checks the X server with it. Before every start it + removes SteamVR's crash marker (`config/vrserver_crash_timestamp.txt`, written when vrserver + crashes) and clears `driver_droiddeck.blocked_by_safe_mode`: after a crash early in a run SteamVR + starts in safe mode and blocks every add-on driver, so a single crash left the glasses blank on + every later launch (`HmdNotFound`). The driver also stops its pose, vsync and presenter threads + if vrserver exits without deactivating it, since a running `std::thread` destroyed at exit aborts + the process. Imported eye textures carry the same format list SteamVR's mutable sRGB images do. With it Turnip keeps UBWC compression on, and an import that disagreed read compressed data as garbage. @@ -136,20 +148,315 @@ Verified on the AYN Thor (SD 8 Gen 2, Turnip): SteamVR 2.17.10 starts with the D the compositor runs driver-direct at about 60 fps, the dashboard (Steam's library in VR, with the controller's laser) shows cleanly in both eyes at the panel's resolution while Steam keeps running, and a pose written to the shared file turns SteamVR's view. Started from the drawer button through the agent. +Once a headset (VITURE, RayNeo or XREAL glasses) is connected and under DroidDeck's control, the +Steam page shows **Play SteamVR** at the top left, with a **Settings** badge that drops that headset's +settings from the top; before that neither appears. Play SteamVR starts Steam and then SteamVR +(installing it first if needed) once the glasses show a picture, and SteamVR stops when the glasses +are unplugged. **Play Steam** never starts it: while SteamVR runs, Steam keeps its own window in front +of flat games. Through a VITURE Mobile Dock the glasses are only a display: the dock does not pass +their controls or head tracking to the phone, so the Steam page shows neither, and only the session +drawer's glasses page (output and **Game on glasses**) applies. + +SteamVR defaults to 2D output: a single eye fills the glasses while head tracking remains active. +Enable **3D (side-by-side)** manually or opt into **3D for VR games** in the glasses controls for stereo. Automatic +switching waits until SteamVR reports that it is running; starting or installing it does not change +the display mode. A saved **3D for VR games** preference is retained. + +With **Game on glasses** off, SteamVR keeps the glasses' picture settings and IMU while Android +mirrors the phone output to the glasses. Turning off the dedicated display must not turn off the +renderer/HMD proximity flag: SteamVR otherwise reports the headset as taken off and enters standby, +freezing the view on both displays. Without a glasses display or an enabled phone VR viewer, SteamVR +uses a single-eye phone preview with the phone's tracking preference. These effective rendering +settings last only while SteamVR runs; the saved Cardboard setting is unchanged. +Verified on the VITURE Pro 2 with Game on glasses off: live glasses IMU, roughly 60 fps beyond +the previous standby point, and user-confirmed updating images and correct head tracking on both +screens. The rendering regression tests cover mirrored/dedicated parity and phone fallback. + +A separate freeze occurred after swapping Pro 2 for GT Max and restarting SteamVR in the same +DroidDeck process (2026-10-14, session 52): GT Max board `0x41` kept streaming poses and the HMD +stayed active, but `vrcompositor` reported `vkerror=-4` (`VK_ERROR_DEVICE_LOST`) from 17:18:53. +Restarting the Steam session alone reproduced it (sessions 53/54). Restarting DroidDeck itself +cleared it; session 55 continued rendering with live GT Max tracking for over a minute without +new device-loss errors. This is a recovery workaround, not a confirmed permanent fix; retained +graphics state across restarts is a hypothesis. Frame counters alone do not prove recovery: +the presenter can keep presenting an old SteamVR image while the compositor reports device loss. + +### Joy-Con controllers + +Nintendo Joy-Cons paired over Bluetooth become SteamVR hands while SteamVR runs, and stay an ordinary +game pad otherwise. It needs Android 12 or later (motion sensors on an `InputDevice`) and a kernel whose +`hid-nintendo` driver reports the IMU (Android common kernel `android14-6.1` or newer; older 5.x GKI +builds expose the buttons only). A Joy-Con without motion sensors stays a game pad. +**Controller settings > Joy-Cons as VR controllers** (on by default) turns it off. + +`input/JoyConVr.kt` takes a Joy-Con once SteamVR reports that it is running. It reads the Joy-Con's +gyroscope and accelerometer on a thread of its own. A Mahony filter turns them into an orientation, +and the gyro bias is learned while the Joy-Con rests. Each IMU report carries three samples every +10 ms. On the tested kernel they arrive newest first and about four in ten reports repeat the +previous report's newest sample, while the kernel's timestamps run about twice as fast in an uneven +3.3/3.3/13.3 ms pattern. `JoyConReports` collects a report's samples, learns their order from the +repeats, drops the repeated one and times the rest from the report's arrival (report interval divided +by new samples). That cut the gyro's error from 13 to 48 degrees per movement to about 2 to 3. + +The accelerometer also measures movement. Linux removes the gyro's offset but not the +accelerometer's (one Joy-Con read 10.08 m/s² lying still, the other 9.51), so `JoyConAccelCalibration` +learns offset and scale by fitting a sphere to the readings of a Joy-Con resting in at least five +different positions; it happens on its own while you play and is saved per Joy-Con. `JoyConMotion` +takes gravity away in the world frame, integrates what is left into a velocity and a displacement, +sets the velocity to zero whenever the Joy-Con is still, and leaks it away quickly once a movement +lasts longer than a second, so the displacement holds for short lifts, pushes and punches and cannot +run away. It also keeps a second velocity that is never zeroed and only leaks over 30 s, the plain +integral of the acceleration, for camera tracking. The pose, displacement, both velocities, buttons, +stick and battery go to a 224-byte seqlocked file, `joycon_shm.h`, at +`/run/droiddeck/droiddeck-joycon`. The game pad no longer sees that Joy-Con; +it gets it back when SteamVR stops, the Joy-Con disconnects or the setting is turned off. + +The driver adds a left and a right Index controller (`knuckles`, Valve's Index input profile and +models) when the first Joy-Con appears. The pad hand on that side switches off. A Joy-Con cannot +measure where it is, only how it turns and how it speeds up. Without the camera, Google VR's arm model +places the hand from its orientation and the head, and the measured displacement moves the hand on +top of that before it settles back over 0.6 s (1.5 s while the trigger or grip is held, at most +35 cm away), so a quick lift or punch shows but a slow sideways drift does not. With camera hand +tracking (below) the camera places the hand instead. Its pointing direction is the Index controller's +aim pose. Its heading is matched to the head when it connects. Holding Home (right) or Capture +(left) for 0.8 s matches it again, with a short buzz; point the Joy-Con where you look first. A short +press is the system button. A hand that sends nothing for 500 ms disconnects. + +| Index | Left Joy-Con | Right Joy-Con | +|--------------|------------------|----------------| +| Trigger | ZL | ZR | +| Grip | L, SL, SR | R, SL, SR | +| A | down, right | A, B | +| B | up, left, minus | X, Y, plus | +| Thumbstick | stick, click | stick, click | +| System | Capture (press) | Home (press) | + +The fingers follow the buttons on a simulated hand: the index finger curls with the trigger, the +other three with the grip, the thumb when it is on the stick or a face button. SteamVR's haptic +events go to that hand's Joy-Con through `@droiddeck-rumble` (slots `0x100` and `0x101`) and respect +the rumble setting. + +Outside SteamVR two Joy-Cons are combined into one player: left stick, D-pad, L, ZL, minus and Capture +from the left one, the face buttons, right stick, R, ZR, plus and Home from the right. Face buttons +keep their positions (Nintendo X is the top button, Y on Xbox). SL and SR are shoulder buttons on a lone +Joy-Con and are ignored while two are combined. A combined pair splits rumble the way a pad does, +strong to the left and weak to the right. A lone Joy-Con plays the stronger of the two. + +#### Camera hand tracking + +The device's camera can watch the player so each Joy-Con hand sits where the real hand is, including +lifting, lowering, reaching and moving sideways. With no controller connected it tracks bare hands +instead (below). It needs Android 12 or later. The first time a headset connects, DroidDeck asks whether to use the camera. On a device +with cameras on both sides it offers the back one (usually sharper and wider) or the front one; with +cameras on one side only it uses those without asking. Android's camera permission is requested +right after. **Headset sheet > Head tracking > Hand tracking** turns it on or off, switches the +camera, asks for the permission again and opens the placement check. + +When camera tracking is on, **Play SteamVR** first shows the placement check: the live picture with +the detected body drawn on it, what to fix, and **Skip**. It reads the device's gravity sensor and +asks to stand a device up when it lies flat face up or face down, or to stand it more upright when +the camera points more than 45 degrees up or down. Then it asks to step into view, to step back or +come closer (the shoulders should take 12 to 26 percent of the picture's width, about 0.75 to 1.6 m +from a 90 degree camera), to step back when an elbow or wrist is near the edge of the picture, and +to hold both hands in view. Once +everything has been fine for 1.2 s SteamVR starts on its own. With the back camera the screen faces +away from the player, so the advice is also spoken aloud. + +`handtracking/HandCamera.kt` runs while SteamVR runs and either a Joy-Con is taken or no game +controller is connected at all. It picks the widest +camera on the chosen side (up to 100 degrees, zooming a logical camera out when it can), holds it at +its fastest fixed frame rate up to 30 per second so exposures stay short and moving hands do not +blur, takes 960x720 frames from CameraX, turns them upright by gravity, and runs MediaPipe's Pose +Landmarker (Lite) on the CPU in video mode, which follows the body from frame to frame and smooths it, +15 times a second (10 and then 6 when the device reports it is getting hot). The pose model's depth +cannot tell a hand at the chest from one reaching out (it read about 25 cm in front of the shoulders +for both), so each hand is measured on its own: `HandModel` runs MediaPipe's Hand Landmarker on a +square around the hand. To find a hand it looks for up to two hands in a square around the pose's +wrist and keeps the one whose wrist is nearest, taking turns between a square 4 shoulder widths across +centred on the wrist and one 2.5 shoulder widths across moved toward the fingers. The palm detector +misses many hands in a tight square: with the player about 0.65 m away the 2.5-wide square found 73 +percent of the hands in view (a 1.4-wide one 49, the whole picture 68), and with the player lying close +to the camera, where hands look large, the wide square found 77 percent of the hands the tracker had +seen and the two in turn 82 (the 2.5-wide one alone 54). After that it follows the hand in a square 2.8 +hands across, and finds it again in the same frame when it is lost. A hand whose wrist is nearer the other arm's wrist than its own is dropped. +The hand's 21 landmarks come in metres and in pixels, so a least-squares fit gives the hand's +distance from the size it appears (`BodyPose.handFix`); the middle of the palm is the point used. +The head is fitted the same way from the pose model's face landmarks. Each hand is given relative to +the eyes in a frame levelled by gravity, along with the face's heading from the nose and ears. When +the hand model misses a hand the pose still sees, the hand keeps its last distance for half a second +and is marked as estimated. The frame's exposure time, moved onto the monotonic clock, the hands, each hand-model hand's 21 +landmarks relative to the middle of its palm (in metres, in the same levelled frame), the face heading +and whether bare hands are wanted go to a 568-byte seqlocked file, `handcam_shm.h`, at +`/run/droiddeck/droiddeck-handcam`. +No picture is kept or sent. + +The driver turns the camera's frame to SteamVR's by comparing the face's heading with the headset's +heading at the same moment, averaged over about 3 s and restarted when they disagree for half a +second (after a recenter). Each hand then runs a Kalman filter (`HandKalman` in `hand_motion.h`) with +position and velocity on each axis. The Joy-Con's plain velocity integral moves it forward between +camera frames; its reports are timed 45 ms earlier than they arrive, which lines their movement up +with the camera's. The Joy-Con is trusted less while it moves hard, since a small misalignment then +turns into a large error. The filter's speed fades with a 0.3 s time constant (0.1 s once the camera +has not seen the hand for 0.2 s, since a small tilt error then pushes the hand steadily), and while the Joy-Con +has been still for 0.1 s (turning under 0.3 rad/s and accelerating under 0.6 m/s²) it is told the hand +is not moving, so a hand the camera loses settles where it was instead of sliding away, and a resting +hand does not shake (on a recording, 3.6 to 4.3 cm of shake fell to 1.0 to 1.5 cm). A camera sighting +is applied at the moment the picture was taken, 60 to 140 ms in the past, and the Joy-Con movement +since then is replayed on top, so the hand is where it is now, not where it was. A hand-model sighting +counts as about 3 cm of camera noise, an estimated one as 6 cm. +One sighting far from the filter (the model swapping left and right) is ignored unless three in a row +agree. When the camera has not seen a hand for 0.75 s, the arm model takes over from where the hand +was, and changes between the two blend over a quarter of a second. + +#### Bare hands + +With camera tracking on and no game controller or Joy-Con connected (checked again whenever an input +device comes or goes), the app sets a flag in the camera file and the driver gives SteamVR two hands +from the camera alone, the way a Quest's hand tracking does; the pad hands switch off. Each hand is +still an Index controller to SteamVR, so games' Index bindings and the dashboard work, but its render +model becomes a white glove with a light grey band at the wrist, like the plain hands a Quest draws +(`{droiddeck}droiddeck_hand_left` and `_right`, built at build time by `tools/vr/make_hand_models.cpp` +from the same skeleton). The glove is a smooth surface: tapered capsules along each finger bone, a +domed palm and the band are blended into one distance field and meshed with surface nets (a 2.6 mm +grid, up to about 10,000 vertices a part). SteamVR draws render models almost unlit, so the shading +is baked in: ambient occlusion sampled from the distance field, and a soft light on the back of the +hand, pick a column of a 64x64 texture whose rows run from the glove's white to the band's grey. It +has two parts with two shapes each: the palm with the middle, ring and little fingers (open, or +curled into a fist while `/input/grip/touch` is on, at half closed), and the thumb with the index +finger (open, or pinching while `/input/trigger/click` is on). The thumb part carries a copy of the +palm 0.3 mm inside the other part's, so the two blend without a seam. SteamVR only routes a part to +its input when the part also names a legacy button (`controller_button`, as the Index model's buttons +do; vrserver then logs `inputRouting=1` for it), and a hand whose laser points at the dashboard sends +its trigger to the dashboard, so that hand clicks without drawing the pinch. Its pointer and grip poses are the Index +controller's, so the laser leaves along the fingers. Games that draw hands from the +skeleton get the camera's finger curls. Joy-Cons connected later take over; their hands go back to the +Index model. + +| Gesture | SteamVR input | +| --- | --- | +| Thumb and index finger together | Trigger: it starts pulling 6 cm apart, clicks under 3.5 cm and lets go over 4.5 cm | +| Middle, ring and little fingers closed | Grip, clicking when fully closed | +| Thumb to middle finger | B | +| Thumb to ring finger | A | +| Right palm toward the face, then pinch | System (the SteamVR dashboard) | +| Left palm toward the face, then pinch | B (most games' menu) | + +A gesture has to hold for 50 ms to press and be gone for 100 ms to release, and a palm has to face the +headset for 0.3 s before its pinch becomes the menu. A and B only count with the hand open, take the +fingertips within 2.5 cm (released over 3.5 cm), and while the palm faces the headset the trigger stays +released. The distances were checked on 504 photos from the public HaGRID gesture set (50 each of +eleven gestures) through the same hand model: MediaPipe puts touching thumb and index tips a median +3.4 cm apart, so a 2.5 cm pinch caught only 27 percent of the "ok" signs; at 3.5 cm it catches 51 +percent while open hands, pointing, "stop", "four", "three" and thumbs-up misfire in 4 percent or +fewer. A fist pinches in a quarter of them, which games read as grabbing with the trigger held. The +grip read closed for 92 percent of fists and 96 percent of pointing hands and for none of the open +hands. + +`CameraHand` in `hand_pose.h` does the work. Each camera frame's landmarks are turned by the camera's +yaw. The finger curls come from each finger's joint angles, looked up in a table of the skeleton's own +angles. The skeleton's palm at those curls (wrist and four knuckles) is fitted to the camera's with +Horn's closed-form quaternion method, and the root is placed so the skeleton's palm lands on the palm +position. Only sightings with hand-model landmarks count; the pose-only estimates are left out. The +position goes through a 1€ filter (Casiez, Roussel and Vogel, 2012): a low-pass at 1 Hz while the +hand is still that opens up the further and faster the hand gets ahead of it. On a recording of bare hands it cut +the wobble of a still hand from the 9 to 11 mm the Joy-Con Kalman filter left to 2.5 to 3 mm, and the +largest jump between frames from 10 to 17 cm to about 1.3 cm. The turn goes through the same kind of +filter (1 Hz when still, 0.2 Hz more per radian per second), and the curls follow within 80 ms. A fit +more than 2 cm off is skipped, and a fit that turns the hand more than 45 degrees at once is held back +until the next one agrees within 25 degrees (the hand model now and then flips a hand around; the +largest turn between frames fell from 14 to 7.5 degrees). When the camera has not seen a hand's landmarks for half a second, +the hand reports itself out of range and lets go of every input; it reappears wherever it is seen +next. There is no stick or trackpad, so moving around needs a game's own hand tracking or pointing +to teleport. + +On MediaPipe's own example pictures (a raised index finger, a thumbs-up) the right-hand skeleton fits +the palm within 7 mm, the curls read as the pose looks, and the palm direction points at the camera +when the palm faces it. + +Depth from one camera is approximate: the model expects one person, up to about 4 m away, with the +head in view. + +Joy-Con orientation and buttons were tested on a REDMAGIC tablet, and the movement code was tuned on +recordings from its Joy-Cons. Camera tracking was recorded on the same tablet with the front camera: +the camera holding 30 frames per second and video mode took the hands' frame-to-frame jitter from +about 5 cm to 1 to 2 cm; the hand model and the filter were then tuned on that recording. For a +diagnosis, an empty file named `handcam-record` in the app's files folder makes the next sessions save +up to 6 minutes of small camera pictures with every landmark and timing to the app's cache +(`handcam-rec/`), and the driver writes its camera, Joy-Con and filter values to +`/run/droiddeck/droiddeck-handtrace.csv` while they run. `tools/vr/build.sh` runs `test_joycon` (arm +model, aim pose, hand skeleton, movement, the filter's latency, noise and outlier handling, yaw, +both file layouts, a lost hand settling, the palm fit, curls, gestures, bare camera hands, the turn +filter and the hand render model). `JoyConTest`, +`JoyConOrientationTest`, `JoyConMotionTest`, `JoyConShmTest`, `JoyConPadTest`, `RumbleComponentTest`, +`BodyPoseTest`, `TrackingCamerasTest`, `PlacementAdviceTest`, `HandCameraShmTest`, `HandTrackingTest` +and `HandCameraPromptTest` cover the app side. + +### Device-loss recovery + +During a SteamVR session, DroidDeck reads new `vrcompositor.txt` output every two seconds and detects +`VK_ERROR_DEVICE_LOST` or `vkerror=-4`. Existing errors at session start, ordinary acquire timeouts, +and unrelated failures do not trigger recovery. Reads are bounded to the newest 128 KiB and handle +log replacement, truncation, and partial lines. + +A detected fault shows an eight-second cancellable countdown while the session is foreground. +Touches outside the prompt do not dismiss it; the user can explicitly choose Keep open. +Recovery stops the guest, gives teardown and diagnostic collection up to 15 seconds, then uses a +private `:vr_recovery` activity to kill only DroidDeck's main process and reopen SteamVR. That process +does not initialize the glasses SDK or guest runtime. It recreates the native graphics context; an +Activity recreation or SteamVR-only restart does not. Running games close and unsaved progress cannot +be restored. A persisted ten-minute cooldown disables another automatic attempt, while still +offering a manual restart. Session events record detection and recovery requests. This mitigates +the freeze; it does not fix the underlying Vulkan device loss. + +Validated on the phone with a clearly marked `SIMULATED_RECOVERY_TEST: VK_ERROR_DEVICE_LOST` +log entry in session 58. Detection and the eight-second countdown led to a new main-process PID +and session 59 automatically reached READY and requested SteamVR. The +cooldown timestamp survived the process restart. This was a recovery-flow test, not a new GPU +failure. Subsequent user feedback and `vrserver.txt` showed that SteamVR had blocked `driver_droiddeck` +after a previous crash: the server ran, but reported `HmdNotFound` and never produced VR output. +Recovery now clears only `driver_droiddeck.blocked_by_safe_mode`, atomically after stopping the old +process and before launching the new session. It preserves global safe-mode preferences and other +drivers' blocks. A running server or resumed sensor stream alone is not proof of successful recovery. +The follow-up marked test in session 63 exercised the manual retry during the cooldown. Recovery +logged the driver unblock, restarted into session 64, and SteamVR recognized the DroidDeck HMD. +Fresh compositor output and a 3840x1080 `DroidDeck Cardboard VR` presenter appeared, with subsequent +display intervals at 56–59 fps. The user confirmed the GT Max VR view and tracking work after this +recovery. The 19 SteamVR unit tests and APK runtime-input verification passed. +The glasses/SteamVR suite includes stale/rotated/split log handling, countdown, +cancellation, foreground-only behavior, and cooldown checks. + +Stereo also requires a physical side-by-side display mode. On the tested Astra 2 +firmware the Pro XR command succeeds but DisplayPort falls back to 640×480; +direct-connection stereo remains unresolved. DroidDeck remembers such a fallback for that glasses +model and stops asking for 3D on its own; **Retry** in the glasses settings tries again. See the +[Astra/VITURE investigation](development/astra-viture-stereo.md) for measured +results, app recovery behavior, and the unverified powered-adapter design. ### Not done yet - **Controllers**: the driver adds two Vive-profile hands driven by the session's game pad. It reads the absolute-state snapshot the app already publishes in fake input ring 0 - (`FAKE_EVDEV_MEMFD_PATHS`), so the app needs no new plumbing. They are a gaze pointer: low in front - of each hand, pointing where the head looks. Mapping, right hand: right stick = trackpad, A/R3 = - trackpad click, RT = trigger, RB = grip, Start = menu, guide = system. Left hand: left stick, X/L3, - LT, LB, Select. Both hands activate in SteamVR and their laser shows on the dashboard; guide, Start and - A presses show up in the ring the driver reads. + (`FAKE_EVDEV_MEMFD_PATHS`), so the app needs no new plumbing. They rest low in front of each side and + point where the head looks. Each stick moves its hand sideways (40 cm), up (50 cm) and down (30 cm) + and turns it as if from an elbow 45 cm behind, so pointers and aim follow it too; the hand eases + back when the stick is let go. The d-pad's up and down move both hands in and out; while a hand's + trigger or grip is held they move only that hand (a slingshot or bow pulled back), and it eases back + on release. Clicking the left stick hands the sticks to the game as trackpads (walking, turning, + teleport) and back. Mapping, right hand: A/R3 = trackpad click, RT = trigger, RB = grip, Start = menu, guide = + system. Left hand: X, LT, LB, Select. Both hands activate in SteamVR and their laser shows on the + dashboard; guide, Start and A presses show up in the ring the driver reads. They are drawn as Index + controllers and switch off while another driver's controller is connected. A Joy-Con hand replaces + the pad hand on its side, and bare camera hands replace both while no controller is connected + (above). - **Layers**: only the first layer of each frame is drawn. SteamVR's compositor composites the dashboard into it, so this has not mattered yet. -- **Timing**: no reprojection, and the presenter blits whatever was submitted last, a guard delay - after it arrived. The chain is SteamVR → presenter → DroidDeck compositor → lens pass. +- **Timing**: no reprojection. The presenter blits the frame submitted last once `vrcompositor`'s GPU + work for it is done: the layer fences that process's submits and counts issued and finished ones in + `present-sync` beside the `active` marker, and the presenter sleeps on the count (a futex). Without + the file it falls back to a guard delay after the frame arrived (`DROIDDECK_VR_PRESENT_GUARD_MS`, + 4 ms). It hands its own picture to Wayland only once its blit is done, because KGSL does not order + the DroidDeck compositor's copy after it. The chain is SteamVR → presenter → DroidDeck compositor → + lens pass. - **Cross-process GPU sync** is a poll away from exact (see the semaphore note above); binary semaphores are not bridged. - **Apps and games**: Windows VR games run through Proton ARM64 (FEX for the x86-64 game, DXVK, and diff --git a/docs/development/astra-viture-stereo.md b/docs/development/astra-viture-stereo.md new file mode 100644 index 000000000..b1debfa0f --- /dev/null +++ b/docs/development/astra-viture-stereo.md @@ -0,0 +1,130 @@ +# Astra 2 and VITURE stereo + +## Tested connection + +2026-10-14: REDMAGIC Astra 2, model `NP06J`, Android 16, +`RedMagicOS11.5.16_NP06J_GB`, Snapdragon `SM8850P`; VITURE Pro XR +(`35ca:101d`), firmware `07.0.03.013_20250422`. The tablet reports a locked +bootloader and green verified boot. Tests used the ordinary ADB shell, without root. + +2D negotiates **1920×1080 at 120 Hz**. The glasses accept stereo command +`0x0008 [32]` with reply `00 32`, but Android then exposes **only 640×480 at +60 Hz**. An accepted USB command is not proof of a working stereo signal. +In this state the glasses reject a 2D command (`01 31` or `01 34`) and their +mode query returns `01 00`. A physical cable reconnect restored 1920×1080/120 +with this update; Android then requested USB permission again and control resumed. + +On the fresh attempt, `0x030D [03]` arrived 3.1 seconds after the accepted stereo +command. The vendor SDK maps that report to 2D/120 (`0x34`); the subsequent mode +query was rejected. The report does not encode a fallback reason or establish a +fixed timeout. The earlier command acknowledgement cannot prove stereo retention. + +## What was checked + +| Route | Result on this firmware | +| --- | --- | +| Pro full SBS command `0x32` | Accepted by MCU; physical output falls to 640×480 | +| Android preferred mode 3840×1080/60 | Preference saved; physical supported modes remain 640×480 only; preference cleared after test | +| USB source/host → source/device | Request issued; actual port stayed source/host | +| USB data disable/enable | Data status changed and was restored; physical display remained 640×480 | +| `IUsbManager.resetUsbPort` through ADB | HAL callback reported success (`0`); physical display mode did not change | +| External-display power off/reset | Did not recover the signal; glasses still rejected 2D | +| Physical cable reconnect | Restored 1920×1080/120 and USB controls after the permission prompt | +| OEM mirror/3D controls, source review | Change virtual-display routing, not physical stereo timings | +| OEM resolution selection, source review | Chooses cached resolution candidates and prefers stale 16:9 mode | +| Pro startup mode / half SBS, SDK review | No supported Pro startup-mode setting or half-SBS input | + +Public driver decompilations from the related REDMAGIC 11 Pro (`NX809J`, SM8850) +[retain an active mode override](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_get_modes.c#L37-L44) +across a glasses hotplug. Their [USB-PD disconnect path](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/dp_display_usbpd_disconnect_cb.c#L127-L147) +clears that override; their simulated hotplug path does not. This is consistent +with the Astra's measured behavior, but does not prove its exact kernel +implementation. That driver also [validates saved user modes against EDID](https://github.com/Coding-BR/android_kernel_nubia_sm8850_qwjujube/blob/efee6f411184886b0fbdaf63fc9db36b3ed96036/decompiled/msm_drm/nubia_edid_modes.c#L226-L250), +so active overrides and saved preferences must not be treated as the same +mechanism. The independent [MagicDesk investigation](https://github.com/mekhontsev/magicdesk/blob/main/docs/xr-resolution-fix.md) +documents a related failure on its separately identified REDMAGIC firmware. + +The [VITURE SDK matrix](https://www.viture.com/en-SG/developer/glasses-sdk/glasses) +specifies full SBS at 3840×1080/60 for Pro. Persistent startup mode is restricted +to Luma/Luma Pro. Among the Luma families supported here, 90 Hz stereo is an +Ultra capability. The connected Pro firmware matches both the N6PL entry in the +[public updater catalog](https://static.viture.com/dfu-util/js/vers.js) and the newest +N6P entry in the [public release-note service](https://cloud.viture.dev/api/v1/system/releasenote) +checked on 2026-10-14. This does not rule out unpublished, device-specific updates. + +## Powered dock results + +A VITURE Pro Mobile Dock (`35ca:3011`) was connected between the Astra and Pro XR. +Its official read-only P9 version query returned `2.01.03.0.03` with a valid +checksum. The Astra negotiated 1920×1080/120, but enumerated only the dock's +USB interfaces, not the glasses' `35ca:101d` control/IMU interfaces. A one-second +DroidDeck IMU sample returned zero packets. + +DroidDeck treats the glasses as a display behind the dock: no USB permission +prompt, video at 1080p/120 on the glasses, no Play SteamVR, refresh, 3D or tracking +settings. The dock alone, without glasses, does not count as glasses. + +VITURE's updater calls this dock P9 (app `35ca:3011`, bootloader `35ca:3010`). Its +HID messages cover dock versions, battery, inputs and LEDs; none sets a display +mode or forwards glasses commands or IMU data. `2.01.03.0.03` is public release +`1.03.0.03_20250709`; the latest, `1.03.0.06_20260409`, adds Beast support and +updater fixes only. The updater also reads a Lontium LT8668 in the dock, a +DisplayPort receiver with its own EDID handling, so the tablet most likely sees the +dock as its DisplayPort sink rather than the glasses. + +[VITURE's Pro Dock specifications](https://www.viture.com/product/viture-pro-mobile-dock) +confirm 3840×1080/60 video support and exclude phone features requiring +communication with the glasses. The vendor SDK also treats the dock as a separate +accessory, not an IMU device. This dock may test the video path, but it does not +provide DroidDeck's current head-tracking path. Stereo negotiation and mode +retention during an upstream-only reconnect still require a physical test. + +## No-root hardware design to test + +A powered pass-through connection may let the tablet see the stereo EDID on a +fresh USB-PD connection while the glasses retain their stereo mode: + +1. Power the adapter and glasses independently of the tablet connection. +2. Enable stereo on the glasses. +3. Disconnect only the tablet's upstream USB-C connection promptly after the + stereo acknowledgement, keeping the glasses powered. Compare with a delayed + disconnect to test whether the observed mode report affects retention. +4. Reconnect the tablet and check its physical supported modes before starting a game. + +This requires an adapter that preserves glasses power and mode during the +upstream disconnect, passes 3840×1080/60 video, and forwards USB HID for controls +and head tracking. [VITURE's Adapter Ultra documentation](https://www.viture.com/academy/adapters/usb-c-xr-charging-adapter-ultra) +explicitly excludes wired glasses data on Android, so it does not satisfy +DroidDeck's current USB control/tracking path. Its power and video specifications +also do not promise mode retention across upstream disconnect. This design +remains **unverified**, not a purchasing recommendation. First verifying stereo +retention through the adapter on a compatible host would provide a useful control. +An intermediary presenting the stereo EDID before first host +connection is another possible design; it must pass the actual stereo timing +and preserve HID access, not merely advertise a wider desktop. + +VITURE advertises full Android functionality for its separate +[USB-C to Glasses and Charging Adapter](https://www.viture.com/en-MY/product/usb-c-to-glasses-and-charging-adapter). +That makes it a candidate for the USB-data experiment, not a confirmed fix. +First check USB enumeration, brightness controls and live IMU data through the +adapter; then test power/mode retention and the stereo timing. + +Success requires Android to expose and select 3840×1080/60, different left/right +eye images to reach the lenses, live head tracking, and repeatable return to 2D. +A wide screenshot or `wm size` override alone does not meet those conditions. + +## App safeguards + +DroidDeck waits up to 20 seconds for physical stereo confirmation. When the +attempt ends at a low-resolution fallback, DroidDeck remembers it for that glasses +model on this device, so Play SteamVR stops requesting 3D after a restart; **Retry** in the +glasses settings clears it. A display that stays missing, such as an unanswered +cast prompt, fails only that attempt. Automatic retries do not loop. +USB mode reports are tracked separately from the physical display. When a session +enabled 3D, stopping it requests a return to 2D, including during negotiation; +this firmware can still require a cable reconnect. Output timing follows +the active glasses display rather than the tablet's requested refresh rate. + +Keep hardware experiments separate from game loads. End the session, force-stop +DroidDeck, and reopen its home screen after each game test. Read current HAL +temperatures rather than cached thermal-service samples. diff --git a/docs/development/cardboard-vr-learnings.md b/docs/development/cardboard-vr-learnings.md index 34c897890..10e75a762 100644 --- a/docs/development/cardboard-vr-learnings.md +++ b/docs/development/cardboard-vr-learnings.md @@ -44,12 +44,16 @@ OpenVR to SteamVR's ARM64 `vrclient.so`, so no x86 SteamVR is involved. - `helloxr` blocked in a wait-idle in `vrclient.so`; - DXVK's `dxvk-queue` thread blocked in Proton games. - The layer now gives a submit's wait on an imported shared value 3 ms, then drops it. It is kept - when the value is already reached, or when this process has queued the signal itself. Two details - mattered: + The layer now gives a submit's wait on an imported shared value 3 ms, then drops it, at once when + the shared counter has not moved for 100 ms. It is kept when the value is already reached, when + this process has queued the signal itself, or while a Quest session's frame socket exists (checked + at most once a second). Three details mattered: - the waits are judged before the submit's own signals count, because an acquire waits on one value and signals the next on the same semaphore; - - SteamVR's client interop device is Vulkan 1.0, so only the `KHR` entry points resolve there. + - SteamVR's client interop device is Vulkan 1.0, so only the `KHR` entry points resolve there; + - a Proton game waits on two values a frame that are never signalled (5,500 dropped, none reached + within 200 ms afterwards). With the grace on each, Angry Birds VR's submit thread lost 6 ms a + frame and the game ran at 60 fps; without it, 120. 4. **UBWC layouts must agree.** An imported image has to be created with the same `VkImageFormatListCreateInfo` (sRGB + UNORM) as its creator, or Turnip lays out the compression differently and the reader sees garbage. The presenter's eye-texture import does this. @@ -83,6 +87,21 @@ OpenVR to SteamVR's ARM64 `vrclient.so`, so no x86 SteamVR is involved. - **Memory.** Steam, SteamVR and one VR game fit in the Thor's 12 GB. A large game such as The Lab, or two games at once, push the device into critical pressure, and the low-memory killer takes DroidDeck itself (`ended-without-teardown.txt` in the session logs, `lowmemorykiller` in logcat). +- **Frames shown out of order.** Video from the glasses jumped back a few frames in bursts while the + head turned. Two causes, each found by stamping a frame counter into the presenter's output: + - the presenter copied SteamVR's eye texture a fixed 4 ms after `Present`, with no fence. Under + GPU load `vrcompositor` had not drawn it yet, so the copy got that texture's previous frame. The + layer now fences `vrcompositor`'s submits and the presenter waits for the frame's count; + - DroidDeck's compositor copies a Wayland client's dma-buf without waiting for its implicit fence, + and KGSL does not order the copy after the client's GPU work. The presenter's blit sat behind the + game's frame, so the compositor showed the swapchain image's older picture. The presenter now + waits for its blit before it presents. +- **Flat games under SteamVR.** With SteamVR running, Steam lists a flat game after its own window in + gamescope's base layer, so Big Picture keeps the focus and the game never shows, theatre setting or + not. SteamVR is therefore started only by Play SteamVR, never with the glasses alone. +- **A new projection needs an event.** `SetDisplayProjectionRaw` changed what `vrserver` reports, but + `vrcompositor` kept the frustum it started with, so the glasses' field of view did nothing until + SteamVR restarted. The driver now follows it with `VREvent_LensDistortionChanged`. - **Head-tracking axes.** With the display at `ROTATION_90`, screen x is minus device y and screen y is device x. Getting this wrong made a turn at the hips tilt the view. Recentring now keeps the world vertical and resets the heading only. @@ -123,8 +142,9 @@ OpenVR to SteamVR's ARM64 `vrclient.so`, so no x86 SteamVR is involved. - **SteamVR Home** needs `IVROverlayView_002` in Proton's `vrclient`, or an ARM64 build of Home. - **The Lab and other large games** need memory headroom. Candidates: stop Steam's own web helpers while a VR game runs, or lower SteamVR's footprint. -- **Wait drops are a heuristic.** A 3 ms grace keeps the common case in sync. A submit thread in the - layer, deferring submits until their waits are reachable, would be exact. +- **Wait drops are a heuristic.** A 3 ms grace keeps the common case in sync, and an idle counter + skips it. A submit thread in the layer, deferring submits until their waits are reachable, would be + exact. - **Layers:** only the first layer of each frame is presented. No reprojection. - **Controllers** are a gaze pointer driven by the pad. Pad presses did not close the SteamVR menu in testing. Untested: whether that came from gamescope focus being on Steam, or from the system button diff --git a/docs/development/rayneo-xr.md b/docs/development/rayneo-xr.md new file mode 100644 index 000000000..e197b8a8d --- /dev/null +++ b/docs/development/rayneo-xr.md @@ -0,0 +1,179 @@ +# RayNeo Air / GT XR integration + +Built on the current `feat/viture-xr` PR #732 at bdf876d47cc9bb6fa810d014370da5bbb3c28039. +The supplied historical hashes a127e66, 7f9dd75, e8e3e6b and f0b3297 were not +resolvable in the fetched branch; no native projection or synchronization files are changed. + +## Discovery and transport + +`Glasses.kt` owns USB permission, attachment/detachment and presentation-display +selection. There is no manifest USB VID filter: discovery uses `UsbManager.deviceList`. +VITURE discovery is retained. `RayNeoModel` accepts the scaffold's confirmed +`1bbb:af50` and verncat's confirmed GT identity `3941:af50`; other products under `1bbb` or bridge vendor `22d9` must identify +as RayNeo/TCL Air or GT in their USB product name. This covers named Air 2, 2s, +3, 4 Pro and GT devices without claiming arbitrary TCL phones or bridge chips. +Additional unnamed VID/PID pairs require captures before being allowlisted. +Other products under `3941` are rejected. Without a meaningful USB product name, +`3941:af50` is labeled "RayNeo GT series"; a supplied GT MAX name is preserved. +GT Max was observed on the Samsung SM-F976U with this USB identity on 2026-10-14; +USB permission and the command handshake succeeded. +EDID/product display names containing RayNeo, Ray Neo, or TCL Air/GT use the existing +external-screen path. Android's supported modes, not a synthetic refresh value, +determine whether 120 Hz can be selected. Stereo modes may have a lower maximum rate. +GT Max's observed display identity is `SmartGlasses`, EDID manufacturer `TCL`, +product `980` (1920x1080, 60/120 Hz in 2D). This exact metadata combination is +recognized even though neither display name contains RayNeo. Other generic +SmartGlasses/TCL products are not accepted by this rule. + +Game output uses the same `GlassesScreen` presentation as VITURE, with the phone +retaining the session controls; no screen-capture permission is needed. Discovery +checks both Android's display name and its product name. On Android 12 and newer, +a missing product-info object is treated like an unnamed panel: a recognized +glasses USB device can identify a single presentation display. Unrelated named +monitors and multiple unnamed displays are not automatically selected by that +fallback. `Game on glasses` remains enabled by default. +The presentation holder is selected before `show()`, and the phone SurfaceView +is hidden only after the compositor attaches to the glasses holder. Late phone +surface callbacks cannot reset the external output. + +`RayNeoLink` claims a paired IN/OUT interface and translates the controller's +internal commands to RayNeo packets. For `3941:af50`, selection prefers interface +number 5 (not array index 5), as tested in verncat's GT contribution. Only endpoints +that can carry 64-byte packets qualify. Firmware with a different interface number +can use a unique qualifying IN/OUT pair; ambiguous alternatives are rejected. +It requests device info (0x66/0), enables IMU +(0x66/1) and waits for acknowledgments, warms up for two seconds, and stops IMU +(0x66/2) when no pose consumer remains or the link closes. SBS uses commands 6/7. +Unsupported VITURE brightness, volume and electrochromic commands are never sent; +the VITURE hardware-control UI is hidden for RayNeo. +GT spatial-button notification 0x08 and volume/display ACKs remain non-device-info +responses and cannot satisfy unrelated startup commands. The spatial button is +left to the glasses' firmware; no undocumented spatial-mode command is sent. + +The parser/filter are adapted from the user's scaffold at +581dbf712ed601ece4cf5e4a7c1f52e780299c88. Sensor packets are framed with 0x99/0x65, +with little-endian acceleration at byte 4, gyro (degrees/sec) at 16 and a uint32 +100us device tick at 40. Fragmented and coalesced reports are assembled before decoding. +Board 0x3a (Air 4 Pro) receives the scaffold's -20-degree X IMU mounting correction. +GT Max (`3941:af50`, board `0x41`) uses the mapping measured on 2026-10-14: +gyro and acceleration `(-y, z, -x)`, then +35 degrees around X. +At the user's level pose the raw acceleration was approximately +`(5.70, 0.00, 7.95)` m/s². A directed left-turn-and-hold capture produced positive +X/Z gyro (e.g. `(20.5, 2.0, 28.2)` deg/s), and upward looks produced negative Y +gyro. The 35-degree mounting estimate comes from that level capture, +not manufacturer calibration; frame fit and other GT variants remain unverified. +Gravity initializes orientation before the first published pose. Subsequent +body-to-world fusion uses `measuredUp × predictedUp`, correcting tilt toward gravity +instead of amplifying the error. These fusion corrections also apply to Air; +the GT Max sensor remapping does not apply to Air or VITURE. + +The non-Max GT shares USB `3941:af50` but reports board `0x40`, confirmed by +switching between both physical pairs on the same phone. Its sensor axes use +the ordinary XYZ basis with a measured -25-degree X mounting correction, rather +than the Max permutation. Its confirmed upside-down resting acceleration was +approximately `(0.020, -8.819, -4.106)` m/s²; the worn level capture was near +`(0.09, 9.26, 3.11)`. Left turns produced positive Y/Z gyro. The mounting estimate +uses the resting surface; per-device calibration refines its tilt and gyro bias. +Both the pose filter and calibration collector select the same board profile. +Non-Max calibration keys have a `:gt-standard-v1` suffix so they cannot replace +existing Max profiles when USB identity fields coincide. Calibration loading +waits for the device-info response; detached-device callbacks cannot change the +replacement device's calibration key. Existing Max keys remain compatible. +After installing this correction, the non-Max completed the upside-down +calibration on the phone with `saved=true`, phase `SAVED`, progress 100. +All 186 glasses/shared-pose/SteamVR-launch/UI regression checks passed and the +installed APK's packaged runtime inputs verified. After calibration and the +recenter check, the wearer confirmed on 2026-10-14 that the non-Max GT view +looks and feels good. + +## Existing pose and SteamVR pipeline + +The scaffold fuses gyro/accel into wxyz. The Android adapter converts its Y-up +coordinate basis and component order into VITURE's Z-up xyzw convention before +calling the same `Glasses.PoseSink` used by VITURE. `HeadTracker` and `HeadPose` +retain their existing recenter/seated-reference behavior. The path continues through +`WaylandCompositor.nativeSetCardboardPose`, the compositor's `headpose_shm.h` +shared-memory record and `tools/vr/src/driver_droiddeck.cpp`'s pose-file reader. +The OpenVR projection, presenter, Vulkan wait logic and vblank counters are untouched. +An explicit Play SteamVR request polls the guest runtime independently of display +attachment and USB-control readiness. Both RayNeo and VITURE call the same +`droiddeck-steamvr start` helper. Stopping cancels the pending launch request; +automatic hardware 3D switching still respects the shared Auto 3D setting. + +## Optional stationary calibration + +Glasses → Tracking → Tracking calibration guides the user to rest the connected +RayNeo upside down on a level surface. It allows three seconds to settle, then +requires five continuous seconds of stationary samples (at least 200 reports). +Invalid gravity, the wrong orientation, motion/variance, or gaps longer than 250 ms +restart the window. A 30-second deadline fails without replacing a saved profile; +USB stream loss also terminates the operation. Cancellation and link replacement +invalidate queued callbacks by operation revision and connection generation. + +The profile contains a raw gyro bias and a small mounting tilt correction, applied +to both sensor vectors before fusion. It does not estimate heading, full accelerometer +scale/bias, or translation. Profiles persist by vendor/product/serial; when a serial +is unavailable the product and model name share a fallback profile. Reset deletes +only the current profile and restores the model mapping. VITURE's fused SDK poses +do not expose the raw inputs this measurement needs, so this control is RayNeo-only. + +While measuring, live pose publication pauses. On completion the user wears the +glasses, looks ahead and taps Done to recenter the active session. Dismissing the +dialog or leaving the tracking panel cancels an in-progress measurement. Agent +diagnostics expose the calibration state through `glasses`; operations are +`calibrate`, `cancel-calibration`, and `reset-calibration`. +The calibration build passed 157 targeted sensor, lifecycle, pose, launch and +Compose UI tests. Installing this feature and validating a real upside-down capture +on the phone remain pending a debugging connection. + +## Regression and hardware validation + +### SteamVR library launch routing (2026-10-14) + +DroidDeck's SteamVR library entry (appid 250820), including its game shortcut, +now sets the same `EXTRA_STEAMVR` request as Play SteamVR instead of sending a +normal Steam game URL. Existing sessions resume with that request as well. +Other games retain their normal launch path and arguments. + +Inside Steam's library, the existing SharedJSContext bridge wraps +`SteamClient.Apps.RunGame` for exactly 250820 and dispatches it to +`droiddeck-steamvr launch`. This calls the same install/start helper used by the +button, including OpenVR registration, the ARM64 runtime and Vulkan layer setup. +It does not change Steam's saved launch options. The library bridge is installed +for both desktop and Big Picture. It relies on the live Steam UI API and needs +verification against the actual phone's Steam version after installation. + +The helper serializes installation/startup and avoids restarting an active +DroidDeck SteamVR session. Library launch diagnostics are in +`${XDG_RUNTIME_DIR:-/tmp}/droiddeck-steamvr/library-launch.log`; normal runtime +logs remain in `vrstartup.log` and Steam's logs directory. It does not intercept +arbitrary external `steam://` URLs or change VR game launch arguments. + +Validation: seven bridge/dispatch tests passed under Node/Python, five Android +SteamVR launch tests passed, shell syntax passed and the debug APK built with +runtime input verification. The broader Linux compatibility suite cannot run +unmodified on Windows because it requires `fcntl`; no Linux runtime was available +for the helper's lock/process integration tests. Installation and live library +Play verification are pending while the phone charges. + +Run `./gradlew -PskipRust=true :app:testDebugUnitTest --tests 'com.droiddeck.launcher.glasses.*'`. +Protocol tests cover malformed/fragmented packets, unsigned timestamps, model gating, +quaternion normalization, yaw basis and tick rollover. USB integration tests cover +permissions, GT interface preference/fallback, interleaved GT notifications and +ACKs during startup, 2D/3D writes, and stale pose callbacks. Hardware validation is still required per model: +USB enumeration, acknowledgment/stream startup, axes and recenter, hot unplug/replug, +SBS negotiation, Android's actual 120Hz mode, and SteamVR pose/frame pacing. +GT Max was verified on SM-F976U with USB `3941:af50` and the TCL/980 display: +the compositor attached to its separate 1920x1080 display, the Play SteamVR +request started native `vrserver`/`vrcompositor` through the shared helper, and +the presenter logged roughly 60 frames/s while the IMU packet count increased. +The test retained the user's disabled Auto 3D setting. After installing the corrected +GT sensor mapping, the user confirmed an upright starting view and correct movement +in all directions. SteamVR and the glasses IMU remained running. Stereo negotiation, +long-term drift, and alternate bridge firmware variants still need their own +hardware validation. The targeted glasses, Cardboard pose, and SteamVR launch +suite passed 134 tests after the tracking correction. Tracking regression coverage +checks initial gravity alignment, tilt convergence, the recorded GT Max level pose, +and yaw/pitch/roll through the shared VITURE basis conversion and recenter path. + +Attribution and the scaffold's Apache-2.0 license are in `docs/third-party/rayneo/`. diff --git a/docs/development/viture-sdk.md b/docs/development/viture-sdk.md new file mode 100644 index 000000000..c6f009d5e --- /dev/null +++ b/docs/development/viture-sdk.md @@ -0,0 +1,92 @@ +# VITURE Android SDK integration + +The optional adapter is built against the user-supplied VITURE XR Glasses SDK +2.4.0. Set `droiddeck.vitureSdk=C:/path/to/VITURE_XR_Glasses_SDK_for_Android` +in the ignored `local.properties`, or pass `-PvitureSdkDir=/path/to/sdk`. +The directory must contain `include/` and `android/arm64-v8a/`. + +Gradle compiles `droiddeck_viture`, packages `libglasses`, `libcarina_vio`, and +`libcloud_protocol`, and includes the SDK's LICENSE and NOTICE under +`assets/licenses/viture`. Vendor headers and binaries stay outside the repository. +A build without the optional SDK still supports the existing legacy VITURE and +RayNeo transports, but does not advertise control for SDK-dependent glasses. + +## Supported app functions + +| Device | Transport and tracking | App controls | +| --- | --- | --- | +| Pro 2 XR | SDK Gen2 pose callbacks, observed OpenGL axes converted to NWU; negotiated pose frequency | 1080p 2D through 120 Hz, SBS 60 Hz, brightness 0–8, volume 0–8 | +| Luma Ultra | SDK Carina USB transport; OpenGL poses converted to NWU | 1200p 2D through 120 Hz, SBS 60/90 Hz, brightness, volume, binary dimming | +| Beast | SDK Gen2; native/bypass display commands selected by capability and current mode | 1200p output, SBS, brightness, volume 0–15, dimming toggle | + +Pro 2 identification uses the SDK's PID validation and market name, not a guessed +USB ID. Pro 2 has no electrochromic film control. Its display profile uses the +[official 50° FOV and 1080p/SBS limits](https://www.viture.com/academy/product/viture-pro-2-xr-glasses). +Device firmware can reject individual modes; Android's physical display size +must confirm SBS before the app considers the switch successful. + +All three feed the existing `Glasses.PoseSink`, head recentering, compositor and +SteamVR pipeline. Ultra explicitly selects 3DoF before SDK initialization because +the current pipeline consumes orientation. Positional 6DoF and camera passthrough +are outside this integration. The SDK's optional statistics reporter is not enabled. + +The SDK owns USB interface selection and I/O. `UsbDeviceConnection` remains open +until stop/shutdown/destroy finish. A generation token rejects callbacks from an +old connection. Device brightness, volume and dimming events update the app. +Beast native and standard display codes have separate meanings; they are mapped +explicitly. Host tracking suspends native DOF to prevent applying rotation twice +and restores the previous native DOF on stop. Older firmware rejecting a bypass +mode can fall back to the native table, with rollback on failure. + +`Game on glasses` uses Android's existing Presentation output. USB control alone +does not create a video display: the phone, cable and adapter must expose a usable +external display. Discovery checks both display name and product metadata, with +a single unnamed-display fallback when recognized glasses USB is present. + +Screen width and distance apply to **Virtual screen** rendering. In **Automatic**, +ordinary 2D games stay flat, ordinary games with a confirmed SBS display use a +virtual screen, and a running SteamVR session uses the direct stereo view. +The direct view uses SteamVR field of view instead of virtual-screen dimensions +while SteamVR is running. A plain side-by-side game fills each eye and does not +use the SteamVR field-of-view or recenter controls. +The settings UI and session renderer share this mode selection through +`GlassesRendering`; home-screen changes configure sessions rather than resizing +Android's mirrored launcher. Regular refresh rate applies to 2D output; SBS uses +the model's separate 3D refresh rate. +Software picture controls remain available while a display is connected even if +USB control is lost; hardware controls and head tracking still require USB. +Presentation refresh preferences are revalidated on display changes even when +the requested Hz is unchanged, and keep both dimensions of the active mode. + +## Validation + +Run `:app:externalNativeBuildDebug` and +`:app:testDebugUnitTest --tests 'com.droiddeck.launcher.glasses.*'` with the SDK +configured. Compile `app/src/test/cpp/viture_display_modes_test.cpp` with the NDK +C++17 compiler and the SDK include directory to check native/standard mappings +against the vendor constants. The Kotlin tests cover model capabilities, axis +conversion, invalid poses, rejected tracking starts and stale callbacks. + +Physical acceptance checks remain: connect each model, grant USB permission, +verify the Play SteamVR control, verify game output moves to the glasses, +test head rotation/recenter and SBS, then unplug/reconnect. Building and unit +testing do not establish hardware compatibility for every firmware revision. + +### Pro 2 pose-axis correction, 2026-10-14 + +The connected Pro 2 delivered approximately 200 normalized poses/sec. The wearer +reported yaw moving the view vertically, pitch rolling it, and roll turning it +horizontally. Directed captures showed a left turn primarily increasing quaternion +Y (approximately 0.004 to 0.253), while repeated upward looks varied quaternion X +(approximately -0.187 to +0.082) with Y/Z remaining near zero. This is the OpenGL +right/up/back basis, despite the SDK 2.4.0 generic Gen2 callback documentation +describing NWU. + +The adapter therefore selects the existing OpenGL-to-shared-glasses conversion +for the SDK-identified Pro 2 family as well as Carina. Other Gen2 models retain +their previous mapping. Tests exercise model selection and yaw/pitch/roll through +the complete shared `HeadPose` conversion and recenter path. This fixes a model +coordinate mismatch; it does not claim to run a vendor sensor-calibration command +or save a RayNeo-style calibration for VITURE. All 188 glasses/shared-pose/ +SteamVR-launch/UI regression checks passed, packaged runtime inputs verified, +and the corrected build was installed on the phone. Visual verification is pending. diff --git a/docs/development/xreal-investigation.md b/docs/development/xreal-investigation.md new file mode 100644 index 000000000..bcad03146 --- /dev/null +++ b/docs/development/xreal-investigation.md @@ -0,0 +1,242 @@ +# XREAL 1S / One Pro feasibility — 2026-10-14 + +The user owns XREAL 1S and also wants One Pro support. Supporting these in DroidDeck +is feasible based on existing open drivers, but the supplied Monado Air driver is +not their transport. The initial investigation below preceded implementation. +An experimental native 1S driver is now enabled; see the hardware validation +section at the end for what has actually been verified. + +## Supplied Monado source + +Inspected `C:/Users/conno/Downloads/monado-main/monado-main/src/xrt/drivers/xreal_air/` +and `src/xrt/targets/common/target_builder_xreal_air.c`. + +| Model | USB vendor:product | Sensor/control interface | Sensor report | +| --- | --- | --- | --- | +| Air | 3318:0424 | 3 / 4 | 64 bytes | +| Air 2 | 3318:0428 | 3 / 4 | 64 bytes | +| Air 2 Pro | 3318:0432 | 3 / 4 | 64 bytes | +| Air 2 Ultra | 3318:0426 | 2 / 0 | 512 bytes | + +This driver implements HID IMU input, segmented factory-calibration JSON reads, +gravity/gyro fusion, brightness 0–7 and 2D/3D mode commands. It does not register +1S or One Pro. Copying those Air HID commands to the new products is unjustified. +Its source carries Tobias Frisch's BSL-1.0 notice. + +## Applicable One-family references + +* [XRLinuxDriver](https://github.com/wheaney/XRLinuxDriver), inspected commit + `36e2139dd482f5984112d0c3ebafcc07a78f99c0`. `src/devices/xreal.c` lists vendor + `3318`, One Pro products `0435/0436`, and 1S products `043d/043e`. These use the + non-HID transport. Its README explicitly lists both models and requires current + firmware with glasses stabilization/anchor disabled when using external tracking. +* [xrealOneDeviceKit](https://github.com/wheaney/xrealOneDeviceKit), inspected commit + `f5833f611265f418b1ea549da20dc4764d86a4ec`. Its IMU adapter consumes the One TCP + driver, interprets gyro as radians/sec and acceleration as m/s², then feeds fusion. + Its factory-calibration JSON hook returns unsupported. The maintained Linux + integration disables its SBS-control capability when no MCU connection is open; + a static model table alone is not proof of working display switching. +* [xreal_one_driver](https://github.com/rohitsangwan01/xreal_one_driver), MIT, + inspected commit `3e912315ec16e94e9a47688770d649cfef293624`. Opens TCP + `169.254.2.1:52998` with a two-second timeout, assembles 84-byte IMU records, + reads a timestamp at byte 14, gyro floats at 34 and acceleration floats at 46. + This is a transport/parser reference, not a drop-in Android glasses controller. + +The source check confirms a practical path for both requested models. It does not +establish that this Samsung phone exposes the glasses' USB Ethernet interface to +ordinary Android apps, or establish the physical tracking axes on this hardware. + +## DroidDeck integration path + +1. Attach the 1S and inspect its USB descriptors, presentation display identity, + Ethernet network/link properties and whether the IMU endpoint is reachable. + Obtain the same evidence for One Pro before claiming hardware validation. +2. Add exact model discovery and external-display selection. Preserve each panel's + Android-advertised dimensions and modes; do not assume the Air's resolution. +3. Implement a bounded One-family stream parser and cancellable socket transport. + On Android, bind the socket to the relevant Ethernet `Network` when available, + leaving Steam's Internet route alone. If Android does not expose that link, + a USB network transport is additional work; no root requirement is assumed. +4. Establish gyro units, timestamp deltas and axis mapping using stationary, + directed yaw, pitch and roll captures. Route the validated fused pose through + the same glasses/HeadTracker/SteamVR pipeline as VITURE and RayNeo. Avoid layering + DroidDeck tracking on top of the glasses' own anchor/stabilization. +5. Gate brightness and automatic stereo switching separately on a verified control + protocol. IMU streaming alone does not justify showing those controls as working. +6. Test malformed/fragmented/coalesced records, missing network, timeout/reconnect, + USB detach, screen handoff, SteamVR launch and each physical movement direction. + +At this initial investigation stage, no external SDK was installed, firmware was +modified, or XREAL commands were sent. + +## Further source investigation while the phone charges + +Inspected [xr-tools](https://github.com/0xcaff/xr-tools), commit +`65c11105434a998f09ac4a5f8e4de8736bb4d36c`, particularly +`packages/xreal_one_driver/src/proto/net/`, `src/proto/usb/mod.rs`, and the IMU +example. This describes considerably more of the One protocol than the simple +TCP IMU reader above: + +* Reports use TCP `169.254.2.1:52998`; controls use `169.254.2.1:52999`. + Both have a two-byte message identifier followed by a four-byte **big-endian** + body length. Control messages additionally contain a big-endian transaction ID + and protobuf payload. A port must bound lengths, use unique transaction IDs, + enforce deadlines, and close both sockets on detach. +* Sensor messages `28 36` have a **128-byte body**, hence 134 bytes including the + header. The older reader's 84-byte extraction is not the complete frame format. + Body offsets: device ID 0, device timestamp 8, report type 24, gyro 28, + acceleration 40, magnetometer 52, temperature 64 and IMU ID 68. Numeric sensor + fields are little-endian; timestamp is documented as nanoseconds, gyro rad/s, + acceleration m/s². Type 11 is IMU; type 4 is magnetometer. Do not interpret every + report as gyro data or use arrival time as the device's integration interval. +* Control `27 1f` reads factory JSON: IMU bias, temperature-dependent gyro bias, + and display-to-IMU transforms. Its example subtracts the factory biases, fuses + gyro/acceleration and applies the display rotation. This is preferable to + guessing a fixed mounting angle, but the example's frame convention still + needs conversion and directed-motion verification in DroidDeck. +* `27 1c` sets brightness 0–9; `27 27` sets dimming; `28 22` selects regular/SBS + input; `27 5e/5f` reads/sets display configuration; `28 a6/a7` reads/sets EIS. + Documented display configurations are 1920×1080 at 60/90/120 Hz and 3840×1080 + at 60 Hz. These are evidence for One/One Pro, not confirmed 1S modes. Preserve + the Android-advertised modes and verify controls separately on 1S firmware. +* `28 29` is labelled buttons-enabled/buttons-disabled scene mode. Do not assume + that command disables stabilization; the separate EIS control exists. +* This project's USB detection labels `3318:0436` One Pro and `3318:0438` One as + normal devices, and `0435/0437` as **recovery** devices. XRLinuxDriver accepting + both IDs does not justify opening recovery products as ordinary glasses or + sending firmware commands. xr-tools does not register 1S; its applicability + there is a protocol-family inference, not hardware validation. + +No license file or Cargo license declaration was found in this inspected xr-tools +revision. No implementation was copied into DroidDeck. Use protocol observations +for an independently written adapter, or resolve source licensing before a port. +The MIT TCP driver and GPL XRLinuxDriver remain explicitly licensed references. + +Also inspected XRLinuxDriver commit +[`05d0cfb`](https://github.com/wheaney/XRLinuxDriver/commit/05d0cfb86abaacbf073e1a285ad4eabf681ccb05). +It moves per-device pitch adjustment into the core pose path, after coordinate +conversion; it does not add an Android transport or new One control protocol. +Its pitch constants should not replace the GT mapping already verified by the user. + +Next hardware check remains read-only: enumerate the 1S USB interfaces, display +and Ethernet link; attempt bounded reads from both TCP services, save the factory +configuration, then capture stationary/yaw/pitch/roll data. No XREAL support is +advertised in this build. Android Ethernet availability and 1S control compatibility +are still unverified. + +## Official XREAL documentation and SDK inspection + +Checked the user's [official documentation](https://docs.xreal.com/) on +2026-10-14. This establishes an additional integration candidate alongside the +direct TCP driver; it does not make that candidate a drop-in Android controller. + +* The [device page](https://docs.xreal.com/XREALDevices/XREAL%20Glasses) explicitly + includes One, One Pro and 1S in the X1-based One family with rotational tracking. +* [SDK 3.1.0 release notes](https://docs.xreal.com/Release%20Note/XREAL%20SDK%203.1.0) + add positional tracking for the One series with XREAL Eye and updated firmware. + Beam Pro and Samsung S25 are its listed tested hosts. This does not establish + 6DoF on a camera-less 1S or validation on the user's SM-F976U. +* [ControlGlasses](https://docs.xreal.com/Tools/ControlGlasses) is a required server + for official SDK apps on compatible Android phones. It supplies device services, + 3D switching and dynamically loaded algorithm libraries. Beam Pro uses MyGlasses + instead. An SDK integration would have a companion-app/version dependency. +* The [quickstart](https://docs.xreal.com/Getting%20Started%20with%20XREAL%20SDK) + describes Unity XR Plugin integration, ARM64 and OpenGL ES3. The + [dual-display guide](https://docs.xreal.com/Tools/Dual%20Screen%20Display) + confirms separate phone/glasses views and continued glasses output while the + phone runs another app. This demonstrates the desired behavior in the SDK; + it does not itself supply a native Kotlin implementation. +* The [compatibility page](https://docs.xreal.com/XREALDevices/Compatibility) + currently flags unresolved Android 16 compatibility. The + [FAQ](https://docs.xreal.com/Frequently%20Asked%20Questions) also says DeX can + interfere with SDK display operation, and Android 16 USB permission may require + camera permission first when the device exposes an enabled UVC interface. Apply + that permission requirement only when relevant; ordinary tracking must not + start camera capture or enable UVC automatically. + +Downloaded, but did not install or bundle, the official release archive linked +from the 3.1.0 release notes: +`https://public-resource.xreal.com/download/XREALSDK_Release_3.1.0.20251125/com.xreal.xr.tar.gz`. +SHA-256: `fd7d0fce103712e7acc85bfcbb8b127007e1d84f64596efb6c11580bea058d90`. +Size: 248,169,074 bytes. + +Package inspection found Android AARs including `nr_api` and `nr_loader`, ARM64 +`libXREALXRPlugin.so` / `libXREALNativeSessionManager.so`, and Java +`com.xreal.sdk.display.GlassesDisplay` plus display/surface listeners. The C# layer +calls native exports for tracking type, resolution, refresh rate, component poses +and session lifecycle; the loader also integrates Unity subsystems. Presence of +these libraries does not establish a supported standalone JNI API or permission +to redistribute a stripped subset. No C headers were present in the archive file +listing inspected. No SDK source or binaries were added to the app. + +Decision: retain both candidates. Before choosing the official native-library +route, establish non-Unity initialization, head-pose access, ControlGlasses +compatibility, display-surface ownership and distribution terms. Before choosing +direct TCP, establish the Android network link and 1S protocol behavior. Compare +both against DroidDeck's existing compositor instead of replacing its rendering +architecture merely to fit the SDK's Unity sample. + +## Native 1S implementation and hardware validation + +The pre-implementation checkpoint is `4b045519`. The new driver uses independently +written Kotlin TCP framing, control transactions, sensor parsing and gyro/gravity +fusion. It does not bundle XREAL SDK binaries or copy the unlicensed xr-tools +source. Protocol observations came from the references above and +[xr-tools](https://github.com/0xcaff/xr-tools), inspected commit +`65c11105434a998f09ac4a5f8e4de8736bb4d36c`. + +Verified on the connected SM-F976U (Android reports release 17): + +* Normal 1S USB identity is `3318:043e`, product name `XREAL 1S`. Only that exact + product enables this driver; Air, One Pro and alternate/recovery IDs are excluded. +* Android exposes NCM/ECM Ethernet networks at `169.254.2.10/24` and + `169.254.1.10/24`. Individual sockets bound to the first network reach the + glasses at `169.254.2.1`, control port 52999 and report port 52998. The process + default network remains unchanged, preserving Steam's Internet connectivity. +* Requests require the high bit of the transaction ID; responses clear that bit. + Replies must match both command and transaction before values are published. +* GET_DISPLAY returned payload `22 02 10 1d`: mode **29**, with Android reporting + **1920 x 1200 at 60 Hz**. This differs from the older One's mode 2 (1080 lines). + The 1S default 60 Hz mapping preserves mode 29. +* A two-second capture delivered **2,000 valid IMU poses (1,000 Hz)**. Reports + use the 128-byte body, type 11, little-endian device nanoseconds and float + radians/sec / m/s² format. The framing handles partial and coalesced TCP reads. + +The driver plugs into the shared glasses display and head-pose paths, including +SteamVR. The FOV calculation now uses the actual per-eye aspect ratio, including +the 1S's 16:10 display. Controls use acknowledged commands, bounded queues and +timeouts; connection failures clear tracking state and retry. Volume is hidden +because no volume protocol has been established. Missing Ethernet is reported +without requesting HID permission or claiming USB interfaces from Android. + +Directed wearer captures on the same device established the 1S sensor basis: +level acceleration is approximately `(0, -9.65, -1.07)` m/s²; turning left gives +negative gyro Y; looking up gives positive gyro X; tilting toward the right +shoulder gives positive gyro Z with negative acceleration X. Both vectors are +mapped as `(x, -y, -z)` into the shared right/up/back basis before initial gravity +alignment and fusion. No RayNeo GT mounting angle is reused. Regression tests +exercise those measured signs through `HeadPose`, check an upright first pose +before recenter, and preserve the device's radians/sec and nanosecond units. + +Stereo switching was verified at 3840 x 1080 / 60 Hz, returning to native +1920 x 1200. On 1S, SET_INPUT performs the EDID switch itself; immediately sending +SET_DISPLAY returns busy status 5001 (`22 03 08 89 27`). The driver now polls +GET_DISPLAY across the temporary empty reply (`22 00`) and only sends a second +mode command if the settled mode differs. Hardware retesting kept the connection +and sensor stream running through the switch. The busy status is never accepted +as a successful acknowledgement. + +After installing the corrected build on 2026-10-14, the wearer confirmed that +the view and motion feel good in response to the upright-view, yaw, pitch and +head-tilt check. The SteamVR-requested session was running with a rendered first +frame, stereo output and active tracking. All 181 glasses, shared-pose, SteamVR +launch and glasses-UI regression checks passed; packaged runtime inputs verified. + +The wearer also confirmed on 2026-10-14 that XREAL 1S brightness control works. +Higher refresh rates were not tested. + +Still experimental: dimmer, 90/120 Hz compatibility modes and +extended gameplay stability need hardware validation. One successful view/motion +check does not establish long-session stability. Users must select Follow mode and disable the glasses' own +stabilization. Factory calibration and XREAL Eye/6DoF are not implemented. +One Pro is not enabled or claimed as tested. diff --git a/docs/third-party/handtracking/LICENSE_MEDIAPIPE b/docs/third-party/handtracking/LICENSE_MEDIAPIPE new file mode 100644 index 000000000..0e03e3911 --- /dev/null +++ b/docs/third-party/handtracking/LICENSE_MEDIAPIPE @@ -0,0 +1,218 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +=========================================================================== +For files under tasks/cc/text/language_detector/custom_ops/utils/utf/ +=========================================================================== +/* + * The authors of this software are Rob Pike and Ken Thompson. + * Copyright (c) 2002 by Lucent Technologies. + * Permission to use, copy, modify, and distribute this software for any + * purpose without fee is hereby granted, provided that this entire notice + * is included in all copies of any software which is or includes a copy + * or modification of this software and in all copies of the supporting + * documentation for such software. + * THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED + * WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR LUCENT TECHNOLOGIES MAKE ANY + * REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY + * OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + */ diff --git a/docs/third-party/handtracking/LICENSE_SDPOLYGON b/docs/third-party/handtracking/LICENSE_SDPOLYGON new file mode 100644 index 000000000..9f5ae67a2 --- /dev/null +++ b/docs/third-party/handtracking/LICENSE_SDPOLYGON @@ -0,0 +1,3 @@ +The MIT License +Copyright © 2019 Inigo Quilez +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/docs/third-party/handtracking/README.md b/docs/third-party/handtracking/README.md new file mode 100644 index 000000000..653d1c4ce --- /dev/null +++ b/docs/third-party/handtracking/README.md @@ -0,0 +1,101 @@ +# Camera hand tracking attribution + +## Included + +[MediaPipe](https://github.com/google-ai-edge/mediapipe) Tasks Vision 0.10.21 +(`com.google.mediapipe:tasks-vision`, tag `v0.10.21` at cad7f3ab99ebf175947e40c5252c642612aae927; +copyright Google LLC and the MediaPipe authors, Apache License 2.0, `LICENSE_MEDIAPIPE` here) finds +the player's body in each camera frame. It comes from Maven Central with its dependencies +(Guava, Flogger, protobuf-javalite and Google's `datatransport`), all Apache-2.0. + +`app/src/main/assets/handtracking/pose_landmarker_lite.task` is MediaPipe's Pose Landmarker (Lite) +model, BlazePose GHUM 3D, unchanged from +`https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task` +(SHA-256 `59929e1d1ee95287735ddd833b19cf4ac46d29bc7afddbbf6753c459690d574a`). Its +[model card](https://storage.googleapis.com/mediapipe-assets/Model%20Card%20BlazePose%20GHUM%203D.pdf) +licenses it under the Apache License 2.0 and lists its limits: one person, up to about 4 metres +from the camera, the head in view, and depth that is approximate rather than metric. + +`app/src/main/assets/handtracking/hand_landmarker.task` is MediaPipe's Hand Landmarker (full) model +bundle, a palm detector and a hand landmark model, unchanged from +`https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task` +(SHA-256 `fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1`). Its +[model card](https://storage.googleapis.com/mediapipe-assets/Model%20Card%20Hand%20Tracking%20(Lite_Full)%20with%20Fairness%20Oct%202021.pdf) +licenses it under the Apache License 2.0; it gives 21 landmarks per hand in the picture and in metres, +based on the GHUM hand model, and is meant for prominently shown hands. + +[CameraX](https://developer.android.com/jetpack/androidx/releases/camera) 1.4.2 +(`androidx.camera:camera-camera2`, `camera-lifecycle`; copyright The Android Open Source Project, +Apache License 2.0) delivers the frames. + +`tools/vr/src/hand_mesh.h` ports Inigo Quilez's signed distance to a polygon (`sdPolygon`, from +[2D distance functions](https://iquilezles.org/articles/distfunctions2d/) and his Shadertoy +"Polygon - exact", `wdBXRW`; copyright 2019 Inigo Quilez, MIT License, `LICENSE_SDPOLYGON` here) for +the glove's palm outline. + +MediaPipe's task runner reports usage statistics through Google's `datatransport` library. The app +manifest removes that library's `CctBackendFactory` entry, so those reports have nowhere to go and +are dropped on the device. Camera frames are never stored or sent anywhere. + +## References + +No code from these projects is included. They were read to design and check the camera tracking +and the Joy-Con movement that it corrects: + +- [JoyConVr](https://github.com/F8LEFT/JoyConVr) by F8LEFT and contributors (MIT), `android-camera` + at 76466c28829396651be3c68143f6297ed68923e4: an Android app that runs MediaPipe's pose landmarker + for Joy-Con VR hands, levels the camera with gravity and slows inference down as needed. +- Google's `GvrArmModel.cs` in the [Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) + v1.30.0 (Apache-2.0): its optional accelerometer mode moves the elbow by a filtered, integrated + velocity and keeps the offset within a range, the idea behind the short Joy-Con movements here. +- MediaPipe's [pose landmarker guide](https://developers.google.com/edge/mediapipe/solutions/vision/pose_landmarker), + [hand landmarker guide](https://developers.google.com/edge/mediapipe/solutions/vision/hand_landmarker), + `pose_landmarker_graph.cc` and `pose_landmarks_detector_graph.cc` (Apache-2.0): image and world + landmarks, and the tracking and landmark smoothing that video mode applies to a single pose, which + the app uses. MediaPipe Holistic's idea of finding the hands from the pose's wrists informed the hand + crops. +- R. E. Kalman, "A New Approach to Linear Filtering and Prediction Problems", Journal of Basic + Engineering 82(1), 1960, and the usual treatment of late measurements by replaying the stored + inputs after them: the per-hand filter that joins Joy-Con movement and camera sightings. +- Android's [Camera2 reference](https://developer.android.com/reference/android/hardware/camera2/CameraCharacteristics) + (`SENSOR_ORIENTATION`, `LENS_FACING`, `LENS_INTRINSIC_CALIBRATION`, `CONTROL_ZOOM_RATIO_RANGE`, + `SENSOR_INFO_TIMESTAMP_SOURCE`): choosing the camera, its focal length and turning frames upright. +- [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c) + (GPL-2.0+): the Joy-Con IMU report, three samples every 10 ms, and its timestamps. +- E. Foxlin, "Pedestrian Tracking with Shoe-Mounted Inertial Sensors", IEEE Computer Graphics and + Applications 25(6), 2005: zero-velocity updates while the sensor is still. +- I. Frosio, F. Pedersini, N. A. Borghese, "Autocalibration of MEMS Accelerometers", IEEE + Transactions on Instrumentation and Measurement 58(6), 2009: learning an accelerometer's offset + and scale from resting positions. + +- [ALVR](https://github.com/alvr-org/ALVR) (MIT), `alvr/server_core/src/hand_gestures.rs` at + ebec107b7e5f81677fb9c631d239daf2110bf573: turning tracked hands into controller input (thumb pinches + to each finger, a pinch range from touching to pulling, grip from the other fingers, a held palm + for the menu). The distances, mapping and timing here are DroidDeck's own. +- Meta Quest's hand tracking: the palm-toward-the-face pinch that opens the system menu, and the + plain white hands it draws, which the gesture and the glove model follow. +- B. K. P. Horn, "Closed-form solution of absolute orientation using unit quaternions", Journal of + the Optical Society of America A 4(4), 1987: fitting the skeleton's palm to the camera's. +- G. Casiez, N. Roussel, D. Vogel, "1€ Filter: A Simple Speed-based Low-pass Filter for Noisy Input in + Interactive Systems", CHI 2012: the bare hands' position and turn filters. +- Valve's [OpenVR wiki](https://github.com/ValveSoftware/openvr/wiki) pages "Render Model Reference" + and "Driver Render Models": the render model JSON, its components, `component_path` and visibility. + The glove's tip, base, handgrip and grip poses are the numbers in SteamVR's Valve Index controller + model (`valve_controller_knu_1_0_left` and `_right`), so the pointer leaves the hand as it leaves an + Index controller, and its button parts showed which fields SteamVR needs to route a part to an input. +- S. F. F. Gibson, "Constrained Elastic Surface Nets", MICCAI 1998, and Mikola Lysenko's + [Smooth Voxel Terrain (Part 2)](https://0fps.net/2012/07/12/smooth-voxel-terrain-part-2/): meshing + the glove's distance field with surface nets. +- [HaGRID](https://github.com/hukenovs/hagrid) (A. Kapitanov et al., "HaGRID - HAnd Gesture Recognition + Image Dataset", WACV 2024; CC BY-SA 4.0), through the 384p sample + [cj-mills/hagrid-sample-30k-384p](https://huggingface.co/datasets/cj-mills/hagrid-sample-30k-384p): + 550 of its photos were used, on the development machine only, to check the pinch and grip distances. + No picture is included. +- Inigo Quilez's articles on [smooth minimum](https://iquilezles.org/articles/smin/) and + [3D distance functions](https://iquilezles.org/articles/distfunctions/) (the polynomial smooth + minimum and the ellipsoid bound) and his talk "Rendering Worlds with Two Triangles" (NVScene 2008; + ambient occlusion from a distance field): blending the glove's capsules, palm and wrist band, and + baking its shading. + +Nintendo Switch and Joy-Con are trademarks of Nintendo. This project is not affiliated with Nintendo, +Google, Meta or Valve. diff --git a/docs/third-party/joycon/LICENSE_GVR b/docs/third-party/joycon/LICENSE_GVR new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/docs/third-party/joycon/LICENSE_GVR @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/third-party/joycon/LICENSE_MONADO b/docs/third-party/joycon/LICENSE_MONADO new file mode 100644 index 000000000..cff35365a --- /dev/null +++ b/docs/third-party/joycon/LICENSE_MONADO @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, execute, +and transmit the Software, and to prepare derivative works of the Software, +and to permit third-parties to whom the Software is furnished to do so, all +subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, must +be included in all copies of the Software, in whole or in part, and all derivative +works of the Software, unless such copies or derivative works are solely in +the form of machine-executable object code generated by a source language +processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES +OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/docs/third-party/joycon/README.md b/docs/third-party/joycon/README.md new file mode 100644 index 000000000..2e445ef67 --- /dev/null +++ b/docs/third-party/joycon/README.md @@ -0,0 +1,55 @@ +# Joy-Con VR controller attribution + +## Adapted code + +`tools/vr/src/arm_model.h` ports the arm model of Google's `GvrArmModel.cs` from the +[Google VR SDK for Unity](https://github.com/googlevr/gvr-unity-sdk) +(`Assets/GoogleVR/Scripts/Controller/ArmModel/GvrArmModel.cs` at +f999ed727a1d238c246ba4a504b77d55b59c9e9a; copyright 2016 Google Inc., Apache License 2.0, LICENSE_GVR +here). It is rewritten in C++ in OpenVR's coordinate frame. The rest positions, extension +angles, elbow bend, extension weight and the torso following the gaze are Google's. The neck +offset is left out, as `GvrArmModel` leaves it out by default. + +`tools/vr/src/hand_skeleton.h` adapts `hand_simulation.cpp` from Valve's OpenVR +`handskeletonsimulation` driver sample +([ValveSoftware/openvr](https://github.com/ValveSoftware/openvr), `samples/drivers/drivers/handskeletonsimulation` +at 0924064316de3effbcd1acf1e309182a2deb1c05; copyright Valve Corporation, BSD-3-Clause, +`tools/vr/src/OPENVR_LICENSE`). Valve's sample is inspired by Moshi Turner's hand simulation in +[Monado](https://gitlab.freedesktop.org/monado/monado) (`src/xrt/auxiliary/util/u_hand_simulation.c`). + +`tools/vr/src/index_pose.h` uses the Valve Index tip rotation and grip offset listed in Monado's +`src/xrt/auxiliary/vive/vive_poses.c` (at 8b259763fdd23c46592fd4cdd30b4eea997d4b70; copyright +Collabora, Ltd., author Daniel Willmott, Boost Software License 1.0, LICENSE_MONADO here). + +## References + +No code from these projects is included. They were read to learn how Joy-Cons, Android and +SteamVR behave, and to check this implementation: + +- [hid-nintendo](https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo.c), the Linux + Joy-Con driver by Daniel J. Ogorchock and contributors (GPL-2.0+): button codes, stick axes, IMU + axes and units, and the rumble channels Android exposes. +- [Nintendo_Switch_Reverse_Engineering](https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering) + by dekuNukem: the Joy-Con's buttons, sensors and rumble. +- [SDL](https://github.com/libsdl-org/SDL) (zlib): how Switch controller sensors map to a game + controller's axes. +- [Android Open Source Project](https://github.com/aosp-mirror/platform_frameworks_base) (Apache-2.0): + `data/keyboards/Generic.kl`, which Android applies to Joy-Cons, and the `InputDevice` sensor and + vibrator APIs. +- [ALVR](https://github.com/alvr-org/ALVR) (MIT): presenting controllers to SteamVR as Index + controllers and handling their haptic events. +- [OpenComposite](https://gitlab.com/znixian/OpenOVR) (GPL-3.0): cross-check of the Index input + profile and skeleton. +- [PSMoveSteamVRBridge](https://github.com/HipsterSloth/PSMoveSteamVRBridge) (Apache-2.0): driving + SteamVR hands from orientation-only controllers. +- [JoyConVr](https://github.com/F8LEFT/JoyConVr) by F8LEFT (MIT): a Joy-Con button layout for SteamVR + and recentering by holding a button. +- [Daydream-Controller-SteamVR](https://github.com/krnelpan1c/Daydream-Controller-SteamVR) by + krnelpan1c: snapping a 3DoF controller's heading to the view by holding Home. +- [joyconvr](https://github.com/yoyobuae/joyconvr) by yoyobuae: Joy-Con sensor axes in SteamVR. +- [Moonlight for Android](https://github.com/moonlight-stream/moonlight-android) (GPL-3.0) and + [Dolphin](https://github.com/dolphin-emu/dolphin) (GPL-2.0+): reading a game controller's motion + sensors through Android's `InputDevice` API. + +Nintendo Switch and Joy-Con are trademarks of Nintendo. Valve Index and SteamVR are trademarks of +Valve Corporation. This project is not affiliated with either. diff --git a/docs/third-party/rayneo/LICENSE b/docs/third-party/rayneo/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/docs/third-party/rayneo/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/third-party/rayneo/LICENSE_VERNcat b/docs/third-party/rayneo/LICENSE_VERNcat new file mode 100644 index 000000000..7d00e6f18 --- /dev/null +++ b/docs/third-party/rayneo/LICENSE_VERNcat @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 verncat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/docs/third-party/rayneo/NOTICE_MONADO.md b/docs/third-party/rayneo/NOTICE_MONADO.md new file mode 100644 index 000000000..fce40fcb9 --- /dev/null +++ b/docs/third-party/rayneo/NOTICE_MONADO.md @@ -0,0 +1,13 @@ +Monado Reference Attribution +============================ + +This project's RayNeo implementation was developed by referencing behavior and protocol details from the Monado project: + +- Project: https://gitlab.freedesktop.org/monado/monado +- Relevant area: src/xrt/drivers/rayneo/ + +Notes: + +- The app code in this repository was written for this project. +- Protocol understanding (packet IDs/layout, command flow, and initialization sequencing) was informed by the Monado RayNeo driver implementation. +- Monado is distributed under the Boost Software License 1.0 (BSL-1.0). See the upstream repository for full licensing and notices. diff --git a/docs/third-party/rayneo/README.md b/docs/third-party/rayneo/README.md new file mode 100644 index 000000000..6762c50d1 --- /dev/null +++ b/docs/third-party/rayneo/README.md @@ -0,0 +1,21 @@ +# RayNeo protocol and orientation filter attribution + +RayNeoProtocol.kt and RayNeoOrientationFilter.kt are adapted from +https://github.com/informalTechCode/RayNeo-Air-Series-3DoF-Scaffold +at 581dbf712ed601ece4cf5e4a7c1f52e780299c88 (Apache License 2.0; LICENSE here). +The scaffold references Monado for protocol details; its notice is preserved here. + +DroidDeck adaptations add frame validation, unsigned device ticks, endpoint filtering, +and conversion into the existing VITURE pose coordinate convention. The transport, +USB discovery and DroidDeck integration are implemented for this project. + +GT USB identification and interface selection reference +https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR +at dfd2e8abab7052d88a1047266d89260347d0dd67, especially merged PR #3: +https://github.com/verncat/RayNeo-Air-3S-Pro-OpenVR/pull/3 +(MIT; copyright 2025 verncat; full notice in LICENSE_VERNcat). +That contribution reports GT hardware tests for 3941:af50 on interface 5, +IMU streaming, spatial-button notification 0x08, and display/volume ACK handling. +DroidDeck keeps its Android transport and existing command-specific ACK parser; +it prefers interface 5 for this identity and falls back only to a unique pair +of protocol-sized endpoints. GT Max is not separately verified by this source. diff --git a/tools/cardboard/test_cardboard_params.c b/tools/cardboard/test_cardboard_params.c index 4eb35ffda..3c3b8453a 100644 --- a/tools/cardboard/test_cardboard_params.c +++ b/tools/cardboard/test_cardboard_params.c @@ -35,9 +35,29 @@ int main(void) { q.source = CARDBOARD_SOURCE_SBS; cardboard_push_constants(&q, pose, 3840, 1080, 2400, 1080, pc); CHECK(near(pc[17], 1920.0f / 1080.0f) && near(pc[18], 1.0f)); + q.mono = 1; + cardboard_push_constants(&q, pose, 3840, 1080, 1920, 1080, pc); + CHECK(near(pc[3], 0.0f) && near(pc[19], 1920.0f / 1080.0f) && near(pc[20], 1.0f)); + q.mono = 0; + cardboard_push_constants(&q, pose, 3840, 1080, 1920, 1080, pc); + CHECK(near(pc[3], 0.032f) && near(pc[19], 960.0f / 1080.0f) && near(pc[20], 0.0f)); cardboard_push_constants(&q, pose, 0, 0, 0, 0, pc); CHECK(isfinite(pc[17]) && isfinite(pc[19])); + // Narrow optical profiles and the lower SteamVR slider values must reach + // the projection unchanged, rather than all becoming a 30-degree view. + const float glasses_fovs[] = {22.0f, 23.3f, 24.0f, 25.8f, 29.0f}; + for (unsigned i = 0; i < sizeof(glasses_fovs) / sizeof(glasses_fovs[0]); i++) { + q.fov_deg = glasses_fovs[i]; + cardboard_clamp(&q); + CHECK(near(q.fov_deg, glasses_fovs[i])); + cardboard_push_constants(&q, pose, 3840, 1080, 3840, 1080, pc); + CHECK(near(pc[14], tanf(glasses_fovs[i] * 0.5f * (float)M_PI / 180.0f))); + } + q.fov_deg = 0.0f; + cardboard_clamp(&q); + CHECK(q.fov_deg > 0.0f && q.fov_deg < glasses_fovs[0]); + if (failures) return 1; printf("PASS: cardboard params\n"); return 0; diff --git a/tools/droiddeckctl b/tools/droiddeckctl index 65e0e7445..d0a5bbb79 100755 --- a/tools/droiddeckctl +++ b/tools/droiddeckctl @@ -517,6 +517,8 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: if not args.url.startswith("steam://"): raise ControlError("Steam URL must start with steam://", "INVALID_STEAM_URL") request["steamUrl"] = args.url + if args.steamvr: + request["steamVr"] = True if args.reuse: current = get_state(adb, serial)["session"] wanted = {"steam": "steam", "desktop": "lxqt"}[args.mode] @@ -660,6 +662,24 @@ def execute(args: argparse.Namespace, adb: str, serial: str) -> dict: if command == "access": return add_serial(checked(provider_call(adb, serial, "access"), "ACCESS_FAILED"), serial) + if command == "glasses": + request = {"op": args.glasses_op or "status"} + if args.glasses_op == "set": + request.update(key=args.key, value=args.value) + elif args.glasses_op == "simulate": + request["seconds"] = args.seconds + if args.yaw is not None: + request["yaw"] = args.yaw + if args.pitch is not None: + request["pitch"] = args.pitch + elif args.glasses_op == "mode": + request["mode"] = args.mode + elif args.glasses_op == "imu": + request["ms"] = args.ms + elif args.glasses_op == "raw": + request.update(msgId=args.msg_id, payload=args.payload or "") + return add_serial(checked(provider_call(adb, serial, "glasses", request=request), "GLASSES_FAILED"), serial) + if command == "logs": state = get_state(adb, serial) session = state.get("session", {}) @@ -710,6 +730,7 @@ def build_parser() -> argparse.ArgumentParser: start = commands.add_parser("start", help="start Steam or the desktop") start.add_argument("mode", choices=["steam", "desktop"]) start.add_argument("--ui", choices=["bigpicture", "desktop"], default="bigpicture") + start.add_argument("--steamvr", action="store_true", help="start SteamVR with the session (VITURE glasses)") start.add_argument("--url", help="steam:// URL to pass to the client") start.add_argument("--wait", action="store_true", help="wait for READY after starting") start.add_argument("--reuse", action="store_true", help="keep a running session of this mode instead of failing") @@ -828,6 +849,27 @@ def build_parser() -> argparse.ArgumentParser: commands.add_parser("access", help="whether debugging tools are enabled on the device") + glasses = commands.add_parser("glasses", help="VITURE glasses: status, settings, head tracking test") + glasses_ops = glasses.add_subparsers(dest="glasses_op") + glasses_ops.add_parser("status") + glasses_ops.add_parser("allow", help="ask for USB control of the glasses again") + glasses_set = glasses_ops.add_parser("set", help="refreshHz, sbsHz, brightness, volume, sunblock, 3d, gameOnGlasses, headTracking, auto3d, picture3d, screenWidthDm, screenDistanceDm, vrFovDeg") + glasses_set.add_argument("key") + glasses_set.add_argument("value") + glasses_sim = glasses_ops.add_parser("simulate", help="feed a moving head pose (yaw, pitch and roll sweeps), or hold one") + glasses_sim.add_argument("--seconds", type=int, default=20) + glasses_sim.add_argument("--yaw", type=float, help="hold this yaw (degrees, left positive) instead of sweeping") + glasses_sim.add_argument("--pitch", type=float, help="hold this pitch (degrees, down positive) instead of sweeping") + glasses_ops.add_parser("stop-simulate") + glasses_ops.add_parser("query", help="ask the glasses for their display mode, brightness, volume and IMU state") + glasses_mode = glasses_ops.add_parser("mode", help="send a display mode byte, e.g. 0x31 (1080p60), 0x34 (1080p120), 0x32 (3D)") + glasses_mode.add_argument("mode") + glasses_imu = glasses_ops.add_parser("imu", help="stream the glasses' IMU for a moment and show what it reports") + glasses_imu.add_argument("--ms", type=int, default=2000) + glasses_raw = glasses_ops.add_parser("raw", help="send one MCU command (debugging tools)") + glasses_raw.add_argument("msg_id", help="hex message id, e.g. 0x0007") + glasses_raw.add_argument("payload", nargs="?", help="hex bytes") + logs = commands.add_parser("logs", help="pull the latest session artifact directory") logs.add_argument("which", choices=["latest"]) logs.add_argument("destination", nargs="?") diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session index 2cc48280a..65e835686 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-session +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-session @@ -636,6 +636,10 @@ BUSCONF exit 69 fi + # The UI bridge launches SteamVR, which needs the guest agent's library environment. + _bl_compat_env=(env -u LD_LIBRARY_PATH -u STEAM_RUNTIME) + [ "${LD_LIBRARY_PATH+x}" ] && _bl_compat_env+=("LD_LIBRARY_PATH=$LD_LIBRARY_PATH") + [ "${STEAM_RUNTIME+x}" ] && _bl_compat_env+=("STEAM_RUNTIME=$STEAM_RUNTIME") export STEAM_RUNTIME=1 export LD_LIBRARY_PATH=$steam_root/steamrtarm64:$steam_root/steamrtarm64/panorama${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH} export SDL_VIDEO_X11_DGAMOUSE=0 @@ -686,7 +690,7 @@ BUSCONF echo "== steam: devtools port $BL_CDP_PORT, guest processes only" fi [ -n "${BL_LAUNCH_DIR:-}" ] && mkdir -p "$BL_LAUNCH_DIR/agent" && echo "$BL_CDP_PORT" > "$BL_LAUNCH_DIR/agent/cdp-port" - droiddeck-steam-compat "$steam_root" 2>&1 & + "${_bl_compat_env[@]}" droiddeck-steam-compat "$steam_root" 2>&1 & _bl_ui=(-gamepadui) [ "${BL_STEAM_UI:-}" = desktop ] && _bl_ui=() _bl_cef=(-cef-force-gpu -cef-use-gl=angle -cef-use-angle=vulkan) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat index e1ab89bf8..3cb4c9cf3 100755 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat @@ -153,6 +153,82 @@ SWITCH_JS = r""" })() """.replace("__BINDING__", SWITCH_BINDING) SESSION_SELECT = "/usr/bin/steamos-session-select" +VR_BINDING = "droiddeckLaunchSteamVr" +VR_HELPER = "/usr/local/bin/droiddeck-steamvr" +VR_JS = r""" +(() => { + if (window.__droiddeckSteamVr) return "present"; + const apps = SteamClient.Apps; + if (!apps || typeof apps.RunGame !== "function") throw new Error("Steam RunGame is not ready"); + const run = apps.RunGame; + const self = { running: false, pending: new Map(), lastError: null }; + const flush = () => { + if (!self.running) return; + const queued = Array.from(self.pending.values()); + self.pending.clear(); + for (const item of queued) { + clearTimeout(item.timer); + run.call(item.receiver, item.id, ...item.args); + } + }; + if (typeof SteamClient.OpenVR?.RegisterForVRModeChange === "function") { + self.subscription = SteamClient.OpenVR.RegisterForVRModeChange(running => { + self.running = !!running; + flush(); + }); + } + const wrapped = function (id, ...args) { + const gameid = String(id); + if (typeof window.__BINDING__ !== "function") return run.call(this, id, ...args); + if (gameid === "250820") { + window.__BINDING__(gameid); + return; + } + const overview = window.appStore?.GetAppOverviewByGameID?.(gameid); + const vr = overview?.vr_only || overview?.vr_supported || overview?.BSupportsVR?.(); + if (vr && self.subscription && /^[1-9][0-9]{0,19}$/.test(gameid)) { + if (self.pending.has(gameid)) return; + self.lastError = null; + const item = { receiver: this, id, args, timer: null }; + item.timer = setTimeout(() => { + self.pending.delete(gameid); + self.lastError = "SteamVR did not become ready. Start SteamVR from DroidDeck and try the game again."; + console.error("DroidDeck: " + self.lastError); + }, 600000); + self.pending.set(gameid, item); + try { window.__BINDING__(gameid); } + catch (e) { clearTimeout(item.timer); self.pending.delete(gameid); throw e; } + flush(); + return; + } + return run.call(this, id, ...args); + }; + apps.RunGame = wrapped; + if (apps.RunGame !== wrapped) throw new Error("Steam RunGame could not be redirected"); + if (typeof SteamClient.OpenVR?.StartVR === "function") { + const start = SteamClient.OpenVR.StartVR; + SteamClient.OpenVR.StartVR = function (...args) { + if (typeof window.__BINDING__ === "function") return window.__BINDING__("250820"); + return start.apply(this, args); + }; + } + window.__droiddeckSteamVr = self; + return "installed"; +})() +""".replace("__BINDING__", VR_BINDING) + + +def launch_steamvr(): + """Keep downloads/startup off the Steam UI bridge's event loop.""" + say("SteamVR library launch: using the DroidDeck ARM64 helper") + state = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "droiddeck-steamvr") + try: + os.makedirs(state, exist_ok=True) + with open(os.path.join(state, "library-launch.log"), "a") as log: + return subprocess.Popen([VR_HELPER, "launch"], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + except OSError as e: + say("SteamVR library launch failed: %s" % e) class Closed(Exception): @@ -405,6 +481,7 @@ class Helper: self.known = set() self.installed = None self.fex_asked = set() + self.vr_launch = None def attach(self): path = shared_context(self.port) @@ -415,6 +492,8 @@ class Helper: if page.evaluate("typeof SteamClient === 'object' && typeof SteamClient.WebUITransport === 'object'") is not True: raise LookupError("SteamClient is not ready") page.call("Runtime.addBinding", {"name": BINDING}) + page.call("Runtime.addBinding", {"name": VR_BINDING}) + page.evaluate(VR_JS) if not self.steamos: page.call("Runtime.addBinding", {"name": SWITCH_BINDING}) page.evaluate(SWITCH_JS) @@ -498,6 +577,14 @@ class Helper: events = self.page.drain() if any(called(e, SWITCH_BINDING) for e in events): switch_to_desktop() + vr_games = {e.get("params", {}).get("payload") for e in events if called(e, VR_BINDING) and + isinstance(e.get("params", {}).get("payload"), str)} + vr_games = {game for game in vr_games if isinstance(game, str) and game.isascii() and + game.isdecimal() and not game.startswith("0") and len(game) <= 20 and + int(game) <= 0xffffffffffffffff} + if vr_games and (self.vr_launch is None or self.vr_launch.poll() is not None): + say("preparing SteamVR for library app(s): " + ", ".join(sorted(vr_games))) + self.vr_launch = launch_steamvr() return events def serve(self, watcher): diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr index 05f2c9a7a..8facf7ddf 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr @@ -42,6 +42,16 @@ stop() { } case "${1:-status}" in +launch) + # Library/URL entry point: use the same installer and start path as the app. + exec 8>"$state/launch.lock" + flock -n 8 || exit 0 + if ! installed; then + "$0" install || exit $? + while [ -f "$state/installing" ]; do sleep 1; done + fi + "$0" start + ;; status) i=0; installed && i=1 r=0; running && r=1 @@ -51,8 +61,9 @@ status) echo "installed=$i running=$r installing=$n driver=$d" ;; install) + exec 7>"$state/install.lock" + flock -n 7 || { echo "already installing"; exit 0; } installed && { echo "SteamVR is already in $steamvr"; exit 0; } - [ -f "$state/installing" ] && { echo "already installing"; exit 0; } : > "$state/installing" trap 'rm -f "$state/installing"' EXIT cache=$HOME/.cache/droiddeck-vr @@ -72,11 +83,63 @@ stop) stop || exit 1 ;; start) + exec 9>"$state/start.lock" + flock -n 9 || { echo "SteamVR start already in progress"; exit 0; } installed || { echo "SteamVR is not installed (droiddeck-steamvr install)"; exit 1; } [ -f "$driver/bin/linuxarm64/driver_droiddeck.so" ] || { echo "the Cardboard driver is missing from $driver"; exit 1; } + if [ -f "$state/active" ] && { running || pgrep -x vrstartup >/dev/null 2>&1; }; then + echo "DroidDeck SteamVR is already running" + exit 0 + fi stop || exit 1 sleep 1 mkdir -p "$HOME/.config/openvr" "$HOME/.config/openxr/1" "$logs" "$HOME/.local/share/Steam/config" + if [ -S "/run/droiddeck/droiddeck-xr-frames" ] && [ -z "${DROIDDECK_VR_PRESENT:-}" ]; then + export DROIDDECK_VR_PRESENT=questwsi + fi + python3 - "$HOME/.local/share/Steam/config/steamvr.vrsettings" <<'PY' +import json +import os +from pathlib import Path +import sys +import tempfile + +path = Path(sys.argv[1]) +try: + settings = json.loads(path.read_text()) if path.exists() else {} + section = settings.setdefault("steamvr", {}) + defaults = {} if os.environ.get("DROIDDECK_VR_PRESENT", "").startswith("quest") else {"supersampleManualOverride": True, "supersampleScale": 1.0} + changed = any(key not in section for key in defaults) + for key, value in defaults.items(): + section.setdefault(key, value) + driver = settings.get("driver_droiddeck") + if isinstance(driver, dict) and driver.get("blocked_by_safe_mode") is True: + driver["blocked_by_safe_mode"] = False + changed = True + print("SteamVR had blocked the DroidDeck driver after a crash; unblocked it") + if changed: + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False) as output: + temporary = Path(output.name) + try: + json.dump(settings, output, indent=3) + output.write("\n") + except BaseException: + temporary.unlink(missing_ok=True) + raise + try: + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) +except (OSError, ValueError, AttributeError, TypeError) as error: + print("Could not set SteamVR resolution defaults: " + str(error), file=sys.stderr) +try: + (path.parent / "vrserver_crash_timestamp.txt").unlink() + print("Removed SteamVR's crash marker so it starts without safe mode") +except FileNotFoundError: + pass +except OSError as error: + print("Could not remove SteamVR's crash marker: " + str(error), file=sys.stderr) +PY cat > "$HOME/.config/openvr/openvrpaths.vrpath" < "$chaperone" <<'JSON' +{ + "jsonid" : "chaperone_info", + "universes" : [ + { + "collision_bounds" : [ + [ [ -1.0, 0.0, -1.0 ], [ -1.0, 2.43, -1.0 ], [ -1.0, 2.43, 1.0 ], [ -1.0, 0.0, 1.0 ] ], + [ [ -1.0, 0.0, 1.0 ], [ -1.0, 2.43, 1.0 ], [ 1.0, 2.43, 1.0 ], [ 1.0, 0.0, 1.0 ] ], + [ [ 1.0, 0.0, 1.0 ], [ 1.0, 2.43, 1.0 ], [ 1.0, 2.43, -1.0 ], [ 1.0, 0.0, -1.0 ] ], + [ [ 1.0, 0.0, -1.0 ], [ 1.0, 2.43, -1.0 ], [ -1.0, 2.43, -1.0 ], [ -1.0, 0.0, -1.0 ] ] + ], + "play_area" : [ 2.0, 2.0 ], + "seated" : { "translation" : [ 0.0, -1.6, 0.0 ], "yaw" : 0.0 }, + "standing" : { "translation" : [ 0.0, 0.0, 0.0 ], "yaw" : 0.0 }, + "universeID" : "2" + } + ], + "version" : 5 +} +JSON export VK_LAYER_PATH="$layer${VK_LAYER_PATH:+:$VK_LAYER_PATH}" export VK_INSTANCE_LAYERS="VK_LAYER_DROIDDECK_vr${VK_INSTANCE_LAYERS:+:$VK_INSTANCE_LAYERS}" export VK_LOADER_LAYERS_DISABLE="${VK_LOADER_LAYERS_DISABLE:-~implicit~}" - if [ -S "/run/droiddeck/droiddeck-xr-frames" ] && [ -z "${DROIDDECK_VR_PRESENT:-}" ]; then - export DROIDDECK_VR_PRESENT=questwsi - fi if [ "${DROIDDECK_VR_PRESENT:-}" = questwsi ]; then quest_settings apply || exit 1 startup_handed_off=0 @@ -128,12 +209,12 @@ JSON cd "$steamvr/bin/linuxarm64" || exit 1 startup_log=$logs/droiddeck-vrstartup.log ln -sf "$startup_log" "$state/vrstartup.log" - setsid ./vrstartup > "$startup_log" 2>&1 < /dev/null & + setsid ./vrstartup 8>&- 9>&- > "$startup_log" 2>&1 < /dev/null & startup_handed_off=1 echo "SteamVR starting; logs in $logs and $state/vrstartup.log" ;; *) - echo "usage: droiddeck-steamvr status|install|start|stop" >&2 + echo "usage: droiddeck-steamvr status|install|start|stop|launch" >&2 exit 2 ;; esac diff --git a/tools/prepare_windows_runtime.py b/tools/prepare_windows_runtime.py new file mode 100644 index 000000000..8329c032c --- /dev/null +++ b/tools/prepare_windows_runtime.py @@ -0,0 +1,340 @@ +"""Prepare the audited Windows runtime from a pinned APK and rebuilt replacements. + +The donor was built from a CI merge, not just the PR head. Only unchanged +components are reused. Session helpers come from this checkout, libblsession +must be rebuilt for all three architectures, Gamescope comes from the current +checksum-pinned release, and the VR components are rebuilt in Docker (or reused +from a verified build manifest). Store engines are omitted (build with skipRust). +""" +import argparse +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +from zipfile import ZipFile + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'tools/release')) +import runtime_inputs as runtime +from check_session_assets import source_assets +sys.path.insert(0, str(ROOT / 'tools/vr')) +import build_windows as vr_build + + +DONOR_SHA256 = 'b7efef6442f384446bdd1750cec21f0eda61b370bc21d9f55187448e5c256760' +DONOR_PR_SOURCE = 'bdf876d47cc9bb6fa810d014370da5bbb3c28039' +DONOR_MAIN_SOURCE = '46fac69438ed428f2ca43ea935cd47790264f380' +DONOR_TREE = 'ddbca4d572b809fb969be0cf80ef83d6ef6580ce' +DONOR_REVISION = 'cf24c5f2c771633ea426046006c2b977c7bd7d32' +DONOR_CERT_SHA256 = 'b241ea7d77822d39b89d3227e06f3ecf62adec6cbfd38ad06196787991b5d3f0' +DONOR_URL = 'https://github.com/Droid-Deck/DroidDeck-CI/releases/download/pr-732/DroidDeck-PR.732-bdf876d.apk' +AUDITED_SOURCE = '1dc66bf9be64a404c80400049c0803614814461b' +LOCAL_BUILD_PLUMBING = {'app/build.gradle', 'tools/release/runtime_inputs.py'} +# These Android-only inputs are compiled by Gradle, never taken from the donor. +# Keep this list explicit so unrelated native or Linux runtime edits still fail. +LOCAL_ANDROID_SOURCES = {'app/src/main/cpp/CMakeLists.txt', + 'app/src/main/cpp/viture/sdk_bridge.cpp', + 'app/src/main/cpp/viture/display_modes.h', + 'app/src/main/cpp/waylandcomp/src/cardboard.c'} +# These audited helper updates are copied from this checkout by runtime.record(). +# Permit them only while source_assets() still guarantees that staging path. +LOCAL_SESSION_HELPERS = {'tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-compat', + 'tools/linuxfs/overlay/usr/local/bin/droiddeck-steamvr'} +VR_PREFIX = 'assets/linuxfs/usr/local/lib/droiddeck-vr/' +CHECK_PATHS = list(dict.fromkeys(runtime.SOURCE_PATHS + [ + 'app/src/main/cpp', 'app/src/main/jniLibs', 'app/src/main/rust/stores', 'tools/vr'])) +REBUILT = {'libblsession.so': 183, 'x86_64/libblsession.so': 62, 'i386/libblsession.so': 3} +REBUILT_SHA256 = { + 'libblsession.so': '1093ce33ca6d0a17a2eec7b3a3cc53969b01ffc459aef16b320cd3857825b738', + 'x86_64/libblsession.so': 'd0a558fee1ac4d5b898170a96b03e15610d9835e7097425027b8c31728da870a', + 'i386/libblsession.so': '785d8d9bf1fa20d083a7fed65d064c7580684d7065530d95a648e2d32901d2a2', +} +JNI = ('libproot.so', 'libproot-loader.so', 'libdirectaudiorelay.so') +PREPARED = ('pulseaudio.tzst', + 'graphics_driver/adrenotools-turnip25.1.0-quest1.tzst', + 'graphics_driver/adrenotools-turnip26.3.0-a6xx.tzst', + 'graphics_driver/adrenotools-turnip26.3.0-quest2.tzst') + + +def git(*args): + return subprocess.check_output(['git', '-C', str(ROOT), *args]) + + +def changed(base, target=None): + args = ['diff', '--no-ext-diff', '--ignore-cr-at-eol', '--name-only', '-z', base] + if target: + args.append(target) + return set(filter(None, git(*args, '--', *CHECK_PATHS).decode().split('\0'))) + + +def check_sources(): + # A branch label alone is insufficient: CI merged this main commit into it. + if git('merge-tree', '--write-tree', DONOR_PR_SOURCE, DONOR_MAIN_SOURCE).decode().splitlines()[0] != DONOR_TREE: + raise ValueError('The donor CI merge does not match the audited source tree') + helper_sources = {path.relative_to(ROOT).as_posix() for path in source_assets()} + reviewed = LOCAL_BUILD_PLUMBING | LOCAL_ANDROID_SOURCES | (LOCAL_SESSION_HELPERS & helper_sources) + unexpected = {p for p in changed(AUDITED_SOURCE, 'HEAD') - reviewed if not vr_build.is_source(p)} + if unexpected: + raise ValueError('Sources changed since the component audit:\n' + '\n'.join(sorted(unexpected))) + local = changed('HEAD') + untracked = set(filter(None, git('ls-files', '--others', '--exclude-standard', '-z', + '--', *CHECK_PATHS).decode().split('\0'))) + unexpected = {p for p in (local - reviewed) | (untracked - LOCAL_ANDROID_SOURCES) + if not vr_build.is_source(p)} + if unexpected: + raise ValueError('Unreviewed runtime/native/Rust changes:\n' + '\n'.join(sorted(unexpected))) + classifications = {} + for path in sorted(changed(DONOR_TREE, 'HEAD') | local | untracked): + if vr_build.is_source(path): + action = 'VR component rebuilt from current source and verified; never imported from donor' + elif path in LOCAL_ANDROID_SOURCES: + action = 'Android native source compiled by Gradle; not imported from donor' + elif path.startswith('app/src/main/rust/stores/'): + action = 'store engines omitted; Gradle must use skipRust=true' + elif path in helper_sources: + action = 'restaged from current source with LF line endings' + elif path in {'tools/linuxfs/preload/net.c', 'tools/linuxfs/preload/webhelper.c'}: + action = 'all three libblsession libraries rebuilt from current source' + elif path == 'tools/gamescope/release.env': + action = 'current checksum-pinned Gamescope archive replaces donor' + elif path in {'tools/build_local.sh', 'tools/directaudio/fetch.sh'}: + action = 'audited download/build cache plumbing; component inputs unchanged' + elif path == 'app/src/main/cpp/waylandcomp/src/compositor.c': + action = 'Android native library rebuilt by Gradle; not imported' + elif path in LOCAL_BUILD_PLUMBING: + action = 'local Windows build plumbing; changes recorded below' + else: + raise ValueError('Unclassified donor source difference: ' + path) + classifications[path] = action + return classifications + + +def validate_relative(name): + path = PurePosixPath(name) + if path.is_absolute() or not path.parts or any(part in {'.', '..'} or ':' in part for part in path.parts): + raise ValueError('Unsafe archive member: ' + name) + if '\\' in name: + raise ValueError('Backslash in archive member: ' + name) + return path + + +def checked_output(relative): + validate_relative(relative) + target = (ROOT / relative).resolve() + if not target.is_relative_to(ROOT.resolve()): + raise ValueError('Output escapes the checkout: ' + relative) + if git('ls-files', '--', relative).strip(): + raise ValueError('Refusing to overwrite tracked file: ' + relative) + return target + + +def read_gamescope(path): + pins = dict(line.split('=', 1) for line in (ROOT / 'tools/gamescope/release.env').read_text().splitlines() + if line and not line.startswith('#')) + if runtime.digest(path) != pins['GAMESCOPE_SHA256']: + raise ValueError('Gamescope archive checksum does not match tools/gamescope/release.env') + data = subprocess.run(['zstd', '-dc', str(path)], capture_output=True, check=True).stdout + files = {} + with tarfile.open(fileobj=io.BytesIO(data)) as archive: + for member in archive: + name = member.name.removeprefix('./') + validate_relative(name) + if member.isdir(): + continue + if not member.isfile(): + raise ValueError('Gamescope archive contains a non-regular member: ' + name) + if name in files: + raise ValueError('Duplicate Gamescope archive member: ' + name) + files[name] = archive.extractfile(member).read() + if set(files) != {'usr/local/bin/gamescope'}: + raise ValueError('Unexpected Gamescope archive payload: ' + ', '.join(files)) + return files, pins + + +def validate_rebuilt(path, machine): + data = path.read_bytes() + if len(data) < 20 or data[:4] != b'\x7fELF' or data[5] != 1 or struct.unpack(' +Date: Tue, 13 Oct 2026 00:00:00 +0000 +Subject: [PATCH] seccomp: answer get_robust_list with the head set_robust_list + was refused for + +diff --git a/src/execve/exit.c b/src/execve/exit.c +index f32d3ec..b5475b3 100644 +--- a/src/execve/exit.c ++++ b/src/execve/exit.c +@@ -473,6 +473,8 @@ void translate_execve_exit(Tracee *tracee) + talloc_set_name_const(tracee->exe, "$exe"); + } + ++ tracee->robust_list = 0; ++ + /* New processes have no heap. */ + if (talloc_reference_count(tracee->heap) >= 1) { + talloc_unlink(tracee, tracee->heap); +diff --git a/src/tracee/seccomp.c b/src/tracee/seccomp.c +index 77d5b27..0af232b 100644 +--- a/src/tracee/seccomp.c ++++ b/src/tracee/seccomp.c +@@ -559,6 +559,24 @@ static int handle_seccomp_event_common(Tracee *tracee) + } + + case PR_set_robust_list: ++ tracee->robust_list = peek_reg(tracee, CURRENT, SYSARG_1); ++ set_result_after_seccomp(tracee, -ENOSYS); ++ break; ++ ++ case PR_get_robust_list: ++ { ++ pid_t pid = (pid_t) peek_reg(tracee, CURRENT, SYSARG_1); ++ if (tracee->robust_list == 0 || (pid != 0 && pid != tracee->pid)) { ++ set_result_after_seccomp(tracee, -ENOSYS); ++ break; ++ } ++ poke_word(tracee, peek_reg(tracee, CURRENT, SYSARG_2), tracee->robust_list); ++ if (errno == 0) ++ poke_word(tracee, peek_reg(tracee, CURRENT, SYSARG_3), 3 * sizeof_word(tracee)); ++ set_result_after_seccomp(tracee, errno != 0 ? -EFAULT : 0); ++ break; ++ } ++ + default: + /* Set errno to -ENOSYS */ + set_result_after_seccomp(tracee, -ENOSYS); +diff --git a/src/tracee/tracee.h b/src/tracee/tracee.h +index 1fca85f..496ca75 100644 +--- a/src/tracee/tracee.h ++++ b/src/tracee/tracee.h +@@ -248,6 +248,8 @@ typedef struct tracee { + * has to be ignored as it's same syscall entry */ + bool seccomp_already_handled_enter; + ++ word_t robust_list; ++ + /********************************************************************** + * Shared or private resources, depending on the CLONE_FS/VM flags. * + **********************************************************************/ diff --git a/tools/release/runtime_inputs.py b/tools/release/runtime_inputs.py index ab2ce1655..19e83c13e 100644 --- a/tools/release/runtime_inputs.py +++ b/tools/release/runtime_inputs.py @@ -18,6 +18,7 @@ SOURCE_PATHS = ['app/build.gradle', 'app/src/main/cpp/fakeinput_steam.cpp', 'app 'tools/mangoapp', 'tools/msitools', 'tools/comet', 'tools/build_local.sh', 'tools/release/runtime_inputs.py', 'app/src/main/assets/desktop-removal', 'tools/quest', 'tools/vr/src', 'tools/vr/driver_droiddeck', 'tools/vr/layer', 'tools/vr/build.sh', + 'app/src/main/cpp/waylandcomp/src/headpose_shm.h', 'app/src/main/cpp/adrenotools/src/hook/kgsl.h', 'app/src/main/assets/graphics_driver/adrenotools-turnip25.1.0.tzst'] @@ -77,6 +78,10 @@ def required(): ['droiddeck/bin/linuxarm64/driver_droiddeck.so', 'droiddeck/driver.vrdrivermanifest', 'droiddeck/resources/settings/default.vrsettings', 'layer/libVkLayer_droiddeck_vr.so', 'layer/VK_LAYER_DROIDDECK_vr.json', 'layer/VK_LAYER_DROIDDECK_vr_implicit.json']] + for side in ['left', 'right']: + paths += [f'assets/linuxfs/usr/local/lib/droiddeck-vr/droiddeck/resources/rendermodels/droiddeck_hand_{side}/{name}' + for name in [f'droiddeck_hand_{side}.json', 'hand.mtl', 'hand.png', 'hand_open.obj', 'hand_fist.obj', + 'thumb_index_open.obj', 'thumb_index_pinch.obj']] paths += ['assets/linuxfs/usr/local/lib/mangoapp/' + name for name in ['mangoapp', 'libfmt.so.10', 'libspdlog.so.1.13', 'libglfw.so.3', 'libtraceevent.so.1', 'libtracefs.so.1']] paths += ['assets/linuxfs/usr/local/lib/droiddeck-comet/' + name for name in diff --git a/tools/run_windows.ps1 b/tools/run_windows.ps1 new file mode 100644 index 000000000..af71ce7bc --- /dev/null +++ b/tools/run_windows.ps1 @@ -0,0 +1,24 @@ +param([string]$Serial) +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + $javaConfig = if (Test-Path -LiteralPath '.gradle/config.properties' -PathType Leaf) { + Get-Content -LiteralPath '.gradle/config.properties' | Where-Object { $_ -match '^java.home=' } | Select-Object -First 1 + } + if ($javaConfig) { $env:JAVA_HOME = $javaConfig.Substring('java.home='.Length).Replace('\\', '\').Replace('\:', ':') } + & .\gradlew.bat :app:assembleDebug --console=plain + if ($LASTEXITCODE -ne 0) { throw 'Android build failed.' } + $adbArgs = @() + if ($Serial) { $adbArgs = @('-s', $Serial) } + & adb @adbArgs install -r 'app/build/outputs/apk/debug/app-debug.apk' + if ($LASTEXITCODE -ne 0) { throw 'APK installation failed; existing app data was not removed.' } + $suffixLine = if (Test-Path -LiteralPath 'local.properties' -PathType Leaf) { + Get-Content -LiteralPath 'local.properties' | Where-Object { $_ -match '^droiddeck.debugApplicationIdSuffix=' } | Select-Object -First 1 + } + $suffix = if ($suffixLine) { $suffixLine.Substring('droiddeck.debugApplicationIdSuffix='.Length) } else { '' } + & adb @adbArgs shell am start -W -n "com.droiddeck.launcher$suffix/com.droiddeck.launcher.MainActivity" + if ($LASTEXITCODE -ne 0) { throw 'App launch failed.' } +} finally { + Pop-Location +} diff --git a/tools/tests/steam_vr_launch_harness.js b/tools/tests/steam_vr_launch_harness.js new file mode 100644 index 000000000..ef9c4d5b6 --- /dev/null +++ b/tools/tests/steam_vr_launch_harness.js @@ -0,0 +1,81 @@ +const assert = require('assert'); +const vm = require('vm'); +const source = require('fs').readFileSync(process.argv[2], 'utf8'); +const calls = [], requests = [], errors = [], timers = new Set(); +let changed; +const apps = { RunGame(...args) { calls.push({receiver: this, args}); return 'native'; } }; +const openvr = { + RegisterForVRModeChange(callback) { changed = callback; callback(false); return {unregister() {}}; }, + StartVR(...args) { return args; }, +}; +const overview = new Map([ + ['611670', {vr_only: true}], + ['620980', {vr_supported: true}], + ['730', {vr_only: false, vr_supported: false}], + ['18446744069414584320', {BSupportsVR() { return true; }}], +]); +const context = { + SteamClient: {Apps: apps, OpenVR: openvr}, + appStore: {GetAppOverviewByGameID: id => overview.get(id)}, + droiddeckLaunchSteamVr: id => requests.push(id), + setTimeout(callback, ms) { assert.strictEqual(ms, 600000); timers.add(callback); return callback; }, + clearTimeout: timer => timers.delete(timer), + console: {error: message => errors.push(message)}, +}; +context.window = context; +vm.createContext(context); +assert.strictEqual(vm.runInContext(source, context), 'installed'); +assert.strictEqual(vm.runInContext(source, context), 'present'); + +assert.strictEqual(apps.RunGame(730, '', -1, 100), 'native'); +assert.deepStrictEqual(requests, []); +assert.strictEqual(calls[0].receiver, apps); + +const receiver = {}; +apps.RunGame.call(receiver, '611670', 'custom args', 2, 100); +apps.RunGame('611670', 'duplicate', -1, 100); +apps.RunGame(620980, '', -1, 100); +assert.deepStrictEqual(requests, ['611670', '620980']); +assert.strictEqual(calls.length, 1); +changed(false); +assert.strictEqual(calls.length, 1); +changed(true); +assert.strictEqual(calls.length, 3); +assert.strictEqual(calls[1].receiver, receiver); +assert.deepStrictEqual(calls[1].args, ['611670', 'custom args', 2, 100]); +assert.deepStrictEqual(calls[2].args, [620980, '', -1, 100]); +assert.strictEqual(timers.size, 0); +changed(true); +assert.strictEqual(calls.length, 3); + +apps.RunGame('18446744069414584320', '', -1, 100); +assert.deepStrictEqual(calls[3].args, ['18446744069414584320', '', -1, 100]); +assert.strictEqual(requests.at(-1), '18446744069414584320'); +assert.strictEqual(timers.size, 0); + +apps.RunGame('250820', '', -1, 100); +openvr.StartVR(true); +assert.deepStrictEqual(requests.slice(-2), ['250820', '250820']); +assert.strictEqual(calls.length, 4); + +changed(false); +apps.RunGame('611670', '', -1, 100); +for (const timeout of [...timers]) { timers.delete(timeout); timeout(); } +assert.strictEqual(context.__droiddeckSteamVr.pending.size, 0); +assert.strictEqual(errors.length, 1); +changed(true); +assert.strictEqual(calls.length, 4); +apps.RunGame('611670', '', -1, 100); +assert.strictEqual(calls.length, 5); +assert.strictEqual(context.__droiddeckSteamVr.lastError, null); + +changed(false); +context.droiddeckLaunchSteamVr = () => { throw new Error('bridge disconnected'); }; +assert.throws(() => apps.RunGame('611670', '', -1, 100), /bridge disconnected/); +assert.strictEqual(context.__droiddeckSteamVr.pending.size, 0); +assert.strictEqual(timers.size, 0); +delete context.droiddeckLaunchSteamVr; +assert.strictEqual(apps.RunGame('611670', '', -1, 100), 'native'); +assert.strictEqual(apps.RunGame('250820', '', -1, 100), 'native'); +assert.deepStrictEqual(openvr.StartVR(true), [true]); +assert.strictEqual(calls.length, 7); diff --git a/tools/tests/test_output_refresh.py b/tools/tests/test_output_refresh.py new file mode 100644 index 000000000..84ad5113a --- /dev/null +++ b/tools/tests/test_output_refresh.py @@ -0,0 +1,169 @@ +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / 'app/src/main/cpp/waylandcomp/src/compositor.c' + + +def extract_function(source, name): + match = re.search(r'^(?:static )?(?:void|int|uint32_t) ' + re.escape(name) + r'\([^;]*?\) \{', + source, re.MULTILINE) + if match is None: + raise AssertionError(f'Missing compositor function: {name}') + cursor, depth = match.end(), 1 + while depth: + depth += (source[cursor] == '{') - (source[cursor] == '}') + cursor += 1 + return source[match.start():cursor] + + +PREAMBLE = r''' +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +struct wl_resource { int version, modes, done, hz, w, h; struct wl_resource *next; }; +struct surface { int xdg_toplevel, toplevel_committed; struct surface *next; }; +struct input_msg { int type, p1, p2, p3, p4; }; +static struct wl_resource second = { .version = 1 }; +static struct wl_resource first = { .version = 4, .next = &second }; +static struct surface surface = { .xdg_toplevel = 1, .toplevel_committed = 1 }; +static int g_input_pipe[2] = {-1, -1}; +static atomic_int g_output_refresh_mhz = 60000; +static atomic_int g_output_refresh_pending; +static int g_output_announced_refresh_mhz = 60000; +static int g_output_w = 1920, g_output_h = 1080; +static int flushes, resizes, pointers, ticks; +static int scale_changes, scale_mode, scale_alignment, renders; +static void *g_display; +#define WL_OUTPUT_MODE_CURRENT 1 +#define WL_OUTPUT_MODE_PREFERRED 2 +#define wl_resource_for_each(r, list) for ((r) = &first; (r); (r) = (r)->next) +#define wl_list_for_each(s, list, link) for ((s) = &surface; (s); (s) = (s)->next) +static int wl_resource_get_version(struct wl_resource *r) { return r->version; } +static void wl_output_send_mode(struct wl_resource *r, int flags, int w, int h, int hz) { + assert(flags == 3); r->modes++; r->hz = hz; r->w = w; r->h = h; +} +static void wl_output_send_done(struct wl_resource *r) { r->done++; } +static void wl_display_flush_clients(void *display) { (void)display; flushes++; } +static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; } +#define droiddeck_log(...) ((void)0) +#define deliver_key(m) ((void)0) +#define pointer_event(...) ((void)0) +#define pointer_button(...) ((void)0) +#define scroll_event(...) ((void)0) +#define pointer_delta(...) ((void)0) +#define deliver_touch(m) ((void)0) +#define deliver_pointer(m) (pointers++) +static void on_vsync(int64_t ns) { assert(ns > 0); ticks++; } +static void vk_present_set_scale_mode(int mode, int alignment) { + scale_changes++; scale_mode = mode; scale_alignment = alignment; +} +static void schedule_render(void) { renders++; } +''' + +CHECKS = r''' +static int queued(void) { int n; assert(ioctl(g_input_pipe[0], FIONREAD, &n) == 0); return n; } +static void drain(void) { on_input_readable(g_input_pipe[0], 0, NULL); } +static void expect(int modes, int hz, int resize_count) { + assert(first.modes == modes && second.modes == modes); + assert(first.hz == hz && second.hz == hz); + assert(first.w == 1920 && second.w == 1920 && first.h == 1080 && second.h == 1080); + assert(first.done == modes && second.done == 0); + assert(resizes == resize_count); + assert(pointers == 0); +} +int main(void) { + droiddeck_wayland_set_output_refresh_rate(120000); + assert(atomic_load(&g_output_refresh_mhz) == 120000); + assert(!atomic_load(&g_output_refresh_pending)); + assert(first.modes == 0 && second.modes == 0); + assert(pipe2(g_input_pipe, O_CLOEXEC | O_NONBLOCK) == 0); + g_output_announced_refresh_mhz = atomic_load(&g_output_refresh_mhz); + droiddeck_wayland_set_scale_mode(1, 4); + assert(queued() == sizeof(struct input_msg)); + drain(); + assert(scale_changes == 1 && scale_mode == 1 && scale_alignment == 4 && renders == 1); + assert(first.modes == 0 && second.modes == 0); + droiddeck_wayland_set_output_refresh_rate(120000); + assert(queued() == 0); + droiddeck_wayland_set_output_refresh_rate(60000); + assert(first.modes == 0 && queued() == sizeof(struct input_msg)); + drain(); expect(1, 60000, 0); assert(flushes == 1); + droiddeck_wayland_set_output_refresh_rate(60000); + assert(queued() == 0); drain(); expect(1, 60000, 0); + droiddeck_wayland_set_output_refresh_rate(90000); + droiddeck_wayland_set_output_refresh_rate(120000); + drain(); expect(2, 120000, 0); + droiddeck_wayland_set_output_refresh_rate(90000); + droiddeck_wayland_set_output_refresh_rate(120000); + drain(); expect(2, 120000, 0); + droiddeck_wayland_set_output_refresh_rate(60000); + droiddeck_wayland_output_resized(); + drain(); expect(3, 60000, 1); assert(flushes == 3); + struct input_msg wake = {10, 0, 0, 0, 0}; + int n = 0; + while (write(g_input_pipe[1], &wake, sizeof(wake)) == sizeof(wake)) n++; + assert(n > 0 && errno == EAGAIN); + droiddeck_wayland_set_output_refresh_rate(120000); + drain(); expect(4, 120000, 1); + droiddeck_wayland_set_output_refresh_rate(0); + drain(); expect(5, 60000, 1); + droiddeck_wayland_set_output_refresh_rate(-1); + assert(queued() == 0); + struct input_msg vsync = {5, 0, 16000000, 0, 0}; + assert(write(g_input_pipe[1], &vsync, sizeof(vsync)) == sizeof(vsync)); + drain(); expect(5, 60000, 1); assert(ticks == 1); + assert(nominal_refresh_ns() == 16666666); + assert(flushes == 5); + assert(scale_changes == 1 && scale_mode == 1 && scale_alignment == 4 && renders == 1); + droiddeck_wayland_set_scale_mode(1, 3); + droiddeck_wayland_set_output_refresh_rate(90000); + drain(); expect(6, 90000, 1); + assert(scale_changes == 2 && scale_mode == 1 && scale_alignment == 3 && renders == 2); + close(g_input_pipe[0]); close(g_input_pipe[1]); + puts("PASS"); +} +''' + + +@unittest.skipUnless(sys.platform.startswith('linux'), 'needs Linux nonblocking pipes') +class OutputRefreshTest(unittest.TestCase): + def test_refresh_notifications_preserve_client_pacing_without_resizing(self): + compiler = shutil.which(os.environ.get('CC', 'cc')) + if compiler is None: + self.skipTest('A host C compiler is required') + source = SOURCE.read_text() + functions = ('nominal_refresh_ns', 'output_send_mode', 'output_notify_mode', + 'output_resized', 'on_input_readable', 'droiddeck_wayland_output_resized', + 'droiddeck_wayland_set_scale_mode', + 'droiddeck_wayland_set_output_refresh_rate') + harness = PREAMBLE + '\n'.join(extract_function(source, name) for name in functions) + CHECKS + with tempfile.TemporaryDirectory(prefix='droiddeck-output-refresh-') as folder: + path = Path(folder) + (path / 'refresh.c').write_text(harness) + binary = path / 'refresh-test' + build = subprocess.run([compiler, '-std=c11', '-Wall', '-Wextra', '-Werror', + '-Wno-unused-parameter', '-O2', str(path / 'refresh.c'), + '-o', str(binary)], capture_output=True, text=True) + self.assertEqual(0, build.returncode, build.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual('PASS', result.stdout.strip()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_prepare_windows_runtime.py b/tools/tests/test_prepare_windows_runtime.py new file mode 100644 index 000000000..85a72b9c7 --- /dev/null +++ b/tools/tests/test_prepare_windows_runtime.py @@ -0,0 +1,178 @@ +import importlib.util +from pathlib import Path +import struct +import tempfile +import unittest +from unittest import mock +from zipfile import ZipFile + + +spec = importlib.util.spec_from_file_location( + 'prepare_windows_runtime', Path(__file__).resolve().parents[1] / 'prepare_windows_runtime.py') +prepare = importlib.util.module_from_spec(spec) +spec.loader.exec_module(prepare) + + +class WindowsRuntimePreparationTest(unittest.TestCase): + def test_archive_paths_cannot_escape_destination(self): + for name in ('../outside', '/absolute', 'dir/../../outside', 'C:/absolute', 'dir\\outside', '.'): + with self.subTest(name=name), self.assertRaises(ValueError): + prepare.validate_relative(name) + + def test_tracked_outputs_cannot_be_overwritten(self): + with mock.patch.object(prepare, 'git', return_value=b'tracked/file\n'): + with self.assertRaisesRegex(ValueError, 'overwrite tracked'): + prepare.checked_output('tracked/file') + + def test_rebuilt_libraries_must_match_each_architecture(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / 'libblsession.so' + elf = bytearray(64) + elf[:4] = b'\x7fELF' + elf[5] = 1 + elf[18:20] = struct.pack('>"%DROIDDECK_TEST_CALLS%"\n' + 'exit /b %DROIDDECK_TEST_GRADLE_EXIT%\n') + (self.root / 'adb.cmd').write_text( + '@echo off\necho adb %*>>"%DROIDDECK_TEST_CALLS%"\nexit /b 0\n') + self.env = dict(os.environ, JAVA_HOME=r'C:\provided-java', + DROIDDECK_TEST_CALLS=str(self.calls), DROIDDECK_TEST_GRADLE_EXIT='0') + self.env['PATH'] = str(self.root) + os.pathsep + self.env.get('PATH', '') + + def launch(self): + return subprocess.run( + [POWERSHELL, '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', + '-File', str(self.root / 'tools/run_windows.ps1'), '-Serial', 'test-phone'], + env=self.env, cwd=self.root, capture_output=True, text=True, timeout=30) + + def test_missing_optional_configs_keep_environment_java_and_base_package(self): + result = self.launch() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = self.calls.read_text().splitlines() + self.assertEqual(calls[0], r'gradle JAVA_HOME=C:\provided-java') + self.assertEqual(calls[1], 'adb -s test-phone install -r app/build/outputs/apk/debug/app-debug.apk') + self.assertEqual(calls[2], 'adb -s test-phone shell am start -W -n ' + 'com.droiddeck.launcher/com.droiddeck.launcher.MainActivity') + + def test_existing_configs_select_java_and_suffixed_package(self): + (self.root / '.gradle').mkdir() + (self.root / '.gradle/config.properties').write_text('java.home=C\\:\\\\configured-java\n') + (self.root / 'local.properties').write_text('droiddeck.debugApplicationIdSuffix=.xr\n') + result = self.launch() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = self.calls.read_text().splitlines() + self.assertEqual(calls[0], r'gradle JAVA_HOME=C:\configured-java') + self.assertTrue(calls[2].endswith('com.droiddeck.launcher.xr/com.droiddeck.launcher.MainActivity')) + + def test_empty_configs_use_defaults(self): + (self.root / '.gradle').mkdir() + (self.root / '.gradle/config.properties').touch() + (self.root / 'local.properties').touch() + result = self.launch() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + calls = self.calls.read_text().splitlines() + self.assertEqual(calls[0], r'gradle JAVA_HOME=C:\provided-java') + self.assertTrue(calls[2].endswith('com.droiddeck.launcher/com.droiddeck.launcher.MainActivity')) + + def test_build_failure_still_stops_before_installation(self): + self.env['DROIDDECK_TEST_GRADLE_EXIT'] = '1' + result = self.launch() + self.assertNotEqual(result.returncode, 0) + self.assertIn('Android build failed.', result.stderr) + self.assertEqual(self.calls.read_text().splitlines(), [r'gradle JAVA_HOME=C:\provided-java']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_runtime_inputs.py b/tools/tests/test_runtime_inputs.py index 92c045314..e895c3a57 100644 --- a/tools/tests/test_runtime_inputs.py +++ b/tools/tests/test_runtime_inputs.py @@ -1,11 +1,12 @@ import importlib.util import gzip import io -from pathlib import Path +from pathlib import Path, PureWindowsPath import subprocess import tarfile import tempfile import unittest +from unittest import mock from zipfile import ZipFile @@ -95,6 +96,29 @@ class RuntimeInputsTest(unittest.TestCase): package.writestr(name, (self.root / output['path']).read_bytes()) runtime.check_apk(apk, self.root) + def test_windows_manifest_matches_portable_apk_paths(self): + path_type = type(self.root) + original_relative_to = path_type.relative_to + + def windows_relative_to(path, *args, **kwargs): + return PureWindowsPath(original_relative_to(path, *args, **kwargs)) + + with mock.patch.object(path_type, 'relative_to', windows_relative_to): + runtime.record(self.root) + data = runtime.verify(self.root) + self.assertIn('tools/linuxfs/preload/test.c', data['sources']) + nested_asset = 'assets/linuxfs/usr/local/bin/gamescope' + self.assertEqual(data['outputs'][nested_asset]['path'], 'app/src/main/' + nested_asset) + for name in data['sources']: + self.assertNotIn('\\', name) + apk = self.root / 'portable.apk' + with ZipFile(apk, 'w') as package: + for name, output in data['outputs'].items(): + self.assertNotIn('\\', name) + self.assertNotIn('\\', output['path']) + package.writestr(name, (self.root / output['path']).read_bytes()) + runtime.check_apk(apk, self.root) + def test_android_expanding_or_renaming_a_compressed_inventory_is_rejected(self): runtime.record(self.root) data = runtime.verify(self.root) diff --git a/tools/tests/test_steam_compat_environment.py b/tools/tests/test_steam_compat_environment.py new file mode 100644 index 000000000..22a15e0d4 --- /dev/null +++ b/tools/tests/test_steam_compat_environment.py @@ -0,0 +1,50 @@ +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' + + +class SteamCompatEnvironmentTest(unittest.TestCase): + def test_library_launch_preserves_guest_environment_without_steam_client_libraries(self): + source = (BIN / 'droiddeck-session').read_text() + setup = source[source.index(' _bl_compat_env='):source.index(' export SDL_VIDEO_X11_DGAMOUSE=')] + launch = next(line for line in source.splitlines() + if 'droiddeck-steam-compat "$steam_root"' in line) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + helper = root / 'droiddeck-steam-compat' + helper.write_text('#!/usr/bin/env python3\nimport json, os, sys\n' + 'json.dump({"env": dict(os.environ), "args": sys.argv[1:]}, ' + 'open(os.environ["BRIDGE_CAPTURE"], "w"))\n') + helper.chmod(0o755) + for inherited in ({}, {'LD_LIBRARY_PATH': '/guest/driver:/guest/libs'}, + {'LD_LIBRARY_PATH': '', 'STEAM_RUNTIME': ''}, + {'LD_LIBRARY_PATH': '/custom path/lib', 'STEAM_RUNTIME': '0'}): + with self.subTest(inherited=inherited): + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + BRIDGE_CAPTURE=str(root / 'captured.json'), + VK_ICD_FILENAMES='/guest/driver/turnip.json', steam_root='/steam') + for key in ('LD_LIBRARY_PATH', 'STEAM_RUNTIME'): + env.pop(key, None) + env.update(inherited) + result = subprocess.run(['bash', '-c', setup + '\n' + launch + '\nwait $!\n' + 'printf "%s\\n" "$STEAM_RUNTIME" "$LD_LIBRARY_PATH"'], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + bridge = json.loads((root / 'captured.json').read_text()) + self.assertEqual(bridge['args'], ['/steam']) + for key in ('LD_LIBRARY_PATH', 'STEAM_RUNTIME', 'VK_ICD_FILENAMES'): + self.assertEqual(bridge['env'].get(key), env.get(key), key) + client_path = '/steam/steamrtarm64:/steam/steamrtarm64/panorama' + if inherited.get('LD_LIBRARY_PATH'): + client_path += ':' + inherited['LD_LIBRARY_PATH'] + self.assertEqual(result.stdout.splitlines(), ['1', client_path]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_steam_compat_live.py b/tools/tests/test_steam_compat_live.py index 03331fc9e..dc4b10cfc 100644 --- a/tools/tests/test_steam_compat_live.py +++ b/tools/tests/test_steam_compat_live.py @@ -108,6 +108,45 @@ class FakePage: @unittest.skipUnless(shutil.which("node"), "node is not installed") class PageScriptTest(unittest.TestCase): + def test_vr_games_wait_for_steamvr_and_preserve_the_original_launch(self): + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "vr.js" + script.write_text(LIVE["VR_JS"]) + harness = Path(__file__).with_name("steam_vr_launch_harness.js") + result = subprocess.run(["node", str(harness), str(script)], + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_steamvr_library_launch_uses_binding_and_other_games_pass_through(self): + harness_source = r""" +const assert = require('assert'); +globalThis.window = globalThis; +const calls = [], requests = []; +const apps = { RunGame(...args) { assert.strictEqual(this, apps); calls.push(args); return 'native'; } }; +globalThis.SteamClient = { Apps: apps }; +window.droiddeckLaunchSteamVr = id => requests.push(id); +const script = require('fs').readFileSync(process.argv[2], 'utf8'); +assert.strictEqual(eval(script), 'installed'); +assert.strictEqual(eval(script), 'present'); +assert.strictEqual(apps.RunGame('250820', '', -1, 100), undefined); +apps.RunGame(250820, '', -1, 100); +assert.deepStrictEqual(requests, ['250820', '250820']); +assert.deepStrictEqual(calls, []); +assert.strictEqual(apps.RunGame('611670', 'custom args', -1, 100), 'native'); +assert.strictEqual(apps.RunGame('2508200', '', -1, 100), 'native'); +delete window.droiddeckLaunchSteamVr; +assert.strictEqual(apps.RunGame('250820', '', -1, 100), 'native'); +assert.deepStrictEqual(calls, [['611670', 'custom args', -1, 100], ['2508200', '', -1, 100], ['250820', '', -1, 100]]); +""" + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "vr.js" + script.write_text(LIVE["VR_JS"]) + harness = Path(tmp) / "test.js" + harness.write_text(harness_source) + result = subprocess.run(["node", str(harness), str(script)], + capture_output=True, text=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + def test_transport_framing_auth_and_notifications(self): with tempfile.TemporaryDirectory() as tmp: script = Path(tmp) / "install.js" @@ -493,6 +532,54 @@ class SwitchToDesktopTest(unittest.TestCase): self.assertNotIn(("Runtime.addBinding", LIVE["SWITCH_BINDING"]), helper.page.calls) self.assertNotIn(LIVE["SWITCH_JS"], helper.page.scripts) + def test_vr_bridge_is_installed_for_both_steam_ui_modes(self): + for steamos in (False, True): + helper = LIVE["Helper"]("/steam", 1, steamos=steamos) + helper.attach() + self.assertIn(("Runtime.addBinding", LIVE["VR_BINDING"]), helper.page.calls) + self.assertIn(LIVE["VR_JS"], helper.page.scripts) + + def test_vr_bridge_accepts_only_steamvr_and_coalesces_requests(self): + helper = LIVE["Helper"]("/steam", 1) + helper.page = mock.Mock() + event = lambda payload: {"method": "Runtime.bindingCalled", "params": { + "name": LIVE["VR_BINDING"], "payload": payload}} + with mock.patch.dict(self.globals, {"launch_steamvr": mock.Mock()}) as patched: + launch = patched["launch_steamvr"] + helper.page.drain.return_value = [event("0"), event("250820; touch /tmp/wrong"), + event("-1"), event("01"), event("18446744073709551616"), + event("\u0661"), event(None), event({})] + helper.drain() + launch.assert_not_called() + helper.page.drain.return_value = [event("250820"), event("250820")] + helper.drain() + launch.assert_called_once_with() + + def test_vr_game_requests_share_an_in_progress_helper_and_retry_after_it_exits(self): + helper = LIVE["Helper"]("/steam", 1) + helper.page = mock.Mock() + event = lambda app: {"method": "Runtime.bindingCalled", "params": { + "name": LIVE["VR_BINDING"], "payload": app}} + child = mock.Mock() + child.poll.return_value = None + with mock.patch.dict(self.globals, {"launch_steamvr": mock.Mock(return_value=child)}) as patched: + helper.page.drain.return_value = [event("611670"), event("620980")] + helper.drain() + helper.drain() + patched["launch_steamvr"].assert_called_once_with() + child.poll.return_value = 0 + helper.drain() + self.assertEqual(patched["launch_steamvr"].call_count, 2) + + def test_vr_launch_runs_helper_without_shell_in_background(self): + with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": tmp}): + with mock.patch.object(self.globals["subprocess"], "Popen") as popen: + LIVE["launch_steamvr"]() + self.assertEqual(popen.call_args.args, ([LIVE["VR_HELPER"], "launch"],)) + self.assertTrue(popen.call_args.kwargs["start_new_session"]) + self.assertNotIn("shell", popen.call_args.kwargs) + self.assertTrue((Path(tmp) / "droiddeck-steamvr/library-launch.log").exists()) + def test_the_request_goes_to_steamos_session_select(self): helper = LIVE["Helper"]("/steam", 1) helper.page = mock.Mock() diff --git a/tools/tests/test_steamvr_settings.py b/tools/tests/test_steamvr_settings.py new file mode 100644 index 000000000..501fc9365 --- /dev/null +++ b/tools/tests/test_steamvr_settings.py @@ -0,0 +1,83 @@ +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +HELPER = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin/droiddeck-steamvr' +SCRIPT = HELPER.read_text().split("<<'PY'\n", 1)[1].split('\nPY\n', 1)[0] + + +class SteamVrSettingsTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'steamvr.vrsettings' + + def run_defaults(self): + result = subprocess.run([sys.executable, '-', str(self.path)], input=SCRIPT, + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(list(self.path.parent.iterdir()), [self.path]) + return result + + def test_new_install_defaults_to_manual_100_percent(self): + self.run_defaults() + self.assertEqual(json.loads(self.path.read_text()), { + 'steamvr': {'supersampleManualOverride': True, 'supersampleScale': 1.0}}) + + def test_existing_automatic_gpu_estimate_does_not_override_app_resolution(self): + settings = {'GpuSpeed': {'gpuSpeedRenderTargetScale': 1.1}, + 'steamvr': {'environmentMode': 0}, 'driver_droiddeck': {'directMode': True}} + self.path.write_text(json.dumps(settings)) + self.run_defaults() + settings['steamvr'].update(supersampleManualOverride=True, supersampleScale=1.0) + self.assertEqual(json.loads(self.path.read_text()), settings) + + def test_explicit_user_and_per_game_settings_are_preserved_without_rewrite(self): + for manual in (False, True): + with self.subTest(manual=manual): + settings = {'steamvr': {'supersampleManualOverride': manual, 'supersampleScale': 0.7}, + 'steam.app.611670': {'resolutionScale': 80}} + content = json.dumps(settings) + self.path.write_text(content) + self.run_defaults() + self.assertEqual(self.path.read_text(), content) + + def test_defaults_are_idempotent(self): + self.run_defaults() + before = self.path.read_bytes() + self.run_defaults() + self.assertEqual(self.path.read_bytes(), before) + + def test_partial_user_settings_are_preserved(self): + self.path.write_text('{"steamvr": {"supersampleScale": 0.5}}') + self.run_defaults() + self.assertEqual(json.loads(self.path.read_text())['steamvr'], { + 'supersampleScale': 0.5, 'supersampleManualOverride': True}) + + def test_a_crash_block_on_the_droiddeck_driver_is_cleared_and_others_kept(self): + settings = {'steamvr': {'supersampleManualOverride': True, 'supersampleScale': 1.0}, + 'driver_droiddeck': {'blocked_by_safe_mode': True, 'directMode': True}, + 'driver_lighthouse': {'blocked_by_safe_mode': True}} + self.path.write_text(json.dumps(settings)) + self.assertIn('unblocked it', self.run_defaults().stdout) + settings['driver_droiddeck']['blocked_by_safe_mode'] = False + self.assertEqual(json.loads(self.path.read_text()), settings) + + def test_the_crash_marker_is_removed_so_steamvr_does_not_start_in_safe_mode(self): + (self.path.parent / 'vrserver_crash_timestamp.txt').write_text('1792116926 1789606310 223') + self.assertIn('crash marker', self.run_defaults().stdout) + + def test_invalid_settings_are_reported_without_losing_the_original(self): + for content in ('{broken', '[]', '{"steamvr": null}', '{"steamvr": []}'): + with self.subTest(content=content): + self.path.write_text(content) + self.assertIn('Could not set SteamVR resolution defaults', self.run_defaults().stderr) + self.assertEqual(self.path.read_text(), content) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/test_windows_vr_build.py b/tools/tests/test_windows_vr_build.py new file mode 100644 index 000000000..df34f31f5 --- /dev/null +++ b/tools/tests/test_windows_vr_build.py @@ -0,0 +1,125 @@ +import importlib.util +import json +from pathlib import Path +import struct +import tempfile +import unittest +from unittest import mock + +spec = importlib.util.spec_from_file_location( + 'windows_vr_build', Path(__file__).resolve().parents[1] / 'vr/build_windows.py') +vr = importlib.util.module_from_spec(spec) +spec.loader.exec_module(vr) + + +class WindowsVrBuildTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.out = self.root / 'build/vr' + self.include = self.root / 'ndk/include' + for name in vr.SOURCE_FILES: + self.write(self.root / name, b'build source\n') + for tree in vr.SOURCE_TREES: + self.write(self.root / tree / 'input.h', b'header\n') + for name, source in vr.CONFIGS.items(): + self.write(self.root / source, b'{"name": "current"}\n') + self.write(self.out / name, b'{"name": "current"}\n') + for name in vr.LIBRARIES: + data = bytearray(64) + data[:6] = b'\x7fELF\x02\x01' + struct.pack_into(' +#include +#include + +#include "hand_mesh.h" + +using namespace droiddeck; + +static bool Write(const std::string &path, const void *data, size_t size) { + FILE *f = fopen(path.c_str(), "wb"); + if (!f) return false; + const bool ok = fwrite(data, 1, size, f) == size; + return fclose(f) == 0 && ok; +} + +static bool Write(const std::string &path, const std::string &text) { return Write(path, text.data(), text.size()); } + +int main(int argc, char **argv) { + if (argc != 2) { + fprintf(stderr, "usage: make_hand_models \n"); + return 2; + } + const std::string root = argv[1]; + mkdir(root.c_str(), 0755); + const std::vector skin = GlovePng(64, {246, 246, 249}, {224, 227, 233}, glove::kDarkest); + for (int right = 0; right < 2; right++) { + const std::string name = right ? "droiddeck_hand_right" : "droiddeck_hand_left"; + const std::string dir = root + "/" + name; + mkdir(dir.c_str(), 0755); + bool ok = Write(dir + "/" + name + ".json", HandModelJson(right)) && Write(dir + "/hand.mtl", HandMtl("hand", "hand.png")) && + Write(dir + "/hand.png", skin.data(), skin.size()) && + Write(dir + "/hand_open.obj", HandObj(BuildHandPart(right, HandPart::Hand, false), "hand")) && + Write(dir + "/hand_fist.obj", HandObj(BuildHandPart(right, HandPart::Hand, true), "hand")) && + Write(dir + "/thumb_index_open.obj", HandObj(BuildHandPart(right, HandPart::ThumbIndex, false), "hand")) && + Write(dir + "/thumb_index_pinch.obj", HandObj(BuildHandPart(right, HandPart::ThumbIndex, true), "hand")); + if (!ok) { + fprintf(stderr, "make_hand_models: could not write %s\n", dir.c_str()); + return 1; + } + } + return 0; +} diff --git a/tools/vr/src/arm_model.h b/tools/vr/src/arm_model.h new file mode 100644 index 000000000..4004677e2 --- /dev/null +++ b/tools/vr/src/arm_model.h @@ -0,0 +1,120 @@ +// Arm model ported from Google's GvrArmModel.cs (Google VR SDK for Unity, Apache-2.0), see docs/third-party/joycon. +#pragma once + +#include +#include + +namespace droiddeck { + +struct Vec3 { + double x = 0.0, y = 0.0, z = 0.0; +}; + +struct Quat { + double w = 1.0, x = 0.0, y = 0.0, z = 0.0; +}; + +inline Vec3 operator+(const Vec3 &a, const Vec3 &b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; } +inline Vec3 operator-(const Vec3 &a, const Vec3 &b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; } +inline Vec3 operator*(const Vec3 &a, double s) { return {a.x * s, a.y * s, a.z * s}; } +inline double Dot(const Vec3 &a, const Vec3 &b) { return a.x * b.x + a.y * b.y + a.z * b.z; } +inline Vec3 Cross(const Vec3 &a, const Vec3 &b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; } +inline double Length(const Vec3 &a) { return std::sqrt(Dot(a, a)); } + +inline Quat operator*(const Quat &a, const Quat &b) { + return {a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w}; +} + +inline Quat Conjugate(const Quat &q) { return {q.w, -q.x, -q.y, -q.z}; } + +inline Quat Normalize(const Quat &q) { + const double n = std::sqrt(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z); + if (!(n > 1e-9)) return {}; + return {q.w / n, q.x / n, q.y / n, q.z / n}; +} + +inline Vec3 Rotate(const Quat &q, const Vec3 &v) { + const Vec3 u{q.x, q.y, q.z}; + const Vec3 t = Cross(u, v) * 2.0; + return v + t * q.w + Cross(u, t); +} + +inline Quat AboutX(double radians) { return {std::cos(radians / 2), std::sin(radians / 2), 0.0, 0.0}; } +inline Quat AboutY(double radians) { return {std::cos(radians / 2), 0.0, std::sin(radians / 2), 0.0}; } + +inline Quat FromTo(const Vec3 &from, const Vec3 &to) { + const double d = Dot(from, to); + if (d < -0.999999) { + Vec3 axis = Cross({1.0, 0.0, 0.0}, from); + if (Length(axis) < 1e-6) axis = Cross({0.0, 1.0, 0.0}, from); + axis = axis * (1.0 / Length(axis)); + return {0.0, axis.x, axis.y, axis.z}; + } + const Vec3 c = Cross(from, to); + return Normalize({1.0 + d, c.x, c.y, c.z}); +} + +inline Quat Nlerp(const Quat &a, const Quat &b, double t) { + const double sign = a.w * b.w + a.x * b.x + a.y * b.y + a.z * b.z < 0.0 ? -1.0 : 1.0; + return Normalize({a.w + (sign * b.w - a.w) * t, a.x + (sign * b.x - a.x) * t, + a.y + (sign * b.y - a.y) * t, a.z + (sign * b.z - a.z) * t}); +} + +inline double AngleDegrees(const Quat &q) { return 2.0 * std::acos(std::min(std::fabs(q.w), 1.0)) * 180.0 / M_PI; } + +inline bool Heading(const Quat &q, double *radians) { + const Vec3 forward = Rotate(q, {0.0, 0.0, -1.0}); + if (std::hypot(forward.x, forward.z) < 0.25) return false; + *radians = std::atan2(-forward.x, -forward.z); + return true; +} + +class ArmModel { +public: + explicit ArmModel(bool right) : side_(right ? 1.0 : -1.0) {} + + void Reset() { torsoSet_ = false; } + + Vec3 Update(const Vec3 &head, const Quat &headRotation, const Quat &controller, double angularSpeed, double dt) { + Vec3 gaze = Rotate(headRotation, {0.0, 0.0, -1.0}); + gaze.y = 0.0; + const double length = Length(gaze); + if (length > 1e-3) { + gaze = gaze * (1.0 / length); + if (!torsoSet_) { + torso_ = gaze; + torsoSet_ = true; + } else { + const double strength = std::clamp((angularSpeed - 0.2) / 45.0, 0.0, 0.1); + const double follow = 1.0 - std::pow(1.0 - strength, std::max(dt, 0.0) * 60.0); + const double angle = std::atan2(Cross(torso_, gaze).y, Dot(torso_, gaze)); + torso_ = Rotate(AboutY(angle * follow), torso_); + } + } + const Quat torso = AboutY(std::atan2(-torso_.x, -torso_.z)); + const Vec3 forward = Rotate(Conjugate(torso) * controller, {0.0, 0.0, -1.0}); + const double elevation = std::asin(std::clamp(forward.y, -1.0, 1.0)) * 180.0 / M_PI; + const Quat swing = FromTo({0.0, 0.0, -1.0}, forward); + const double extension = std::clamp((elevation - kMinExtension) / (kMaxExtension - kMinExtension), 0.0, 1.0); + const Vec3 elbow = Vec3{0.195 * side_, -0.5, -0.005} + Vec3{-0.13 * side_, 0.14, -0.08} * extension; + const double suppression = 1.0 - std::pow(AngleDegrees(swing) / 180.0, 6.0); + const double share = ((1.0 - kElbowBend) + kElbowBend * extension * kExtensionWeight) * suppression; + const Quat lerp = Nlerp({}, swing, share); + const Quat elbowRotation = torso * Conjugate(lerp) * swing; + const Quat wristRotation = elbowRotation * lerp; + const Vec3 elbowAt = head + Rotate(torso, elbow); + const Vec3 wristAt = elbowAt + Rotate(elbowRotation, {0.0, 0.0, -0.25}); + return wristAt + Rotate(wristRotation, {0.0, 0.0, -0.05}); + } + +private: + static constexpr double kMinExtension = 7.0, kMaxExtension = 60.0, kElbowBend = 0.6, kExtensionWeight = 0.4; + double side_; + bool torsoSet_ = false; + Vec3 torso_{0.0, 0.0, -1.0}; +}; + +} diff --git a/tools/vr/src/driver_droiddeck.cpp b/tools/vr/src/driver_droiddeck.cpp index b7d56fa0c..76390d394 100644 --- a/tools/vr/src/driver_droiddeck.cpp +++ b/tools/vr/src/driver_droiddeck.cpp @@ -7,17 +7,32 @@ #include #include #include +#include #include #include +#include #include +#include +#include #include +#include +#include +#include #include #include #include #include #include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h" +#include "arm_model.h" +#include "hand_motion.h" +#include "hand_pose.h" +#include "handcam_shm.h" +#include "hand_skeleton.h" +#include "index_pose.h" +#include "joycon_shm.h" #include "presenter.h" +#include "render_target.h" #include "xr_tracking_file.h" #include "xr_frame_math.h" #include "xr_vsync_timing.h" @@ -27,11 +42,20 @@ #define DD_EXPORT extern "C" __attribute__((visibility("default"))) using namespace vr; +using namespace droiddeck; namespace { const char *const kSettingsSection = "driver_droiddeck"; const char *const kPosePath = "/run/droiddeck/" DROIDDECK_HEADPOSE_FILE; +const char *const kJoyConPath = "/run/droiddeck/" DROIDDECK_JOYCON_FILE; +const char *const kHandCamPath = "/run/droiddeck/" DROIDDECK_HANDCAM_FILE; +const char *const kHandTraceFlag = "/run/droiddeck/droiddeck-handtrace.on"; +const char *const kHandTracePath = "/run/droiddeck/droiddeck-handtrace.csv"; +constexpr float kMinPulseMs = 30.0f; +constexpr int64_t kJoyConStaleNs = 500000000; +constexpr int64_t kHandCamStaleNs = 300000000; +constexpr int64_t kBareHandsStaleNs = 2000000000; void Log(const char *fmt, ...) __attribute__((format(printf, 1, 2))); void Log(const char *fmt, ...) { @@ -44,6 +68,35 @@ void Log(const char *fmt, ...) { fprintf(stderr, "[droiddeck] %s\n", buf); } +class HandTrace { +public: + void Poll(int64_t nowNs) { + if (checkedNs_ && nowNs - checkedNs_ < 1000000000) return; + checkedNs_ = nowNs; + const bool want = access(kHandTraceFlag, F_OK) == 0; + if (want && !file_) { + file_ = fopen(kHandTracePath, "a"); + if (file_) { + fprintf(file_, "S,%lld\n", (long long)nowNs); + Log("hand trace started"); + } + } else if (!want && file_) { + fclose(file_); + file_ = nullptr; + Log("hand trace stopped"); + } + if (file_) fflush(file_); + } + + FILE *File() const { return file_; } + +private: + FILE *file_ = nullptr; + int64_t checkedNs_ = 0; +}; + +HandTrace gHandTrace; + class PoseFile { public: bool Read(droiddeck_headpose *out) { @@ -93,6 +146,8 @@ HmdQuaternion_t QuatFromMatrix(const float m[9]) { return q; } +double VsyncPeriodSeconds(); + HmdMatrix34_t XrPoseMatrix(const DroidDeckXrPose &pose) { const float qx = pose.orientation[0], qy = pose.orientation[1], qz = pose.orientation[2], qw = pose.orientation[3]; HmdMatrix34_t matrix{}; @@ -127,31 +182,45 @@ public: void WaitForPresent() override {} bool GetTimeSinceLastVsync(float *seconds, uint64_t *counter) override { using namespace std::chrono; - double t = duration(steady_clock::now().time_since_epoch()).count(); - double period = 1.0 / 60.0; - *seconds = (float)fmod(t, period); - *counter = (uint64_t)(t / period); + const double period = VsyncPeriodSeconds(); + std::lock_guard lock(vsyncMutex_); + const double t = duration(steady_clock::now().time_since_epoch()).count(); + if (period != period_) { + vsyncBase_ = period_ > 0.0 ? vsyncBase_ + (uint64_t)((t - vsyncAt_) / period_) : (uint64_t)(t / period); + vsyncAt_ = t; + period_ = period; + } + const double since = t - vsyncAt_; + *seconds = (float)fmod(since, period); + *counter = vsyncBase_ + (uint64_t)(since / period); return true; } private: uint64_t frames_ = 0; std::chrono::steady_clock::time_point last_; + std::mutex vsyncMutex_; + double period_ = 0.0, vsyncAt_ = 0.0; + uint64_t vsyncBase_ = 0; }; class Vsync { public: - static constexpr auto kInterval = XrVsyncTiming::kDefaultPeriod; static constexpr std::chrono::microseconds kLead{2000}; + static std::chrono::nanoseconds Interval() { return std::chrono::nanoseconds(intervalNs_.load()); } + static void SetRate(float hz) { + if (hz >= 24.0f && hz <= 240.0f) intervalNs_ = (int64_t)(1e9 / hz); + } + static void Start() { if (running_.exchange(true)) return; timing_.Reset(); thread_ = std::thread([] { XrTrackingFile xrFile; - auto next = std::chrono::steady_clock::now() + kInterval; + auto next = std::chrono::steady_clock::now() + Interval(); while (running_) { - auto interval = kInterval; + auto interval = Interval(); DroidDeckXrTracking xr; if (xrFile.Read(&xr) && XrTrackingFile::Fresh(xr)) { interval = std::chrono::nanoseconds(xr.period_ns); @@ -180,10 +249,13 @@ public: private: static inline std::atomic running_{false}; + static inline std::atomic intervalNs_{16666667}; static inline std::thread thread_; static inline XrVsyncTiming timing_; }; +double VsyncPeriodSeconds() { return Vsync::Interval().count() / 1e9; } + class DirectMode : public IVRDriverDirectModeComponent { public: void ConfigureXr(const DroidDeckXrTracking &tracking) { @@ -382,6 +454,510 @@ private: std::chrono::steady_clock::time_point retry_{}; }; +template +class ShmFile { +public: + ShmFile(const char *path, uint32_t magic, uint32_t version, const char *name) + : path_(path), magic_(magic), version_(version), name_(name) {} + bool Read(T *out) { + if (!map_ && !Open()) return false; + for (int tries = 0; tries < 64; tries++) { + if (tries) sched_yield(); + const uint32_t a = __atomic_load_n(&map_->seq, __ATOMIC_ACQUIRE); + if (a & 1u) continue; + memcpy(out, (const void *)map_, sizeof(*out)); + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (__atomic_load_n(&map_->seq, __ATOMIC_RELAXED) == a) return out->magic == magic_ && out->version == version_; + } + return false; + } + ~ShmFile() { if (map_) munmap((void *)map_, sizeof(*map_)); } + +private: + bool Open() { + const auto now = std::chrono::steady_clock::now(); + if (now < retry_) return false; + retry_ = now + std::chrono::seconds(2); + int fd = open(path_, O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size < (off_t)sizeof(T)) { + close(fd); + return false; + } + void *p = mmap(nullptr, sizeof(T), PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (p == MAP_FAILED) return false; + map_ = (volatile T *)p; + Log("%s file mapped: %s", name_, path_); + return true; + } + const char *const path_; + const uint32_t magic_, version_; + const char *const name_; + volatile T *map_ = nullptr; + std::chrono::steady_clock::time_point retry_{}; +}; + +void SendRumble(uint16_t slot, const VREvent_HapticVibration_t &h) { + if (!(h.fAmplitude > 0.0f) || !(h.fFrequency > 0.0f)) return; + const float seconds = std::isfinite(h.fDurationSeconds) ? h.fDurationSeconds : 0.0f; + const uint16_t ms = (uint16_t)std::clamp(seconds * 1000.0f, kMinPulseMs, 5000.0f); + const uint16_t strength = (uint16_t)std::lround(std::min(h.fAmplitude, 1.0f) * 65535.0f); + const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (fd < 0) return; + sockaddr_un addr = {}; + addr.sun_family = AF_UNIX; + const char name[] = "droiddeck-rumble"; + memcpy(addr.sun_path + 1, name, sizeof(name) - 1); + const socklen_t length = (socklen_t)(offsetof(sockaddr_un, sun_path) + sizeof(name)); + if (connect(fd, (const sockaddr *)&addr, length) == 0) { + const uint16_t data[4] = {strength, strength, ms, slot}; + send(fd, data, sizeof(data), MSG_DONTWAIT | MSG_NOSIGNAL); + } + close(fd); +} + +int64_t MonotonicNs() { + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +bool JoyConLive(const droiddeck_joycon_hand &hand, int64_t nowNs) { + return (hand.flags & DROIDDECK_JOYCON_CONNECTED) && hand.time_ns > 0 && + nowNs - hand.time_ns < kJoyConStaleNs && hand.time_ns - nowNs < kJoyConStaleNs; +} + +class HandController : public ITrackedDeviceServerDriver { +public: + explicit HandController(bool right) : right_(right), arm_(right), camera_(right) {} + + EVRInitError Activate(uint32_t id) override { + auto props = VRProperties(); + const PropertyContainerHandle_t c = props->TrackedDeviceToPropertyContainer(id); + props->SetStringProperty(c, Prop_ModelNumber_String, right_ ? "Knuckles Right" : "Knuckles Left"); + props->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck"); + props->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck"); + props->SetStringProperty(c, Prop_RenderModelName_String, RenderModel(expectBare_.load())); + props->SetStringProperty(c, Prop_ControllerType_String, "knuckles"); + props->SetStringProperty(c, Prop_InputProfilePath_String, "{indexcontroller}/input/index_controller_profile.json"); + props->SetStringProperty(c, Prop_RegisteredDeviceType_String, right_ ? "droiddeck/hand_right" : "droiddeck/hand_left"); + props->SetInt32Property(c, Prop_ControllerRoleHint_Int32, + right_ ? TrackedControllerRole_RightHand : TrackedControllerRole_LeftHand); + props->SetInt32Property(c, Prop_DeviceClass_Int32, TrackedDeviceClass_Controller); + props->SetInt32Property(c, Prop_ControllerHandSelectionPriority_Int32, 100); + props->SetBoolProperty(c, Prop_DeviceProvidesBatteryStatus_Bool, true); + props->SetBoolProperty(c, Prop_DeviceCanPowerOff_Bool, false); + container_ = c; + pose_.qWorldFromDriverRotation.w = 1; + pose_.qDriverFromHeadRotation.w = 1; + pose_.qRotation.w = 1; + pose_.result = TrackingResult_Uninitialized; + auto in = VRDriverInput(); + const auto boolean = [&](const char *path, VRInputComponentHandle_t *h) { in->CreateBooleanComponent(c, path, h); }; + const auto oneSided = [&](const char *path, VRInputComponentHandle_t *h) { + in->CreateScalarComponent(c, path, h, VRScalarType_Absolute, VRScalarUnits_NormalizedOneSided); + }; + const auto twoSided = [&](const char *path, VRInputComponentHandle_t *h) { + in->CreateScalarComponent(c, path, h, VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided); + }; + boolean("/input/system/click", &systemClick_); + boolean("/input/system/touch", &systemTouch_); + boolean("/input/a/click", &aClick_); + boolean("/input/a/touch", &aTouch_); + boolean("/input/b/click", &bClick_); + boolean("/input/b/touch", &bTouch_); + boolean("/input/trigger/click", &triggerClick_); + boolean("/input/trigger/touch", &triggerTouch_); + oneSided("/input/trigger/value", &trigger_); + boolean("/input/grip/touch", &gripTouch_); + oneSided("/input/grip/value", &grip_); + oneSided("/input/grip/force", &gripForce_); + twoSided("/input/thumbstick/x", &stickX_); + twoSided("/input/thumbstick/y", &stickY_); + boolean("/input/thumbstick/click", &stickClick_); + boolean("/input/thumbstick/touch", &stickTouch_); + twoSided("/input/trackpad/x", &padX_); + twoSided("/input/trackpad/y", &padY_); + oneSided("/input/trackpad/force", &padForce_); + boolean("/input/trackpad/touch", &padTouch_); + oneSided("/input/finger/index", &fingers_[0]); + oneSided("/input/finger/middle", &fingers_[1]); + oneSided("/input/finger/ring", &fingers_[2]); + oneSided("/input/finger/pinky", &fingers_[3]); + in->CreateSkeletonComponent(c, right_ ? "/input/skeleton/right" : "/input/skeleton/left", + right_ ? "/skeleton/hand/right" : "/skeleton/hand/left", "/pose/raw", + VRSkeletalTracking_Estimated, nullptr, 0, &skeleton_); + in->CreateHapticComponent(c, "/output/haptic", &haptic_); + SendSkeleton(); + id_.store(id, std::memory_order_release); + Log("%s hand activated", right_ ? "right" : "left"); + return VRInitError_None; + } + void Deactivate() override { id_.store(k_unTrackedDeviceIndexInvalid, std::memory_order_release); } + void EnterStandby() override {} + void *GetComponent(const char *) override { return nullptr; } + void DebugRequest(const char *, char *resp, uint32_t size) override { if (size) resp[0] = 0; } + + DriverPose_t GetPose() override { + std::lock_guard lock(poseMutex_); + return pose_; + } + + void ExpectBare(bool bare) { expectBare_ = bare; } + + VRInputComponentHandle_t Haptic() const { + return id_.load(std::memory_order_acquire) == k_unTrackedDeviceIndexInvalid || bare_.load() ? 0 : haptic_; + } + + void SeeCamera(const Vec3 &fromHead, const Vec3 *joints, int64_t timeNs, bool estimated, int64_t nowNs) { + if (connected_) { + if (!aligning_) kalman_.Measure(timeNs, kHeadPosition + fromHead, estimated ? kEstimatedSigma : kCameraSigma); + } else if (bare_.load()) { + camera_.See(timeNs, kHeadPosition + fromHead, joints, kHeadPosition, nowNs); + } + } + + bool Update(const droiddeck_joycon_hand *hand, bool bare, const HmdQuaternion_t &headPose, int64_t nowNs) { + const uint32_t id = id_.load(std::memory_order_acquire); + if (id == k_unTrackedDeviceIndexInvalid) return false; + const auto now = std::chrono::steady_clock::now(); + const double dt = std::min(std::chrono::duration(now - lastUpdate_).count(), 0.1); + lastUpdate_ = now; + if (!hand) { + Disconnect(id); + if (bare) { + UpdateBare(id, nowNs, dt); + return true; + } + LeaveBare(id); + return false; + } + LeaveBare(id); + const Quat head{headPose.w, headPose.x, headPose.y, headPose.z}; + const Quat q = Normalize({hand->rotation[0], hand->rotation[1], hand->rotation[2], hand->rotation[3]}); + if (!connected_ || hand->recenter != recenter_) { + recenter_ = hand->recenter; + aligning_ = true; + } + connected_ = true; + double headYaw = 0.0, handYaw = 0.0; + if (aligning_ && Heading(head, &headYaw) && Heading(q, &handYaw)) { + yaw_ = headYaw - handYaw; + aligning_ = false; + arm_.Reset(); + follower_.Reset(); + kalman_.Reset(); + shown_ = false; + Log("%s Joy-Con facing forward", right_ ? "right" : "left"); + } + const Quat turn = AboutY(yaw_); + const Quat aligned = turn * q; + const Vec3 rate{hand->angular_velocity[0], hand->angular_velocity[1], hand->angular_velocity[2]}; + const Vec3 palm = arm_.Update(kHeadPosition, head, aligned, Length(rate), dt); + const Vec3 moved = Rotate(turn, {hand->displacement[0], hand->displacement[1], hand->displacement[2]}); + const Vec3 inertial = Rotate(turn, {hand->inertial_velocity[0], hand->inertial_velocity[1], hand->inertial_velocity[2]}); + const bool holding = (hand->buttons & (DROIDDECK_JOYCON_TRIGGER | DROIDDECK_JOYCON_GRIP)) != 0; + kalman_.Input(hand->time_ns - kJoyConDelayNs, inertial, Length(rate)); + const bool camera = kalman_.Ready() && kalman_.TimeNs() - kalman_.SeenNs() <= kCameraHoldNs; + bool switched = false; + if (camera != cameraPlaced_) { + cameraPlaced_ = camera; + switched = true; + Log("%s Joy-Con hand placed by %s", right_ ? "right" : "left", camera ? "the camera" : "the arm model"); + if (!camera && shown_) follower_.Place(shownAt_, moved); + } + const double settle = holding ? kHoldingSettleSeconds : kSettleSeconds; + const Vec3 source = camera ? kalman_.Position() : follower_.Update(palm, moved, dt, settle, kMaxOffset); + const Vec3 velocity = camera ? kalman_.Velocity() : Rotate(turn, {hand->velocity[0], hand->velocity[1], hand->velocity[2]}); + if (shown_ && (switched || Length(source - source_) > kJump)) blend_ = shownAt_ - source; + else if (!shown_) blend_ = Vec3{}; + blend_ = blend_ * std::exp(-dt / kBlendSeconds); + const Vec3 grip = source + blend_; + source_ = source; + shownAt_ = grip; + shown_ = true; + if (FILE *trace = gHandTrace.File()) { + const Vec3 k = kalman_.Ready() ? kalman_.Position() : Vec3{}; + fprintf(trace, "J,%lld,%d,%lld,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%d,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.3f,%.4f,%.4f,%.4f,%u,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%d\n", + (long long)nowNs, right_ ? 1 : 0, (long long)hand->time_ns, moved.x, moved.y, moved.z, palm.x, palm.y, palm.z, + camera ? 1 : 0, k.x, k.y, k.z, grip.x, grip.y, grip.z, Length(rate), velocity.x, velocity.y, velocity.z, + hand->buttons, aligned.w, aligned.x, aligned.y, aligned.z, inertial.x, inertial.y, inertial.z, kalman_.Ready() ? 1 : 0); + } + Quat raw; + Vec3 position; + IndexRawPose(right_, aligned, grip, &raw, &position); + const Vec3 spin = Rotate(aligned, rate); + DriverPose_t p = {}; + p.qWorldFromDriverRotation.w = 1; + p.qDriverFromHeadRotation.w = 1; + p.qRotation = {raw.w, raw.x, raw.y, raw.z}; + p.vecPosition[0] = position.x; + p.vecPosition[1] = position.y; + p.vecPosition[2] = position.z; + p.vecVelocity[0] = velocity.x; + p.vecVelocity[1] = velocity.y; + p.vecVelocity[2] = velocity.z; + p.vecAngularVelocity[0] = spin.x; + p.vecAngularVelocity[1] = spin.y; + p.vecAngularVelocity[2] = spin.z; + p.poseTimeOffset = -std::max(0.0, (nowNs - hand->time_ns) * 1e-9); + p.willDriftInYaw = true; + p.poseIsValid = true; + p.result = TrackingResult_Running_OK; + p.deviceIsConnected = true; + { + std::lock_guard lock(poseMutex_); + pose_ = p; + } + VRServerDriverHost()->TrackedDevicePoseUpdated(id, p, sizeof(p)); + SendInputs(*hand, dt); + SendBattery(*hand); + return true; + } + +private: + void UpdateBare(uint32_t id, int64_t nowNs, double dt) { + if (!bare_.load()) { + bare_ = true; + camera_.Reset(); + bareSeen_ = false; + VRProperties()->SetBoolProperty(container_, Prop_DeviceProvidesBatteryStatus_Bool, false); + VRProperties()->SetStringProperty(container_, Prop_RenderModelName_String, RenderModel(true)); + Log("%s hand follows the camera (no controller connected)", right_ ? "right" : "left"); + } + const CameraHandPose hand = camera_.Update(nowNs, dt); + if (hand.visible != bareSeen_) { + bareSeen_ = hand.visible; + Log("%s camera hand %s", right_ ? "right" : "left", hand.visible ? "seen" : "lost"); + } + DriverPose_t p; + { + std::lock_guard lock(poseMutex_); + p = pose_; + } + p.qWorldFromDriverRotation = {1, 0, 0, 0}; + p.qDriverFromHeadRotation = {1, 0, 0, 0}; + p.deviceIsConnected = true; + p.willDriftInYaw = false; + p.poseTimeOffset = 0.0; + for (int i = 0; i < 3; i++) p.vecAngularVelocity[i] = 0.0; + if (hand.visible) { + p.qRotation = {hand.rotation.w, hand.rotation.x, hand.rotation.y, hand.rotation.z}; + p.vecPosition[0] = hand.position.x; + p.vecPosition[1] = hand.position.y; + p.vecPosition[2] = hand.position.z; + p.vecVelocity[0] = hand.velocity.x; + p.vecVelocity[1] = hand.velocity.y; + p.vecVelocity[2] = hand.velocity.z; + p.poseIsValid = true; + p.result = TrackingResult_Running_OK; + } else { + for (int i = 0; i < 3; i++) p.vecVelocity[i] = 0.0; + p.poseIsValid = false; + p.result = TrackingResult_Running_OutOfRange; + } + { + std::lock_guard lock(poseMutex_); + pose_ = p; + } + VRServerDriverHost()->TrackedDevicePoseUpdated(id, p, sizeof(p)); + if (FILE *trace = gHandTrace.File()) { + const HandButtons &b = hand.buttons; + fprintf(trace, "B,%lld,%d,%d,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%.3f,%d%d%d%d%d%d%d,%.4f\n", + (long long)nowNs, right_ ? 1 : 0, hand.visible ? 1 : 0, hand.position.x, hand.position.y, hand.position.z, + hand.rotation.w, hand.rotation.x, hand.rotation.y, hand.rotation.z, hand.curls.thumb, hand.curls.index, + hand.curls.middle, hand.curls.ring, hand.curls.pinky, b.trigger, b.grip, b.triggerClick, b.gripClick, b.a, b.b, + b.system, b.facing, b.triggerTouch, camera_.Rms()); + } + SendBareInputs(hand); + } + + void LeaveBare(uint32_t id) { + if (!bare_.load()) return; + bare_ = false; + camera_.Reset(); + VRProperties()->SetBoolProperty(container_, Prop_DeviceProvidesBatteryStatus_Bool, true); + VRProperties()->SetStringProperty(container_, Prop_RenderModelName_String, RenderModel(false)); + if (!connected_) { + DriverPose_t p = {}; + p.qWorldFromDriverRotation.w = 1; + p.qDriverFromHeadRotation.w = 1; + p.qRotation.w = 1; + p.result = TrackingResult_Uninitialized; + { + std::lock_guard lock(poseMutex_); + pose_ = p; + } + VRServerDriverHost()->TrackedDevicePoseUpdated(id, p, sizeof(p)); + droiddeck_joycon_hand idle = {}; + SendInputs(idle, 1.0); + } + Log("%s hand no longer follows the camera", right_ ? "right" : "left"); + } + + void SendBareInputs(const CameraHandPose &hand) { + const HandButtons &b = hand.buttons; + SetBool(systemClick_, b.system); + SetBool(systemTouch_, b.system); + SetBool(aClick_, b.a); + SetBool(aTouch_, b.a); + SetBool(bClick_, b.b); + SetBool(bTouch_, b.b); + SetBool(triggerClick_, b.triggerClick); + SetBool(triggerTouch_, b.triggerTouch); + SetScalar(trigger_, b.trigger); + SetBool(gripTouch_, b.grip >= kGripTouch); + SetScalar(grip_, b.grip); + SetScalar(gripForce_, b.gripClick ? 1.0f : 0.0f); + SetScalar(stickX_, 0.0f); + SetScalar(stickY_, 0.0f); + SetBool(stickClick_, false); + SetBool(stickTouch_, false); + SetScalar(padX_, 0.0f); + SetScalar(padY_, 0.0f); + SetScalar(padForce_, 0.0f); + SetBool(padTouch_, false); + if (hand.visible) curls_ = hand.curls; + SetScalar(fingers_[0], (float)curls_.index); + SetScalar(fingers_[1], (float)curls_.middle); + SetScalar(fingers_[2], (float)curls_.ring); + SetScalar(fingers_[3], (float)curls_.pinky); + if (std::chrono::steady_clock::now() - lastSkeleton_ >= kSkeletonPeriod) SendSkeleton(); + } + + const char *RenderModel(bool bare) const { + if (bare) return right_ ? "{droiddeck}droiddeck_hand_right" : "{droiddeck}droiddeck_hand_left"; + return right_ ? "{indexcontroller}valve_controller_knu_1_0_right" : "{indexcontroller}valve_controller_knu_1_0_left"; + } + + void Disconnect(uint32_t id) { + if (!connected_) return; + connected_ = false; + follower_.Reset(); + kalman_.Reset(); + shown_ = false; + DriverPose_t p = {}; + p.qWorldFromDriverRotation.w = 1; + p.qDriverFromHeadRotation.w = 1; + p.qRotation.w = 1; + p.result = TrackingResult_Uninitialized; + { + std::lock_guard lock(poseMutex_); + pose_ = p; + } + VRServerDriverHost()->TrackedDevicePoseUpdated(id, p, sizeof(p)); + droiddeck_joycon_hand idle = {}; + SendInputs(idle, 1.0); + Log("%s Joy-Con hand disconnected", right_ ? "right" : "left"); + } + + void SendInputs(const droiddeck_joycon_hand &hand, double dt) { + const uint32_t b = hand.buttons; + const float x = std::clamp(hand.stick[0], -1.0f, 1.0f), y = std::clamp(hand.stick[1], -1.0f, 1.0f); + const bool stickTouched = (b & DROIDDECK_JOYCON_STICK) || x * x + y * y > kStickTouch * kStickTouch; + const bool trigger = b & DROIDDECK_JOYCON_TRIGGER, grip = b & DROIDDECK_JOYCON_GRIP; + const bool thumb = stickTouched || (b & (DROIDDECK_JOYCON_A | DROIDDECK_JOYCON_B | DROIDDECK_JOYCON_SYSTEM)) != 0; + const bool system = b & DROIDDECK_JOYCON_SYSTEM, a = b & DROIDDECK_JOYCON_A, bButton = b & DROIDDECK_JOYCON_B; + SetBool(systemClick_, system); + SetBool(systemTouch_, system); + SetBool(aClick_, a); + SetBool(aTouch_, a); + SetBool(bClick_, bButton); + SetBool(bTouch_, bButton); + SetBool(triggerClick_, trigger); + SetBool(triggerTouch_, trigger); + SetScalar(trigger_, trigger ? 1.0f : 0.0f); + SetBool(gripTouch_, grip); + SetScalar(grip_, grip ? 1.0f : 0.0f); + SetScalar(gripForce_, grip ? 1.0f : 0.0f); + SetScalar(stickX_, x); + SetScalar(stickY_, y); + SetBool(stickClick_, (b & DROIDDECK_JOYCON_STICK) != 0); + SetBool(stickTouch_, stickTouched); + SetScalar(padX_, 0.0f); + SetScalar(padY_, 0.0f); + SetScalar(padForce_, 0.0f); + SetBool(padTouch_, false); + const double follow = 1.0 - std::exp(-dt / kCurlSeconds); + curls_.thumb += ((thumb ? 1.0 : 0.0) - curls_.thumb) * follow; + curls_.index += ((trigger ? 1.0 : 0.0) - curls_.index) * follow; + const double rest = grip ? 1.0 : 0.0; + curls_.middle += (rest - curls_.middle) * follow; + curls_.ring = curls_.pinky = curls_.middle; + SetScalar(fingers_[0], (float)curls_.index); + for (int f = 1; f < 4; f++) SetScalar(fingers_[f], (float)curls_.middle); + const auto now = std::chrono::steady_clock::now(); + if (now - lastSkeleton_ >= kSkeletonPeriod) SendSkeleton(); + } + + void SendSkeleton() { + lastSkeleton_ = std::chrono::steady_clock::now(); + VRBoneTransform_t bones[kHandBones]; + HandSkeleton(right_, curls_, bones); + VRDriverInput()->UpdateSkeletonComponent(skeleton_, VRSkeletalMotionRange_WithController, bones, kHandBones); + VRDriverInput()->UpdateSkeletonComponent(skeleton_, VRSkeletalMotionRange_WithoutController, bones, kHandBones); + } + + void SendBattery(const droiddeck_joycon_hand &hand) { + const bool charging = hand.flags & DROIDDECK_JOYCON_CHARGING; + if (hand.battery >= 0.0f && std::fabs(hand.battery - battery_) > 0.005f) { + battery_ = hand.battery; + VRProperties()->SetFloatProperty(container_, Prop_DeviceBatteryPercentage_Float, std::min(battery_, 1.0f)); + } + if (charging != charging_) { + charging_ = charging; + VRProperties()->SetBoolProperty(container_, Prop_DeviceIsCharging_Bool, charging_); + } + } + + void SetBool(VRInputComponentHandle_t h, bool on) { + auto &last = booleans_[h]; + if (last == (on ? 2 : 1)) return; + last = on ? 2 : 1; + VRDriverInput()->UpdateBooleanComponent(h, on, 0); + } + void SetScalar(VRInputComponentHandle_t h, float value) { + auto it = scalars_.find(h); + if (it != scalars_.end() && it->second == value) return; + scalars_[h] = value; + VRDriverInput()->UpdateScalarComponent(h, value, 0); + } + + static constexpr float kStickTouch = 0.1f, kGripTouch = 0.5f; + static constexpr double kCurlSeconds = 0.05; + static constexpr std::chrono::milliseconds kSkeletonPeriod{11}; + static constexpr Vec3 kHeadPosition{0.0, 1.6, 0.0}; + static constexpr double kSettleSeconds = 0.6, kHoldingSettleSeconds = 1.5, kMaxOffset = 0.35; + static constexpr double kCameraSigma = 0.03, kEstimatedSigma = 0.06, kJump = 0.08, kBlendSeconds = 0.25; + static constexpr int64_t kCameraHoldNs = 750000000, kJoyConDelayNs = 45000000; + const bool right_; + ArmModel arm_; + HandFollower follower_; + HandKalman kalman_; + CameraHand camera_; + std::atomic bare_{false}, expectBare_{false}; + bool bareSeen_ = false; + Vec3 source_, shownAt_, blend_; + bool shown_ = false; + bool cameraPlaced_ = false; + std::atomic id_{k_unTrackedDeviceIndexInvalid}; + PropertyContainerHandle_t container_ = k_ulInvalidPropertyContainer; + std::mutex poseMutex_; + DriverPose_t pose_ = {}; + bool connected_ = false, aligning_ = true, charging_ = false; + uint32_t recenter_ = 0; + double yaw_ = 0.0; + float battery_ = -1.0f; + FingerCurls curls_; + std::map booleans_; + std::map scalars_; + std::chrono::steady_clock::time_point lastUpdate_ = std::chrono::steady_clock::now(), lastSkeleton_{}; + VRInputComponentHandle_t systemClick_ = 0, systemTouch_ = 0, aClick_ = 0, aTouch_ = 0, bClick_ = 0, bTouch_ = 0, + triggerClick_ = 0, triggerTouch_ = 0, trigger_ = 0, gripTouch_ = 0, grip_ = 0, gripForce_ = 0, + stickX_ = 0, stickY_ = 0, stickClick_ = 0, stickTouch_ = 0, padX_ = 0, padY_ = 0, padForce_ = 0, + padTouch_ = 0, fingers_[4] = {}, skeleton_ = 0, haptic_ = 0; +}; + class CardboardController : public ITrackedDeviceServerDriver { public: explicit CardboardController(bool right) : right_(right) {} @@ -396,7 +972,8 @@ public: VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, xrMode_ ? "Meta" : "DroidDeck"); VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck"); VRProperties()->SetStringProperty(c, Prop_RenderModelName_String, xrMode_ ? - (right_ ? "oculus_quest_plus_controller_right" : "oculus_quest_plus_controller_left") : "generic_controller"); + (right_ ? "oculus_quest_plus_controller_right" : "oculus_quest_plus_controller_left") : + (right_ ? "{indexcontroller}valve_controller_knu_1_0_right" : "{indexcontroller}valve_controller_knu_1_0_left")); VRProperties()->SetStringProperty(c, Prop_ControllerType_String, xrMode_ ? "oculus_touch" : "vive_controller"); VRProperties()->SetStringProperty(c, Prop_InputProfilePath_String, xrMode_ ? "{oculus}/input/touch_profile.json" : "{htc}/input/vive_controller_profile.json"); @@ -432,7 +1009,6 @@ public: void DebugRequest(const char *, char *resp, uint32_t size) override { if (size) resp[0] = 0; } DriverPose_t GetPose() override { return pose_; } - VRInputComponentHandle_t HapticHandle() const { return haptic_; } void UpdateXr(const DroidDeckXrHand &hand, int64_t predicted) { if (id_ == k_unTrackedDeviceIndexInvalid) return; @@ -471,13 +1047,59 @@ public: } } - void Update(const HmdQuaternion_t &head, const PadState *pad) { + VRInputComponentHandle_t Haptic() const { return id_ == k_unTrackedDeviceIndexInvalid ? 0 : haptic_; } + bool XrMode() const { return xrMode_; } + + void Disconnect() { if (id_ == k_unTrackedDeviceIndexInvalid) return; DriverPose_t p = {}; p.qWorldFromDriverRotation.w = 1; p.qDriverFromHeadRotation.w = 1; - p.qRotation = head; - const double off[3] = {right_ ? 0.18 : -0.18, -0.35, -0.30}; + p.qRotation.w = 1; + p.result = TrackingResult_Uninitialized; + pose_ = p; + VRServerDriverHost()->TrackedDevicePoseUpdated(id_, p, sizeof(p)); + auto in = VRDriverInput(); + for (VRInputComponentHandle_t h : {system_, menu_, grip_, triggerClick_, padClick_, padTouch_}) in->UpdateBooleanComponent(h, false, 0); + for (VRInputComponentHandle_t h : {trigger_, padX_, padY_}) in->UpdateScalarComponent(h, 0.0f, 0); + } + + static constexpr float kReachSpeed = 0.6f, kReachMin = -0.2f, kReachMax = 1.0f; + + static bool Grabbing(const PadState &pad, bool right) { + return pad.axes[right ? 4 : 5] > kGrabTrigger || (pad.buttons & (right ? PAD_RB : PAD_LB)); + } + + void Update(const HmdQuaternion_t &head, const PadState *pad, bool sticksMoveHands, float reach) { + if (id_ == k_unTrackedDeviceIndexInvalid) return; + const auto now = std::chrono::steady_clock::now(); + const float dt = std::min(std::chrono::duration(now - lastUpdate_).count(), 0.1f); + lastUpdate_ = now; + const float follow = 1.0f - std::exp(-dt / kHandSmoothing); + const bool grab = pad && Grabbing(*pad, right_); + if (grab && pad->axes[7]) pull_ = std::clamp(pull_ - (pad->axes[7] > 0 ? 1.0f : -1.0f) * kReachSpeed * dt, kReachMin - reach, kReachMax - reach); + else if (!grab) pull_ -= pull_ * follow; + const float stickX = pad ? pad->axes[right_ ? 2 : 0] / 32767.0f : 0.0f, stickY = pad ? -pad->axes[right_ ? 3 : 1] / 32767.0f : 0.0f; + float aimX = 0.0f, aimY = 0.0f; + const float tilt = std::sqrt(stickX * stickX + stickY * stickY); + if (sticksMoveHands && tilt > kStickDeadzone) { + const float scale = std::min((tilt - kStickDeadzone) / (1.0f - kStickDeadzone), 1.0f) / tilt; + aimX = stickX * scale; + aimY = stickY * scale; + } + handX_ += (aimX * kHandRangeX - handX_) * follow; + handY_ += (aimY * (aimY > 0.0f ? kHandRangeUp : kHandRangeDown) - handY_) * follow; + DriverPose_t p = {}; + p.qWorldFromDriverRotation.w = 1; + p.qDriverFromHeadRotation.w = 1; + const double yaw = std::atan2(-handX_, kHandPivot) / 2, pitch = std::atan2(handY_, std::hypot(kHandPivot, handX_)) / 2; + const double cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch); + const double lw = cy * cp, lx = cy * sp, ly = sy * cp, lz = -sy * sp; + p.qRotation.w = head.w * lw - head.x * lx - head.y * ly - head.z * lz; + p.qRotation.x = head.w * lx + head.x * lw + head.y * lz - head.z * ly; + p.qRotation.y = head.w * ly - head.x * lz + head.y * lw + head.z * lx; + p.qRotation.z = head.w * lz + head.x * ly - head.y * lx + head.z * lw; + const double off[3] = {(right_ ? 0.18 : -0.18) + handX_, -0.25 + handY_, -0.35 - reach - pull_}; double m[3][3]; const double w = head.w, x = head.x, y = head.y, z = head.z; m[0][0] = 1 - 2 * (y * y + z * z); m[0][1] = 2 * (x * y - z * w); m[0][2] = 2 * (x * z + y * w); @@ -492,9 +1114,8 @@ public: if (!pad) return; auto in = VRDriverInput(); const uint32_t b = pad->buttons; - const float stickX = pad->axes[right_ ? 2 : 0] / 32767.0f, stickY = -pad->axes[right_ ? 3 : 1] / 32767.0f; const float trig = pad->axes[right_ ? 4 : 5] / 255.0f; - const bool touch = stickX * stickX + stickY * stickY > 0.01f; + const bool touch = !sticksMoveHands && stickX * stickX + stickY * stickY > 0.01f; in->UpdateBooleanComponent(system_, right_ && (b & PAD_GUIDE), 0); in->UpdateBooleanComponent(menu_, (b & (right_ ? PAD_START : PAD_SELECT)) != 0, 0); in->UpdateBooleanComponent(grip_, (b & (right_ ? PAD_RB : PAD_LB)) != 0, 0); @@ -503,14 +1124,18 @@ public: in->UpdateScalarComponent(padX_, touch ? stickX : 0.0f, 0); in->UpdateScalarComponent(padY_, touch ? stickY : 0.0f, 0); in->UpdateBooleanComponent(padTouch_, touch || (b & (right_ ? PAD_A : PAD_X)), 0); - in->UpdateBooleanComponent(padClick_, (b & (right_ ? (PAD_A | PAD_R3) : (PAD_X | PAD_L3))) != 0, 0); + in->UpdateBooleanComponent(padClick_, (b & (right_ ? (PAD_A | PAD_R3) : PAD_X)) != 0, 0); } private: + static constexpr int16_t kGrabTrigger = 153; + static constexpr float kStickDeadzone = 0.12f, kHandRangeX = 0.40f, kHandRangeUp = 0.50f, kHandRangeDown = 0.30f, kHandSmoothing = 0.05f, kHandPivot = 0.45f; bool right_; bool xrMode_ = false; uint32_t id_ = k_unTrackedDeviceIndexInvalid; DriverPose_t pose_ = {}; + float pull_ = 0.0f, handX_ = 0.0f, handY_ = 0.0f; + std::chrono::steady_clock::time_point lastUpdate_ = std::chrono::steady_clock::now(); VRInputComponentHandle_t system_ = 0, menu_ = 0, grip_ = 0, triggerClick_ = 0, trigger_ = 0, padX_ = 0, padY_ = 0, padClick_ = 0, padTouch_ = 0, haptic_ = 0; VRInputComponentHandle_t gripValue_ = 0, triggerTouch_ = 0, primaryClick_ = 0, secondaryClick_ = 0, @@ -522,6 +1147,7 @@ public: EVRInitError Activate(uint32_t unObjectId) override { id_ = unObjectId; PropertyContainerHandle_t c = VRProperties()->TrackedDeviceToPropertyContainer(id_); + container_ = c; width_ = VRSettings()->GetInt32(kSettingsSection, "windowWidth"); height_ = VRSettings()->GetInt32(kSettingsSection, "windowHeight"); if (width_ <= 0) width_ = 1920; @@ -540,7 +1166,15 @@ public: if (pose_.Read(&hp) && hp.tan_half_fov > 0.0f) { tanHalf_ = hp.tan_half_fov; if (hp.eye_aspect > 0.0f) eyeAspect_ = hp.eye_aspect; + sentMono_ = (hp.flags & DROIDDECK_HEADPOSE_MONO) != 0; + renderHeight_ = hp.render_height; + panelHeight_ = hp.eye_h; } + if (!xrMode_ && pose_.Read(&hp) && hp.display_hz >= 24.0f) hz = hp.display_hz; + Vsync::SetRate(hz); + sentHz_ = hz; + sentTan_ = tanHalf_; + sentAspect_ = eyeAspect_; VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, xrMode_ ? "DroidDeck Quest OpenXR" : "DroidDeck Cardboard"); VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "DroidDeck"); VRProperties()->SetStringProperty(c, Prop_TrackingSystemName_String, "droiddeck"); @@ -577,7 +1211,10 @@ public: VRProperties()->SetBoolProperty(c, Prop_ContainsProximitySensor_Bool, true); VRDriverInput()->CreateBooleanComponent(c, "/proximity", &proximity_); VRDriverInput()->UpdateBooleanComponent(proximity_, true, 0); - Log("HMD activated: %dx%d at %.0f Hz, eye tan(half fov) %.3f, eye aspect %.3f", width_, height_, hz, tanHalf_, eyeAspect_); + Log("HMD activated: %dx%d at %.0f Hz, eye tan(half fov) %.3f, eye aspect %.3f", width_, height_, hz, tanHalf_.load(), eyeAspect_.load()); + uint32_t renderW, renderH; + GetRecommendedRenderTargetSize(&renderW, &renderH); + Log("recommended render target: %ux%u per eye (selected height %u)", renderW, renderH, renderHeight_.load()); running_ = true; thread_ = std::thread([this] { PoseLoop(); }); @@ -585,6 +1222,8 @@ public: return VRInitError_None; } + bool Running() const { return running_; } + void Deactivate() override { Vsync::Stop(); droiddeck::Presenter::Shutdown(); @@ -645,8 +1284,9 @@ public: bool IsDisplayRealDisplay() override { return false; } void GetRecommendedRenderTargetSize(uint32_t *w, uint32_t *h) override { if (xrMode_) { *w = width_ / 2; *h = height_; return; } - *w = (uint32_t)(width_ / 2 * 1.4f); - *h = (uint32_t)(height_ * 1.4f); + const auto size = droiddeck::RenderTarget(eyeAspect_, renderHeight_, panelHeight_); + *w = size.width; + *h = size.height; } void GetEyeOutputViewport(EVREye eye, uint32_t *x, uint32_t *y, uint32_t *w, uint32_t *h) override { *y = 0; *w = (uint32_t)width_ / 2; *h = (uint32_t)height_; @@ -674,6 +1314,33 @@ public: } private: + void UpdateEye(const droiddeck_headpose &hp) { + sentTan_ = tanHalf_ = hp.tan_half_fov; + sentAspect_ = eyeAspect_ = hp.eye_aspect; + sentMono_ = (hp.flags & DROIDDECK_HEADPOSE_MONO) != 0; + renderHeight_ = hp.render_height; + panelHeight_ = hp.eye_h; + HmdRect2_t r; + r.vTopLeft.v[0] = -tanHalf_ * eyeAspect_; + r.vTopLeft.v[1] = -tanHalf_; + r.vBottomRight.v[0] = tanHalf_ * eyeAspect_; + r.vBottomRight.v[1] = tanHalf_; + VRServerDriverHost()->SetDisplayProjectionRaw(id_, r, r); + uint32_t w = 0, h = 0; + GetRecommendedRenderTargetSize(&w, &h); + VRServerDriverHost()->SetRecommendedRenderTargetSize(id_, w, h); + VRServerDriverHost()->VendorSpecificEvent(id_, VREvent_LensDistortionChanged, VREvent_Data_t{}, 0.0); + Log("eye now tan(half fov) %.3f, aspect %.3f (%ux%u eye%s), render %ux%u", tanHalf_.load(), eyeAspect_.load(), hp.eye_w, hp.eye_h, + (hp.flags & DROIDDECK_HEADPOSE_MONO) ? ", one eye shown" : "", w, h); + } + + void UpdateRate(float hz) { + sentHz_ = hz; + Vsync::SetRate(hz); + VRProperties()->SetFloatProperty(container_, Prop_DisplayFrequency_Float, hz); + Log("panel now %.0f Hz", hz); + } + void PublishChaperone(const DroidDeckXrTracking &tracking) { chaperone_.Update(tracking, [this](const DroidDeckXrPose &head) { char timestamp[32]; @@ -686,6 +1353,8 @@ private: } void PoseLoop() { uint32_t lastSeq = 0; + droiddeck_joycon joyCons = {}; + bool haveJoyCons = false; auto lastSent = std::chrono::steady_clock::now(); while (running_) { DroidDeckXrTracking xr; @@ -708,9 +1377,22 @@ private: PadState pad; const bool havePad = pad_.Read(&pad); const bool padChanged = havePad && (pad.buttons != lastPad_.buttons || memcmp(pad.axes, lastPad_.axes, sizeof(pad.axes))); - if ((fresh || padChanged || now - lastSent > std::chrono::milliseconds(100)) && id_ != k_unTrackedDeviceIndexInvalid) { + droiddeck_joycon read; + if (joyConFile_.Read(&read)) { + joyCons = read; + haveJoyCons = true; + } + const bool joyConsChanged = haveJoyCons && joyCons.seq != lastJoyConSeq_; + if ((fresh || padChanged || joyConsChanged || now - lastSent > std::chrono::milliseconds(100)) && id_ != k_unTrackedDeviceIndexInvalid) { + if (joyConsChanged) lastJoyConSeq_ = joyCons.seq; if (fresh) { lastSeq = hp.seq; + if (hp.eye_w > 0 && hp.tan_half_fov > 0.0f && hp.eye_aspect > 0.0f && + (fabsf(hp.tan_half_fov - sentTan_) > 1e-3f || fabsf(hp.eye_aspect - sentAspect_) > 1e-3f || + ((hp.flags & DROIDDECK_HEADPOSE_MONO) != 0) != sentMono_ || + hp.render_height != renderHeight_ || hp.eye_h != panelHeight_)) + UpdateEye(hp); + if (hp.display_hz >= 24.0f && fabsf(hp.display_hz - sentHz_) > 0.5f) UpdateRate(hp.display_hz); const bool worn = (hp.flags & DROIDDECK_HEADPOSE_ENABLED) != 0; if (worn != worn_) { worn_ = worn; @@ -720,7 +1402,59 @@ private: } DriverPose_t head = GetPose(); VRServerDriverHost()->TrackedDevicePoseUpdated(id_, head, sizeof(DriverPose_t)); - for (CardboardController *hand : hands_) if (hand) hand->Update(head.qRotation, havePad ? &pad : nullptr); + if (havePad && (pad.buttons & PAD_L3) && !(lastPad_.buttons & PAD_L3)) { + sticksMoveHands_ = !sticksMoveHands_; + Log("sticks now move %s", sticksMoveHands_ ? "the hands" : "in the game"); + } + const float dt = std::min(std::chrono::duration(now - lastReach_).count(), 0.1f); + lastReach_ = now; + if (havePad && pad.axes[7] && !CardboardController::Grabbing(pad, false) && !CardboardController::Grabbing(pad, true)) + reach_ = std::clamp(reach_ - (pad.axes[7] > 0 ? 1.0f : -1.0f) * CardboardController::kReachSpeed * dt, + CardboardController::kReachMin, CardboardController::kReachMax); + if (now - lastScan_ > std::chrono::seconds(1)) { + lastScan_ = now; + const bool other = OtherControllers(); + if (other != otherControllers_) { + otherControllers_ = other; + Log("%s", other ? "other VR controllers connected: pad hands off" : "no other VR controllers: pad hands on"); + } + } + const int64_t nowNs = MonotonicNs(); + gHandTrace.Poll(nowNs); + double headYaw = 0.0; + if (Heading({head.qRotation.w, head.qRotation.x, head.qRotation.y, head.qRotation.z}, &headYaw)) + headings_.Add(nowNs, {-std::sin(headYaw), 0.0, -std::cos(headYaw)}); + droiddeck_handcam cam; + if (handCamFile_.Read(&cam)) { + const bool running = (cam.flags & DROIDDECK_HANDCAM_RUNNING) != 0; + if (cam.frame != lastCamFrame_) { + lastCamFrame_ = cam.frame; + if (running && std::llabs(nowNs - cam.time_ns) < kHandCamStaleNs) SeeCamera(cam, nowNs); + } + bareFlag_ = running && (cam.flags & DROIDDECK_HANDCAM_BARE_HANDS); + bareTimeNs_ = cam.time_ns; + } + const bool bare = bareFlag_ && std::llabs(nowNs - bareTimeNs_) < kBareHandsStaleNs; + if (bare != bareHands_) { + bareHands_ = bare; + Log("%s", bare ? "no controllers: the camera tracks bare hands" : "camera bare hands off"); + } + for (int side = 0; side < 2; side++) { + const droiddeck_joycon_hand *joyCon = + haveJoyCons && JoyConLive(joyCons.hands[side], nowNs) ? &joyCons.hands[side] : nullptr; + if (handControllers_[side]) handControllers_[side]->ExpectBare(!joyCon && bare); + if (joyCon || bare) wantHand_[side] = true; + const bool covered = handControllers_[side] && handControllers_[side]->Update(joyCon, bare, head.qRotation, nowNs); + CardboardController *hand = hands_[side]; + if (!hand) continue; + if (otherControllers_ || covered || bare) { + if (!padHandOff_[side]) hand->Disconnect(); + padHandOff_[side] = true; + continue; + } + padHandOff_[side] = false; + hand->Update(head.qRotation, havePad ? &pad : nullptr, sticksMoveHands_, reach_); + } if (havePad) lastPad_ = pad; lastSent = now; } @@ -728,11 +1462,62 @@ private: } } + void SeeCamera(const droiddeck_handcam &cam, int64_t nowNs) { + Vec3 forward; + const bool heading = headings_.At(cam.time_ns, &forward) && Length(forward) > 0.5; + if ((cam.flags & DROIDDECK_HANDCAM_FACE) && heading) { + const double dt = lastFaceNs_ ? std::max(0.0, (cam.time_ns - lastFaceNs_) * 1e-9) : 0.0; + lastFaceNs_ = cam.time_ns; + cameraYaw_.Add(std::atan2(-forward.x, -forward.z) - cam.face_yaw, dt); + } + double yaw = 0.0; + const bool aligned = cameraYaw_.Get(&yaw); + if (aligned != cameraAligned_) { + cameraAligned_ = aligned; + Log("hand camera %s", aligned ? "lined up with the headset" : "waiting to see your face"); + } + if (FILE *trace = gHandTrace.File()) + fprintf(trace, "C,%lld,%u,%lld,%u,%.4f,%.4f,%d,%.4f,%u,%.4f,%.4f,%.4f,%u,%.4f,%.4f,%.4f\n", (long long)nowNs, cam.frame, + (long long)cam.time_ns, cam.flags, cam.face_yaw, heading ? std::atan2(-forward.x, -forward.z) : 0.0, aligned ? 1 : 0, + aligned ? yaw : 0.0, cam.hands[0].flags, cam.hands[0].position[0], cam.hands[0].position[1], cam.hands[0].position[2], + cam.hands[1].flags, cam.hands[1].position[0], cam.hands[1].position[1], cam.hands[1].position[2]); + if (!aligned) return; + const Quat turn = AboutY(yaw); + for (int side = 0; side < 2; side++) { + const droiddeck_handcam_hand &hand = cam.hands[side]; + if (!(hand.flags & DROIDDECK_HANDCAM_SEEN) || !handControllers_[side]) continue; + Vec3 joints[kHandJoints]; + const bool haveJoints = (hand.flags & DROIDDECK_HANDCAM_JOINTS) != 0; + if (haveJoints) + for (int i = 0; i < kHandJoints; i++) joints[i] = Rotate(turn, {hand.joints[i][0], hand.joints[i][1], hand.joints[i][2]}); + handControllers_[side]->SeeCamera(Rotate(turn, {hand.position[0], hand.position[1], hand.position[2]}), haveJoints ? joints : nullptr, + cam.time_ns, (hand.flags & DROIDDECK_HANDCAM_ESTIMATED) != 0, nowNs); + } + } + + static bool OtherControllers() { + TrackedDevicePose_t poses[k_unMaxTrackedDeviceCount]; + VRServerDriverHost()->GetRawTrackedDevicePoses(0.0f, poses, k_unMaxTrackedDeviceCount); + for (uint32_t i = 0; i < k_unMaxTrackedDeviceCount; i++) { + if (!poses[i].bDeviceIsConnected) continue; + PropertyContainerHandle_t c = VRProperties()->TrackedDeviceToPropertyContainer(i); + if (VRProperties()->GetInt32Property(c, Prop_DeviceClass_Int32) != TrackedDeviceClass_Controller) continue; + if (VRProperties()->GetStringProperty(c, Prop_TrackingSystemName_String) != "droiddeck") return true; + } + return false; + } + uint32_t id_ = k_unTrackedDeviceIndexInvalid; int32_t width_ = 1920, height_ = 1080; - float tanHalf_ = 1.0f, eyeAspect_ = 0.889f; + std::atomic tanHalf_{1.0f}, eyeAspect_{0.889f}; + std::atomic renderHeight_{720}, panelHeight_{0}; + float sentTan_ = 0.0f, sentAspect_ = 0.0f, sentHz_ = 0.0f; + bool sentMono_ = false; + PropertyContainerHandle_t container_ = k_ulInvalidPropertyContainer; public: void SetHands(CardboardController *left, CardboardController *right) { hands_[0] = left; hands_[1] = right; } + void SetHandControllers(HandController *left, HandController *right) { handControllers_[0] = left; handControllers_[1] = right; } + bool WantsHand(int side) const { return wantHand_[side].load(); } private: PoseFile pose_; @@ -743,7 +1528,25 @@ private: PadState lastPad_; VRInputComponentHandle_t proximity_ = 0; bool worn_ = true; + bool sticksMoveHands_ = true; + float reach_ = 0.0f; + std::chrono::steady_clock::time_point lastReach_ = std::chrono::steady_clock::now(); + std::chrono::steady_clock::time_point lastScan_{}; + bool otherControllers_ = false; CardboardController *hands_[2] = {nullptr, nullptr}; + bool padHandOff_[2] = {false, false}; + ShmFile joyConFile_{kJoyConPath, DROIDDECK_JOYCON_MAGIC, DROIDDECK_JOYCON_VERSION, "Joy-Con"}; + uint32_t lastJoyConSeq_ = 0; + ShmFile handCamFile_{kHandCamPath, DROIDDECK_HANDCAM_MAGIC, DROIDDECK_HANDCAM_VERSION, "hand camera"}; + uint32_t lastCamFrame_ = 0; + int64_t lastFaceNs_ = 0; + TimeHistory headings_; + CameraYaw cameraYaw_; + bool cameraAligned_ = false; + bool bareHands_ = false, bareFlag_ = false; + int64_t bareTimeNs_ = 0; + HandController *handControllers_[2] = {nullptr, nullptr}; + std::atomic wantHand_[2] = {false, false}; bool virtualDisplay_ = false, directMode_ = false; VirtualDisplay vd_; DirectMode dm_; @@ -753,6 +1556,10 @@ private: class Provider : public IServerTrackedDeviceProvider { public: + ~Provider() { + if (hmd_.Running()) hmd_.Deactivate(); + } + EVRInitError Init(IVRDriverContext *ctx) override { VR_INIT_SERVER_DRIVER_CONTEXT(ctx); focusEnabled_ = access("/run/droiddeck/droiddeck-xr-frames", F_OK) == 0; @@ -764,6 +1571,7 @@ public: VRServerDriverHost()->TrackedDeviceAdded("droiddeck-pad-right", TrackedDeviceClass_Controller, &right_); hmd_.SetHands(&left_, &right_); } + hmd_.SetHandControllers(&handLeft_, &handRight_); return VRInitError_None; } void Cleanup() override { @@ -773,6 +1581,13 @@ public: } const char *const *GetInterfaceVersions() override { return k_InterfaceVersions; } void RunFrame() override { + for (int side = 0; side < 2; side++) { + if (handAdded_[side] || !hmd_.WantsHand(side)) continue; + handAdded_[side] = true; + if (!VRServerDriverHost()->TrackedDeviceAdded(side ? "droiddeck-hand-right" : "droiddeck-hand-left", + TrackedDeviceClass_Controller, side ? &handRight_ : &handLeft_)) + Log("%s hand could not be added", side ? "right" : "left"); + } VREvent_t e; while (VRServerDriverHost()->PollNextEvent(&e, sizeof(e))) { if (focusEnabled_) { @@ -782,10 +1597,14 @@ public: if (e.eventType == VREvent_DashboardDeactivated) { dashboard_ = false; changed = true; } if (changed) PublishFocus(); } - if (e.eventType == VREvent_Input_HapticVibration) { - const auto &pulse = e.data.hapticVibration; - int hand = pulse.componentHandle == left_.HapticHandle() ? 0 : pulse.componentHandle == right_.HapticHandle() ? 1 : -1; - if (hand >= 0) SendHaptic(hand, pulse); + if (e.eventType != VREvent_Input_HapticVibration) continue; + const VREvent_HapticVibration_t &h = e.data.hapticVibration; + if (!h.componentHandle) continue; + if (h.componentHandle == handLeft_.Haptic()) SendRumble(DROIDDECK_JOYCON_RUMBLE_SLOT, h); + else if (h.componentHandle == handRight_.Haptic()) SendRumble(DROIDDECK_JOYCON_RUMBLE_SLOT + 1, h); + else if (h.componentHandle == left_.Haptic() || h.componentHandle == right_.Haptic()) { + if (left_.XrMode()) SendHaptic(h.componentHandle == right_.Haptic() ? 1 : 0, h); + else SendRumble(0, h); } } } @@ -834,6 +1653,8 @@ private: } CardboardHmd hmd_; CardboardController left_{false}, right_{true}; + HandController handLeft_{false}, handRight_{true}; + bool handAdded_[2] = {false, false}; }; Provider g_provider; diff --git a/tools/vr/src/fence_tracker.h b/tools/vr/src/fence_tracker.h new file mode 100644 index 000000000..d016d9ded --- /dev/null +++ b/tools/vr/src/fence_tracker.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +/* All pool operations require the caller's tracker lock. Reserved fences are + * invisible to the completer until vkQueueSubmit has returned successfully. */ +struct tracked { + struct tracked *next; + VkDevice dev; + VkFence fence; + PFN_vkWaitForFences wait; + PFN_vkResetFences reset; + PFN_vkDestroyFence destroy; + uint32_t mark; + enum { TRACK_IDLE, TRACK_RESERVED, TRACK_SUBMITTED } state; +}; + +static struct tracked *track_pool_take(struct tracked **pool, VkDevice dev, PFN_vkGetDeviceProcAddr gdpa) { + struct tracked *idle = NULL; + for (struct tracked *t = *pool; t; t = t->next) { + if (t->state != TRACK_IDLE) continue; + if (t->dev == dev) { + t->state = TRACK_RESERVED; + return t; + } + if (!idle) idle = t; + } + struct tracked t = {.dev = dev, .state = TRACK_RESERVED}; + t.wait = (PFN_vkWaitForFences)gdpa(dev, "vkWaitForFences"); + t.reset = (PFN_vkResetFences)gdpa(dev, "vkResetFences"); + t.destroy = (PFN_vkDestroyFence)gdpa(dev, "vkDestroyFence"); + PFN_vkCreateFence create = (PFN_vkCreateFence)gdpa(dev, "vkCreateFence"); + if (!t.wait || !t.reset || !t.destroy || !create) return NULL; + /* Never wait for a slot: the next submission may signal a semaphore that + * every outstanding fence is waiting on. Grow only at the high-water mark. */ + struct tracked *slot = idle ? idle : calloc(1, sizeof(*slot)); + if (!slot) return NULL; + VkFenceCreateInfo fci = {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO, NULL, 0}; + if (create(dev, &fci, NULL, &t.fence) != VK_SUCCESS) { + if (!idle) free(slot); + return NULL; + } + if (idle) { + idle->destroy(idle->dev, idle->fence, NULL); + t.next = idle->next; + } else { + t.next = *pool; + *pool = slot; + } + *slot = t; + return slot; +} + +static uint32_t track_pool_submitted(struct tracked *t, VkResult result, uint32_t issued) { + if (result == VK_SUCCESS) { + t->mark = ++issued; + t->state = TRACK_SUBMITTED; + } else { + t->reset(t->dev, 1, &t->fence); + t->state = TRACK_IDLE; + } + return issued; +} + +static int track_pool_reap(struct tracked *pool, uint32_t issued, uint32_t *done) { + int pending = 0; + uint32_t through = issued; + for (struct tracked *t = pool; t; t = t->next) { + if (t->state != TRACK_SUBMITTED) continue; + /* Scan all fences without blocking behind an earlier submission on a + * different queue. Device loss retires fences as in the previous path. */ + if (t->wait(t->dev, 1, &t->fence, VK_TRUE, 0) == VK_TIMEOUT) { + pending = 1; + if ((int32_t)(t->mark - through) <= 0) through = t->mark - 1; + } else { + t->reset(t->dev, 1, &t->fence); + t->state = TRACK_IDLE; + } + } + /* done is a contiguous watermark, not a count of completed fences. Later + * work may free its slot but cannot release an unfinished presenter frame. + * Signed differences also handle the uint32_t counter wrapping. */ + *done = through; + return pending; +} + +static int track_pool_device_busy(struct tracked *pool, VkDevice dev) { + for (struct tracked *t = pool; t; t = t->next) + if (t->dev == dev && t->state != TRACK_IDLE) return 1; + return 0; +} + +/* The caller must first wait for all this device's reservations and work. */ +static void track_pool_forget(struct tracked **pool, VkDevice dev) { + for (struct tracked **p = pool; *p;) { + struct tracked *t = *p; + if (t->dev != dev) { p = &t->next; continue; } + t->destroy(t->dev, t->fence, NULL); + *p = t->next; + free(t); + } +} diff --git a/tools/vr/src/hand_mesh.h b/tools/vr/src/hand_mesh.h new file mode 100644 index 000000000..cfad5781b --- /dev/null +++ b/tools/vr/src/hand_mesh.h @@ -0,0 +1,463 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "hand_pose.h" + +namespace droiddeck { + +struct HandMesh { + std::vector points, normals; + std::vector tints, shades; + std::vector> faces; + + int Add(const Vec3 &p, const Vec3 &n, double tint = 0.0, double shade = 1.0) { + points.push_back(p); + normals.push_back(n); + tints.push_back(tint); + shades.push_back(shade); + return (int)points.size() - 1; + } + + void Face(int a, int b, int c, const Vec3 &outward) { + const Vec3 n = Cross(points[b] - points[a], points[c] - points[a]); + if (Dot(n, outward) < 0.0) faces.push_back({a, c, b}); + else faces.push_back({a, b, c}); + } +}; + +inline FingerCurls RelaxedCurls() { + FingerCurls c; + c.thumb = 0.15; + c.index = 0.1; + c.middle = c.ring = c.pinky = 0.15; + return c; +} + +inline void HandShape(bool right, bool pinch, bool fist, Vec3 out[kHandJoints]) { + using namespace hand; + FingerCurls c = RelaxedCurls(); + if (pinch) { + c.thumb = 0.7; + c.index = 0.55; + } + if (fist) c.middle = c.ring = c.pinky = 1.0; + HandJoints(right, c, out); + if (pinch) { + const Vec3 meet = out[kIndexTip] + (out[kThumbIp] - out[kIndexTip]) * 0.15; + const Vec3 shift = meet - out[kThumbTip]; + out[kThumbTip] = meet; + out[kThumbIp] = out[kThumbIp] + shift * 0.5; + out[kThumbMcp] = out[kThumbMcp] + shift * 0.15; + } +} + +enum class HandPart { Hand, ThumbIndex }; + +namespace glove { + +constexpr double kStep = 0.0026; +constexpr double kPalmHalf = 0.0105; +constexpr double kPalmRound = 0.0085; +constexpr double kWristSide = 0.02; +constexpr double kWristSpan = 0.017; +constexpr double kWristRadius = 0.0165; +constexpr double kCuffStart = 0.006; +constexpr double kCuffLength = 0.016; +constexpr double kCuffWidth[2] = {0.0295, 0.029}; +constexpr double kCuffDepth[2] = {0.021, 0.0205}; +constexpr double kCuffRound = 0.006; +constexpr double kJointBlend = 0.001; +constexpr double kFingerBlend = 0.010; +constexpr double kThumbBlend = 0.016; +constexpr double kWristBlend = 0.012; +constexpr double kCuffBlend = 0.005; +constexpr double kTintEdge = 0.0015; +constexpr double kTouchBlend = 0.008; +constexpr double kCutBlend = 0.004; +constexpr double kDome[3] = {0.030, 0.040, 0.0135}; +constexpr double kDomeAt = 0.55; +constexpr double kDomeBlend = 0.022; +constexpr double kFistRadius = 0.0095; +constexpr double kFistBlend = 0.006; +constexpr double kOcclusionStep = 0.004; +constexpr double kOcclusion = 3.0; +constexpr double kDarkest = 0.4; +constexpr double kInset = 0.0003; +constexpr double kReach = 0.03; +constexpr double kRadii[5][4] = {{0.0135, 0.0122, 0.0110, 0.0096}, + {0.0106, 0.0098, 0.0092, 0.0087}, + {0.0110, 0.0102, 0.0096, 0.0090}, + {0.0105, 0.0097, 0.0091, 0.0086}, + {0.0094, 0.0087, 0.0082, 0.0077}}; + +inline Vec3 Unit(const Vec3 &v) { + const double l = Length(v); + return l > 1e-12 ? v * (1.0 / l) : Vec3{0.0, 1.0, 0.0}; +} + +inline double Smin(double a, double b, double k) { + const double h = std::max(k - std::fabs(a - b), 0.0) / k; + return std::min(a, b) - h * h * k * 0.25; +} + +inline double Cone(const Vec3 &p, const Vec3 &a, const Vec3 &b, double ra, double rb) { + const Vec3 ab = b - a; + const double t = std::min(1.0, std::max(0.0, Dot(p - a, ab) / std::max(Dot(ab, ab), 1e-12))); + return Length(p - (a + ab * t)) - (ra + (rb - ra) * t); +} + +inline double Polygon(const std::vector> &v, double x, double y) { + double d = (x - v[0][0]) * (x - v[0][0]) + (y - v[0][1]) * (y - v[0][1]); + double sign = 1.0; + for (size_t i = 0, j = v.size() - 1; i < v.size(); j = i++) { + const double ex = v[j][0] - v[i][0], ey = v[j][1] - v[i][1]; + const double wx = x - v[i][0], wy = y - v[i][1]; + const double t = std::min(1.0, std::max(0.0, (wx * ex + wy * ey) / std::max(ex * ex + ey * ey, 1e-18))); + const double bx = wx - ex * t, by = wy - ey * t; + d = std::min(d, bx * bx + by * by); + const bool a = y >= v[i][1], b = y < v[j][1], c = ex * wy > ey * wx; + if ((a && b && c) || (!a && !b && !c)) sign = -sign; + } + return sign * std::sqrt(d); +} + +inline double Rounded(double a, double b, double round) { + return std::min(std::max(a, b), 0.0) + std::hypot(std::max(a, 0.0), std::max(b, 0.0)) - round; +} + +struct Finger { + Vec3 joints[4]; + double radii[4]; + + double Distance(const Vec3 &p) const { + double d = Cone(p, joints[0], joints[1], radii[0], radii[1]); + for (int i = 1; i < 3; i++) d = Smin(d, Cone(p, joints[i], joints[i + 1], radii[i], radii[i + 1]), kJointBlend); + return d; + } +}; + +class Glove { +public: + Glove(bool right, HandPart part, bool closed) : part_(part) { + using namespace hand; + Vec3 open[kHandJoints], j[kHandJoints]; + HandShape(right, false, false, open); + HandShape(right, part == HandPart::ThumbIndex && closed, part == HandPart::Hand && closed, j); + origin_ = open[kWrist]; + along_ = Unit(open[kMiddleMcp] - open[kWrist]); + normal_ = Unit(Cross(open[kIndexMcp] - open[kWrist], open[kPinkyMcp] - open[kWrist])); + across_ = Unit(Cross(along_, normal_)); + const Vec3 side = across_ * (Dot(open[kIndexMcp] - open[kPinkyMcp], across_) > 0.0 ? 1.0 : -1.0); + for (const Vec3 &q : {open[kWrist] + side * kWristSide, open[kThumbCmc], open[kIndexMcp], open[kMiddleMcp], open[kRingMcp], + open[kPinkyMcp], open[kWrist] - side * kWristSide}) + outline_.push_back({Dot(q - origin_, across_), Dot(q - origin_, along_)}); + wristA_ = open[kWrist] + across_ * kWristSpan; + wristB_ = open[kWrist] - across_ * kWristSpan; + const int first = part == HandPart::Hand ? 2 : 0, last = part == HandPart::Hand ? 4 : 1; + for (int f = first; f <= last; f++) { + Finger finger; + const int root = f == 0 ? kThumbCmc : kIndexMcp + 4 * (f - 1); + for (int i = 0; i < 4; i++) { + finger.joints[i] = j[root + i]; + finger.radii[i] = kRadii[f][i]; + } + finger.joints[3] = finger.joints[3] - Unit(finger.joints[3] - finger.joints[2]) * (finger.radii[3] * 0.7); + fingers_.push_back(finger); + } + dome_ = open[kWrist] + (open[kMiddleMcp] - open[kWrist]) * kDomeAt; + back_ = normal_ * (Dot(normal_, open[kThumbTip] - dome_) > 0.0 ? -1.0 : 1.0); + fist_ = part == HandPart::Hand && closed; + for (int f = 0; f < 3 && fist_; f++) { + fistLine_[f] = Vec3{}; + for (int i = 0; i < 4; i++) fistLine_[f] = fistLine_[f] + j[kMiddleMcp + 4 * f + i] * 0.25; + } + lo_ = hi_ = origin_; + const auto grow = [&](const Vec3 &q, double r) { + lo_ = Vec3{std::min(lo_.x, q.x - r), std::min(lo_.y, q.y - r), std::min(lo_.z, q.z - r)}; + hi_ = Vec3{std::max(hi_.x, q.x + r), std::max(hi_.y, q.y + r), std::max(hi_.z, q.z + r)}; + }; + for (int i : {kWrist, kThumbCmc, kIndexMcp, kMiddleMcp, kRingMcp, kPinkyMcp}) grow(open[i], kPalmHalf + kPalmRound + kWristSide); + grow(origin_ - along_ * kCuffLength, kCuffWidth[1] + kCuffRound); + for (const Finger &f : fingers_) + for (int i = 0; i < 4; i++) grow(f.joints[i], f.radii[i] + kThumbBlend); + } + + double Distance(const Vec3 &p) const { + double palm = Smin(Smin(Slab(p), Cone(p, wristA_, wristB_, kWristRadius, kWristRadius), kWristBlend), Cuff(p), kCuffBlend); + if (part_ == HandPart::ThumbIndex) { + double reach = 1e9; + for (const Finger &f : fingers_) reach = std::min(reach, f.Distance(p)); + palm = -Smin(-(palm + kInset), kReach - reach, kCutBlend); + } + double fingers = fingers_[0].Distance(p); + for (size_t i = 1; i < fingers_.size(); i++) fingers = Smin(fingers, fingers_[i].Distance(p), kTouchBlend); + if (fist_) + fingers = Smin(fingers, std::min(Cone(p, fistLine_[0], fistLine_[1], kFistRadius, kFistRadius), + Cone(p, fistLine_[1], fistLine_[2], kFistRadius, kFistRadius)), kFistBlend); + return Smin(palm, fingers, part_ == HandPart::ThumbIndex ? kThumbBlend : kFingerBlend); + } + + double Tint(const Vec3 &p) const { + double rest = Smin(Slab(p), Cone(p, wristA_, wristB_, kWristRadius, kWristRadius), kWristBlend); + for (const Finger &f : fingers_) rest = std::min(rest, f.Distance(p)); + return std::min(1.0, std::max(0.0, 0.5 + (rest - Cuff(p)) / (2.0 * kTintEdge))); + } + + double Shade(const Vec3 &p, const Vec3 &n) const { + double hidden = 0.0, weight = 1.0; + for (int i = 1; i <= 5; i++) { + const double h = kOcclusionStep * i; + hidden += (h - Distance(p + n * h)) * weight; + weight *= 0.6; + } + const double open = std::min(1.0, std::max(0.0, 1.0 - kOcclusion * hidden / kOcclusionStep / 5.0)); + const double light = 0.88 + 0.12 * Dot(n, back_); + return std::min(1.0, std::max(0.0, open * light)); + } + + Vec3 Low() const { return lo_ - Vec3{2.0, 2.0, 2.0} * kStep; } + Vec3 High() const { return hi_ + Vec3{2.0, 2.0, 2.0} * kStep; } + +private: + double Slab(const Vec3 &p) const { + const Vec3 q = p - origin_; + const double slab = Rounded(Polygon(outline_, Dot(q, across_), Dot(q, along_)), std::fabs(Dot(q, normal_)) - (kPalmHalf - kPalmRound), kPalmRound); + const Vec3 d = p - dome_; + const double x = Dot(d, across_), y = Dot(d, along_), z = Dot(d, normal_); + const double k0 = std::sqrt(x * x / (kDome[0] * kDome[0]) + y * y / (kDome[1] * kDome[1]) + z * z / (kDome[2] * kDome[2])); + const double k1 = std::sqrt(x * x / std::pow(kDome[0], 4) + y * y / std::pow(kDome[1], 4) + z * z / std::pow(kDome[2], 4)); + return Smin(slab, k1 > 1e-12 ? k0 * (k0 - 1.0) / k1 : -kDome[2], kDomeBlend); + } + + double Cuff(const Vec3 &p) const { + const Vec3 q = p - origin_; + const double t = -Dot(q, along_) + kCuffStart; + const double s = std::min(1.0, std::max(0.0, t / kCuffLength)); + const double a = kCuffWidth[0] + (kCuffWidth[1] - kCuffWidth[0]) * s - kCuffRound; + const double b = kCuffDepth[0] + (kCuffDepth[1] - kCuffDepth[0]) * s - kCuffRound; + const double x = Dot(q, across_), y = Dot(q, normal_); + const double k0 = std::hypot(x / a, y / b), k1 = std::hypot(x / (a * a), y / (b * b)); + const double radial = k1 > 1e-12 ? k0 * (k0 - 1.0) / k1 : -std::min(a, b); + return Rounded(radial, std::fabs(t - kCuffLength * 0.5) - (kCuffLength * 0.5 - kCuffRound), kCuffRound); + } + + HandPart part_; + bool fist_ = false; + Vec3 origin_, along_, across_, normal_, wristA_, wristB_, lo_, hi_, dome_, back_, fistLine_[3]; + std::vector> outline_; + std::vector fingers_; +}; + +template Vec3 Gradient(const Field &f, const Vec3 &p) { + constexpr double e = 1e-5; + return Vec3{f(p + Vec3{e, 0.0, 0.0}) - f(p - Vec3{e, 0.0, 0.0}), f(p + Vec3{0.0, e, 0.0}) - f(p - Vec3{0.0, e, 0.0}), + f(p + Vec3{0.0, 0.0, e}) - f(p - Vec3{0.0, 0.0, e})} * + (0.5 / e); +} + +template +HandMesh SurfaceNet(const Field &f, const Painter &paint, const Vec3 &lo, const Vec3 &hi, double step) { + const int nx = (int)std::ceil((hi.x - lo.x) / step) + 1, ny = (int)std::ceil((hi.y - lo.y) / step) + 1, + nz = (int)std::ceil((hi.z - lo.z) / step) + 1; + const auto at = [&](int i, int j, int k) { return (size_t(k) * ny + j) * nx + i; }; + const auto corner = [&](int i, int j, int k) { return Vec3{lo.x + i * step, lo.y + j * step, lo.z + k * step}; }; + std::vector value(size_t(nx) * ny * nz); + for (int k = 0; k < nz; k++) + for (int j = 0; j < ny; j++) + for (int i = 0; i < nx; i++) value[at(i, j, k)] = f(corner(i, j, k)); + std::vector vertex(value.size(), -1); + HandMesh m; + for (int k = 0; k + 1 < nz; k++) + for (int j = 0; j + 1 < ny; j++) + for (int i = 0; i + 1 < nx; i++) { + double c[8]; + int inside = 0; + for (int n = 0; n < 8; n++) inside += (c[n] = value[at(i + (n & 1), j + ((n >> 1) & 1), k + (n >> 2))]) < 0.0; + if (inside == 0 || inside == 8) continue; + Vec3 sum; + int count = 0; + for (int a = 0; a < 8; a++) + for (int bit = 1; bit < 8; bit <<= 1) { + const int b = a | bit; + if (b == a || (c[a] < 0.0) == (c[b] < 0.0)) continue; + const Vec3 pa = corner(i + (a & 1), j + ((a >> 1) & 1), k + (a >> 2)); + const Vec3 pb = corner(i + (b & 1), j + ((b >> 1) & 1), k + (b >> 2)); + sum = sum + pa + (pb - pa) * (c[a] / (c[a] - c[b])); + count++; + } + Vec3 p = sum * (1.0 / count); + for (int it = 0; it < 3; it++) { + const Vec3 g = Gradient(f, p); + const double gg = Dot(g, g); + if (gg < 1e-12) break; + p = p - g * (f(p) / gg); + } + const Vec3 low = corner(i, j, k), high = corner(i + 1, j + 1, k + 1); + p = Vec3{std::clamp(p.x, low.x, high.x), std::clamp(p.y, low.y, high.y), std::clamp(p.z, low.z, high.z)}; + const Vec3 n = Unit(Gradient(f, p)); + const std::array colour = paint(p, n); + vertex[at(i, j, k)] = m.Add(p, n, colour[0], colour[1]); + } + for (int k = 1; k + 1 < nz; k++) + for (int j = 1; j + 1 < ny; j++) + for (int i = 1; i + 1 < nx; i++) { + const bool in = value[at(i, j, k)] < 0.0; + for (int axis = 0; axis < 3; axis++) { + const int di = axis == 0, dj = axis == 1, dk = axis == 2; + if ((value[at(i + di, j + dj, k + dk)] < 0.0) == in) continue; + int q[4]; + if (axis == 0) q[0] = vertex[at(i, j - 1, k - 1)], q[1] = vertex[at(i, j, k - 1)], q[2] = vertex[at(i, j, k)], q[3] = vertex[at(i, j - 1, k)]; + else if (axis == 1) q[0] = vertex[at(i - 1, j, k - 1)], q[1] = vertex[at(i, j, k - 1)], q[2] = vertex[at(i, j, k)], q[3] = vertex[at(i - 1, j, k)]; + else q[0] = vertex[at(i - 1, j - 1, k)], q[1] = vertex[at(i, j - 1, k)], q[2] = vertex[at(i, j, k)], q[3] = vertex[at(i - 1, j, k)]; + if (q[0] < 0 || q[1] < 0 || q[2] < 0 || q[3] < 0) continue; + if (in == (axis == 1)) std::swap(q[1], q[3]); + if (Length(m.points[q[0]] - m.points[q[2]]) < Length(m.points[q[1]] - m.points[q[3]])) { + m.faces.push_back({q[0], q[1], q[2]}); + m.faces.push_back({q[0], q[2], q[3]}); + } else { + m.faces.push_back({q[0], q[1], q[3]}); + m.faces.push_back({q[1], q[2], q[3]}); + } + } + } + return m; +} + +} + +inline HandMesh BuildHandPart(bool right, HandPart part, bool closed) { + const glove::Glove g(right, part, closed); + return glove::SurfaceNet([&](const Vec3 &p) { return g.Distance(p); }, [&](const Vec3 &p, const Vec3 &n) { return std::array{g.Tint(p), g.Shade(p, n)}; }, g.Low(), g.High(), + glove::kStep); +} + +inline std::string HandObj(const HandMesh &m, const char *material) { + std::string out = "mtllib hand.mtl\n"; + char line[160]; + for (const Vec3 &p : m.points) { + snprintf(line, sizeof(line), "v %.5f %.5f %.5f\n", p.x, p.y, p.z); + out += line; + } + for (size_t i = 0; i < m.tints.size(); i++) { + snprintf(line, sizeof(line), "vt %.3f %.3f\n", 0.02 + 0.96 * m.shades[i], 0.02 + 0.46 * m.tints[i]); + out += line; + } + for (const Vec3 &n : m.normals) { + snprintf(line, sizeof(line), "vn %.4f %.4f %.4f\n", n.x, n.y, n.z); + out += line; + } + out += "usemtl "; + out += material; + out += "\n"; + for (const auto &f : m.faces) { + snprintf(line, sizeof(line), "f %d/%d/%d %d/%d/%d %d/%d/%d\n", f[0] + 1, f[0] + 1, f[0] + 1, f[1] + 1, f[1] + 1, f[1] + 1, f[2] + 1, + f[2] + 1, f[2] + 1); + out += line; + } + return out; +} + +inline std::string HandMtl(const char *material, const char *texture) { + std::string out = "newmtl "; + out += material; + out += "\nNs 12.0\nd 1.0\nillum 2\nKa 1.0 1.0 1.0\nKd 1.0 1.0 1.0\nKs 0.1 0.1 0.1\nmap_Kd "; + out += texture; + out += "\n"; + return out; +} + +inline std::string HandModelJson(bool right) { + const auto part = [&](const char *name, const char *path, int button, bool shown) { + char text[512]; + snprintf(text, sizeof(text), + " \"%s\": {\n" + " \"filename\": \"%s.obj\",\n" + " \"motion\": {\n" + " \"type\": \"translate\",\n" + " \"pressed_path\": \"%s\",\n" + " \"controller_button\": %d,\n" + " \"value_mapping\": [0.0, 0.0],\n" + " \"axis\": [0.0, 0.0, 1.0]\n" + " },\n" + " \"visibility\": { \"default\": %s, \"press\": %s }\n" + " },\n", + name, name, path, button, shown ? "false" : "true", shown ? "true" : "false"); + return std::string(text); + }; + const auto pose = [](const char *name, const char *origin, const char *rotate) { + char text[256]; + snprintf(text, sizeof(text), " \"%s\": { \"component_local\": { \"origin\": [%s], \"rotate_xyz\": [%s] } }", name, origin, rotate); + return std::string(text); + }; + std::string out = "{\n \"components\": {\n"; + out += part("hand_open", "/input/grip/touch", 2, false); + out += part("hand_fist", "/input/grip/touch", 2, true); + out += part("thumb_index_open", "/input/trigger/click", 33, false); + out += part("thumb_index_pinch", "/input/trigger/click", 33, true); + out += pose("tip", right ? "-0.006, -0.015, 0.02" : "0.006, -0.015, 0.02", right ? "-40.0, 5.0, 0.0" : "-40.0, -5.0, 0.0") + ",\n"; + out += pose("base", right ? "-0.004758, -0.037977, 0.200466" : "0.004758, -0.037977, 0.200466", + right ? "-155.4, -0.427, -7.081" : "-155.4, -0.427, 7.081") + ",\n"; + out += pose("handgrip", right ? "0.003851, 0.003715, 0.075948" : "-0.003851, 0.003715, 0.075948", + right ? "15.392, 2.071, -0.303" : "15.392, -2.071, 0.303") + ",\n"; + out += pose("grip", "0.0, -0.015, 0.13", right ? "15.392, 2.071, -0.303" : "15.392, -2.071, 0.303") + ",\n"; + out += pose("gdc2015", "0.0, 0.0, 0.0", "0, 0, 0") + "\n"; + out += " }\n}\n"; + return out; +} + +inline uint32_t Crc32(const uint8_t *data, size_t size, uint32_t crc = 0) { + crc = ~crc; + for (size_t i = 0; i < size; i++) { + crc ^= data[i]; + for (int k = 0; k < 8; k++) crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return ~crc; +} + +inline std::vector GlovePng(int size, const std::array &glove, const std::array &cuff, double darkest) { + std::vector raw; + for (int y = 0; y < size; y++) { + raw.push_back(0); + const double t = 1.0 - std::fabs(2.0 * (y + 0.5) / size - 1.0); + for (int x = 0; x < size; x++) { + const double light = darkest + (1.0 - darkest) * x / double(size - 1); + for (int c = 0; c < 3; c++) raw.push_back(uint8_t(std::lround((glove[c] + (cuff[c] - glove[c]) * t) * light))); + } + } + std::vector zlib = {0x78, 0x01, 0x01, uint8_t(raw.size() & 0xff), uint8_t(raw.size() >> 8), + uint8_t(~raw.size() & 0xff), uint8_t((~raw.size() >> 8) & 0xff)}; + zlib.insert(zlib.end(), raw.begin(), raw.end()); + uint32_t a = 1, s = 0; + for (uint8_t v : raw) { + a = (a + v) % 65521; + s = (s + a) % 65521; + } + const uint32_t adler = (s << 16) | a; + for (int i = 3; i >= 0; i--) zlib.push_back(uint8_t(adler >> (8 * i))); + std::vector png = {0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}; + const auto chunk = [&](const char *type, const std::vector &data) { + for (int i = 3; i >= 0; i--) png.push_back(uint8_t(data.size() >> (8 * i))); + std::vector body(type, type + 4); + body.insert(body.end(), data.begin(), data.end()); + png.insert(png.end(), body.begin(), body.end()); + const uint32_t crc = Crc32(body.data(), body.size()); + for (int i = 3; i >= 0; i--) png.push_back(uint8_t(crc >> (8 * i))); + }; + std::vector header; + for (int v : {size, size}) + for (int i = 3; i >= 0; i--) header.push_back(uint8_t(v >> (8 * i))); + header.insert(header.end(), {8, 2, 0, 0, 0}); + chunk("IHDR", header); + chunk("IDAT", zlib); + chunk("IEND", {}); + return png; +} + +} diff --git a/tools/vr/src/hand_motion.h b/tools/vr/src/hand_motion.h new file mode 100644 index 000000000..eaa89c708 --- /dev/null +++ b/tools/vr/src/hand_motion.h @@ -0,0 +1,322 @@ +#pragma once + +#include +#include +#include + +#include "arm_model.h" + +namespace droiddeck { + +class HandFollower { +public: + void Reset() { placed_ = false; } + + void Place(const Vec3 &at, const Vec3 &moved) { + at_ = at; + lastMoved_ = moved; + placed_ = true; + } + + Vec3 Update(const Vec3 &anchor, const Vec3 &moved, double dt, double anchorSeconds, double maxOffset) { + if (!placed_) { + at_ = anchor; + placed_ = true; + } else { + at_ = at_ + (moved - lastMoved_); + const double pull = 1.0 - std::exp(-std::max(dt, 0.0) / anchorSeconds); + at_ = at_ + (anchor - at_) * pull; + const Vec3 off = at_ - anchor; + const double length = Length(off); + if (length > maxOffset) at_ = anchor + off * (maxOffset / length); + } + lastMoved_ = moved; + return at_; + } + +private: + bool placed_ = false; + Vec3 at_, lastMoved_; +}; + +class TimeHistory { +public: + void Clear() { count_ = next_ = 0; } + + void Add(int64_t timeNs, const Vec3 &value) { + if (count_ && timeNs <= time_[Last()]) { + if (timeNs == time_[Last()]) value_[Last()] = value; + return; + } + time_[next_] = timeNs; + value_[next_] = value; + next_ = (next_ + 1) % kSize; + count_ = std::min(count_ + 1, kSize); + } + + bool At(int64_t timeNs, Vec3 *out) const { + if (!count_) return false; + int newer = Last(); + if (timeNs >= time_[newer]) { + *out = value_[newer]; + return true; + } + for (int i = 1; i < count_; i++) { + const int older = (newer - 1 + kSize) % kSize; + if (time_[older] <= timeNs) { + const double t = double(timeNs - time_[older]) / double(time_[newer] - time_[older]); + *out = value_[older] + (value_[newer] - value_[older]) * t; + return true; + } + newer = older; + } + *out = value_[newer]; + return true; + } + +private: + int Last() const { return (next_ - 1 + kSize) % kSize; } + + static constexpr int kSize = 256; + int64_t time_[kSize] = {}; + Vec3 value_[kSize]; + int count_ = 0, next_ = 0; +}; + +class CameraYaw { +public: + void Reset() { + c_ = s_ = weight_ = 0.0; + outliers_ = 0; + } + + void Add(double yaw, double dt) { + double current = 0.0; + if (Get(¤t) && std::fabs(std::remainder(yaw - current, 2.0 * M_PI)) > kJump) { + if (++outliers_ < kOutlierFrames) return; + Reset(); + } + outliers_ = 0; + const double keep = std::exp(-std::max(dt, 0.0) / kSeconds); + c_ = c_ * keep + std::cos(yaw); + s_ = s_ * keep + std::sin(yaw); + weight_ = weight_ * keep + 1.0; + } + + bool Get(double *yaw) const { + if (weight_ < kMinWeight || std::hypot(c_, s_) < kMinAgreement * weight_) return false; + *yaw = std::atan2(s_, c_); + return true; + } + +private: + static constexpr double kSeconds = 3.0, kMinWeight = 4.0, kMinAgreement = 0.7, kJump = 0.6; + static constexpr int kOutlierFrames = 8; + double c_ = 0.0, s_ = 0.0, weight_ = 0.0; + int outliers_ = 0; +}; + +class HandKalman { +public: + void Reset() { + ready_ = false; + haveInput_ = false; + count_ = 0; + outliers_ = 0; + seenNs_ = 0; + stillSeconds_ = 0.0; + } + + void Forget() { + ready_ = false; + outliers_ = 0; + } + + bool Ready() const { return ready_; } + int64_t SeenNs() const { return seenNs_; } + int64_t TimeNs() const { return lastNs_; } + Vec3 Position() const { return {axes_[0].x[0], axes_[1].x[0], axes_[2].x[0]}; } + Vec3 Velocity() const { return {axes_[0].x[1], axes_[1].x[1], axes_[2].x[1]}; } + bool Still() const { return stillSeconds_ > kStillSeconds; } + + void Input(int64_t timeNs, const Vec3 &inertial, double turnRate) { + if (haveInput_ && timeNs <= lastNs_) return; + if (!haveInput_ || timeNs - lastNs_ > kGapNs) { + Reset(); + haveInput_ = true; + lastNs_ = timeNs; + lastInertial_ = inertial; + return; + } + Step step; + step.timeNs = timeNs; + step.dt = (timeNs - lastNs_) * 1e-9; + step.du = inertial - lastInertial_; + lastNs_ = timeNs; + lastInertial_ = inertial; + const bool calm = turnRate < kStillTurn && Length(step.du) < kStillAccel * step.dt; + stillSeconds_ = calm ? stillSeconds_ + step.dt : 0.0; + if (ready_) { + Propagate(axes_, step.du, step.dt, Fade(timeNs - seenNs_)); + if (Still()) Stop(axes_); + for (int a = 0; a < 3; a++) step.after[a] = axes_[a]; + } + steps_[next_] = step; + next_ = (next_ + 1) % kSteps; + count_ = std::min(count_ + 1, kSteps); + } + + bool Measure(int64_t timeNs, const Vec3 &at, double sigma) { + if (!haveInput_ || timeNs > lastNs_ + kFutureNs || (count_ == 0 && timeNs < lastNs_)) return false; + int k = -1; + for (int i = 0; i < count_; i++) { + const Step &s = steps_[Index(i)]; + if (s.timeNs >= timeNs) { + k = i; + break; + } + } + const double z[3] = {at.x, at.y, at.z}; + if (k < 0) { + if (!ready_) Start(axes_, z, sigma); + else if (!Accept(axes_, z, sigma)) return false; + else Correct(axes_, z, sigma); + ready_ = true; + seenNs_ = timeNs; + return true; + } + const Step &first = steps_[Index(k)]; + if (timeNs < first.timeNs - int64_t(first.dt * 1e9)) return false; + Axis axes[3]; + if (ready_) { + if (k == 0) return false; + for (int a = 0; a < 3; a++) axes[a] = steps_[Index(k - 1)].after[a]; + } + const double frac = std::clamp((timeNs - (first.timeNs - first.dt * 1e9)) / (first.dt * 1e9), 0.0, 1.0); + if (ready_) { + Propagate(axes, first.du * frac, first.dt * frac, Fade(timeNs - seenNs_)); + if (!Accept(axes, z, sigma)) return false; + Correct(axes, z, sigma); + } else { + Start(axes, z, sigma); + } + Propagate(axes, first.du * (1.0 - frac), first.dt * (1.0 - frac), kDampingSeconds); + for (int a = 0; a < 3; a++) steps_[Index(k)].after[a] = axes[a]; + for (int i = k + 1; i < count_; i++) { + Step &s = steps_[Index(i)]; + Propagate(axes, s.du, s.dt, Fade(s.timeNs - timeNs)); + for (int a = 0; a < 3; a++) s.after[a] = axes[a]; + } + for (int a = 0; a < 3; a++) axes_[a] = axes[a]; + ready_ = true; + seenNs_ = timeNs; + return true; + } + +private: + struct Axis { + double x[2] = {0.0, 0.0}; + double P[2][2] = {}; + }; + struct Step { + int64_t timeNs = 0; + double dt = 0.0; + Vec3 du; + Axis after[3]; + }; + + int Index(int i) const { return (next_ - count_ + i + kSteps) % kSteps; } + + static void Start(Axis *axes, const double *z, double sigma) { + for (int a = 0; a < 3; a++) { + axes[a] = Axis(); + axes[a].x[0] = z[a]; + axes[a].P[0][0] = sigma * sigma; + axes[a].P[1][1] = kStartSpeed * kStartSpeed; + } + } + + bool Accept(const Axis *axes, const double *z, double sigma) { + double d2 = 0.0; + for (int a = 0; a < 3; a++) { + const double y = z[a] - axes[a].x[0]; + d2 += y * y / (axes[a].P[0][0] + sigma * sigma); + } + if (d2 <= kGate) { + outliers_ = 0; + return true; + } + if (++outliers_ < kOutliers) return false; + outliers_ = 0; + ready_ = false; + return true; + } + + void Correct(Axis *axes, const double *z, double sigma) const { + if (!ready_) { + Start(axes, z, sigma); + return; + } + for (int a = 0; a < 3; a++) Update(axes[a], 0, z[a], sigma); + } + + static void Update(Axis &s, int state, double z, double sigma) { + const double S = s.P[state][state] + sigma * sigma; + const double K[2] = {s.P[0][state] / S, s.P[1][state] / S}; + const double y = z - s.x[state]; + s.x[0] += K[0] * y; + s.x[1] += K[1] * y; + const double p00 = s.P[0][0] - K[0] * s.P[state][0]; + const double p01 = s.P[0][1] - K[0] * s.P[state][1]; + const double p11 = s.P[1][1] - K[1] * s.P[state][1]; + s.P[0][0] = p00; + s.P[0][1] = s.P[1][0] = p01; + s.P[1][1] = p11; + } + + static void Stop(Axis *axes) { + for (int a = 0; a < 3; a++) Update(axes[a], 1, 0.0, kStillSpeed); + } + + static double Fade(int64_t sinceSeenNs) { return sinceSeenNs > kStaleNs ? kStaleDampingSeconds : kDampingSeconds; } + + static void Propagate(Axis *axes, const Vec3 &du, double dt, double damping) { + if (dt <= 0.0) return; + const double motion = Length(du); + const double g = kMotionError * kMotionError * motion * motion; + const double d[3] = {du.x, du.y, du.z}; + const double keep = std::exp(-dt / damping); + const double q00 = kNoise * dt * dt * dt / 3.0 + g * dt * dt / 4.0; + const double q01 = kNoise * dt * dt / 2.0 + g * dt / 2.0; + const double q11 = kNoise * dt + g; + for (int a = 0; a < 3; a++) { + Axis &s = axes[a]; + s.x[0] += s.x[1] * dt + 0.5 * d[a] * dt; + s.x[1] = (s.x[1] + d[a]) * keep; + const double p00 = s.P[0][0] + 2.0 * dt * s.P[0][1] + dt * dt * s.P[1][1] + q00; + const double p01 = keep * (s.P[0][1] + dt * s.P[1][1]) + q01; + const double p11 = keep * keep * s.P[1][1] + q11; + s.P[0][0] = p00; + s.P[0][1] = s.P[1][0] = p01; + s.P[1][1] = p11; + } + } + + static constexpr int kSteps = 192; + static constexpr int64_t kGapNs = 250000000; + static constexpr int64_t kFutureNs = 50000000, kStaleNs = 200000000; + static constexpr double kNoise = 2.0, kMotionError = 0.3, kDampingSeconds = 0.3, kStaleDampingSeconds = 0.1; + static constexpr double kStartSpeed = 0.5; + static constexpr double kStillTurn = 0.3, kStillAccel = 0.6, kStillSeconds = 0.1, kStillSpeed = 0.05; + static constexpr double kGate = 25.0; + static constexpr int kOutliers = 3; + Step steps_[kSteps]; + Axis axes_[3]; + Vec3 lastInertial_; + int64_t lastNs_ = 0, seenNs_ = 0; + double stillSeconds_ = 0.0; + int count_ = 0, next_ = 0, outliers_ = 0; + bool ready_ = false, haveInput_ = false; +}; + +} diff --git a/tools/vr/src/hand_pose.h b/tools/vr/src/hand_pose.h new file mode 100644 index 000000000..2d7f12ada --- /dev/null +++ b/tools/vr/src/hand_pose.h @@ -0,0 +1,443 @@ +#pragma once + +#include +#include +#include + +#include "hand_skeleton.h" + +namespace droiddeck { + +constexpr int kHandJoints = 21; + +namespace hand { + +enum : int { + kWrist = 0, kThumbCmc = 1, kThumbMcp = 2, kThumbIp = 3, kThumbTip = 4, + kIndexMcp = 5, kIndexPip = 6, kIndexDip = 7, kIndexTip = 8, + kMiddleMcp = 9, kMiddleTip = 12, kRingMcp = 13, kRingTip = 16, kPinkyMcp = 17, kPinkyTip = 20, +}; + +constexpr int kPalm[5] = {kWrist, kIndexMcp, kMiddleMcp, kRingMcp, kPinkyMcp}; +constexpr int kCurlSteps = 20; + +inline double Angle(const Vec3 &a, const Vec3 &b) { + const double la = Length(a), lb = Length(b); + if (la < 1e-9 || lb < 1e-9) return 0.0; + return std::acos(std::clamp(Dot(a, b) / (la * lb), -1.0, 1.0)); +} + +inline double Bend(const Vec3 joints[kHandJoints], int finger) { + if (finger == 0) + return Angle(joints[kThumbMcp] - joints[kThumbCmc], joints[kThumbIp] - joints[kThumbMcp]) + + Angle(joints[kThumbIp] - joints[kThumbMcp], joints[kThumbTip] - joints[kThumbIp]); + const int mcp = kIndexMcp + (finger - 1) * 4; + return Angle(joints[mcp] - joints[kWrist], joints[mcp + 1] - joints[mcp]) + + Angle(joints[mcp + 1] - joints[mcp], joints[mcp + 2] - joints[mcp + 1]) + + Angle(joints[mcp + 2] - joints[mcp + 1], joints[mcp + 3] - joints[mcp + 2]); +} + +inline double &CurlOf(FingerCurls &c, int finger) { + switch (finger) { + case 0: return c.thumb; + case 1: return c.index; + case 2: return c.middle; + case 3: return c.ring; + default: return c.pinky; + } +} + +} + +inline void HandJoints(bool right, const FingerCurls &curls, Vec3 out[kHandJoints]) { + using namespace hand; + vr::VRBoneTransform_t bones[kHandBones]; + HandSkeleton(right, curls, bones); + const Quat wristRotation = ToQuat(bones[1].orientation); + const Vec3 wrist = ToVec(bones[1].position); + out[kWrist] = wrist; + const auto chain = [&](int first, int count, Vec3 *joints) { + Quat r = wristRotation; + Vec3 p = wrist; + for (int b = 0; b < count; b++) { + p = p + Rotate(r, ToVec(bones[first + b].position)); + r = r * ToQuat(bones[first + b].orientation); + joints[b] = p; + } + }; + chain(2, 4, &out[kThumbCmc]); + for (int f = 0; f < 4; f++) { + Vec3 joints[5]; + chain(6 + f * 5, 5, joints); + for (int j = 0; j < 4; j++) out[kIndexMcp + f * 4 + j] = joints[j + 1]; + } +} + +class HandModel { +public: + explicit HandModel(bool right) : right_(right) { + Vec3 open[kHandJoints], closed[kHandJoints]; + HandJoints(right, FingerCurls{}, open); + FingerCurls fist; + fist.thumb = fist.index = fist.middle = fist.ring = fist.pinky = 1.0; + HandJoints(right, fist, closed); + for (int i = 0; i < 5; i++) center_ = center_ + open[hand::kPalm[i]] * 0.2; + const Vec3 normal = Cross(open[hand::kIndexMcp] - open[hand::kWrist], open[hand::kPinkyMcp] - open[hand::kWrist]); + normalSign_ = Dot(normal, closed[hand::kMiddleTip] - open[hand::kMiddleTip]) >= 0.0 ? 1.0 : -1.0; + for (int s = 0; s <= hand::kCurlSteps; s++) { + FingerCurls c; + c.thumb = c.index = c.middle = c.ring = c.pinky = double(s) / hand::kCurlSteps; + Vec3 joints[kHandJoints]; + HandJoints(right, c, joints); + for (int f = 0; f < 5; f++) bends_[f][s] = hand::Bend(joints, f); + } + } + + bool Right() const { return right_; } + Vec3 PalmCenter() const { return center_; } + + bool Fit(const Vec3 joints[kHandJoints], const FingerCurls &curls, Quat *rotation, Vec3 *center, double *rms) const { + Vec3 shape[kHandJoints], palm[5], model; + HandJoints(right_, curls, shape); + for (int i = 0; i < 5; i++) model = model + shape[hand::kPalm[i]] * 0.2; + for (int i = 0; i < 5; i++) palm[i] = shape[hand::kPalm[i]] - model; + Vec3 observed[5], mean; + for (int i = 0; i < 5; i++) mean = mean + joints[hand::kPalm[i]] * 0.2; + for (int i = 0; i < 5; i++) observed[i] = joints[hand::kPalm[i]] - mean; + double S[3][3] = {}; + for (int i = 0; i < 5; i++) { + const double m[3] = {palm[i].x, palm[i].y, palm[i].z}; + const double o[3] = {observed[i].x, observed[i].y, observed[i].z}; + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) S[r][c] += m[r] * o[c]; + } + double N[4][4] = { + {S[0][0] + S[1][1] + S[2][2], S[1][2] - S[2][1], S[2][0] - S[0][2], S[0][1] - S[1][0]}, + {S[1][2] - S[2][1], S[0][0] - S[1][1] - S[2][2], S[0][1] + S[1][0], S[2][0] + S[0][2]}, + {S[2][0] - S[0][2], S[0][1] + S[1][0], -S[0][0] + S[1][1] - S[2][2], S[1][2] + S[2][1]}, + {S[0][1] - S[1][0], S[2][0] + S[0][2], S[1][2] + S[2][1], -S[0][0] - S[1][1] + S[2][2]}, + }; + double q[4]; + if (!LargestEigenvector(N, q)) return false; + const Quat fitted = Normalize({q[0], q[1], q[2], q[3]}); + double squares = 0.0; + for (int i = 0; i < 5; i++) { + const Vec3 d = Rotate(fitted, palm[i]) - observed[i]; + squares += Dot(d, d); + } + *rotation = fitted; + if (center) *center = model; + if (rms) *rms = std::sqrt(squares / 5.0); + return true; + } + + Vec3 PalmNormal(const Vec3 joints[kHandJoints]) const { + const Vec3 n = Cross(joints[hand::kIndexMcp] - joints[hand::kWrist], joints[hand::kPinkyMcp] - joints[hand::kWrist]); + const double length = Length(n); + return length > 1e-9 ? n * (normalSign_ / length) : Vec3{}; + } + + FingerCurls Curls(const Vec3 joints[kHandJoints]) const { + FingerCurls out; + for (int f = 0; f < 5; f++) { + const double bend = hand::Bend(joints, f); + const double *table = bends_[f]; + double curl = bend <= table[0] ? 0.0 : 1.0; + for (int s = 0; s < hand::kCurlSteps; s++) { + if (bend < table[s] || bend > table[s + 1]) continue; + const double span = table[s + 1] - table[s]; + curl = (s + (span > 1e-9 ? (bend - table[s]) / span : 0.0)) / hand::kCurlSteps; + break; + } + hand::CurlOf(out, f) = curl; + } + return out; + } + +private: + static bool LargestEigenvector(double A[4][4], double out[4]) { + double V[4][4] = {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1}}; + for (int sweep = 0; sweep < 50; sweep++) { + double off = 0.0; + for (int p = 0; p < 4; p++) + for (int r = p + 1; r < 4; r++) off += A[p][r] * A[p][r]; + if (off < 1e-24) break; + for (int p = 0; p < 4; p++) + for (int r = p + 1; r < 4; r++) { + if (std::fabs(A[p][r]) < 1e-30) continue; + const double theta = (A[r][r] - A[p][p]) / (2.0 * A[p][r]); + const double t = (theta >= 0.0 ? 1.0 : -1.0) / (std::fabs(theta) + std::sqrt(theta * theta + 1.0)); + const double c = 1.0 / std::sqrt(t * t + 1.0), s = t * c; + for (int k = 0; k < 4; k++) { + const double akp = A[k][p], akr = A[k][r]; + A[k][p] = c * akp - s * akr; + A[k][r] = s * akp + c * akr; + } + for (int k = 0; k < 4; k++) { + const double apk = A[p][k], ark = A[r][k]; + A[p][k] = c * apk - s * ark; + A[r][k] = s * apk + c * ark; + } + for (int k = 0; k < 4; k++) { + const double vkp = V[k][p], vkr = V[k][r]; + V[k][p] = c * vkp - s * vkr; + V[k][r] = s * vkp + c * vkr; + } + } + } + int best = 0; + for (int i = 1; i < 4; i++) + if (A[i][i] > A[best][best]) best = i; + double length = 0.0; + for (int k = 0; k < 4; k++) length += V[k][best] * V[k][best]; + if (!(length > 1e-12)) return false; + for (int k = 0; k < 4; k++) out[k] = V[k][best] / std::sqrt(length); + return true; + } + + bool right_; + Vec3 center_; + double normalSign_ = 1.0; + double bends_[5][hand::kCurlSteps + 1] = {}; +}; + +class Debounce { +public: + bool Update(bool in, int64_t nowNs, int64_t onNs, int64_t offNs) { + if (in == on_) { + pending_ = false; + return on_; + } + if (!pending_) { + pending_ = true; + sinceNs_ = nowNs; + } + if (nowNs - sinceNs_ >= (in ? onNs : offNs)) { + on_ = in; + pending_ = false; + } + return on_; + } + void Reset() { on_ = pending_ = false; } + bool On() const { return on_; } + +private: + bool on_ = false, pending_ = false; + int64_t sinceNs_ = 0; +}; + +struct HandButtons { + float trigger = 0.0f, grip = 0.0f; + bool triggerClick = false, triggerTouch = false, gripClick = false; + bool a = false, b = false, system = false, facing = false; +}; + +class HandGestures { +public: + explicit HandGestures(bool right) : right_(right) {} + + HandButtons Update(const HandModel &model, const Vec3 joints[kHandJoints], const FingerCurls &curls, const Vec3 &palm, + const Vec3 &head, int64_t nowNs) { + using namespace hand; + const double index = Length(joints[kThumbTip] - joints[kIndexTip]); + const double middle = Length(joints[kThumbTip] - joints[kMiddleTip]); + const double ring = Length(joints[kThumbTip] - joints[kRingTip]); + const Vec3 toHead = head - palm; + const double distance = Length(toHead); + const bool towards = distance > 1e-6 && Dot(model.PalmNormal(joints), toHead * (1.0 / distance)) > kFacing; + HandButtons out; + out.facing = facing_.Update(towards, nowNs, kFacingOnNs, kOffNs); + const double fist = (curls.middle + curls.ring + curls.pinky) / 3.0; + out.grip = (float)std::clamp((fist - kGripStart) / (kGripFull - kGripStart), 0.0, 1.0); + out.gripClick = grip_.Update(gripClick_ ? out.grip > kGripRelease : out.grip >= 1.0f, nowNs, kOnNs, kOffNs); + gripClick_ = out.gripClick; + const bool pinch = pinch_.Update(index < (pinchHeld_ ? kPinchOff : kPinchOn), nowNs, kOnNs, kOffNs); + pinchHeld_ = pinch; + const bool loose = out.grip < 0.5f; + const bool aPinch = a_.Update(loose && !pinch && Touched(ring, a_.On()), nowNs, kOnNs, kOffNs); + const bool bPinch = b_.Update(loose && !pinch && Touched(middle, b_.On()), nowNs, kOnNs, kOffNs); + if (out.facing) { + if (right_) out.system = pinch; + else out.b = pinch; + return out; + } + out.trigger = (float)std::clamp((kPinchFar - index) / (kPinchFar - kPinchNear), 0.0, 1.0); + out.triggerClick = pinch; + out.triggerTouch = out.trigger > 0.0f; + out.a = aPinch; + out.b = bPinch; + return out; + } + + void Reset() { + pinch_.Reset(); + a_.Reset(); + b_.Reset(); + grip_.Reset(); + facing_.Reset(); + pinchHeld_ = gripClick_ = false; + } + + static constexpr double kPinchOn = 0.035, kPinchOff = 0.045, kPinchNear = 0.03, kPinchFar = 0.06; + static constexpr double kTouchOn = 0.025, kTouchOff = 0.035; + static constexpr double kGripStart = 0.35, kGripFull = 0.75, kGripRelease = 0.7; + static constexpr double kFacing = 0.6; + static constexpr int64_t kOnNs = 50000000, kOffNs = 100000000, kFacingOnNs = 300000000; + +private: + static bool Touched(double distance, bool held) { return distance < (held ? kTouchOff : kTouchOn); } + + bool right_; + Debounce pinch_, a_, b_, grip_, facing_; + bool pinchHeld_ = false, gripClick_ = false; +}; + +class SmoothTurn { +public: + void Reset() { ready_ = false; } + + Quat Update(const Quat &target, double dt) { + if (!ready_) { + at_ = last_ = target; + speed_ = 0.0; + ready_ = true; + return at_; + } + if (!(dt > 0.0)) return at_; + const double moved = AngleDegrees(Conjugate(last_) * target) * M_PI / 180.0; + last_ = target; + speed_ += (moved / dt - speed_) * Alpha(dt, kSpeedCutoff); + at_ = Nlerp(at_, target, Alpha(dt, kMinCutoff + kBeta * speed_)); + return at_; + } + + static constexpr double kMinCutoff = 1.0, kBeta = 0.2, kSpeedCutoff = 1.0; + +private: + static double Alpha(double dt, double hz) { return 1.0 / (1.0 + 1.0 / (2.0 * M_PI * hz * dt)); } + + bool ready_ = false; + Quat at_, last_; + double speed_ = 0.0; +}; + +class SmoothPoint { +public: + void Reset() { ready_ = false; } + + Vec3 Update(const Vec3 &target, double dt) { + if (!ready_) { + at_ = target; + speed_ = 0.0; + velocity_ = Vec3{}; + ready_ = true; + return at_; + } + if (!(dt > 0.0)) return at_; + const Vec3 before = at_; + speed_ += (Length(target - at_) / dt - speed_) * Alpha(dt, kSpeedCutoff); + at_ = at_ + (target - at_) * Alpha(dt, kMinCutoff + kBeta * speed_); + velocity_ = velocity_ + ((at_ - before) * (1.0 / dt) - velocity_) * Alpha(dt, kSpeedCutoff); + return at_; + } + + Vec3 Velocity() const { return velocity_; } + + static constexpr double kMinCutoff = 1.0, kBeta = 0.5, kSpeedCutoff = 1.0; + +private: + static double Alpha(double dt, double hz) { return 1.0 / (1.0 + 1.0 / (2.0 * M_PI * hz * dt)); } + + bool ready_ = false; + Vec3 at_, velocity_; + double speed_ = 0.0; +}; + +struct CameraHandPose { + bool visible = false; + Quat rotation; + Vec3 position, velocity; + FingerCurls curls; + HandButtons buttons; +}; + +class CameraHand { +public: + explicit CameraHand(bool right) : model_(right), gestures_(right) {} + + void Reset() { + Hide(); + shown_ = false; + } + + void See(int64_t timeNs, const Vec3 &palm, const Vec3 *joints, const Vec3 &head, int64_t nowNs) { + if (!joints) return; + const FingerCurls curls = model_.Curls(joints); + Quat rotation; + Vec3 center; + double rms = 1.0; + if (!model_.Fit(joints, curls, &rotation, ¢er, &rms) || rms > kMaxRms) return; + palm_ = palm; + seenNs_ = timeNs; + rms_ = rms; + if (seen_ && AngleDegrees(Conjugate(target_) * rotation) > kMaxTurnDegrees) { + const bool agrees = pending_ && AngleDegrees(Conjugate(pendingTurn_) * rotation) < kAgreeDegrees; + pending_ = !agrees; + pendingTurn_ = rotation; + if (!agrees) return; + } + pending_ = false; + seen_ = true; + target_ = rotation; + center_ = center; + targetCurls_ = curls; + buttons_ = gestures_.Update(model_, joints, curls, palm, head, nowNs); + } + + CameraHandPose Update(int64_t nowNs, double dt) { + CameraHandPose out; + if (!seen_ || nowNs - seenNs_ > kHoldNs) { + if (shown_) Hide(); + shown_ = false; + return out; + } + if (!shown_) curls_ = targetCurls_; + shown_ = true; + const double follow = 1.0 - std::exp(-std::max(dt, 0.0) / kCurlSeconds); + for (int f = 0; f < 5; f++) hand::CurlOf(curls_, f) += (hand::CurlOf(targetCurls_, f) - hand::CurlOf(curls_, f)) * follow; + out.visible = true; + out.rotation = turn_.Update(target_, dt); + out.position = place_.Update(palm_, dt) - Rotate(out.rotation, center_); + out.velocity = place_.Velocity(); + out.curls = curls_; + out.buttons = buttons_; + return out; + } + + double Rms() const { return rms_; } + + static constexpr double kMaxRms = 0.02, kCurlSeconds = 0.08, kMaxTurnDegrees = 45.0, kAgreeDegrees = 25.0; + static constexpr int64_t kHoldNs = 500000000; + +private: + void Hide() { + place_.Reset(); + turn_.Reset(); + gestures_.Reset(); + buttons_ = HandButtons(); + seen_ = pending_ = false; + } + + HandModel model_; + HandGestures gestures_; + SmoothPoint place_; + SmoothTurn turn_; + Quat target_, pendingTurn_; + Vec3 center_, palm_; + FingerCurls targetCurls_, curls_; + HandButtons buttons_; + int64_t seenNs_ = 0; + double rms_ = 0.0; + bool seen_ = false, pending_ = false, shown_ = false; +}; +} diff --git a/tools/vr/src/hand_skeleton.h b/tools/vr/src/hand_skeleton.h new file mode 100644 index 000000000..8ade4a609 --- /dev/null +++ b/tools/vr/src/hand_skeleton.h @@ -0,0 +1,128 @@ +// Adapted from Valve's OpenVR handskeletonsimulation driver sample (BSD-3-Clause, see OPENVR_LICENSE), +// which follows Moshi Turner's hand simulation in Monado. +#pragma once + +#include "arm_model.h" +#include "openvr_driver.h" + +namespace droiddeck { + +constexpr uint32_t kHandBones = 31; + +struct FingerCurls { + double thumb = 0.0, index = 0.0, middle = 0.0, ring = 0.0, pinky = 0.0; +}; + +namespace hand { + +constexpr double kDegree = M_PI / 180.0; + +constexpr double kLengths[5][5] = { + {0.05, 0.05, 0.035, 0.025, 0.0}, + {0.03, 0.073, 0.045, 0.025, 0.02}, + {0.01, 0.091, 0.049, 0.03, 0.02}, + {0.02, 0.073, 0.045, 0.03, 0.03}, + {0.03, 0.067, 0.03, 0.025, 0.02}, +}; + +struct Joint { + double swing[2] = {0.0, 0.0}; + double twist = 0.0; +}; + +struct Finger { + Joint metacarpal, proximal; + double intermediate = 0.0, distal = 0.0; +}; + +inline Quat SwingTwist(const Joint &j) { + const double squared = j.swing[0] * j.swing[0] + j.swing[1] * j.swing[1]; + const double cosTwist = std::cos(j.twist * 0.5), sinTwist = std::sin(j.twist * 0.5); + double cosSwing = 1.0, sinOver = 0.5; + if (squared > 0.0) { + const double theta = std::sqrt(squared); + cosSwing = std::cos(theta * 0.5); + sinOver = std::sin(theta * 0.5) / theta; + } + return {cosSwing * cosTwist, cosSwing * sinTwist, + j.swing[1] * cosTwist * sinOver - j.swing[0] * sinTwist * sinOver, + j.swing[0] * cosTwist * sinOver + j.swing[1] * sinTwist * sinOver}; +} + +inline Quat AboutZ(double radians) { return {std::cos(radians * 0.5), 0.0, 0.0, std::sin(radians * 0.5)}; } + +inline vr::VRBoneTransform_t Bone(bool right, const Quat &q, Vec3 position) { + if (right) position.x = -position.x; + vr::VRBoneTransform_t t; + t.position = {{(float)position.x, (float)position.y, (float)position.z, 1.0f}}; + t.orientation = {(float)q.w, (float)q.x, (float)q.y, (float)q.z}; + return t; +} + +inline vr::VRBoneTransform_t Metacarpal(bool right, const Quat &orientation, double length) { + Quat q = Quat{0.5, 0.5, -0.5, 0.5} * orientation; + const Vec3 position = Rotate(q, {length, 0.0, 0.0}); + if (right) q = {q.x, -q.w, q.z, -q.y}; + return Bone(right, q, position); +} + +inline Quat ToQuat(const vr::HmdQuaternionf_t &q) { return {q.w, q.x, q.y, q.z}; } +inline Vec3 ToVec(const vr::HmdVector4_t &v) { return {v.v[0], v.v[1], v.v[2]}; } + +} + +inline void HandSkeleton(bool right, const FingerCurls &curls, vr::VRBoneTransform_t out[kHandBones]) { + using namespace hand; + out[0] = {{{0.0f, 0.0f, 0.0f, 1.0f}}, {1.0f, 0.0f, 0.0f, 0.0f}}; + out[1] = {{{-0.034038f, 0.036503f, 0.164722f, 1.0f}}, {-0.055147f, -0.078608f, -0.920279f, 0.379296f}}; + if (right) { + out[1].position.v[0] = -out[1].position.v[0]; + out[1].orientation.y = -out[1].orientation.y; + out[1].orientation.z = -out[1].orientation.z; + } + + Joint thumbMetacarpal, thumbProximal; + thumbMetacarpal.swing[0] = 10 * kDegree + curls.thumb * 5 * kDegree; + thumbMetacarpal.swing[1] = 40 * kDegree; + thumbProximal.swing[0] = curls.thumb * 90 * kDegree; + const double thumbDistal = curls.thumb * 90 * kDegree; + out[2] = Metacarpal(right, SwingTwist(thumbMetacarpal), kLengths[0][0]); + out[3] = Bone(right, SwingTwist(thumbProximal), {kLengths[0][1], 0.0, 0.0}); + out[4] = Bone(right, AboutZ(thumbDistal), {kLengths[0][2], 0.0, 0.0}); + out[5] = Bone(right, Quat{}, {kLengths[0][3], 0.0, 0.0}); + + const double metacarpalSplay[4] = {13.0, 0.0, -15.0, -27.0}; + const double proximalSplay[4] = {3.0, 0.0, -1.0, -2.0}; + const double fingerCurls[4] = {curls.index, curls.middle, curls.ring, curls.pinky}; + for (int f = 0; f < 4; f++) { + Finger finger; + finger.metacarpal.swing[0] = fingerCurls[f] * 5 * kDegree; + finger.metacarpal.swing[1] = metacarpalSplay[f] * kDegree; + finger.proximal.swing[0] = fingerCurls[f] * 90 * kDegree; + finger.proximal.swing[1] = proximalSplay[f] * kDegree; + finger.intermediate = 5 * kDegree + fingerCurls[f] * 80 * kDegree; + finger.distal = 5 * kDegree + fingerCurls[f] * 80 * kDegree; + const int base = 6 + f * 5; + const double *length = kLengths[f + 1]; + out[base] = Metacarpal(right, SwingTwist(finger.metacarpal), length[0]); + out[base + 1] = Bone(right, SwingTwist(finger.proximal), {length[1], 0.0, 0.0}); + out[base + 2] = Bone(right, AboutZ(finger.intermediate), {length[2], 0.0, 0.0}); + out[base + 3] = Bone(right, AboutZ(finger.distal), {length[3], 0.0, 0.0}); + out[base + 4] = Bone(right, Quat{}, {length[4], 0.0, 0.0}); + } + + const int distal[5] = {4, 9, 14, 19, 24}; + for (int f = 0; f < 5; f++) { + const int first = f == 0 ? 2 : 6 + (f - 1) * 5; + Quat rotation = ToQuat(out[1].orientation); + Vec3 position = ToVec(out[1].position); + for (int b = first; b <= distal[f]; b++) { + position = position + Rotate(rotation, ToVec(out[b].position)); + rotation = rotation * ToQuat(out[b].orientation); + } + out[26 + f] = {{{(float)position.x, (float)position.y, (float)position.z, 1.0f}}, + {(float)rotation.w, (float)rotation.x, (float)rotation.y, (float)rotation.z}}; + } +} + +} diff --git a/tools/vr/src/handcam_shm.h b/tools/vr/src/handcam_shm.h new file mode 100644 index 000000000..732b25a4f --- /dev/null +++ b/tools/vr/src/handcam_shm.h @@ -0,0 +1,51 @@ +#ifndef HANDCAM_SHM_H +#define HANDCAM_SHM_H +#include +#include + +#define DROIDDECK_HANDCAM_MAGIC 0x4D434844u +#define DROIDDECK_HANDCAM_VERSION 2u +#define DROIDDECK_HANDCAM_JOINT_COUNT 21 +#define DROIDDECK_HANDCAM_FILE "droiddeck-handcam" + +enum { + DROIDDECK_HANDCAM_RUNNING = 1u << 0, + DROIDDECK_HANDCAM_FACE = 1u << 1, + DROIDDECK_HANDCAM_BARE_HANDS = 1u << 2, +}; + +enum { + DROIDDECK_HANDCAM_SEEN = 1u << 0, + DROIDDECK_HANDCAM_ESTIMATED = 1u << 1, + DROIDDECK_HANDCAM_JOINTS = 1u << 2, +}; + +struct droiddeck_handcam_hand { + uint32_t flags; + float position[3]; + float joints[DROIDDECK_HANDCAM_JOINT_COUNT][3]; +}; + +struct droiddeck_handcam { + uint32_t magic; + uint32_t version; + uint32_t seq; + uint32_t flags; + int64_t time_ns; + uint32_t frame; + float face_yaw; + struct droiddeck_handcam_hand hands[2]; +}; + +#ifdef __cplusplus +#define DROIDDECK_HANDCAM_ASSERT static_assert +#else +#define DROIDDECK_HANDCAM_ASSERT _Static_assert +#endif +DROIDDECK_HANDCAM_ASSERT(offsetof(struct droiddeck_handcam_hand, joints) == 16, "handcam hand layout"); +DROIDDECK_HANDCAM_ASSERT(sizeof(struct droiddeck_handcam_hand) == 268, "handcam hand layout"); +DROIDDECK_HANDCAM_ASSERT(offsetof(struct droiddeck_handcam, time_ns) == 16, "handcam layout"); +DROIDDECK_HANDCAM_ASSERT(offsetof(struct droiddeck_handcam, hands) == 32, "handcam layout"); +DROIDDECK_HANDCAM_ASSERT(sizeof(struct droiddeck_handcam) == 568, "handcam layout"); + +#endif diff --git a/tools/vr/src/index_pose.h b/tools/vr/src/index_pose.h new file mode 100644 index 000000000..79a301eb5 --- /dev/null +++ b/tools/vr/src/index_pose.h @@ -0,0 +1,17 @@ +// Index controller tip and grip offsets in its raw frame, as Monado's vive_poses.c (BSL-1.0) lists them; see docs/third-party/joycon. +#pragma once + +#include "arm_model.h" + +namespace droiddeck { + +inline Quat IndexTipRotation(bool right) { + return AboutY((right ? 5.0 : -5.0) * M_PI / 180.0) * AboutX(-40.0 * M_PI / 180.0); +} + +inline void IndexRawPose(bool right, const Quat &aim, const Vec3 &grip, Quat *raw, Vec3 *position) { + *raw = Normalize(aim * Conjugate(IndexTipRotation(right))); + *position = grip - Rotate(*raw, {0.0, -0.015, 0.13}); +} + +} diff --git a/tools/vr/src/joycon_shm.h b/tools/vr/src/joycon_shm.h new file mode 100644 index 000000000..3655b9abd --- /dev/null +++ b/tools/vr/src/joycon_shm.h @@ -0,0 +1,61 @@ +#ifndef JOYCON_SHM_H +#define JOYCON_SHM_H +#include +#include + +#define DROIDDECK_JOYCON_MAGIC 0x4E434A44u +#define DROIDDECK_JOYCON_VERSION 3u +#define DROIDDECK_JOYCON_FILE "droiddeck-joycon" +#define DROIDDECK_JOYCON_RUMBLE_SLOT 0x100u + +enum { + DROIDDECK_JOYCON_CONNECTED = 1u << 0, + DROIDDECK_JOYCON_CHARGING = 1u << 1, +}; + +enum { + DROIDDECK_JOYCON_TRIGGER = 1u << 0, + DROIDDECK_JOYCON_GRIP = 1u << 1, + DROIDDECK_JOYCON_A = 1u << 2, + DROIDDECK_JOYCON_B = 1u << 3, + DROIDDECK_JOYCON_STICK = 1u << 4, + DROIDDECK_JOYCON_SYSTEM = 1u << 5, +}; + +struct droiddeck_joycon_hand { + uint32_t flags; + uint32_t buttons; + float rotation[4]; + float angular_velocity[3]; + float stick[2]; + float battery; + uint32_t recenter; + uint32_t pad; + int64_t time_ns; + float displacement[3]; + float velocity[3]; + float inertial_velocity[3]; + uint32_t reserved; +}; + +struct droiddeck_joycon { + uint32_t magic; + uint32_t version; + uint32_t seq; + uint32_t reserved; + struct droiddeck_joycon_hand hands[2]; +}; + +#ifdef __cplusplus +#define DROIDDECK_JOYCON_ASSERT static_assert +#else +#define DROIDDECK_JOYCON_ASSERT _Static_assert +#endif +DROIDDECK_JOYCON_ASSERT(sizeof(struct droiddeck_joycon_hand) == 104, "joycon hand layout"); +DROIDDECK_JOYCON_ASSERT(offsetof(struct droiddeck_joycon_hand, time_ns) == 56, "joycon hand layout"); +DROIDDECK_JOYCON_ASSERT(offsetof(struct droiddeck_joycon_hand, displacement) == 64, "joycon hand layout"); +DROIDDECK_JOYCON_ASSERT(offsetof(struct droiddeck_joycon_hand, inertial_velocity) == 88, "joycon hand layout"); +DROIDDECK_JOYCON_ASSERT(sizeof(struct droiddeck_joycon) == 224, "joycon layout"); +DROIDDECK_JOYCON_ASSERT(offsetof(struct droiddeck_joycon, hands) == 16, "joycon layout"); + +#endif diff --git a/tools/vr/src/present_sync.h b/tools/vr/src/present_sync.h new file mode 100644 index 000000000..e87d0e963 --- /dev/null +++ b/tools/vr/src/present_sync.h @@ -0,0 +1,10 @@ +#pragma once +#include + +#define DROIDDECK_PRESENT_SYNC_NAME "present-sync" +#define DROIDDECK_PRESENT_SYNC_SIZE 4096 + +struct droiddeck_present_sync { + uint32_t issued; + uint32_t done; +}; diff --git a/tools/vr/src/presenter.cpp b/tools/vr/src/presenter.cpp index 939a000c6..e8c15bcba 100644 --- a/tools/vr/src/presenter.cpp +++ b/tools/vr/src/presenter.cpp @@ -8,11 +8,14 @@ typedef unsigned long VisualID; #include #include "xdg-shell-client-protocol.h" +#include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h" #include "presenter.h" +#include "present_sync.h" #include "xr_tracking_file.h" #include "xr_frame_channel.h" #include "producer_lease.h" +#include #include #include #include @@ -22,7 +25,11 @@ typedef unsigned long VisualID; #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -137,10 +144,12 @@ VkFormat UnormOf(uint32_t f) { class Impl { public: static Impl &Get() { - static Impl impl; - return impl; + static Impl *impl = made_ = new Impl; + return *impl; } + static Impl *Made() { return made_; } + void Add(const uint64_t handles[3], const int fds[3], uint32_t format, uint32_t w, uint32_t h, uint32_t producerPid) { std::lock_guard lock(mutex_); if (failed_ || exit_) return; @@ -168,6 +177,11 @@ public: EnsureThread(); frame_ = f; frameAt_ = std::chrono::steady_clock::now(); + const droiddeck_present_sync *sync = MapSync(frameAt_); + const uint32_t issued = sync ? __atomic_load_n(&sync->issued, __ATOMIC_ACQUIRE) : 0; + frameTracked_ = sync && issued != lastIssued_; + frameMark_ = issued; + lastIssued_ = issued; haveFrame_ = true; submitted_++; uint64_t ticket = ++ticket_; @@ -220,12 +234,16 @@ private: Log("presenter: unavailable, frames are not shown"); Fail(); return; } - uint64_t shown = 0; + if (const char *g = getenv("DROIDDECK_VR_PRESENT_GUARD_MS")) guard_ = std::chrono::microseconds((long)(atof(g) * 1000)); + uint64_t shown = 0, waited = 0, late = 0; + std::chrono::steady_clock::duration longest{}; auto lastLog = std::chrono::steady_clock::now(); while (true) { EyeFrame f; uint64_t ticket = 0; std::chrono::steady_clock::time_point at; + uint32_t mark = 0; + bool tracked = false; std::vector imports; std::vector removals; { @@ -236,6 +254,8 @@ private: removals.swap(removals_); f = frame_; at = frameAt_; + mark = frameMark_; + tracked = frameTracked_; bool draw = haveFrame_; haveFrame_ = false; inFlight_ = draw; ticket = ticket_; @@ -244,7 +264,19 @@ private: if (wl_) { wl_display_dispatch_pending(wl_); wl_display_flush(wl_); } for (auto &p : imports) Import(p); for (auto h : removals) Forget(h); - if (f.texture[0] && !frameChannel_) std::this_thread::sleep_until(at + guard_); + if (f.texture[0] && !frameChannel_ && tracked) { + const auto start = std::chrono::steady_clock::now(); + const int r = WaitDrawn(mark, start + kDrawnTimeout); + if (r > 0) { + waited++; + longest = std::max(longest, std::chrono::steady_clock::now() - start); + } else if (r < 0) { + late++; + } + } else if (f.texture[0] && !frameChannel_) { + std::this_thread::sleep_until(at + guard_); + } + if (f.texture[0] && wayland_) FollowPanel(); if (f.texture[0] && (frameChannel_ ? Capture(f) : Draw(f))) shown++; { std::lock_guard lock(mutex_); @@ -253,9 +285,11 @@ private: } auto now = std::chrono::steady_clock::now(); if (now - lastLog > std::chrono::seconds(10)) { - Log("presenter: %llu frames shown, %llu submitted (last 10 s)", (unsigned long long)shown, - (unsigned long long)submitted_.exchange(0)); - shown = 0; + Log("presenter: %llu frames shown, %llu submitted, %llu waited for the GPU (longest %.1f ms), %llu late (last 10 s)", + (unsigned long long)shown, (unsigned long long)submitted_.exchange(0), (unsigned long long)waited, + std::chrono::duration(longest).count(), (unsigned long long)late); + shown = waited = late = 0; + longest = {}; lastLog = now; } } @@ -278,7 +312,79 @@ private: surface_ = VK_NULL_HANDLE; inst_ = VK_NULL_HANDLE; } + bool PanelSize(uint32_t *w, uint32_t *h) { + if (!pose_) { + int fd = open("/run/droiddeck/" DROIDDECK_HEADPOSE_FILE, O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + void *m = mmap(nullptr, sizeof(droiddeck_headpose), PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (m == MAP_FAILED) return false; + pose_ = (const volatile droiddeck_headpose *)m; + } + for (int tries = 0; tries < 8; tries++) { + const uint32_t a = __atomic_load_n(&pose_->seq, __ATOMIC_ACQUIRE); + if (a & 1u) continue; + const uint32_t ew = pose_->eye_w, eh = pose_->eye_h; + __atomic_thread_fence(__ATOMIC_ACQUIRE); + if (__atomic_load_n(&pose_->seq, __ATOMIC_RELAXED) != a) continue; + if (!ew || !eh || pose_->magic != DROIDDECK_HEADPOSE_MAGIC) return false; + *w = ew * 2; + *h = eh; + return true; + } + return false; + } + + void FollowPanel() { + uint32_t w = 0, h = 0; + if (!PanelSize(&w, &h)) return; + const bool resized = w != width_ || h != height_; + const auto now = std::chrono::steady_clock::now(); + if (!resized && (swapchain_ || now < retryAt_)) return; + vk_.DeviceWaitIdle(dev_); + if (swapchain_) vk_.DestroySwapchainKHR(dev_, swapchain_, nullptr); + swapchain_ = VK_NULL_HANDLE; + images_.clear(); + if (resized) Log("presenter: panel now %ux%u (was %ux%u)", w, h, width_, height_); + width_ = w; + height_ = h; + if (!CreateSwapchain()) retryAt_ = now + std::chrono::seconds(1); + } + std::chrono::microseconds guard_{4000}; + std::chrono::steady_clock::time_point retryAt_{}; + static constexpr std::chrono::milliseconds kDrawnTimeout{100}; + + const droiddeck_present_sync *MapSync(std::chrono::steady_clock::time_point now) { + if (sync_) return sync_; + if (syncTried_.time_since_epoch().count() && now - syncTried_ < std::chrono::seconds(1)) return nullptr; + syncTried_ = now; + const char *rt = getenv("XDG_RUNTIME_DIR"); + std::string path = std::string(rt && *rt ? rt : "/run/droiddeck") + "/droiddeck-steamvr/" DROIDDECK_PRESENT_SYNC_NAME; + int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd < 0) return nullptr; + void *m = mmap(nullptr, DROIDDECK_PRESENT_SYNC_SIZE, PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (m == MAP_FAILED) return nullptr; + sync_ = (const droiddeck_present_sync *)m; + Log("presenter: frames wait for the compositor's GPU work (%s)", path.c_str()); + return sync_; + } + + int WaitDrawn(uint32_t mark, std::chrono::steady_clock::time_point until) { + const droiddeck_present_sync *s = sync_; + int slept = 0; + while (true) { + const uint32_t done = __atomic_load_n(&s->done, __ATOMIC_ACQUIRE); + if ((int32_t)(done - mark) >= 0) return slept; + const auto left = until - std::chrono::steady_clock::now(); + if (left <= std::chrono::steady_clock::duration::zero()) return -1; + const auto ns = std::chrono::duration_cast(left).count(); + struct timespec ts = {(time_t)(ns / 1000000000), (long)(ns % 1000000000)}; + syscall(SYS_futex, const_cast(&s->done), FUTEX_WAIT, done, &ts, nullptr, 0); + slept = 1; + } + } struct wl_display *wl_ = nullptr; struct wl_compositor *wlComp_ = nullptr; @@ -333,12 +439,7 @@ private: wl_surface_commit(wlSurf_); for (int i = 0; i < 20 && !configured_; i++) wl_display_roundtrip(wl_); width_ = height_ = 0; - int fd = open("/run/droiddeck/droiddeck-headpose", O_RDONLY | O_CLOEXEC); - if (fd >= 0) { - uint32_t eye[2] = {0, 0}; - if (pread(fd, eye, sizeof(eye), 72) == (ssize_t)sizeof(eye) && eye[0] && eye[1]) { width_ = eye[0] * 2; height_ = eye[1]; } - close(fd); - } + if (!PanelSize(&width_, &height_)) width_ = height_ = 0; XrTrackingFile xrFile; DroidDeckXrTracking tracking; if (xrFile.Read(&tracking) && XrTrackingFile::Fresh(tracking)) { @@ -351,7 +452,6 @@ private: } bool InitWindow() { - if (const char *g = getenv("DROIDDECK_VR_PRESENT_GUARD_MS")) guard_ = std::chrono::microseconds((long)(atof(g) * 1000)); if (!x_.Load()) { Log("presenter: no libX11"); return false; } const char *name = getenv("DROIDDECK_VR_DISPLAY"); if (!name || !*name) name = ":0"; @@ -439,7 +539,7 @@ private: VkSemaphoreCreateInfo semci = {VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO, nullptr, 0}; vk_.CreateSemaphore(dev_, &semci, nullptr, &acquired_); vk_.CreateSemaphore(dev_, &semci, nullptr, &rendered_); - if (!frameChannel_ && !CreateSwapchain()) return false; + if (!frameChannel_ && !CreateSwapchain() && !wayland_) return false; Log("presenter: Vulkan on %s, queue family %u", props.deviceName, qfam_); return true; } @@ -480,7 +580,11 @@ private: sci.presentMode = pm; sci.clipped = VK_TRUE; VkResult r = vk_.CreateSwapchainKHR(dev_, &sci, nullptr, &swapchain_); - if (r != VK_SUCCESS) { Log("presenter: vkCreateSwapchainKHR %d", r); return false; } + if (r != VK_SUCCESS) { + swapchain_ = VK_NULL_HANDLE; + Log("presenter: vkCreateSwapchainKHR %d", r); + return false; + } uint32_t ni = 0; vk_.GetSwapchainImagesKHR(dev_, swapchain_, &ni, nullptr); images_.resize(ni); @@ -693,7 +797,7 @@ private: bool Draw(const EyeFrame &f) { const Imported *eye[2] = {Find(f.texture[0]), Find(f.texture[1])}; - if (!eye[0] || !eye[1]) return false; + if (!eye[0] || !eye[1] || !swapchain_) return false; vk_.WaitForFences(dev_, 1, &fence_, VK_TRUE, UINT64_MAX); uint32_t idx = 0; VkResult r = vk_.AcquireNextImageKHR(dev_, swapchain_, 100000000ull, acquired_, VK_NULL_HANDLE, &idx); @@ -745,6 +849,10 @@ private: VkPipelineStageFlags wait = VK_PIPELINE_STAGE_TRANSFER_BIT; VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO, nullptr, 1, &acquired_, &wait, 1, &cmd_, 1, &rendered_}; if ((r = vk_.QueueSubmit(queue_, 1, &si, fence_)) != VK_SUCCESS) { Log("presenter: submit %d", r); return false; } + if (vk_.WaitForFences(dev_, 1, &fence_, VK_TRUE, (uint64_t)std::chrono::nanoseconds(kDrawnTimeout).count()) == VK_TIMEOUT) { + static int said; + if (said++ < 3) Log("presenter: the copy took over %lld ms; showing it anyway", (long long)kDrawnTimeout.count()); + } VkPresentInfoKHR pi = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR, nullptr, 1, &rendered_, 1, &swapchain_, &idx, nullptr}; r = vk_.QueuePresentKHR(queue_, &pi); if (r != VK_SUCCESS && r != VK_SUBOPTIMAL_KHR) { @@ -760,6 +868,11 @@ private: std::vector removals_; EyeFrame frame_{}; std::chrono::steady_clock::time_point frameAt_{}; + uint32_t frameMark_ = 0, lastIssued_ = 0; + bool frameTracked_ = false; + const droiddeck_present_sync *sync_ = nullptr; + const volatile droiddeck_headpose *pose_ = nullptr; + std::chrono::steady_clock::time_point syncTried_{}; bool haveFrame_ = false, exit_ = false, started_ = false, inFlight_ = false, failed_ = false; uint64_t ticket_ = 0, completed_ = 0; bool frameChannel_ = false; @@ -770,6 +883,7 @@ private: uint32_t captureWidth_ = 0, captureHeight_ = 0; std::atomic submitted_{0}; std::thread thread_; + static inline std::atomic made_{nullptr}; X11 x_; Display *dpy_ = nullptr; @@ -798,6 +912,8 @@ private: void Presenter::AddTextures(const uint64_t h[3], const int fds[3], uint32_t f, uint32_t w, uint32_t hh, uint32_t pid) { Impl::Get().Add(h, fds, f, w, hh, pid); } void Presenter::RemoveTexture(uint64_t h) { Impl::Get().Remove(h); } void Presenter::Submit(const EyeFrame &f) { Impl::Get().Submit(f); } -void Presenter::Shutdown() { Impl::Get().Shutdown(); } +void Presenter::Shutdown() { + if (Impl *impl = Impl::Made()) impl->Shutdown(); +} } diff --git a/tools/vr/src/render_target.h b/tools/vr/src/render_target.h new file mode 100644 index 000000000..78c6e2745 --- /dev/null +++ b/tools/vr/src/render_target.h @@ -0,0 +1,21 @@ +#ifndef DROIDDECK_RENDER_TARGET_H +#define DROIDDECK_RENDER_TARGET_H + +#include +#include +#include + +namespace droiddeck { + +struct RenderTargetSize { uint32_t width, height; }; + +inline RenderTargetSize RenderTarget(float aspect, uint32_t selectedHeight, uint32_t panelHeight) { + uint32_t height = selectedHeight > 0 ? std::min(selectedHeight, 8192u) : 720u; + if (panelHeight > 0) height = std::min(height, panelHeight); + if (!std::isfinite(aspect) || aspect <= 0.0f) aspect = 1.0f; + const float width = std::clamp(height * aspect, 1.0f, 8192.0f); + return {static_cast(std::lround(width)), height}; +} + +} +#endif diff --git a/tools/vr/src/vk_layer_droiddeck_vr.c b/tools/vr/src/vk_layer_droiddeck_vr.c index 4ddbca5de..653d20583 100644 --- a/tools/vr/src/vk_layer_droiddeck_vr.c +++ b/tools/vr/src/vk_layer_droiddeck_vr.c @@ -291,6 +291,7 @@ static VkResult VKAPI_CALL CreateDevice(VkPhysicalDevice pd, const VkDeviceCreat } static void forget_device_semaphores(VkDevice dev); +static void track_forget_device(VkDevice dev); static void producer_destroy_device(struct dev *device, const VkAllocationCallbacks *allocator); static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallbacks *alloc) { @@ -301,6 +302,7 @@ static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallback #endif if (d->producer) producer_destroy_device(d, alloc); forget_device_semaphores(device); + track_forget_device(device); PFN_vkDestroyDevice f = d->destroy; pthread_mutex_lock(&g_lock); memset(d, 0, sizeof(*d)); @@ -309,19 +311,23 @@ static void VKAPI_CALL DestroyDevice(VkDevice device, const VkAllocationCallback } +#include +#include #include +#include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h" #define FAKE_DISPLAY ((VkDisplayKHR)(uintptr_t)0xdd0001) #define FAKE_MODE ((VkDisplayModeKHR)(uintptr_t)0xdd0002) static VkSurfaceKHR g_hmd_surface; static uint32_t g_mode_w = 1920, g_mode_h = 1080, g_mode_mhz = 60000; +static int g_mode_fixed; static void mode_init(void) { static int done; if (done++) return; const char *m = getenv("DROIDDECK_VR_DISPLAY_MODE"); unsigned w, h, hz; - if (m && sscanf(m, "%ux%u@%u", &w, &h, &hz) == 3 && w && h && hz) { g_mode_w = w; g_mode_h = h; g_mode_mhz = hz * 1000; } + if (m && sscanf(m, "%ux%u@%u", &w, &h, &hz) == 3 && w && h && hz) { g_mode_w = w; g_mode_h = h; g_mode_mhz = hz * 1000; g_mode_fixed = 1; } } static uint64_t now_ns(void) { @@ -329,7 +335,57 @@ static uint64_t now_ns(void) { clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; } -static uint64_t frame_ns(void) { return 1000000000000ull / g_mode_mhz; } +static _Atomic(const struct droiddeck_headpose *) g_pose; +static _Atomic uint64_t g_pose_tried; + +static const struct droiddeck_headpose *pose_map(void) { + const struct droiddeck_headpose *p = atomic_load(&g_pose); + if (p) return p; + uint64_t t = now_ns(), last = atomic_load(&g_pose_tried); + if (last && t - last < 1000000000ull) return NULL; + atomic_store(&g_pose_tried, t); + int fd = open("/run/droiddeck/" DROIDDECK_HEADPOSE_FILE, O_RDONLY | O_CLOEXEC); + if (fd < 0) return NULL; + void *m = mmap(NULL, sizeof(struct droiddeck_headpose), PROT_READ, MAP_SHARED, fd, 0); + close(fd); + if (m == MAP_FAILED) return NULL; + const struct droiddeck_headpose *expected = NULL; + if (!atomic_compare_exchange_strong(&g_pose, &expected, (const struct droiddeck_headpose *)m)) { + munmap(m, sizeof(struct droiddeck_headpose)); + return expected; + } + return m; +} + +static uint32_t mode_mhz(void) { + mode_init(); + if (g_mode_fixed) return g_mode_mhz; + const struct droiddeck_headpose *p = pose_map(); + if (!p || __atomic_load_n(&p->magic, __ATOMIC_RELAXED) != DROIDDECK_HEADPOSE_MAGIC) return g_mode_mhz; + uint32_t bits = __atomic_load_n((const uint32_t *)&p->display_hz, __ATOMIC_RELAXED); + float hz; + memcpy(&hz, &bits, sizeof(hz)); + return hz >= 24.0f && hz <= 240.0f ? (uint32_t)(hz * 1000.0f + 0.5f) : g_mode_mhz; +} + +static uint64_t frame_ns(void) { return 1000000000000ull / mode_mhz(); } + +static pthread_mutex_t g_vblank_lock = PTHREAD_MUTEX_INITIALIZER; +static uint64_t g_vblank_base, g_vblank_at, g_vblank_frame; + +static uint64_t vblank_count(void) { + uint64_t f = frame_ns(); + pthread_mutex_lock(&g_vblank_lock); + uint64_t t = now_ns(); + if (f != g_vblank_frame) { + g_vblank_base = g_vblank_frame ? g_vblank_base + (t - g_vblank_at) / g_vblank_frame : t / f; + g_vblank_at = t; + g_vblank_frame = f; + } + uint64_t n = g_vblank_base + (t - g_vblank_at) / f; + pthread_mutex_unlock(&g_vblank_lock); + return n; +} static VkDisplayPropertiesKHR display_props(void) { mode_init(); @@ -379,7 +435,7 @@ static VkResult VKAPI_CALL GetDisplayPlaneSupportedDisplaysKHR(VkPhysicalDevice } static VkDisplayModePropertiesKHR mode_props(void) { mode_init(); - VkDisplayModePropertiesKHR m = {FAKE_MODE, {{g_mode_w, g_mode_h}, g_mode_mhz}}; + VkDisplayModePropertiesKHR m = {FAKE_MODE, {{g_mode_w, g_mode_h}, mode_mhz()}}; return m; } static VkResult VKAPI_CALL GetDisplayModePropertiesKHR(VkPhysicalDevice pd, VkDisplayKHR d, uint32_t *count, VkDisplayModePropertiesKHR *p) { @@ -467,7 +523,7 @@ static VkResult VKAPI_CALL RegisterDisplayEventEXT(VkDevice d, VkDisplayKHR disp } static VkResult VKAPI_CALL GetSwapchainCounterEXT(VkDevice d, VkSwapchainKHR sc, VkSurfaceCounterFlagBitsEXT counter, uint64_t *value) { mode_init(); - *value = now_ns() / frame_ns(); + *value = vblank_count(); return VK_SUCCESS; } @@ -661,6 +717,9 @@ struct bridged { int fd; int timeline; unsigned long ino; + uint64_t submitted; + uint64_t seen, seen_us; + int imported; }; static pthread_mutex_t g_br_lock = PTHREAD_MUTEX_INITIALIZER; static struct bridged g_br[MAX_SEMS]; @@ -705,6 +764,12 @@ static int sem_trace_on(void) { return on; } +static uint64_t now_us(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000u + (uint64_t)ts.tv_nsec / 1000u; +} + static void *poller(void *arg) { unsigned ticks = 0; for (;;) { @@ -722,10 +787,11 @@ static void *poller(void *arg) { uint64_t local = 0; if (!get || get(b->dev, b->sem, &local) != VK_SUCCESS) continue; uint64_t shared = atomic_load(b->counter); + if (shared != b->seen) { b->seen = shared; b->seen_us = now_us(); } if (trace) { char line[192]; snprintf(line, sizeof(line), "%s sem %p %s counter %lu local %llu shared %llu", program_invocation_short_name, - (void *)b->sem, b->fd >= 0 ? "made here" : "imported", b->ino, (unsigned long long)local, (unsigned long long)shared); + (void *)b->sem, b->imported ? "imported" : "made here", b->ino, (unsigned long long)local, (unsigned long long)shared); say("%s", line); } if (local > shared) { @@ -808,7 +874,7 @@ static VkResult VKAPI_CALL CreateSemaphore(VkDevice d, const VkSemaphoreCreateIn atomic_store(counter, timeline ? type_info->initialValue : 0); pthread_mutex_lock(&g_br_lock); struct bridged *slot = bridged_find(VK_NULL_HANDLE); - if (slot) *slot = (struct bridged){*out, d, counter, fd, timeline, fd_ino(fd)}; + if (slot) *slot = (struct bridged){*out, d, counter, fd, timeline, fd_ino(fd), 0, atomic_load(counter), now_us(), 0}; pthread_mutex_unlock(&g_br_lock); if (!slot) { munmap((void *)counter, 4096); @@ -869,7 +935,7 @@ static VkResult VKAPI_CALL ImportSemaphoreFdKHR(VkDevice d, const VkImportSemaph struct bridged *b = bridged_find(info->semaphore); if (b) bridged_drop(b); b = bridged_find(VK_NULL_HANDLE); - if (b) *b = (struct bridged){info->semaphore, d, counter, info->fd, timeline, ino}; + if (b) *b = (struct bridged){info->semaphore, d, counter, info->fd, timeline, ino, 0, atomic_load(counter), now_us(), 1}; pthread_mutex_unlock(&g_br_lock); if (!b) { munmap((void *)counter, 4096); @@ -877,10 +943,98 @@ static VkResult VKAPI_CALL ImportSemaphoreFdKHR(VkDevice d, const VkImportSemaph } if (timeline) poller_start(); static int said; - if (said++ < 4) say("imported a shared timeline semaphore (%s): waits retained", program_invocation_short_name); + if (said++ < 4) say("imported a shared timeline semaphore (%s): bridged", program_invocation_short_name); return VK_SUCCESS; } +#define SUBMIT_GRACE_US 3000 +#define SHARED_IDLE_US 100000 + +static int quest_frames(void) { + static _Atomic uint64_t checked_us; + static _Atomic int present; + const uint64_t t = now_us(); + const uint64_t last = atomic_load(&checked_us); + if (!last || t - last > 1000000) { + atomic_store(&present, access("/run/droiddeck/droiddeck-xr-frames", F_OK) == 0); + atomic_store(&checked_us, t); + } + return atomic_load(&present); +} + +static int submit_drop_wait(VkSemaphore s, uint64_t value) { + pthread_mutex_lock(&g_br_lock); + struct bridged *b = bridged_find(s); + if (!b || !b->timeline || !b->imported) { pthread_mutex_unlock(&g_br_lock); return 0; } + const uint64_t queued = b->submitted; + const uint64_t seen_us = b->seen_us; + VkDevice dev = b->dev; + _Atomic uint64_t *counter = b->counter; + pthread_mutex_unlock(&g_br_lock); + if (!value || value <= queued) return 0; + struct dev *d = dev_for(dev); + if (!d || quest_frames()) return 0; + PFN_vkGetSemaphoreCounterValue get = (PFN_vkGetSemaphoreCounterValue)d->gdpa(dev, "vkGetSemaphoreCounterValue"); + if (!get) get = (PFN_vkGetSemaphoreCounterValue)d->gdpa(dev, "vkGetSemaphoreCounterValueKHR"); + PFN_vkSignalSemaphore sig = (PFN_vkSignalSemaphore)d->gdpa(dev, "vkSignalSemaphore"); + if (!sig) sig = (PFN_vkSignalSemaphore)d->gdpa(dev, "vkSignalSemaphoreKHR"); + if (!get) return 0; + const uint64_t start = now_us(); + const uint64_t until = start + (start - seen_us > SHARED_IDLE_US ? 0 : SUBMIT_GRACE_US); + for (;;) { + uint64_t local = 0; + if (get(dev, s, &local) == VK_SUCCESS && local >= value) return 0; + uint64_t shared = atomic_load(counter); + if (shared >= value && sig) { + VkSemaphoreSignalInfo si = {VK_STRUCTURE_TYPE_SEMAPHORE_SIGNAL_INFO, NULL, s, shared}; + if (sig(dev, &si) == VK_SUCCESS) return 0; + } + if (now_us() >= until) break; + usleep(100); + } + static _Atomic int dropped; + if (atomic_fetch_add(&dropped, 1) == 0) + say("%s: dropping a submit's wait on a shared value nobody has signalled yet (Turnip would block the submit)", + program_invocation_short_name); + return 1; +} + +static void note_signal(VkSemaphore s, uint64_t value) { + pthread_mutex_lock(&g_br_lock); + struct bridged *b = bridged_find(s); + if (b && b->timeline && value > b->submitted) b->submitted = value; + pthread_mutex_unlock(&g_br_lock); +} + +static void note_submit_signals(uint32_t count, const VkSubmitInfo *submits) { + for (uint32_t i = 0; i < count; i++) { + const VkTimelineSemaphoreSubmitInfo *t = NULL; + for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext) + if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p; + for (uint32_t j = 0; t && j < submits[i].signalSemaphoreCount && j < t->signalSemaphoreValueCount; j++) + note_signal(submits[i].pSignalSemaphores[j], t->pSignalSemaphoreValues[j]); + } +} + +static void note_submit2_signals(uint32_t count, const VkSubmitInfo2 *submits) { + for (uint32_t i = 0; i < count; i++) + for (uint32_t j = 0; j < submits[i].signalSemaphoreInfoCount; j++) + note_signal(submits[i].pSignalSemaphoreInfos[j].semaphore, submits[i].pSignalSemaphoreInfos[j].value); +} + +static uint32_t keep_waits(const VkSemaphore *in, const VkPipelineStageFlags *stages, const uint64_t *values, uint32_t n, + VkSemaphore *out, VkPipelineStageFlags *out_stages, uint64_t *out_values) { + uint32_t k = 0; + for (uint32_t i = 0; i < n; i++) { + if (submit_drop_wait(in[i], values ? values[i] : 0)) continue; + out[k] = in[i]; + if (stages) out_stages[k] = stages[i]; + if (values) out_values[k] = values[i]; + k++; + } + return k; +} + static void trace_sem(const char *what, VkSemaphore sem, uint64_t value) { if (!sem_trace_on() || value > 3) return; pthread_mutex_lock(&g_br_lock); @@ -894,6 +1048,113 @@ static void trace_sem(const char *what, VkSemaphore sem, uint64_t value) { say("%s", line); } +#include +#include +#include +#include "present_sync.h" +#include "fence_tracker.h" +#include + +static pthread_mutex_t g_trk_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t g_trk_cond = PTHREAD_COND_INITIALIZER; +static struct tracked *g_trk; +static struct droiddeck_present_sync *g_sync; +static pthread_once_t g_sync_once = PTHREAD_ONCE_INIT; + +static void sync_wake(struct droiddeck_present_sync *s) { + syscall(SYS_futex, &s->done, FUTEX_WAKE, INT_MAX, NULL, NULL, 0); +} + +static void *completer(void *arg) { + struct droiddeck_present_sync *s = arg; + pthread_mutex_lock(&g_trk_lock); + for (;;) { + uint32_t done; + int pending = track_pool_reap(g_trk, __atomic_load_n(&s->issued, __ATOMIC_ACQUIRE), &done); + if (done != __atomic_load_n(&s->done, __ATOMIC_RELAXED)) { + __atomic_store_n(&s->done, done, __ATOMIC_RELEASE); + sync_wake(s); + } + pthread_cond_broadcast(&g_trk_cond); + if (pending) { + struct timespec until; + clock_gettime(CLOCK_REALTIME, &until); + until.tv_nsec += 1000000; /* Poll pending GPU work at most every 1 ms. */ + if (until.tv_nsec >= 1000000000) { until.tv_sec++; until.tv_nsec -= 1000000000; } + pthread_cond_timedwait(&g_trk_cond, &g_trk_lock, &until); + } else { + pthread_cond_wait(&g_trk_cond, &g_trk_lock); + } + } + return NULL; +} + +static void sync_init(void) { + if (strcmp(program_invocation_short_name, "vrcompositor")) return; + const char *rt = getenv("XDG_RUNTIME_DIR"); + char path[512]; + snprintf(path, sizeof(path), "%s/droiddeck-steamvr/%s", rt && *rt ? rt : "/run/droiddeck", DROIDDECK_PRESENT_SYNC_NAME); + int fd = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0600); + if (fd < 0) return; + void *m = ftruncate(fd, DROIDDECK_PRESENT_SYNC_SIZE) == 0 + ? mmap(NULL, DROIDDECK_PRESENT_SYNC_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0) : MAP_FAILED; + close(fd); + if (m == MAP_FAILED) return; + struct droiddeck_present_sync *s = m; + __atomic_store_n(&s->done, __atomic_load_n(&s->issued, __ATOMIC_ACQUIRE), __ATOMIC_RELEASE); + sync_wake(s); + pthread_t t; + if (pthread_create(&t, NULL, completer, s) != 0) { munmap(m, DROIDDECK_PRESENT_SYNC_SIZE); return; } + pthread_detach(t); + g_sync = s; + say("%s", "frames go to the presenter once their GPU work is done"); +} + +static struct tracked *track_take(VkQueue q) { + pthread_once(&g_sync_once, sync_init); + if (!g_sync) return NULL; + struct dev *d = dev_for(q); + if (!d) return NULL; + pthread_mutex_lock(&g_trk_lock); + struct tracked *t = track_pool_take(&g_trk, d->handle, d->gdpa); + pthread_mutex_unlock(&g_trk_lock); + return t; +} + +static void track_submitted(struct tracked *t, VkResult r) { + pthread_mutex_lock(&g_trk_lock); + uint32_t issued = track_pool_submitted(t, r, __atomic_load_n(&g_sync->issued, __ATOMIC_RELAXED)); + __atomic_store_n(&g_sync->issued, issued, __ATOMIC_RELEASE); + pthread_cond_broadcast(&g_trk_cond); + pthread_mutex_unlock(&g_trk_lock); +} + +static void track_forget_device(VkDevice dev) { + pthread_mutex_lock(&g_trk_lock); + while (track_pool_device_busy(g_trk, dev)) + pthread_cond_wait(&g_trk_cond, &g_trk_lock); + track_pool_forget(&g_trk, dev); + pthread_mutex_unlock(&g_trk_lock); +} + +static VkResult submit_tracked(PFN_vkQueueSubmit next, VkQueue q, uint32_t count, const VkSubmitInfo *submits, VkFence fence) { + struct tracked *slot = track_take(q); + if (!slot) return next(q, count, submits, fence); + const VkFence mine = slot->fence; + VkResult r = next(q, count, submits, fence ? fence : mine); + track_submitted(slot, r == VK_SUCCESS && fence ? next(q, 0, NULL, mine) : r); + return r; +} + +static VkResult submit2_tracked(PFN_vkQueueSubmit2 next, VkQueue q, uint32_t count, const VkSubmitInfo2 *submits, VkFence fence) { + struct tracked *slot = track_take(q); + if (!slot) return next(q, count, submits, fence); + const VkFence mine = slot->fence; + VkResult r = next(q, count, submits, fence ? fence : mine); + track_submitted(slot, r == VK_SUCCESS && fence ? next(q, 0, NULL, mine) : r); + return r; +} + static VkResult VKAPI_CALL QueueSubmit(VkQueue q, uint32_t count, const VkSubmitInfo *submits, VkFence fence) { PFN_vkQueueSubmit next = (PFN_vkQueueSubmit)dev_for(q)->gdpa(dev_for(q)->handle, "vkQueueSubmit"); if (sem_trace_on()) @@ -906,10 +1167,61 @@ static VkResult VKAPI_CALL QueueSubmit(VkQueue q, uint32_t count, const VkSubmit for (uint32_t j = 0; j < submits[i].signalSemaphoreCount; j++) trace_sem("signals", submits[i].pSignalSemaphores[j], t && j < t->signalSemaphoreValueCount ? t->pSignalSemaphoreValues[j] : 0); } + int any = 0; + for (uint32_t i = 0; i < count && !any; i++) { + const VkTimelineSemaphoreSubmitInfo *t = NULL; + for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext) + if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p; + for (uint32_t j = 0; submits[i].pWaitSemaphores && j < submits[i].waitSemaphoreCount && !any; j++) + any = submit_drop_wait(submits[i].pWaitSemaphores[j], t && j < t->waitSemaphoreValueCount ? t->pWaitSemaphoreValues[j] : 0); + } + VkSubmitInfo *copy = NULL; + VkTimelineSemaphoreSubmitInfo *tl = NULL; + void **bufs = NULL; + if (any) { + copy = calloc(count, sizeof(*copy)); + tl = calloc(count, sizeof(*tl)); + bufs = calloc(count * 3, sizeof(void *)); + if (!copy || !tl || !bufs) { free(copy); free(tl); free(bufs); return VK_ERROR_OUT_OF_HOST_MEMORY; } + for (uint32_t i = 0; i < count; i++) { + copy[i] = submits[i]; + uint32_t n = submits[i].waitSemaphoreCount; + if (!n) continue; + VkSemaphore *sems = calloc(n, sizeof(*sems)); + VkPipelineStageFlags *stages = calloc(n, sizeof(*stages)); + uint64_t *vals = calloc(n, sizeof(*vals)); + bufs[i * 3] = sems; bufs[i * 3 + 1] = stages; bufs[i * 3 + 2] = vals; + if (!sems || !stages || !vals) { + for (uint32_t k = 0; k < count * 3; k++) free(bufs[k]); + free(bufs); free(tl); free(copy); + return VK_ERROR_OUT_OF_HOST_MEMORY; + } + const VkTimelineSemaphoreSubmitInfo *t = NULL; + for (const VkBaseInStructure *p = submits[i].pNext; p; p = p->pNext) + if (p->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO) t = (const void *)p; + uint32_t k = keep_waits(submits[i].pWaitSemaphores, submits[i].pWaitDstStageMask, + t && t->waitSemaphoreValueCount ? t->pWaitSemaphoreValues : NULL, n, sems, stages, vals); + copy[i].waitSemaphoreCount = k; + copy[i].pWaitSemaphores = sems; + copy[i].pWaitDstStageMask = stages; + if (t) { + tl[i] = *t; + tl[i].waitSemaphoreValueCount = t->waitSemaphoreValueCount ? k : 0; + tl[i].pWaitSemaphoreValues = vals; + tl[i].pNext = t->pNext; + copy[i].pNext = &tl[i]; + } + } + } struct dev *device = dev_for(q); if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, q); } - VkResult result = next(q, count, submits, fence); + VkResult result = submit_tracked(next, q, count, any ? copy : submits, fence); if (device->producer) pthread_mutex_unlock(&g_submission_lock); + note_submit_signals(count, submits); + if (any) { + for (uint32_t i = 0; i < count * 3; i++) free(bufs[i]); + free(bufs); free(tl); free(copy); + } return result; } @@ -923,10 +1235,41 @@ static VkResult VKAPI_CALL QueueSubmit2(VkQueue q, uint32_t count, const VkSubmi for (uint32_t j = 0; j < submits[i].signalSemaphoreInfoCount; j++) trace_sem("signals", submits[i].pSignalSemaphoreInfos[j].semaphore, submits[i].pSignalSemaphoreInfos[j].value); } + int any = 0; + for (uint32_t i = 0; i < count && !any; i++) + for (uint32_t j = 0; submits[i].pWaitSemaphoreInfos && j < submits[i].waitSemaphoreInfoCount && !any; j++) + any = submit_drop_wait(submits[i].pWaitSemaphoreInfos[j].semaphore, submits[i].pWaitSemaphoreInfos[j].value); + VkSubmitInfo2 *copy = NULL; + VkSemaphoreSubmitInfo **waits = NULL; + if (any) { + copy = calloc(count, sizeof(*copy)); + waits = calloc(count, sizeof(*waits)); + if (!copy || !waits) { free(copy); free(waits); return VK_ERROR_OUT_OF_HOST_MEMORY; } + for (uint32_t i = 0; i < count; i++) { + copy[i] = submits[i]; + uint32_t n = submits[i].waitSemaphoreInfoCount, k = 0; + waits[i] = calloc(n + 1, sizeof(VkSemaphoreSubmitInfo)); + if (!waits[i]) { + for (uint32_t j = 0; j < i; j++) free(waits[j]); + free(waits); free(copy); + return VK_ERROR_OUT_OF_HOST_MEMORY; + } + for (uint32_t j = 0; j < n; j++) + if (!submit_drop_wait(submits[i].pWaitSemaphoreInfos[j].semaphore, submits[i].pWaitSemaphoreInfos[j].value)) + waits[i][k++] = submits[i].pWaitSemaphoreInfos[j]; + copy[i].waitSemaphoreInfoCount = k; + copy[i].pWaitSemaphoreInfos = waits[i]; + } + } struct dev *device = dev_for(q); if (device->producer) { pthread_mutex_lock(&g_submission_lock); producer_record(device, q); } - VkResult result = next(q, count, submits, fence); + VkResult result = submit2_tracked(next, q, count, any ? copy : submits, fence); if (device->producer) pthread_mutex_unlock(&g_submission_lock); + note_submit2_signals(count, submits); + if (any) { + for (uint32_t i = 0; i < count; i++) free(waits[i]); + free(waits); free(copy); + } return result; } diff --git a/tools/vr/tests/test_fence_tracker.c b/tools/vr/tests/test_fence_tracker.c new file mode 100644 index 000000000..8eecc3936 --- /dev/null +++ b/tools/vr/tests/test_fence_tracker.c @@ -0,0 +1,146 @@ +#include +#include +#include +#include "fence_tracker.h" + +struct fake_fence { VkDevice dev; VkResult result; unsigned waits; }; +static unsigned created, destroyed; +static int fail_create; + +static struct fake_fence *fake(VkFence fence) { return (struct fake_fence *)(uintptr_t)fence; } + +static VkResult VKAPI_CALL create_fence(VkDevice dev, const VkFenceCreateInfo *info, + const VkAllocationCallbacks *alloc, VkFence *out) { + (void)alloc; + assert(info->flags == 0); + if (fail_create) return VK_ERROR_OUT_OF_HOST_MEMORY; + struct fake_fence *f = calloc(1, sizeof(*f)); + assert(f); + f->dev = dev; + f->result = VK_TIMEOUT; + *out = (VkFence)(uintptr_t)f; + created++; + return VK_SUCCESS; +} + +static VkResult VKAPI_CALL wait_fence(VkDevice dev, uint32_t count, const VkFence *fences, + VkBool32 all, uint64_t timeout) { + assert(count == 1 && all && timeout == 0); + struct fake_fence *f = fake(*fences); + assert(f->dev == dev); + f->waits++; + return f->result; +} + +static VkResult VKAPI_CALL reset_fence(VkDevice dev, uint32_t count, const VkFence *fences) { + assert(count == 1 && fake(*fences)->dev == dev); + fake(*fences)->result = VK_TIMEOUT; + return VK_SUCCESS; +} + +static void VKAPI_CALL destroy_fence(VkDevice dev, VkFence fence, const VkAllocationCallbacks *alloc) { + (void)alloc; + assert(fake(fence)->dev == dev); + free(fake(fence)); + destroyed++; +} + +static PFN_vkVoidFunction VKAPI_CALL gdpa(VkDevice dev, const char *name) { + (void)dev; + if (!strcmp(name, "vkCreateFence")) return (PFN_vkVoidFunction)create_fence; + if (!strcmp(name, "vkWaitForFences")) return (PFN_vkVoidFunction)wait_fence; + if (!strcmp(name, "vkResetFences")) return (PFN_vkVoidFunction)reset_fence; + if (!strcmp(name, "vkDestroyFence")) return (PFN_vkVoidFunction)destroy_fence; + assert(0); + return NULL; +} + +static struct tracked *submit(struct tracked **pool, VkDevice dev, uint32_t *issued) { + struct tracked *t = track_pool_take(pool, dev, gdpa); + assert(t && t->state == TRACK_RESERVED); + *issued = track_pool_submitted(t, VK_SUCCESS, *issued); + return t; +} + +static void saturated_pool_and_out_of_order_completion(void) { + struct tracked *pool = NULL, *pending[130]; + VkDevice dev = (VkDevice)(uintptr_t)1; + uint32_t issued = 0, done = 0; + /* These waits can all depend on a signal from a later queue submission. + * A fixed 64-slot pool blocks that signal before it reaches the driver. */ + for (unsigned i = 0; i < 130; i++) pending[i] = submit(&pool, dev, &issued); + assert(issued == 130); + assert(track_pool_reap(pool, issued, &done) && done == 0); + for (unsigned i = 0; i < 130; i++) assert(fake(pending[i]->fence)->waits == 1); + + fake(pending[129]->fence)->result = VK_SUCCESS; + assert(track_pool_reap(pool, issued, &done) && done == 0); + assert(pending[129]->state == TRACK_IDLE); + unsigned before = created; + struct tracked *signal = submit(&pool, dev, &issued); + assert(signal == pending[129] && created == before && issued == 131); + /* Freeing the latest fence must not mark the older work as drawn. */ + for (unsigned i = 0; i < 129; i++) fake(pending[i]->fence)->result = VK_SUCCESS; + assert(track_pool_reap(pool, issued, &done) && done == 130); + fake(signal->fence)->result = VK_SUCCESS; + assert(!track_pool_reap(pool, issued, &done) && done == 131); + assert(!track_pool_device_busy(pool, dev)); + track_pool_forget(&pool, dev); + assert(!pool); +} + +static void wrapped_watermark(void) { + struct tracked *pool = NULL; + VkDevice dev = (VkDevice)(uintptr_t)1; + uint32_t issued = UINT32_MAX - 1, done = issued; + struct tracked *first = submit(&pool, dev, &issued); + struct tracked *second = submit(&pool, dev, &issued); + struct tracked *third = submit(&pool, dev, &issued); + assert(issued == 1); + fake(second->fence)->result = VK_SUCCESS; + fake(third->fence)->result = VK_SUCCESS; + assert(track_pool_reap(pool, issued, &done) && done == UINT32_MAX - 1); + fake(first->fence)->result = VK_SUCCESS; + assert(!track_pool_reap(pool, issued, &done) && done == 1); + track_pool_forget(&pool, dev); +} + +static void reservations_failures_and_devices(void) { + struct tracked *pool = NULL; + VkDevice a = (VkDevice)(uintptr_t)1, b = (VkDevice)(uintptr_t)2; + uint32_t issued = 7, done = 7; + struct tracked *reserved = track_pool_take(&pool, a, gdpa); + assert(track_pool_device_busy(pool, a)); + assert(!track_pool_reap(pool, issued, &done) && done == 7); + assert(fake(reserved->fence)->waits == 0); + issued = track_pool_submitted(reserved, VK_ERROR_DEVICE_LOST, issued); + assert(issued == 7 && !track_pool_device_busy(pool, a)); + fail_create = 1; + assert(!track_pool_take(&pool, b, gdpa)); + assert(pool == reserved && pool->dev == a); + fail_create = 0; + unsigned before = destroyed; + struct tracked *other = submit(&pool, b, &issued); + assert(other == reserved && destroyed == before + 1); + struct tracked *own = submit(&pool, a, &issued); + fake(other->fence)->result = VK_SUCCESS; + assert(track_pool_reap(pool, issued, &done) && done == 8); + track_pool_forget(&pool, b); + assert(pool == own && track_pool_device_busy(pool, a)); + fake(own->fence)->result = VK_ERROR_DEVICE_LOST; + assert(!track_pool_reap(pool, issued, &done) && done == 9); + track_pool_forget(&pool, a); + assert(!pool); + fail_create = 1; + assert(!track_pool_take(&pool, a, gdpa) && !pool); + fail_create = 0; +} + +int main(void) { + saturated_pool_and_out_of_order_completion(); + wrapped_watermark(); + reservations_failures_and_devices(); + assert(created == destroyed); + puts("fence tracker: all regression scenarios passed"); + return 0; +} diff --git a/tools/vr/tests/test_joycon.cpp b/tools/vr/tests/test_joycon.cpp new file mode 100644 index 000000000..fabf87994 --- /dev/null +++ b/tools/vr/tests/test_joycon.cpp @@ -0,0 +1,549 @@ +#include "arm_model.h" +#include "hand_motion.h" +#include "hand_mesh.h" +#include "hand_pose.h" +#include "hand_skeleton.h" +#include "index_pose.h" +#include "handcam_shm.h" +#include "joycon_shm.h" + +#include +#include +#include + +using namespace droiddeck; + +static int failures = 0; + +static void check(bool ok, const char *what) { + if (ok) return; + fprintf(stderr, "FAILED: %s\n", what); + failures++; +} + +static bool near(double a, double b, double tolerance = 1e-6) { return std::fabs(a - b) <= tolerance; } + +static Vec3 Forward(const Quat &q) { return Rotate(q, {0.0, 0.0, -1.0}); } + +static void restingHandsHangInFrontAtElbowHeight() { + const Vec3 head{0.0, 1.6, 0.0}; + ArmModel right(true), left(false); + const Vec3 r = right.Update(head, {}, {}, 0.0, 0.01); + const Vec3 l = left.Update(head, {}, {}, 0.0, 0.01); + check(near(r.x, 0.195, 1e-9) && near(l.x, -0.195, 1e-9), "rest hands sit 19.5 cm either side"); + check(near(r.y, 1.1, 1e-9) && near(l.y, 1.1, 1e-9), "rest hands hang 50 cm below the eyes"); + check(near(r.z, -0.305, 1e-9) && near(l.z, -0.305, 1e-9), "rest hands reach 30.5 cm forward"); +} + +static void raisingTheControllerExtendsTheArm() { + const Vec3 head{0.0, 1.6, 0.0}; + ArmModel rest(true), raised(true); + const Vec3 low = rest.Update(head, {}, {}, 0.0, 0.01); + const Vec3 high = raised.Update(head, {}, AboutX(60.0 * M_PI / 180.0), 0.0, 0.01); + check(high.y > low.y + 0.2, "pointing up lifts the hand"); + check(high.x < low.x, "an extended arm moves the elbow inward"); +} + +static void theTorsoOnlyFollowsTheHeadWhileTheControllerMoves() { + const Vec3 head{0.0, 1.6, 0.0}; + ArmModel arm(true); + arm.Update(head, {}, {}, 0.0, 0.01); + const Quat lookLeft = AboutY(M_PI / 2); + const Vec3 still = arm.Update(head, lookLeft, {}, 0.0, 0.5); + check(near(still.x, 0.195, 1e-9), "a still controller keeps the torso where it was"); + for (int i = 0; i < 400; i++) arm.Update(head, lookLeft, lookLeft, 5.0, 0.01); + const Vec3 turned = arm.Update(head, lookLeft, lookLeft, 0.0, 0.01); + check(near(turned.z, -0.195, 1e-3) && near(turned.x, -0.305, 1e-3), "a moving controller lets the torso turn to the gaze"); + arm.Reset(); + const Vec3 snapped = arm.Update(head, {}, {}, 0.0, 0.01); + check(near(snapped.x, 0.195, 1e-9), "a reset snaps the torso to the gaze"); +} + +static void headingAlignmentTurnsTheControllerToTheGaze() { + double heading = 0.0; + check(Heading(AboutY(0.7), &heading) && near(heading, 0.7), "heading of a yaw rotation"); + check(!Heading(AboutX(M_PI / 2), &heading), "a controller pointing straight up has no heading"); + const Quat hand = AboutY(-1.1) * AboutX(0.3); + const Quat head = AboutY(0.4) * AboutX(-0.2); + double headYaw = 0.0, handYaw = 0.0; + check(Heading(head, &headYaw) && Heading(hand, &handYaw), "both headings exist"); + const Quat aligned = AboutY(headYaw - handYaw) * hand; + check(Heading(aligned, &heading) && near(heading, 0.4), "the aligned controller faces where the head faces"); + check(near(Forward(aligned).y, Forward(hand).y), "alignment keeps the controller's pitch"); +} + +static void theIndexTipPointsWhereTheJoyConPoints() { + for (bool right : {false, true}) { + const Quat aim = AboutY(0.5) * AboutX(-0.3); + const Vec3 grip{0.2, 1.1, -0.3}; + Quat raw; + Vec3 position; + IndexRawPose(right, aim, grip, &raw, &position); + const Vec3 tip = Forward(raw * IndexTipRotation(right)), want = Forward(aim); + check(near(tip.x, want.x) && near(tip.y, want.y) && near(tip.z, want.z), "the tip pose follows the Joy-Con"); + const Vec3 gripAt = position + Rotate(raw, {0.0, -0.015, 0.13}); + check(near(gripAt.x, grip.x) && near(gripAt.y, grip.y) && near(gripAt.z, grip.z), "the Index grip sits at the arm model's hand"); + const Vec3 handle = Forward(raw); + check(handle.y > Forward(aim).y + 0.4, "the raw handle tilts up from the pointing direction"); + } +} + +static void skeletonsAreFiniteMirroredAndNormalised() { + vr::VRBoneTransform_t left[kHandBones], right[kHandBones]; + for (double curl : {0.0, 0.5, 1.0}) { + FingerCurls curls; + curls.thumb = curls.index = curls.middle = curls.ring = curls.pinky = curl; + HandSkeleton(false, curls, left); + HandSkeleton(true, curls, right); + for (uint32_t b = 0; b < kHandBones; b++) { + for (const auto *bones : {left, right}) { + const auto &q = bones[b].orientation; + check(near(std::sqrt(q.w * q.w + q.x * q.x + q.y * q.y + q.z * q.z), 1.0, 1e-3), "bone orientations are unit quaternions"); + } + for (int i = 0; i < 3; i++) check(std::isfinite(left[b].position.v[i]) && std::isfinite(right[b].position.v[i]), "bone positions are finite"); + check(left[b].position.v[3] == 1.0f && right[b].position.v[3] == 1.0f, "bone positions are points"); + } + check(near(left[1].position.v[0], -right[1].position.v[0]), "the wrist is mirrored between hands"); + check(near(left[7].position.v[0], -right[7].position.v[0]), "finger bones are mirrored between hands"); + } + FingerCurls open, fist; + fist.index = 1.0; + HandSkeleton(false, open, left); + HandSkeleton(false, fist, right); + const auto reach = [](const vr::VRBoneTransform_t *bones) { + return std::hypot(bones[27].position.v[0] - bones[1].position.v[0], bones[27].position.v[1] - bones[1].position.v[1], + bones[27].position.v[2] - bones[1].position.v[2]); + }; + check(reach(right) < reach(left) - 0.05, "a curled index finger pulls its tip towards the wrist"); +} + +static void measuredMovementCarriesTheHandAndTheAnchorPullsItBack() { + HandFollower hand; + const Vec3 anchor{0.2, 1.1, -0.3}; + const Vec3 start = hand.Update(anchor, {5.0, 5.0, 5.0}, 0.01, 0.6, 0.35); + check(near(start.x, anchor.x) && near(start.y, anchor.y) && near(start.z, anchor.z), "the hand starts at the anchor"); + const Vec3 lifted = hand.Update(anchor, {5.0, 5.25, 5.0}, 0.0, 0.6, 0.35); + check(near(lifted.y, 1.35) && near(lifted.x, 0.2) && near(lifted.z, -0.3), "a measured 25 cm lift raises the hand 25 cm"); + Vec3 held = lifted; + for (int i = 0; i < 60; i++) held = hand.Update(anchor, {5.0, 5.25, 5.0}, 0.01, 0.6, 0.35); + check(near(held.y, 1.1 + 0.25 * std::exp(-1.0), 1e-3), "a held hand settles back over the anchor's time constant"); + hand.Update(anchor, {5.0, 6.25, 5.0}, 0.0, 0.6, 0.35); + const Vec3 far = hand.Update(anchor, {5.0, 6.25, 5.0}, 0.0, 0.6, 0.35); + check(near(Length(far - anchor), 0.35, 1e-9), "the hand stays within reach of the anchor"); + hand.Reset(); + const Vec3 reset = hand.Update({0.0, 1.0, 0.0}, {9.0, 9.0, 9.0}, 0.01, 0.6, 0.35); + check(near(reset.x, 0.0) && near(reset.y, 1.0) && near(reset.z, 0.0), "a reset puts the hand back on the anchor"); + const Vec3 after = hand.Update({0.0, 1.0, 0.0}, {9.0, 9.0, 8.9}, 0.0, 0.6, 0.35); + check(near(after.z, -0.1), "movement after a reset counts from the reset"); +} + +static void feedJoyCon(HandKalman &k, double from, double to, double (*pos)(double), double bias, double turn) { + const double dt = 0.01; + for (double t = from + dt; t < to + 1e-9; t += dt) { + const double v = (pos(t + 1e-4) - pos(t - 1e-4)) / 2e-4; + k.Input(int64_t(t * 1e9), {0.0, v + bias * t, 0.0}, turn); + } +} + +static double wave(double t) { return 1.0 + 0.2 * std::sin(2.0 * M_PI * 0.7 * t); } + +static void theFilterPlacesTheHandWhereItIsNowNotWhenThePictureWasTaken() { + HandKalman k; + const int64_t ms = 1000000; + k.Input(0, {0.0, 0.0, 0.0}, 1.0); + check(!k.Measure(-5 * ms, {0.0, 1.0, 0.0}, 0.02), "a sighting from before the Joy-Con started is skipped"); + double t = 0.0; + double worst = 0.0, squares = 0.0, pictureSquares = 0.0; + int counted = 0; + unsigned noise = 12345; + for (int frame = 0; frame < 150; frame++) { + feedJoyCon(k, t, t + 1.0 / 15.0, wave, 0.3, 1.0); + t += 1.0 / 15.0; + const double seen = t - 0.1; + noise = noise * 1103515245u + 12345u; + const double jitter = (int((noise >> 16) % 2001) - 1000) / 1000.0 * 0.02; + k.Measure(int64_t(seen * 1e9), {0.3, wave(seen) + jitter, -0.4}, 0.03); + if (frame > 45) { + const double error = k.Position().y - wave(t); + const double picture = wave(seen) + jitter - wave(t); + worst = std::max(worst, std::fabs(error)); + squares += error * error; + pictureSquares += picture * picture; + counted++; + } + } + check(k.Ready(), "the filter runs once the camera has seen the hand"); + check(std::sqrt(squares / counted) < 0.55 * std::sqrt(pictureSquares / counted) && worst < 0.07, + "a waving hand is shown nearer where it is now than the 0.1 s late, noisy picture, despite a drifting Joy-Con"); + check(near(k.Position().x, 0.3, 0.01) && near(k.Position().z, -0.4, 0.01), "still axes stay on the camera"); +} + +static void aLateSightingCorrectsThePastAndKeepsTheMoveSince() { + HandKalman k; + const int64_t ms = 1000000; + for (int i = 0; i <= 20; i++) k.Input(i * 10 * ms, {0.0, 0.0, 0.0}, 1.0); + check(k.Measure(200 * ms, {0.0, 1.0, 0.0}, 0.01), "the first sighting starts the filter"); + for (int i = 0; i < 30; i++) k.Measure((200 + i) * ms, {0.0, 1.0, 0.0}, 0.01); + for (int i = 21; i <= 30; i++) k.Input(i * 10 * ms, {0.0, 1.0, 0.0}, 1.0); + const double moved = k.Position().y - 1.0; + check(moved > 0.08, "a quick Joy-Con lift moves the hand at once"); + k.Measure(205 * ms, {0.0, 1.0, 0.0}, 0.01); + check(near(k.Position().y - 1.0, moved, 0.03), "a late sighting from before the lift does not undo it"); +} + +static void aWildSightingIsIgnoredUntilItRepeats() { + HandKalman k; + const int64_t ms = 1000000; + for (int i = 0; i <= 60; i++) { + k.Input(i * 10 * ms, {0.0, 0.0, 0.0}, 1.0); + if (i % 7 == 0) k.Measure(i * 10 * ms, {0.2, 1.1, -0.3}, 0.02); + } + check(!k.Measure(600 * ms, {-0.4, 1.1, -0.3}, 0.02), "one sighting far from the hand is ignored"); + check(!k.Measure(600 * ms, {-0.4, 1.1, -0.3}, 0.02), "a second one is ignored too"); + check(near(k.Position().x, 0.2, 0.02), "the hand stays where it was"); + check(k.Measure(600 * ms, {-0.4, 1.1, -0.3}, 0.02) && near(k.Position().x, -0.4, 0.03), "a third sighting in a row moves the hand there"); + k.Input(2000 * ms, {0.0, 0.0, 0.0}, 1.0); + check(!k.Ready(), "a long gap in Joy-Con reports starts over"); +} + +static double slide(double t) { return t < 1.0 ? 0.5 * t : 0.5; } +static double flat(double) { return 0.0; } + +static double lostAfterSlide(double turn, double seconds, double *speed) { + HandKalman k; + k.Input(0, {0.0, 0.0, 0.0}, 1.0); + double t = 0.0; + while (t < 1.0 - 1e-9) { + feedJoyCon(k, t, t + 1.0 / 15.0, flat, 0.0, 1.0); + t += 1.0 / 15.0; + k.Measure(int64_t(t * 1e9), {slide(t), 1.0, -0.4}, 0.03); + } + feedJoyCon(k, t, t + seconds, flat, 0.0, turn); + *speed = Length(k.Velocity()); + return k.Position().x - slide(t); +} + +static void aHandTheCameraLosesSettlesInsteadOfRunningAway() { + double speed = 0.0; + const double moving = lostAfterSlide(1.0, 2.0, &speed); + check(moving > 0.0 && moving < 0.1 && speed < 0.01, + "a hand the Joy-Con never felt moving settles within 10 cm of its last sighting instead of sliding on"); + const double still = lostAfterSlide(0.0, 0.5, &speed); + check(still > -0.01 && still < 0.06 && speed < 0.01, "a still Joy-Con stops the hand within a few cm"); +} + +static void posed(const HandModel &model, const FingerCurls &curls, const Quat &turn, const Vec3 &at, Vec3 out[kHandJoints]) { + Vec3 joints[kHandJoints]; + HandJoints(model.Right(), curls, joints); + for (int i = 0; i < kHandJoints; i++) out[i] = at + Rotate(turn, joints[i] - model.PalmCenter()); +} + +static void cameraFingerCurlsReadBackFromTheJoints() { + for (bool right : {false, true}) { + const HandModel model(right); + for (double c : {0.0, 0.2, 0.5, 0.8, 1.0}) { + FingerCurls curls; + curls.thumb = c; + curls.index = 1.0 - c; + curls.middle = curls.ring = curls.pinky = c; + Vec3 joints[kHandJoints]; + posed(model, curls, Normalize(AboutY(0.7) * AboutX(-0.4)), {0.1, 1.2, -0.4}, joints); + const FingerCurls read = model.Curls(joints); + check(near(read.thumb, curls.thumb, 0.02) && near(read.index, curls.index, 0.02) && near(read.middle, c, 0.02) && + near(read.ring, c, 0.02) && near(read.pinky, c, 0.02), + "finger curls read from camera joints match the curls that made them"); + } + } +} + +static void thePalmFitFindsTheHandsTurnAndPlace() { + for (bool right : {false, true}) { + const HandModel model(right); + const Quat turn = Normalize(AboutY(-2.1) * AboutX(0.9) * Quat{0.9, 0.0, 0.0, 0.43}); + FingerCurls curls; + curls.index = 0.6; + curls.middle = curls.ring = curls.pinky = 0.9; + Vec3 joints[kHandJoints]; + posed(model, curls, turn, {-0.2, 1.1, -0.35}, joints); + Quat fitted; + Vec3 center; + double rms = 1.0; + check(model.Fit(joints, model.Curls(joints), &fitted, ¢er, &rms), "the palm fit converges"); + check(AngleDegrees(Conjugate(turn) * fitted) < 0.5 && rms < 0.001, "the palm fit recovers the hand's turn even with bent fingers"); + Vec3 shape[kHandJoints]; + HandJoints(right, curls, shape); + check(Length(Rotate(fitted, shape[hand::kIndexTip] - center) - (joints[hand::kIndexTip] - (joints[0] + joints[5] + joints[9] + joints[13] + joints[17]) * 0.2)) < 0.002, + "the fitted skeleton puts the fingertips where the camera saw them"); + unsigned noise = 99; + for (int i = 0; i < kHandJoints; i++) { + noise = noise * 1103515245u + 12345u; + const double n = (int((noise >> 16) % 2001) - 1000) / 1000.0 * 0.004; + joints[i] = joints[i] + Vec3{n, -n, n * 0.5}; + } + check(model.Fit(joints, model.Curls(joints), &fitted, ¢er, &rms) && AngleDegrees(Conjugate(turn) * fitted) < 6.0, + "4 mm of landmark noise moves the fit a few degrees"); + } +} + +static void thePalmNormalPointsOutOfThePalm() { + for (bool right : {false, true}) { + const HandModel model(right); + Vec3 open[kHandJoints], fist[kHandJoints]; + FingerCurls closed; + closed.index = closed.middle = closed.ring = closed.pinky = 1.0; + posed(model, FingerCurls{}, Quat{}, {}, open); + posed(model, closed, Quat{}, {}, fist); + const Vec3 normal = model.PalmNormal(open); + check(near(Length(normal), 1.0, 1e-6) && Dot(normal, fist[hand::kIndexTip] - open[hand::kIndexTip]) > 0.0, + "the palm normal points the way the fingers close"); + } +} + +static void pinchesAndFistsBecomeButtons() { + const HandModel model(true); + HandGestures gestures(true); + const int64_t ms = 1000000; + Vec3 joints[kHandJoints]; + posed(model, FingerCurls{}, Quat{}, {0.2, 1.2, -0.4}, joints); + const Vec3 palm{0.2, 1.2, -0.4}; + const Vec3 normal = model.PalmNormal(joints); + const Vec3 away = palm - normal * 0.5; + HandButtons b = gestures.Update(model, joints, model.Curls(joints), palm, away, 0); + check(!b.triggerClick && !b.gripClick && !b.system && b.trigger == 0.0f, "an open hand presses nothing"); + joints[hand::kThumbTip] = joints[hand::kIndexTip] + Vec3{0.01, 0.0, 0.0}; + b = gestures.Update(model, joints, model.Curls(joints), palm, away, 10 * ms); + check(!b.triggerClick && b.trigger > 0.9f, "a pinch pulls the trigger at once but clicks only after it holds"); + b = gestures.Update(model, joints, model.Curls(joints), palm, away, 70 * ms); + check(b.triggerClick && !b.system, "a held pinch clicks the trigger"); + joints[hand::kThumbTip] = joints[hand::kIndexTip] + Vec3{0.04, 0.0, 0.0}; + b = gestures.Update(model, joints, model.Curls(joints), palm, away, 90 * ms); + check(b.triggerClick, "a pinch that loosens a little stays clicked"); + joints[hand::kThumbTip] = joints[hand::kIndexTip] + Vec3{0.08, 0.0, 0.0}; + gestures.Update(model, joints, model.Curls(joints), palm, away, 100 * ms); + b = gestures.Update(model, joints, model.Curls(joints), palm, away, 210 * ms); + check(!b.triggerClick && b.trigger == 0.0f, "opening the pinch releases the trigger"); + joints[hand::kThumbTip] = joints[hand::kIndexTip] + Vec3{0.0, 0.03, 0.0}; + gestures.Update(model, joints, model.Curls(joints), palm, away, 300 * ms); + b = gestures.Update(model, joints, model.Curls(joints), palm, away, 360 * ms); + check(b.triggerClick && b.trigger == 1.0f, "fingertips the camera reads 3 cm apart still pinch"); + + HandGestures fist(true); + FingerCurls closed; + closed.middle = closed.ring = closed.pinky = 1.0; + posed(model, closed, Quat{}, palm, joints); + fist.Update(model, joints, model.Curls(joints), palm, away, 0); + b = fist.Update(model, joints, model.Curls(joints), palm, away, 60 * ms); + check(b.gripClick && b.grip == 1.0f && !b.a && !b.b, "a fist grips without pressing A or B"); +} + +static void aPalmTurnedToTheFaceOpensTheMenus() { + const HandModel rightModel(true), leftModel(false); + HandGestures right(true), left(false); + const int64_t ms = 1000000; + const Vec3 palm{0.0, 1.3, -0.35}; + for (int side = 0; side < 2; side++) { + const HandModel &model = side ? rightModel : leftModel; + HandGestures &gestures = side ? right : left; + Vec3 joints[kHandJoints]; + posed(model, FingerCurls{}, Quat{}, palm, joints); + const Vec3 face = palm + model.PalmNormal(joints) * 0.4; + HandButtons b = gestures.Update(model, joints, model.Curls(joints), palm, face, 0); + check(!b.facing, "a palm must face the headset for a moment before it counts"); + joints[hand::kThumbTip] = joints[hand::kIndexTip]; + gestures.Update(model, joints, model.Curls(joints), palm, face, 310 * ms); + b = gestures.Update(model, joints, model.Curls(joints), palm, face, 380 * ms); + check(b.facing && !b.triggerClick && b.trigger == 0.0f, "a pinch with the palm facing the headset is not a trigger pull"); + check(side ? (b.system && !b.b) : (b.b && !b.system), + side ? "a right palm pinch presses the system button (SteamVR menu)" : "a left palm pinch presses B (game menu)"); + } +} + +static void aBareHandFollowsTheCameraAndHidesWhenLost() { + const int64_t ms = 1000000; + const Vec3 head{0.0, 1.6, 0.0}; + for (int side = 0; side < 2; side++) { + CameraHand hand(side == 1); + const Quat turn = Normalize(AboutY(side ? -0.4 : 0.4) * AboutX(-0.3)); + FingerCurls curls; + curls.index = 0.2; + curls.middle = curls.ring = curls.pinky = 0.4; + Vec3 shape[kHandJoints], mean, seen[kHandJoints]; + HandJoints(side == 1, curls, shape); + for (int i : hand::kPalm) mean = mean + shape[i] * 0.2; + for (int i = 0; i < kHandJoints; i++) seen[i] = Rotate(turn, shape[i] - mean); + Vec3 palm{side ? 0.15 : -0.15, 1.2, -0.4}; + CameraHandPose pose; + int64_t t = 0; + const auto run = [&](int64_t until, bool camera) { + for (; t <= until; t += 10 * ms) { + if (camera && t % (70 * ms) == 0) hand.See(t - 90 * ms, palm, seen, head, t); + pose = hand.Update(t, 0.01); + } + }; + run(90 * ms, false); + check(!pose.visible, "a camera hand stays hidden until the camera sees it"); + run(1000 * ms, true); + check(pose.visible && AngleDegrees(Conjugate(turn) * pose.rotation) < 1.0, "a camera hand turns the way the camera saw it"); + check(Length(pose.position - (palm - Rotate(turn, mean))) < 0.01, "a camera hand's skeleton root sits so its palm lands on the camera's palm"); + check(std::fabs(pose.curls.index - 0.2) < 0.05 && std::fabs(pose.curls.ring - 0.4) < 0.05, "a camera hand bends its fingers as seen"); + check(!pose.buttons.triggerClick, "a loose hand pulls nothing"); + seen[hand::kThumbTip] = seen[hand::kIndexTip]; + run(1200 * ms, true); + check(pose.buttons.triggerClick, "a pinch the camera sees clicks the trigger"); + run(1800 * ms, false); + check(!pose.visible && !pose.buttons.triggerClick, "a hand the camera loses hides and lets go"); + palm = {side ? -0.1 : 0.1, 1.45, -0.3}; + run(2400 * ms, true); + check(pose.visible && Length(pose.position - (palm - Rotate(turn, mean))) < 0.01, "a hand that comes back somewhere else shows up there"); + } +} + +static void theTurnSmootherSettlesAndKeepsUpWithFastTurns() { + SmoothTurn slow, fast; + Quat out = slow.Update(Quat{}, 0.01); + const Quat jitter = AboutX(4.0 * M_PI / 180.0); + double worst = 0.0; + for (int i = 0; i < 300; i++) { + out = slow.Update((i / 7) % 2 ? jitter : Conjugate(jitter), 0.01); + if (i > 100) worst = std::max(worst, AngleDegrees(out)); + } + check(worst < 2.5, "frame-to-frame wobble of a still hand is damped"); + fast.Update(Quat{}, 0.01); + for (int i = 1; i <= 50; i++) out = fast.Update(AboutY(3.0 * i * 0.01), 0.01); + check(AngleDegrees(Conjugate(AboutY(1.5)) * out) < 25.0, "a fast turn is followed closely"); +} + +static void theHandRenderModelIsAClosedOutwardFacingHand() { + for (int right = 0; right < 2; right++) { + for (HandPart part : {HandPart::Hand, HandPart::ThumbIndex}) { + for (int closed = 0; closed < 2; closed++) { + const HandMesh m = BuildHandPart(right, part, closed); + bool finite = !m.faces.empty() && m.points.size() < 65000, cuff = false; + size_t inward = 0; + for (const Vec3 &p : m.points) + finite = finite && std::isfinite(p.x) && std::isfinite(p.y) && std::isfinite(p.z) && Length(p) < 0.3; + for (double tint : m.tints) cuff = cuff || tint > 0.99; + std::map, int> edges; + for (const auto &f : m.faces) { + const Vec3 n = Cross(m.points[f[1]] - m.points[f[0]], m.points[f[2]] - m.points[f[0]]); + if (Dot(n, m.normals[f[0]] + m.normals[f[1]] + m.normals[f[2]]) < 0.0) inward++; + for (int e = 0; e < 3; e++) edges[{f[e], f[(e + 1) % 3]}]++; + } + bool sealed = true; + for (const auto &entry : edges) sealed = sealed && edges.count({entry.first.second, entry.first.first}) == 1; + check(finite, "hand model parts are small, finite meshes SteamVR can load"); + check(inward * 1000 < m.faces.size(), "hand model faces point out"); + check(sealed, "hand model parts are watertight"); + check(cuff, "the glove has a cuff at the wrist"); + } + } + Vec3 pinch[kHandJoints]; + HandShape(right, true, false, pinch); + check(Length(pinch[hand::kThumbTip] - pinch[hand::kIndexTip]) < 0.015, "the pinch model's thumb meets the index finger"); + Vec3 open[kHandJoints]; + HandShape(right, false, false, open); + check((open[hand::kThumbTip].x > 0.0) == !right, "the hand models are mirrored for the left and right hands"); + const std::string json = HandModelJson(right); + for (const char *name : {"hand_open.obj", "hand_fist.obj", "thumb_index_open.obj", "thumb_index_pinch.obj", "\"tip\"", "\"base\"", + "\"handgrip\"", "\"pressed_path\": \"/input/trigger/click\"", "\"pressed_path\": \"/input/grip/touch\"", + "\"controller_button\": 33", "\"press\": true"}) + check(json.find(name) != std::string::npos, "the hand model lists its parts, their inputs and the pointer poses"); + } + const std::vector png = GlovePng(64, {246, 246, 249}, {224, 227, 233}, glove::kDarkest); + check(png.size() > 40 && png[1] == 'P' && png[2] == 'N' && png[3] == 'G', "the hand texture is a PNG"); +} + +static void sharedLayoutMatchesTheAppWriter() { + check(offsetof(droiddeck_joycon, seq) == 8, "sequence offset"); + check(offsetof(droiddeck_joycon_hand, buttons) == 4, "buttons offset"); + check(offsetof(droiddeck_joycon_hand, rotation) == 8, "rotation offset"); + check(offsetof(droiddeck_joycon_hand, angular_velocity) == 24, "angular velocity offset"); + check(offsetof(droiddeck_joycon_hand, stick) == 36, "stick offset"); + check(offsetof(droiddeck_joycon_hand, battery) == 44, "battery offset"); + check(offsetof(droiddeck_joycon_hand, recenter) == 48, "recenter offset"); + check(offsetof(droiddeck_joycon_hand, velocity) == 76, "velocity offset"); + check(offsetof(droiddeck_joycon_hand, inertial_velocity) == 88, "inertial velocity offset"); + check(sizeof(droiddeck_joycon) == 224, "file size"); + check(DROIDDECK_JOYCON_VERSION == 3, "file version"); + check(DROIDDECK_JOYCON_TRIGGER == 1 && DROIDDECK_JOYCON_GRIP == 2 && DROIDDECK_JOYCON_A == 4 && + DROIDDECK_JOYCON_B == 8 && DROIDDECK_JOYCON_STICK == 16 && DROIDDECK_JOYCON_SYSTEM == 32, + "button bits match JoyCon.kt"); +} + +static void historyInterpolatesBetweenSamples() { + TimeHistory history; + Vec3 out; + check(!history.At(0, &out), "an empty history has nothing to give"); + history.Add(1000, {0.0, 0.0, 0.0}); + history.Add(11000, {0.1, 0.0, -0.2}); + history.Add(5000, {9.0, 9.0, 9.0}); + check(history.At(6000, &out) && near(out.x, 0.05) && near(out.z, -0.1), "a time between samples is interpolated"); + check(history.At(50000, &out) && near(out.x, 0.1), "a time after the newest sample uses the newest"); + check(history.At(0, &out) && near(out.x, 0.0), "a time before the oldest sample uses the oldest"); + history.Add(11000, {0.2, 0.0, 0.0}); + check(history.At(11000, &out) && near(out.x, 0.2), "a repeated time replaces its sample"); + for (int i = 0; i < 1000; i++) history.Add(20000 + i * 1000, {double(i), 0.0, 0.0}); + check(history.At(0, &out) && out.x > 700.0, "only the newest samples are kept"); +} + +static void theCameraYawAveragesFaceReadingsAndFollowsARecenter() { + CameraYaw yaw; + double out = 0.0; + for (int i = 0; i < 3; i++) yaw.Add(0.5 + (i % 2 ? 0.1 : -0.1), 1.0 / 15.0); + check(!yaw.Get(&out), "a few readings are not enough"); + for (int i = 3; i < 30; i++) yaw.Add(0.5 + (i % 2 ? 0.1 : -0.1), 1.0 / 15.0); + check(yaw.Get(&out) && near(out, 0.5, 0.02), "noisy readings average out"); + for (int i = 0; i < 7; i++) yaw.Add(1.7, 1.0 / 15.0); + check(yaw.Get(&out) && near(out, 0.5, 0.02), "a short run of far readings is ignored"); + for (int i = 0; i < 6; i++) yaw.Add(1.7, 1.0 / 15.0); + check(yaw.Get(&out) && near(out, 1.7, 1e-6), "a lasting change starts a new average"); + CameraYaw wrapped; + for (int i = 0; i < 20; i++) wrapped.Add(i % 2 ? M_PI - 0.05 : -M_PI + 0.05, 1.0 / 15.0); + check(wrapped.Get(&out) && near(std::fabs(out), M_PI, 0.01), "readings either side of half a turn average to half a turn"); +} + +static void handCameraLayoutMatchesTheAppWriter() { + check(offsetof(droiddeck_handcam, seq) == 8, "camera sequence offset"); + check(offsetof(droiddeck_handcam, flags) == 12, "camera flags offset"); + check(offsetof(droiddeck_handcam, frame) == 24, "camera frame offset"); + check(offsetof(droiddeck_handcam, face_yaw) == 28, "camera face yaw offset"); + check(offsetof(droiddeck_handcam_hand, position) == 4, "camera hand position offset"); + check(offsetof(droiddeck_handcam, hands) + sizeof(droiddeck_handcam_hand) + offsetof(droiddeck_handcam_hand, joints) == 316, + "second hand's joints offset"); + check(DROIDDECK_HANDCAM_VERSION == 2 && DROIDDECK_HANDCAM_RUNNING == 1 && DROIDDECK_HANDCAM_FACE == 2 && + DROIDDECK_HANDCAM_BARE_HANDS == 4 && DROIDDECK_HANDCAM_SEEN == 1 && DROIDDECK_HANDCAM_ESTIMATED == 2 && + DROIDDECK_HANDCAM_JOINTS == 4 && DROIDDECK_HANDCAM_JOINT_COUNT == kHandJoints, + "camera flags match HandCameraShm.kt"); +} + +int main() { + restingHandsHangInFrontAtElbowHeight(); + raisingTheControllerExtendsTheArm(); + theTorsoOnlyFollowsTheHeadWhileTheControllerMoves(); + headingAlignmentTurnsTheControllerToTheGaze(); + theIndexTipPointsWhereTheJoyConPoints(); + skeletonsAreFiniteMirroredAndNormalised(); + measuredMovementCarriesTheHandAndTheAnchorPullsItBack(); + sharedLayoutMatchesTheAppWriter(); + historyInterpolatesBetweenSamples(); + theFilterPlacesTheHandWhereItIsNowNotWhenThePictureWasTaken(); + aLateSightingCorrectsThePastAndKeepsTheMoveSince(); + aWildSightingIsIgnoredUntilItRepeats(); + aHandTheCameraLosesSettlesInsteadOfRunningAway(); + theCameraYawAveragesFaceReadingsAndFollowsARecenter(); + handCameraLayoutMatchesTheAppWriter(); + cameraFingerCurlsReadBackFromTheJoints(); + thePalmFitFindsTheHandsTurnAndPlace(); + thePalmNormalPointsOutOfThePalm(); + pinchesAndFistsBecomeButtons(); + aPalmTurnedToTheFaceOpensTheMenus(); + aBareHandFollowsTheCameraAndHidesWhenLost(); + theTurnSmootherSettlesAndKeepsUpWithFastTurns(); + theHandRenderModelIsAClosedOutwardFacingHand(); + if (failures) { + fprintf(stderr, "%d Joy-Con check(s) failed\n", failures); + return 1; + } + printf("Joy-Con arm model, movement, camera, pose and layout checks passed\n"); + return 0; +} diff --git a/tools/vr/tests/test_render_target.cpp b/tools/vr/tests/test_render_target.cpp new file mode 100644 index 000000000..a6d3cea40 --- /dev/null +++ b/tools/vr/tests/test_render_target.cpp @@ -0,0 +1,37 @@ +#include "render_target.h" +#include "../../../app/src/main/cpp/waylandcomp/src/headpose_shm.h" +#include +#include +#include +#include + +static void check(float aspect, uint32_t selected, uint32_t panel, uint32_t w, uint32_t h) { + const auto result = droiddeck::RenderTarget(aspect, selected, panel); + if (result.width != w || result.height != h) { + std::fprintf(stderr, "FAIL: aspect %g, height %u, panel %u gave %ux%u, expected %ux%u\n", + aspect, selected, panel, result.width, result.height, w, h); + std::abort(); + } +} + +int main() { + static_assert(sizeof(droiddeck_headpose) == 128); + static_assert(offsetof(droiddeck_headpose, render_height) == 84); + check(1920.0f / 1080, 720, 1080, 1280, 720); + check(1920.0f / 1080, 900, 1080, 1600, 900); + check(1920.0f / 1080, 1080, 1080, 1920, 1080); + check(1920.0f / 1080, 1440, 1080, 1920, 1080); + check(1920.0f / 1200, 1200, 1200, 1920, 1200); + check(960.0f / 1080, 720, 1080, 640, 720); + check(1200.0f / 1080, 720, 1080, 800, 720); + check(16.0f / 9, 720, 0, 1280, 720); + check(16.0f / 9, 0, 1080, 1280, 720); + check(16.0f / 9, 720, 480, 853, 480); + check(0, 0, 0, 720, 720); + check(std::numeric_limits::quiet_NaN(), 720, 1080, 720, 720); + check(std::numeric_limits::infinity(), 720, 1080, 720, 720); + check(-1, 720, 1080, 720, 720); + check(1, UINT32_MAX, 0, 8192, 8192); + check(100, 720, 1080, 8192, 720); + std::puts("PASS: VR render targets follow the selected height and fit the panel"); +} diff --git a/tools/vr/tests/test_semaphore_layer.c b/tools/vr/tests/test_semaphore_layer.c index 575d64e92..89f4ce8ae 100644 --- a/tools/vr/tests/test_semaphore_layer.c +++ b/tools/vr/tests/test_semaphore_layer.c @@ -9,6 +9,7 @@ static const VkSemaphoreWaitInfo *expected_wait; static unsigned create_calls; static unsigned destroyed; static VkResult submit_result = VK_SUCCESS; +static int expect_dropped_wait; static const void *expected_image_chain; static const void *expected_external_tail; static int expect_cloned_external; @@ -67,6 +68,13 @@ static void check_shared_images(VkDevice device) { static VkResult VKAPI_CALL mock_submit(VkQueue queue, uint32_t count, const VkSubmitInfo *submits, VkFence fence) { assert(queue && count == 1 && fence == VK_NULL_HANDLE); + if (expect_dropped_wait) { + assert(submits != expected_submit && submits->waitSemaphoreCount == 0); + const VkTimelineSemaphoreSubmitInfo *timeline = submits->pNext; + assert(timeline->sType == VK_STRUCTURE_TYPE_TIMELINE_SEMAPHORE_SUBMIT_INFO && timeline->waitSemaphoreValueCount == 0); + assert(expected_submit->waitSemaphoreCount == 1); + return submit_result; + } assert(submits == expected_submit); assert(submits->waitSemaphoreCount == 1); return submit_result; @@ -140,8 +148,22 @@ static VkResult VKAPI_CALL mock_device_idle(VkDevice device) { assert(device); check_queue_lock(); return VK_ERROR_DEVICE_LOST; } +static VkResult VKAPI_CALL mock_counter(VkDevice device, VkSemaphore semaphore, uint64_t *value) { + (void)device; (void)semaphore; + *value = 0; + return VK_SUCCESS; +} + +static VkResult VKAPI_CALL mock_signal(VkDevice device, const VkSemaphoreSignalInfo *info) { + (void)device; (void)info; + assert(0); + return VK_SUCCESS; +} + static PFN_vkVoidFunction VKAPI_CALL mock_proc(VkDevice device, const char *name) { (void)device; + if (!strcmp(name, "vkGetSemaphoreCounterValue")) return (PFN_vkVoidFunction)mock_counter; + if (!strcmp(name, "vkSignalSemaphore")) return (PFN_vkVoidFunction)mock_signal; if (!strcmp(name, "vkCreateImage")) return (PFN_vkVoidFunction)mock_image; if (!strcmp(name, "vkQueueWaitIdle")) return (PFN_vkVoidFunction)mock_queue_idle; if (!strcmp(name, "vkDeviceWaitIdle")) return (PFN_vkVoidFunction)mock_device_idle; @@ -223,6 +245,13 @@ int main(void) { assert(ImportSemaphoreFdKHR(device, &import_info) == VK_ERROR_INVALID_EXTERNAL_HANDLE); import_info.flags = 0; assert(ImportSemaphoreFdKHR(device, &import_info) == VK_SUCCESS); + VkSubmitInfo imported_wait = submit; + imported_wait.pWaitSemaphores = &binary; + expected_submit = &imported_wait; + submit_result = VK_SUCCESS; + expect_dropped_wait = 1; + assert(QueueSubmit(queue, 1, &imported_wait, VK_NULL_HANDLE) == VK_SUCCESS); + expect_dropped_wait = 0; VkSemaphoreGetFdInfoKHR get_info = {VK_STRUCTURE_TYPE_SEMAPHORE_GET_FD_INFO_KHR, NULL, binary, VK_EXTERNAL_SEMAPHORE_HANDLE_TYPE_OPAQUE_FD_BIT}; int reexported = -1; @@ -235,6 +264,6 @@ int main(void) { assert(fcntl(descriptor, F_GETFD) == -1 && errno == EBADF); DestroySemaphore(device, semaphore, NULL); assert(destroyed == 2 && !bridged_find(semaphore)); - puts("semaphore layer: pending waits retained, errors propagated, unsupported binary imports refused, fd ownership passed"); + puts("semaphore layer: pending waits retained, unsignalled imported waits dropped, errors propagated, unsupported binary imports refused, fd ownership passed"); return 0; }