diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4975e3f..eacf8b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ permissions: contents: read jobs: + windows-app: + uses: ./.github/workflows/windows-package.yml + python-release: runs-on: ubuntu-latest steps: @@ -197,10 +200,14 @@ jobs: riftlift-compat.zip.sha256 release-bundle: - needs: [python-release, compatibility, xrizer, dxvk] + needs: [python-release, compatibility, xrizer, dxvk, windows-app] runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - uses: actions/download-artifact@v5 + with: + name: riftlift-windows-installer + path: release - uses: actions/download-artifact@v5 with: name: riftlift-python-release @@ -224,7 +231,7 @@ jobs: version=$(PYTHONPATH=src python -c 'from riftlift import __version__; print(__version__)') tag=$(python -c 'import re,sys; value=sys.argv[1]; match=re.fullmatch(r"(\d+(?:\.\d+){2,3})(?:a(\d+))?", value); assert match; print(f"v{match.group(1)}" + (f"-alpha.{match.group(2)}" if match.group(2) else ""))' "$version") scripts/generate-release-installer.sh "$tag" release release/riftlift-installer.sh - (cd release && sha256sum riftlift-* > SHA256SUMS) + (cd release && sha256sum riftlift-* RiftLift-Setup-*.exe > SHA256SUMS) (cd release && sha256sum -c SHA256SUMS) bash -n release/riftlift-installer.sh grep -F "release_tag='$tag'" release/riftlift-installer.sh diff --git a/.github/workflows/windows-host.yml b/.github/workflows/windows-host.yml new file mode 100644 index 0000000..f3d1e46 --- /dev/null +++ b/.github/workflows/windows-host.yml @@ -0,0 +1,17 @@ +name: Windows +on: + push: + pull_request: +jobs: + windows-host: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -e . pytest + - run: python -m riftlift.cli --help + - run: python -m pytest tests/test_windows_package.py tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py tests/test_platform_exports.py -q + env: + QT_QPA_PLATFORM: offscreen diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 0000000..fc26c2d --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,72 @@ +name: Windows installer + +on: + workflow_dispatch: + workflow_call: + pull_request: + paths: + - '.github/workflows/windows-package.yml' + - 'scripts/*windows*' + - 'src/riftlift/**' + - 'runtime/**' + - 'compat/**' + - 'pyproject.toml' + +permissions: + contents: read + +jobs: + package: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install . pyinstaller==6.22.0 pefile ninja cmake pytest + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Fetch pinned native dependencies + shell: bash + run: | + scripts/prepare-runtime-ci-deps.sh + git clone https://github.com/microsoft/Detours.git build/Detours + git -C build/Detours checkout e4bfd6b03e50de46b47abfbd1e46b384f0c5f833 + git clone https://github.com/KhronosGroup/OpenXR-SDK.git build/OpenXR-SDK + git -C build/OpenXR-SDK checkout 8899a91c17ce9618f565f42408b47db1d6e9ccc7 + - name: Prepare signed SDK files + shell: pwsh + run: | + Invoke-WebRequest 'https://securecdn.oculus.com/binaries/download/?id=4377593722298679' -OutFile build/sdk.zip + if ((Get-FileHash build/sdk.zip).Hash -ne '3c8aeb66c822af731a8d1f1cb1cf97ab706100bf0bd003ef126f4bb9812906b3') { throw 'SDK checksum mismatch' } + Expand-Archive build/sdk.zip runtime/Externals -Force + Invoke-WebRequest 'https://securecdn-atl3-3.oculus.com/binaries/download/?id=3766757683456363' -OutFile build/platform.zip + if ((Get-FileHash build/platform.zip).Hash -ne 'adbdc5f0285a2ac2ead6fdd34522de98de1bf6782017d9857ea4044b2d2fd009') { throw 'Platform checksum mismatch' } + Expand-Archive build/platform.zip build/meta-runtime -Force + - name: Build complete Windows native runtime + shell: pwsh + run: | + cmake -S runtime -B build/native -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DBUILD_TESTING=OFF "-DDETOURS_SOURCE_DIR=$PWD/build/Detours" "-DOPENXR_SDK_SOURCE_DIR=$PWD/build/OpenXR-SDK" "-DMETA_PLATFORM_IMPL=$PWD/build/meta-runtime/LibOVRPlatformImpl64_1.dll" + if ($LASTEXITCODE -ne 0) { throw 'Native configuration failed' } + cmake --build build/native -j 4 + if ($LASTEXITCODE -ne 0) { throw 'Native build failed' } + - name: Build self-contained app and per-user installer + run: ./scripts/build-windows.ps1 -RuntimeDirectory build/native/bin + - name: Check installation, upgrade, launch and uninstall + run: ./scripts/test-windows-install.ps1 + - name: Windows regression tests + run: python -m pytest tests/test_windows_package.py tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py tests/test_platform_exports.py -q + env: + QT_QPA_PLATFORM: offscreen + - uses: actions/upload-artifact@v4 + with: + name: riftlift-windows-installer + path: | + dist/windows/RiftLift-Setup-*.exe + dist/windows/RiftLift-Setup-*.exe.sha256 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: riftlift-windows-install-checks + path: build/install-check/ diff --git a/.gitignore b/.gitignore index 3c36753..503bc4e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ out/ # Runtime artifacts written by launched games in the repository working tree. logs/ logConfig.xml + +# Native Windows install and local verification output. +portable/ +*.local.log diff --git a/README.md b/README.md index fbf76aa..3252da7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # RiftLift -**Play your Meta Rift (Oculus Rift) PC VR games on Linux.** +**Play your Meta Rift (Oculus Rift) PC VR games on Linux and Windows.** -RiftLift is a Linux compatibility app for Meta Rift PC VR games. Its desktop +RiftLift is a compatibility app for Meta Rift PC VR games. Its desktop GUI handles Meta sign-in, owned-game downloads, Steam shortcuts, local playtime, and launching through your existing VR headset setup. It supports Rift Store releases and compatible Steam games that include an Oculus mode. RiftLift can @@ -13,13 +13,13 @@ use SteamVR directly or a Monado-based OpenXR setup. > [!WARNING] > **RiftLift is alpha software.** Game compatibility is still expanding. -See the [compatibility wiki](docs/COMPATIBILITY.md) for games tested with real VR output. +See the [compatibility wiki](docs/COMPATIBILITY.md) for Windows and Linux results. ## Quick start Before you start, you need: -- a 64-bit Linux PC; +- a 64-bit Linux or Windows PC; - Steam; - a VR headset that already works through SteamVR or Monado/OpenXR; and - a Meta account that owns a **Rift / PC VR** game. @@ -29,7 +29,14 @@ drivers or Monado. ### 1. Install RiftLift -Download `riftlift-installer.sh` from the latest GitHub release, then run: +**Windows:** Windows builds produce `RiftLift-Setup--x64.exe`. +Open the installer, choose **Install**, then launch **RiftLift** from the Start +menu. Python, Git, administrator access, and a separate runtime download are +not required. A desktop shortcut is optional. RiftLift selects your VR runtime +automatically. Re-running the installer updates the app; Windows **Installed +apps** can uninstall it while preserving your games and settings. + +**Linux:** Download `riftlift-installer.sh` from the latest GitHub release, then run: ```bash bash riftlift-installer.sh @@ -43,6 +50,12 @@ Source installs use the checkout and selected payloads directly; release artifact hashes are enforced by the generated all-in-one installer, not baked into the application source. +For Windows source development, run `./install-windows.ps1` with Python 3.12 and +Git installed, then open `RiftLift.cmd`. To package a native build, install +PyInstaller 6.22.0 and Inno Setup 6, then run +`./scripts/build-windows.ps1 -RuntimeDirectory `. +The Windows installer workflow builds and checks the standalone distribution. + ### 2. Check your setup and sign in Open **RiftLift** and click **System** to verify the setup. diff --git a/RiftLift-Simulator.cmd b/RiftLift-Simulator.cmd new file mode 100644 index 0000000..2596274 --- /dev/null +++ b/RiftLift-Simulator.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +call "%~dp0RiftLift.cmd" simulate --runtime "%~dp0portable\tools\SteamVR" +exit /b %errorlevel% diff --git a/RiftLift.cmd b/RiftLift.cmd new file mode 100644 index 0000000..722ada1 --- /dev/null +++ b/RiftLift.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal +set "RIFTLIFT_HOME=%~dp0portable" +if not exist "%~dp0.venv\Scripts\python.exe" ( + echo Run install-windows.ps1 first. + exit /b 1 +) +if "%~1"=="" ( + start "" "%~dp0.venv\Scripts\pythonw.exe" -m riftlift.windows_app +) else ( + "%~dp0.venv\Scripts\python.exe" -m riftlift.cli %* +) +exit /b %errorlevel% diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index fedf948..40befdf 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,22 +1,26 @@ # RiftLift compatibility wiki -These games have been tested successfully with RiftLift. Results can vary with Wine, driver, GPU, and game updates. +Compatibility results are listed separately for Windows and Linux. Results can vary with runtime, Wine, driver, GPU, and game updates. ## Tested games -| Game | Build tested | Status | Oculus exclusive? | Setup notes | -| --- | --- | --- | --- | --- | -| [Aircar](https://store.steampowered.com/app/1073390/Aircar/) | Steam Oculus mode | ✅ Working | No | — | -| Echo VR | Community PCVR installation | ✅ Working | Yes, originally | Requires the community installer and patch; follow the [Echo VR setup guide](https://gist.github.com/Villagers654/d5bf4d11f56fc60d1eab91e7bf3f41c5). | -| [Five Nights at Freddy's: Help Wanted](https://store.steampowered.com/app/732690/FIVE_NIGHTS_AT_FREDDYS_HELP_WANTED/) | Steam Oculus mode | ✅ Working | No | — | -| [Keep Talking and Nobody Explodes](https://store.steampowered.com/app/341800/Keep_Talking_and_Nobody_Explodes/) | Steam Oculus mode | ✅ Working | No | — | -| [Lone Echo](https://www.youtube.com/watch?v=2pmV2mwAV9k) | Meta Rift Store | ✅ Working | Yes | — | -| [Lone Echo 2](https://www.meta.com/experiences/pcvr/lone-echo-ii/1711938725528735/) | Meta Rift Store | ✅ Working | Yes | — | -| [Oculus First Contact](https://www.meta.com/experiences/pcvr/oculus-first-contact/1217155751659625/) | Meta Rift Store | ✅ Working | Yes | — | -| [StereoPaint](https://store.steampowered.com/app/1920760/StereoPaint/) | Steam | ✅ Working | Yes | — | -| [Stormland](https://www.meta.com/experiences/pcvr/stormland/1360938750683878/) | Meta Rift Store | ✅ Working | Yes | — | -| [SUPERHOT VR](https://store.steampowered.com/app/617830/SUPERHOT_VR/) | Steam Oculus mode | ✅ Working | No | — | -| [Vader Immortal: Episode I](https://www.playstation.com/en-us/games/vader-immortal-a-star-wars-vr-series/) | Meta Rift Store | ✅ Working | Yes | — | +✅ = renders a menu or scene without major rendering errors; ❌ = does not work; ⚠️ = known problems; Untested = not tested. A check does not require a full gameplay pass. + +| Game | Build tested | Windows | Linux | Oculus exclusive? | Setup notes | +| --- | --- | --- | --- | --- | --- | +| [Aircar](https://store.steampowered.com/app/1073390/Aircar/) | Steam Oculus mode | Untested | ✅ | No | — | +| Echo VR | Windows: original 34.4.636386.0; Linux: community PCVR installation | ✅ | ✅ | Yes, originally | Windows renders the original client's shutdown notice with hands. Online play requires the community installer and patch; follow the [Echo VR setup guide](https://gist.github.com/Villagers654/d5bf4d11f56fc60d1eab91e7bf3f41c5). Community client untested on Windows. | +| [Five Nights at Freddy's: Help Wanted](https://store.steampowered.com/app/732690/FIVE_NIGHTS_AT_FREDDYS_HELP_WANTED/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Keep Talking and Nobody Explodes](https://store.steampowered.com/app/341800/Keep_Talking_and_Nobody_Explodes/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Lone Echo](https://www.youtube.com/watch?v=2pmV2mwAV9k) | Meta Rift Store; Windows: 3.17.4 | ✅ | ✅ | Yes | Windows: stereo main menu and hands. | +| [Lone Echo 2](https://www.meta.com/experiences/pcvr/lone-echo-ii/1711938725528735/) | Meta Rift Store | Untested | ✅ | Yes | — | +| [Oculus First Contact](https://www.meta.com/experiences/pcvr/oculus-first-contact/1217155751659625/) | Meta Rift Store; Windows: 1.1.9 | ✅ | ✅ | Yes | Windows: stereo room and hands at standing height. | +| [StereoPaint](https://store.steampowered.com/app/1920760/StereoPaint/) | Steam | Untested | ✅ | Yes | — | +| [Stormland](https://www.meta.com/experiences/pcvr/stormland/1360938750683878/) | Meta Rift Store; Windows: Stormland_008 | ✅ | ✅ | Yes | Windows: stereo island scene. | +| [SUPERHOT VR](https://store.steampowered.com/app/617830/SUPERHOT_VR/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Vader Immortal: Episode I](https://www.playstation.com/en-us/games/vader-immortal-a-star-wars-vr-series/) | Meta Rift Store; Windows: 1.1.0 | ✅ | ✅ | Yes | Windows: VR settings menu and hands; simulated button input advances setup. | + +Windows results use the local `windows-native` development build with a simulated headset. Images were inspected from game render textures while the desktop was locked; headset presentation and full interactive gameplay remain unverified. These results do not establish compatibility for the pinned release. ## What “Oculus exclusive” means here @@ -36,4 +40,4 @@ riftlift doctor The command includes concise setup details and recent launch evidence, then creates a shareable diagnostic paste. Attach that result when opening a [compatibility report](https://github.com/Villagers654/RiftLift/issues). -Last updated: August 2026. +Last updated: September 2, 2026. diff --git a/install-windows.ps1 b/install-windows.ps1 new file mode 100644 index 0000000..02a144a --- /dev/null +++ b/install-windows.ps1 @@ -0,0 +1,24 @@ +param([string]$PythonVersion = '3.12') +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { +if (-not (Test-Path '.venv/Scripts/python.exe')) { + & py "-$PythonVersion" -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Python environment creation failed' } +} +& ./.venv/Scripts/python.exe -m pip install -e . +if ($LASTEXITCODE -ne 0) { throw 'RiftLift dependency installation failed' } +$env:RIFTLIFT_HOME = Join-Path $PSScriptRoot 'portable' +& ./.venv/Scripts/python.exe -m riftlift.cli setup +if ($LASTEXITCODE -ne 0) { throw 'Native runtime installation failed' } +& ./.venv/Scripts/python.exe -m riftlift.cli doctor --no-paste +if ($LASTEXITCODE -eq 2) { + Write-Host 'Runtime installed. Configure the headset/OpenXR runtime before game testing.' +} elseif ($LASTEXITCODE -ne 0) { + throw 'Windows diagnostics failed' +} + +} finally { + Pop-Location +} +exit 0 diff --git a/pyproject.toml b/pyproject.toml index c9077d5..feb65a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,17 @@ build-backend = "setuptools.build_meta" [project] name = "riftlift" dynamic = ["version"] -description = "Plug-and-play Meta Rift games for working Linux OpenXR runtimes" +description = "Meta Rift games for OpenXR runtimes on Linux and Windows" readme = "README.md" requires-python = ">=3.10.12" license = "GPL-3.0-or-later" authors = [{name = "Villagers654"}] -keywords = ["linux", "meta", "monado", "oculus", "openxr", "rift", "vr"] +keywords = ["linux", "windows", "meta", "monado", "oculus", "openxr", "rift", "vr"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "Topic :: Games/Entertainment", ] @@ -33,6 +34,8 @@ xrizer = "https://github.com/Villagers654/xrizer" [project.scripts] riftlift = "riftlift.cli:main" + +[project.gui-scripts] riftlift-gui = "riftlift.gui:main" [tool.setuptools.packages.find] diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt new file mode 100644 index 0000000..50566ef --- /dev/null +++ b/runtime/CMakeLists.txt @@ -0,0 +1,109 @@ +cmake_minimum_required(VERSION 3.24) +project(RiftLiftNative LANGUAGES C CXX) +if(NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "RiftLift native requires a Windows x64 MSVC toolchain") +endif() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +file(MAKE_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") +configure_file(LICENSE "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LICENSE" COPYONLY) +configure_file(../LICENSE "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/RIFTLIFT-LICENSE" COPYONLY) +file(COPY windows-openvr/Input DESTINATION "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") +set(DETOURS_SOURCE_DIR "" CACHE PATH "Microsoft Detours v4.0.1 source") +set(OPENXR_SDK_SOURCE_DIR "" CACHE PATH "Khronos OpenXR SDK source") +if(NOT EXISTS "${DETOURS_SOURCE_DIR}/src/detours.cpp" OR NOT EXISTS "${OPENXR_SDK_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "Set DETOURS_SOURCE_DIR and OPENXR_SDK_SOURCE_DIR") +endif() + +include(CTest) +set(ext "${CMAKE_CURRENT_SOURCE_DIR}/Externals") +if(BUILD_TESTING) + add_executable(simulated-input-test tests/simulated-input.cpp) + target_include_directories(simulated-input-test PRIVATE "${ext}/LibOVR/Include") + target_link_libraries(simulated-input-test PRIVATE RiftLiftOpenVR64) + add_test(NAME simulated-input COMMAND simulated-input-test) + set_tests_properties(simulated-input PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "RIFTLIFT_SIMULATOR=1;RIFTLIFT_SIMULATED_CONTROLLERS=1") + add_executable(compositor-probe tests/compositor-probe.cpp) + target_link_libraries(compositor-probe PRIVATE openvr_api64 d3d11 dxgi) + add_executable(boundary-buffer-test tests/boundary-buffer.cpp) + target_include_directories(boundary-buffer-test PRIVATE "${ext}/LibOVR/Include") + target_link_libraries(boundary-buffer-test PRIVATE RiftLiftOpenVR64 openvr_api64) + add_test(NAME boundary-buffer COMMAND boundary-buffer-test) + set_tests_properties(boundary-buffer PROPERTIES SKIP_RETURN_CODE 77) +endif() +set(DYNAMIC_LOADER OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) +add_subdirectory("${OPENXR_SDK_SOURCE_DIR}" openxr) +set_property(TARGET openxr_loader PROPERTY MSVC_RUNTIME_LIBRARY MultiThreaded) +set(detours_sources detours modules disasm image creatwth disolx86 disolx64 disolia64 disolarm disolarm64) +list(TRANSFORM detours_sources PREPEND "${DETOURS_SOURCE_DIR}/src/") +list(TRANSFORM detours_sources APPEND ".cpp") +add_library(detours STATIC ${detours_sources}) +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/include/detours") +configure_file("${DETOURS_SOURCE_DIR}/src/detours.h" "${CMAKE_BINARY_DIR}/include/detours/detours.h" COPYONLY) +configure_file("${DETOURS_SOURCE_DIR}/src/detver.h" "${CMAKE_BINARY_DIR}/include/detours/detver.h" COPYONLY) +target_include_directories(detours PUBLIC "${CMAKE_BINARY_DIR}/include") +set(ovr_util "${ext}/LibOVR/Shim/OVR_CAPI_Util.cpp" "${ext}/LibOVR/Shim/OVR_StereoProjection.cpp") +set(vrcore dirtools_public envvartools_public hmderrors_public pathtools_public sharedlibtools_public strtools_public vrpathregistry_public) +list(TRANSFORM vrcore PREPEND "${ext}/openvr/src/vrcore/") +list(TRANSFORM vrcore APPEND ".cpp") +add_library(openvr_api64 SHARED ${vrcore} "${ext}/openvr/src/jsoncpp.cpp" "${ext}/openvr/src/openvr_api_public.cpp" "${ext}/openvr_dllmain.cpp" "${ext}/openvr_api.def") +target_include_directories(openvr_api64 PUBLIC "${ext}/openvr/headers" PRIVATE "${ext}/openvr/src" "${ext}/openvr/src/vrcore") +target_compile_definitions(openvr_api64 PRIVATE WIN64 VRCORE_NO_PLATFORM VR_API_PUBLIC _CRT_SECURE_NO_WARNINGS _CRT_NONSTDC_NO_DEPRECATE) +target_link_libraries(openvr_api64 PRIVATE detours shell32 advapi32) +foreach(backend openxr openvr) + file(GLOB sources CONFIGURE_DEPENDS "windows-${backend}/*.cpp") + set(target "RiftLift${backend}64") + if(backend STREQUAL "openxr") + set(target RiftLiftOpenXR64) + set(exports windows-openxr/RiftLiftOpenXR.def) + else() + set(target RiftLiftOpenVR64) + set(exports windows-openvr/RiftLiftOpenVR.def) + endif() + add_library(${target} SHARED ${sources} ${ovr_util} "${ext}/glad/src/glad.c" ${exports}) + target_include_directories(${target} PRIVATE "${ext}/microprofile" "${ext}/LibOVR/Include" "${ext}/glad/include" "${ext}/Vulkan/include" "${CMAKE_BINARY_DIR}/include") + target_compile_definitions(${target} PRIVATE UNICODE _UNICODE NOMINMAX OVR_DLL_BUILD MICROPROFILE_ENABLED=0 MICROPROFILE_GPU_TIMERS=0 XR_USE_PLATFORM_WIN32 VK_NO_PROTOTYPES VK_USE_PLATFORM_WIN32_KHR _CRT_SECURE_NO_WARNINGS) + target_link_libraries(${target} PRIVATE detours ws2_32 opengl32 d3d11 dxgi dxguid dsound winmm shlwapi pathcch) + if(backend STREQUAL "openxr") + target_link_libraries(${target} PRIVATE openxr_loader) + else() + target_sources(${target} PRIVATE "${ext}/glad/src/glad_wgl.c") + target_link_libraries(${target} PRIVATE openvr_api64) + find_program(FXC_EXECUTABLE fxc REQUIRED) + foreach(shader VertexShader MirrorShader CompositorShader) + set(profile ps_4_0) + if(shader STREQUAL "VertexShader") + set(profile vs_4_0) + endif() + set(header "${CMAKE_BINARY_DIR}/include/${shader}.hlsl.h") + add_custom_command(OUTPUT "${header}" COMMAND "${FXC_EXECUTABLE}" /nologo /T ${profile} /E main /Fh "${header}" /Vn "g_${shader}" "${CMAKE_CURRENT_SOURCE_DIR}/windows-openvr/${shader}.hlsl" DEPENDS "windows-openvr/${shader}.hlsl" VERBATIM) + target_sources(${target} PRIVATE "${header}") + endforeach() + endif() +endforeach() +add_executable(RiftLiftLauncher windows-launcher/main.cpp) +target_compile_definitions(RiftLiftLauncher PRIVATE UNICODE _UNICODE NOMINMAX _CRT_SECURE_NO_WARNINGS) +target_link_libraries(RiftLiftLauncher PRIVATE detours openvr_api64 shlwapi shell32) + +set(META_PLATFORM_IMPL "" CACHE FILEPATH "Original platform implementation DLL for export forwarding") +if(META_PLATFORM_IMPL) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + set(platform_def "${CMAKE_BINARY_DIR}/platform-forwarders.c") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/platform-exports.py" "${META_PLATFORM_IMPL}" "${CMAKE_CURRENT_SOURCE_DIR}/../compat/riftlift-platform-shim.c" "${platform_def}" COMMAND_ERROR_IS_FATAL ANY) + add_library(LibOVRPlatformImpl64_1 SHARED ../compat/riftlift-platform-shim.c "${platform_def}") + set_property(TARGET LibOVRPlatformImpl64_1 PROPERTY C_STANDARD 11) + target_compile_definitions(LibOVRPlatformImpl64_1 PRIVATE _CRT_SECURE_NO_WARNINGS) + get_filename_component(meta_runtime_dir "${META_PLATFORM_IMPL}" DIRECTORY) + foreach(dependency LibOVRPlatform64_1.dll LibOVRP2P64_1.dll LibOVRRT64_1.dll) + configure_file("${meta_runtime_dir}/${dependency}" "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${dependency}" COPYONLY) + endforeach() + configure_file("${META_PLATFORM_IMPL}" "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LibOVRPlatformImpl64_1_real.dll" COPYONLY) + if(BUILD_TESTING) + add_executable(platform-load-test tests/platform-load.cpp) + add_test(NAME platform-load COMMAND RiftLiftLauncher /openvr /wait "$") + set_tests_properties(platform-load PROPERTIES ENVIRONMENT "RIFTLIFT_PLATFORM_DLL=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LibOVRPlatformImpl64_1.dll;RIFTLIFT_PLATFORM_OFFLINE=1") + endif() +endif() diff --git a/runtime/FrameCapture.h b/runtime/FrameCapture.h new file mode 100644 index 0000000..8838968 --- /dev/null +++ b/runtime/FrameCapture.h @@ -0,0 +1,82 @@ +#pragma once + +// Opt-in diagnostics of application-owned render textures, before compositor +// submission. Never reads a desktop/window surface. At most six frames per process. +#include +#include +#include +#include +#include +#include +#include +#include + +inline void CaptureApplicationFrame(ID3D11Texture2D* texture, ID3D11On12Device* interop = nullptr) +{ + static const char* directory = std::getenv("RIFTLIFT_CAPTURE_FRAMES_DIR"); + if (!directory || !*directory || !texture) return; + D3D11_TEXTURE2D_DESC desc; + texture->GetDesc(&desc); + const bool bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_TYPELESS || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + const bool rgba = desc.Format == DXGI_FORMAT_R8G8B8A8_TYPELESS || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + const bool half = desc.Format == DXGI_FORMAT_R16G16B16A16_TYPELESS || desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT; + if (!(bgra || rgba || half) || desc.SampleDesc.Count != 1 || desc.Width < 64 || desc.Height < 64) return; + static std::mutex lock; + static unsigned count = 0; + static ULONGLONG previous = 0; + static const ULONGLONG started = GetTickCount64(); + static const unsigned delay = [] { + const char* value = std::getenv("RIFTLIFT_CAPTURE_DELAY_SECONDS"); + return value ? static_cast(std::atoi(value)) : 0U; + }(); + std::lock_guard guard(lock); + const auto now = GetTickCount64(); + if (now - started < static_cast(delay) * 1000 || count >= 6 || now - previous < 5000) return; + previous = now; + ++count; + Microsoft::WRL::ComPtr device; + Microsoft::WRL::ComPtr context; + Microsoft::WRL::ComPtr staging; + texture->GetDevice(&device); + device->GetImmediateContext(&context); + desc.MipLevels = desc.ArraySize = 1; + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = desc.MiscFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + if (FAILED(device->CreateTexture2D(&desc, nullptr, &staging))) return; + ID3D11Resource* wrapped = texture; + if (interop) interop->AcquireWrappedResources(&wrapped, 1); + const auto release = [&] { + if (interop) { interop->ReleaseWrappedResources(&wrapped, 1); context->Flush(); } + }; + context->CopySubresourceRegion(staging.Get(), 0, 0, 0, 0, texture, 0, nullptr); + D3D11_MAPPED_SUBRESOURCE mapped = {}; + if (FAILED(context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped))) { release(); return; } + char path[MAX_PATH]; + FILE* output = nullptr; + if (sprintf_s(path, "%s/application-%lu-%u.ppm", directory, GetCurrentProcessId(), count) > 0 && + fopen_s(&output, path, "wb") == 0) { + std::fprintf(output, "P6\n# format=%u; HDR uses Reinhard tone mapping and gamma 2.2\n%u %u\n255\n", desc.Format, desc.Width, desc.Height); + std::vector line(static_cast(desc.Width) * 3); + for (UINT y = 0; y < desc.Height; ++y) { + const auto row = static_cast(mapped.pData) + y * mapped.RowPitch; + for (UINT x = 0; x < desc.Width; ++x) { + for (UINT c = 0; c < 3; ++c) { + if (half) { + const auto value = reinterpret_cast(row)[x*4+c]; + const unsigned exponent = (value >> 10) & 31, fraction = value & 1023; + float linear = exponent ? std::ldexp(1.0f + fraction / 1024.0f, static_cast(exponent)-15) : std::ldexp(static_cast(fraction), -24); + if ((value & 0x8000) || exponent == 31) linear = 0; + line[x*3+c] = static_cast(255 * std::pow(linear/(1+linear), 1.0f/2.2f)); + } else { + line[x*3+c] = row[x*4+(bgra ? 2-c : c)]; + } + } + } + std::fwrite(line.data(), 1, line.size(), output); + } + std::fclose(output); + } + context->Unmap(staging.Get(), 0); + release(); +} diff --git a/runtime/PlatformCompat.h b/runtime/PlatformCompat.h new file mode 100644 index 0000000..9e2849b --- /dev/null +++ b/runtime/PlatformCompat.h @@ -0,0 +1,22 @@ +#pragma once + +// Launch-scoped platform compatibility; never changes a machine-wide runtime. +inline const char* PlatformRedirect(LPCSTR name) +{ + if (!name) + return nullptr; + const char* base = PathFindFileNameA(name); + if (_stricmp(base, "LibOVRPlatformImpl64_1.dll") != 0) + return nullptr; + return getenv("RIFTLIFT_PLATFORM_DLL"); +} + +inline const char* PlatformRedirect(LPCWSTR name) +{ + if (!name) + return nullptr; + const wchar_t* base = PathFindFileNameW(name); + if (_wcsicmp(base, L"LibOVRPlatformImpl64_1.dll") != 0) + return nullptr; + return getenv("RIFTLIFT_PLATFORM_DLL"); +} diff --git a/runtime/tests/boundary-buffer.cpp b/runtime/tests/boundary-buffer.cpp new file mode 100644 index 0000000..c94ef5e --- /dev/null +++ b/runtime/tests/boundary-buffer.cpp @@ -0,0 +1,38 @@ +#include +#include +#include +#include +#include + +int main() +{ + vr::EVRInitError error; + vr::VR_Init(&error, vr::VRApplication_Background); + if (error != vr::VRInitError_None) { + std::printf("SKIP: an active OpenVR runtime is needed (%d)\n", error); + return 77; + } + int failures = 0; + const auto check = [&failures](bool ok, const char* message) { + if (!ok) { std::printf("FAIL: %s\n", message); ++failures; } + }; + check(ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, nullptr, nullptr) + == ovrError_InvalidParameter, "null count must be rejected"); + int count = -1; + const auto query = ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, nullptr, &count); + check(OVR_SUCCESS(query), "size query must ignore the input count"); + check(count == 0 || count == 4, "boundary contains zero or four corners"); + std::array guard; + std::memset(guard.data(), 0x5A, sizeof(guard)); + const auto before = guard; + int capacity = 0; + const auto result = ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, guard.data(), &capacity); + check(std::memcmp(guard.data(), before.data(), sizeof(guard)) == 0, + "a zero-capacity buffer must never be written"); + check(count ? result == ovrError_InsufficientArraySize : result == ovrSuccess_BoundaryInvalid, + "return the correct capacity/boundary result"); + check(capacity == count, "report the required point count"); + vr::VR_Shutdown(); + std::printf("Boundary buffer checks: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/runtime/tests/compositor-probe.cpp b/runtime/tests/compositor-probe.cpp new file mode 100644 index 0000000..9c2eb46 --- /dev/null +++ b/runtime/tests/compositor-probe.cpp @@ -0,0 +1,86 @@ +// Read the VR compositor's eye texture, without creating or capturing a desktop window. +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +int main(int argc, char** argv) +{ + vr::EVRInitError error; + auto system = vr::VR_Init(&error, vr::VRApplication_Background); + if (error != vr::VRInitError_None) { + std::printf("OpenVR initialization error: %d\n", error); + return 2; + } + auto compositor = vr::VRCompositor(); + vr::Compositor_CumulativeStats stats = {}; + compositor->GetCumulativeStats(&stats, sizeof(stats)); + std::printf("scene_pid=%u renderer_pid=%u stats_pid=%u presents=%u dropped=%u reprojected=%u\n", + compositor->GetCurrentSceneFocusProcess(), compositor->GetLastFrameRenderer(), + stats.m_nPid, stats.m_nNumFramePresents, stats.m_nNumDroppedFrames, stats.m_nNumReprojectedFrames); + vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount] = {}; + system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses, vr::k_unMaxTrackedDeviceCount); + std::printf("hmd_connected=%d pose_valid=%d position=%.3f,%.3f,%.3f\n", + poses[0].bDeviceIsConnected, poses[0].bPoseIsValid, + poses[0].mDeviceToAbsoluteTracking.m[0][3], poses[0].mDeviceToAbsoluteTracking.m[1][3], poses[0].mDeviceToAbsoluteTracking.m[2][3]); + if (argc < 2) { vr::VR_Shutdown(); return 0; } + int32_t adapterIndex = 0; + system->GetDXGIOutputInfo(&adapterIndex); + ComPtr factory; + ComPtr adapter; + ComPtr device; + ComPtr context; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (SUCCEEDED(hr)) hr = factory->EnumAdapters1(adapterIndex, &adapter); + if (SUCCEEDED(hr)) hr = D3D11CreateDevice(adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, 0, + nullptr, 0, D3D11_SDK_VERSION, &device, nullptr, &context); + if (FAILED(hr)) { std::printf("D3D device error: %08X\n", (unsigned)hr); vr::VR_Shutdown(); return 3; } + ID3D11ShaderResourceView* mirror = nullptr; + const auto result = compositor->GetMirrorTextureD3D11(vr::Eye_Left, device.Get(), reinterpret_cast(&mirror)); + if (result != vr::VRCompositorError_None || !mirror) { + std::printf("Eye texture unavailable: %d\n", result); vr::VR_Shutdown(); return 4; + } + ComPtr resource; + ComPtr texture; + mirror->GetResource(&resource); + hr = resource.As(&texture); + D3D11_TEXTURE2D_DESC desc = {}; + if (SUCCEEDED(hr)) texture->GetDesc(&desc); + std::printf("eye_width=%u eye_height=%u format=%u\n", desc.Width, desc.Height, desc.Format); + const bool bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_TYPELESS || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + const bool rgba = desc.Format == DXGI_FORMAT_R8G8B8A8_TYPELESS || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + int status = 5; + if (bgra || rgba) { + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = desc.MiscFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + ComPtr staging; + hr = device->CreateTexture2D(&desc, nullptr, &staging); + if (SUCCEEDED(hr)) { + context->CopyResource(staging.Get(), texture.Get()); + D3D11_MAPPED_SUBRESOURCE mapped = {}; + hr = context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped); + if (SUCCEEDED(hr)) { + FILE* output = nullptr; + if (fopen_s(&output, argv[1], "wb") == 0) { + std::fprintf(output, "P6\n%u %u\n255\n", desc.Width, desc.Height); + for (UINT y = 0; y < desc.Height; ++y) { + const auto row = static_cast(mapped.pData) + y * mapped.RowPitch; + for (UINT x = 0; x < desc.Width; ++x) { + const unsigned char rgb[] = {row[x*4+(bgra?2:0)], row[x*4+1], row[x*4+(bgra?0:2)]}; + std::fwrite(rgb, 1, 3, output); + } + } + status = std::fclose(output) == 0 ? 0 : 6; + } + context->Unmap(staging.Get(), 0); + } + } + } + compositor->ReleaseMirrorTextureD3D11(mirror); + vr::VR_Shutdown(); + return status; +} diff --git a/runtime/tests/platform-load.cpp b/runtime/tests/platform-load.cpp new file mode 100644 index 0000000..74fcc77 --- /dev/null +++ b/runtime/tests/platform-load.cpp @@ -0,0 +1,20 @@ +#include +#include +#include + +int main() +{ + HMODULE platform = LoadLibraryW(L"LibOVRPlatform64_1.dll"); + if (!platform) { std::printf("Platform load failed: %lu\n", GetLastError()); return 1; } + auto initialize = reinterpret_cast(GetProcAddress(platform, "ovr_PlatformInitializeWindows")); + auto fromString = reinterpret_cast(GetProcAddress(platform, "ovrID_FromString")); + auto initialized = reinterpret_cast(GetProcAddress(platform, "ovr_IsPlatformInitialized")); + if (!initialize || !fromString || !initialized) { std::puts("Required export missing"); return 1; } + uint64_t id = 0; + if (initialize("0") != 0 || !initialized() || !fromString(&id, "42") || id != 42) { + std::puts("Platform forwarding/initialization failed"); return 1; + } + std::puts("Signed platform facade, offline initialization, and utility forwarding: PASSED"); + FreeLibrary(platform); + return 0; +} diff --git a/runtime/tests/simulated-input.cpp b/runtime/tests/simulated-input.cpp new file mode 100644 index 0000000..96834a4 --- /dev/null +++ b/runtime/tests/simulated-input.cpp @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include + +int main() +{ + if (OVR_FAILURE(ovr_Initialize(nullptr))) return 77; + ovrSession session = nullptr; + ovrGraphicsLuid luid; + if (OVR_FAILURE(ovr_Create(&session, &luid))) { ovr_Shutdown(); return 1; } + int failures = 0; + const auto check = [&](bool ok, const char* message) { + if (!ok) { ++failures; std::printf("FAIL: %s\n", message); } + }; + check((ovr_GetConnectedControllerTypes(session) & ovrControllerType_Touch) == ovrControllerType_Touch, "two simulated hands connected"); + ovrInputState input = {}; + check(OVR_SUCCESS(ovr_GetInputState(session, ovrControllerType_Touch, &input)), "input query succeeds"); + check(input.ControllerType == ovrControllerType_Touch && input.Buttons == 0, "static controllers have no pressed buttons"); + const double now = ovr_GetTimeInSeconds(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::printf("Clock advanced %.6f seconds in 100ms\n", ovr_GetTimeInSeconds() - now); + auto state = ovr_GetTrackingState(session, now, false); + for (int attempt = 0; !state.StatusFlags && attempt < 100; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + state = ovr_GetTrackingState(session, 0, false); + } + ovrTrackedDeviceType types[] = {ovrTrackedDevice_LTouch, ovrTrackedDevice_RTouch}; + ovrPoseStatef poses[2] = {}; + check(OVR_SUCCESS(ovr_GetDevicePoses(session, types, 2, now, poses)), "device pose query succeeds without physical controllers"); + for (int hand = 0; hand < 2; ++hand) { + check(state.HandStatusFlags[hand] != 0, "hand tracking flags set"); + check(std::abs(poses[hand].ThePose.Position.x - state.HandPoses[hand].ThePose.Position.x) < 0.001f, "both pose APIs agree"); + } + ovr_SetTrackingOriginType(session, ovrTrackingOrigin_FloorLevel); + const auto standing = ovr_GetTrackingState(session, 0, false); + check(standing.HeadPose.ThePose.Position.y > 1.0f, "simulated standing head is above the floor"); + check(standing.HandPoses[0].ThePose.Position.y > 0.7f, "simulated hands follow standing height"); + ovr_Destroy(session); + ovr_Shutdown(); + std::printf("Simulated input: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/runtime/windows-openvr/InputManager.cpp b/runtime/windows-openvr/InputManager.cpp index 472ea42..a371432 100644 --- a/runtime/windows-openvr/InputManager.cpp +++ b/runtime/windows-openvr/InputManager.cpp @@ -8,7 +8,6 @@ #include #include #include -#include ovrResult InputManager::InputErrorToOvrError(vr::EVRInputError error) { diff --git a/runtime/windows-openvr/REV_CAPI.cpp b/runtime/windows-openvr/REV_CAPI.cpp index 5d41086..4f573c5 100644 --- a/runtime/windows-openvr/REV_CAPI.cpp +++ b/runtime/windows-openvr/REV_CAPI.cpp @@ -19,6 +19,26 @@ #define REV_DEFAULT_TIMEOUT 10000 +static bool SimulatedControllersEnabled() +{ + const char* simulator = getenv("RIFTLIFT_SIMULATOR"); + const char* controllers = getenv("RIFTLIFT_SIMULATED_CONTROLLERS"); + return simulator && strcmp(simulator, "1") == 0 && + controllers && strcmp(controllers, "1") == 0; +} + +static void AddSimulatedHands(ovrTrackingState& state) +{ + for (int hand = 0; hand < ovrHand_Count; ++hand) + { + state.HandPoses[hand] = {}; + state.HandPoses[hand].ThePose = OVR::Posef(state.HeadPose.ThePose) * + OVR::Posef(OVR::Quatf::Identity(), OVR::Vector3f(hand ? 0.25f : -0.25f, -0.25f, -0.45f)); + state.HandPoses[hand].TimeInSeconds = state.HeadPose.TimeInSeconds; + state.HandStatusFlags[hand] = state.StatusFlags; + } +} + HMODULE g_D3D11 = nullptr; unsigned int g_MinorVersion = OVR_MINOR_VERSION; vr::EVRInitError g_InitError = vr::VRInitError_Init_NotInitialized; @@ -109,7 +129,7 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_Initialize(const ovrInitParams* params) MicroProfileSetForceMetaCounters(true); MicroProfileWebServerStart(); - g_MinorVersion = params->RequestedMinorVersion; + g_MinorVersion = params ? params->RequestedMinorVersion : OVR_MINOR_VERSION; DetachDetours(); @@ -117,7 +137,11 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_Initialize(const ovrInitParams* params) AttachDetours(); - uint32_t timeout = params->ConnectionTimeoutMS; + TraceOculusValue("ovr_Initialize.openvrError", g_InitError); + if (g_InitError != vr::VRInitError_None) + return InitErrorToOvrError(g_InitError); + + uint32_t timeout = params ? params->ConnectionTimeoutMS : 0; if (timeout == 0) timeout = REV_DEFAULT_TIMEOUT; @@ -309,6 +333,15 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetSessionStatus(ovrSession session, ovrSessi sessionStatus->IsVisible = (RunningUnderWine() || vr::VRCompositor()->CanRenderScene()) && !first_call; first_call = false; + // The explicitly selected desktop simulator has no proximity sensor. + // Keep its virtual headset mounted so games can exercise the frame loop. + const char* simulator = getenv("RIFTLIFT_SIMULATOR"); + if (simulator && strcmp(simulator, "1") == 0) + { + sessionStatus->HmdMounted = true; + sessionStatus->IsVisible = true; + sessionStatus->HasInputFocus = true; + } static const bool do_sleep = session->UseHack(HACK_SLEEP_IN_SESSION_STATUS); if (do_sleep) @@ -403,6 +436,33 @@ OVR_PUBLIC_FUNCTION(ovrTrackingState) ovr_GetTrackingState(ovrSession session, d return state; session->Input->GetTrackingState(session, &state, absTime); + if (SimulatedControllersEnabled()) + { + // The null headset has no movement; its current pose also serves early + // queries made before the compositor has a predicted-frame history. + if (!state.StatusFlags) + session->Input->GetTrackingState(session, &state, 0); + if (!state.StatusFlags) + { + // SteamVR's null driver can have a raw pose before a seated origin + // exists. Use that pose for the static simulator only. + vr::TrackedDevicePose_t pose = {}; + vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &pose, 1); + if (pose.bPoseIsValid && pose.bDeviceIsConnected) + { + const REV::Matrix4f matrix(pose.mDeviceToAbsoluteTracking); + state.HeadPose.ThePose = OVR::Posef(OVR::Quatf(matrix), matrix.GetTranslation()); + state.HeadPose.TimeInSeconds = absTime; + state.StatusFlags = ovrStatus_OrientationValid | ovrStatus_PositionValid | + ovrStatus_OrientationTracked | ovrStatus_PositionTracked; + } + } + // The null driver's origin is at the floor. Eye-level games supply + // their own player height; floor-level games expect it in the pose. + if (vr::VRCompositor()->GetTrackingSpace() == vr::TrackingUniverseStanding) + state.HeadPose.ThePose.Position.y += 1.65f; + AddSimulatedHands(state); + } return state; } @@ -413,6 +473,23 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetDevicePoses(ovrSession session, ovrTracked if (!session) return ovrError_InvalidSession; + if (deviceCount < 0 || (deviceCount > 0 && (!deviceTypes || !outDevicePoses))) + return ovrError_InvalidParameter; + if (SimulatedControllersEnabled()) + { + ovrTrackingState state = ovr_GetTrackingState(session, absTime, false); + for (int i = 0; i < deviceCount; ++i) + { + if (deviceTypes[i] == ovrTrackedDevice_HMD) outDevicePoses[i] = state.HeadPose; + else if (deviceTypes[i] == ovrTrackedDevice_LTouch) outDevicePoses[i] = state.HandPoses[ovrHand_Left]; + else if (deviceTypes[i] == ovrTrackedDevice_RTouch) outDevicePoses[i] = state.HandPoses[ovrHand_Right]; + else { + ovrResult result = session->Input->GetDevicePoses(session, &deviceTypes[i], 1, absTime, &outDevicePoses[i]); + if (OVR_FAILURE(result)) return result; + } + } + return ovrSuccess; + } return session->Input->GetDevicePoses(session, deviceTypes, deviceCount, absTime, outDevicePoses); } @@ -551,6 +628,25 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetInputState(ovrSession session, ovrControll ovrInputState state = { 0 }; ovrResult result = session->Input->GetInputState(session, controllerType, &state); + if (SimulatedControllersEnabled() && (controllerType & ovrControllerType_Touch)) + { + state.ControllerType = static_cast(state.ControllerType | (controllerType & ovrControllerType_Touch)); + state.TimeInSeconds = ovr_GetTimeInSeconds(); + // A test harness may supply button bits through a launch-scoped file. + // This does not synthesize Windows keyboard or mouse input. + if (const char* path = getenv("RIFTLIFT_SIMULATED_INPUT")) + { + FILE* input = nullptr; + if (fopen_s(&input, path, "r") == 0) + { + unsigned buttons = 0; + if (fscanf_s(input, "%u", &buttons) == 1) + state.Buttons |= buttons & (ovrButton_A | ovrButton_B | ovrButton_X | ovrButton_Y | ovrButton_Enter); + fclose(input); + } + } + result = ovrSuccess; + } // We need to make sure we don't write outside of the bounds of the struct // when the client expects a pre-1.7 version of LibOVR. @@ -574,7 +670,7 @@ OVR_PUBLIC_FUNCTION(unsigned int) ovr_GetConnectedControllerTypes(ovrSession ses // XR runtimes may publish interaction profiles after session creation. // Query the current OpenVR roles instead of returning a startup-time cache. session->Input->UpdateConnectedControllers(); - return session->Input->ConnectedControllers; + return session->Input->ConnectedControllers | (SimulatedControllersEnabled() ? ovrControllerType_Touch : 0); } OVR_PUBLIC_FUNCTION(ovrTouchHapticsDesc) ovr_GetTouchHapticsDesc(ovrSession session, ovrControllerType controllerType) @@ -733,13 +829,21 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetBoundaryGeometry(ovrSession session, ovrBo { REV_TRACE(ovr_GetBoundaryGeometry); - vr::HmdQuad_t playRect; + if (!outFloorPointsCount || (outFloorPoints && *outFloorPointsCount < 0)) + return ovrError_InvalidParameter; + vr::HmdQuad_t playRect = {}; bool valid = vr::VRChaperone()->GetPlayAreaRect(&playRect); + const int capacity = outFloorPoints ? *outFloorPointsCount : 0; + *outFloorPointsCount = valid ? 4 : 0; + // A missing play area reports zero points. Never copy into the caller's + // zero-sized buffer on its second query (the null headset has no bounds). + if (!valid) + return ovrSuccess_BoundaryInvalid; + if (outFloorPoints && capacity < 4) + return ovrError_InsufficientArraySize; if (outFloorPoints) memcpy(outFloorPoints, playRect.vCorners, 4 * sizeof(ovrVector3f)); - if (outFloorPointsCount) - *outFloorPointsCount = valid ? 4 : 0; - return valid ? ovrSuccess : ovrSuccess_BoundaryInvalid; + return ovrSuccess; } OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetBoundaryDimensions(ovrSession session, ovrBoundaryType boundaryType, ovrVector3f* outDimensions) diff --git a/runtime/windows-openvr/TextureD3D.cpp b/runtime/windows-openvr/TextureD3D.cpp index fe70380..b4ae1cd 100644 --- a/runtime/windows-openvr/TextureD3D.cpp +++ b/runtime/windows-openvr/TextureD3D.cpp @@ -1,6 +1,7 @@ #include "TextureD3D.h" #include "Common.h" #include "OVR_CAPI.h" +#include "../FrameCapture.h" #include #include @@ -37,6 +38,8 @@ void TextureD3D::ToVRTexture(vr::Texture_t& texture) if (m_pDevice12) { + if (m_pDevice11on12) + CaptureApplicationFrame(m_pTexture.Get(), m_pDevice11on12.Get()); if (m_pResolveList) m_pQueue->ExecuteCommandLists(1, (ID3D12CommandList**)m_pResolveList.GetAddressOf()); @@ -45,6 +48,7 @@ void TextureD3D::ToVRTexture(vr::Texture_t& texture) } else { + CaptureApplicationFrame(m_pTexture.Get()); texture.eType = vr::TextureType_DirectX; texture.handle = m_pTexture.Get(); } diff --git a/runtime/windows-openvr/main.cpp b/runtime/windows-openvr/main.cpp index 43d75f1..06db557 100644 --- a/runtime/windows-openvr/main.cpp +++ b/runtime/windows-openvr/main.cpp @@ -5,6 +5,8 @@ #include #include #include +#include "../PlatformCompat.h" +#include "Common.h" #include "Extras\OVR_CAPI_Util.h" #include "OVR_Version.h" @@ -146,6 +148,8 @@ bool IsOvrRuntimeName(LPCWSTR lpModuleName) HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -156,6 +160,9 @@ HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpFileName, HANDLE file, DWORD flags) { + if (!(flags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -166,6 +173,8 @@ HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpFileName, HANDLE file, DWORD flags) HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -179,6 +188,9 @@ HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) HMODULE WINAPI HookLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpLibFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -228,7 +240,7 @@ void AttachDetours() DetourAttach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourAttach((PVOID*)&TrueOpenEvent, HookOpenEvent); DetourAttach((PVOID*)&TrueDXGIFactory, HookDXGIFactory); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } void DetachDetours() @@ -245,7 +257,7 @@ void DetachDetours() DetourDetach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourDetach((PVOID*)&TrueOpenEvent, HookOpenEvent); DetourDetach((PVOID*)&TrueDXGIFactory, HookDXGIFactory); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } BOOL APIENTRY DllMain(HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) diff --git a/runtime/windows-openxr/Runtime.cpp b/runtime/windows-openxr/Runtime.cpp index d2a4578..0f88e99 100644 --- a/runtime/windows-openxr/Runtime.cpp +++ b/runtime/windows-openxr/Runtime.cpp @@ -21,9 +21,11 @@ const char* Runtime::s_required_extensions[] = { // extension. Requesting the Windows D3D12 or Vulkan extensions at the same // time therefore produces duplicate XR_KHR_vulkan_enable names for runtimes // such as SteamVR, which correctly reject the instance create request. D3D12 -// Oculus clients are selected for the OpenVR bridge by the launcher instead. -// OpenGL remains distinct after Wine's translation and can coexist here. +// Keep that restriction under Wine only; native Windows clients such as Echo VR +// need the D3D12 extension for their command queue and swapchains. const char* Runtime::s_optional_extensions[] = { + "XR_KHR_D3D12_enable", + "XR_KHR_vulkan_enable", "XR_KHR_opengl_enable", XR_MND_HEADLESS_EXTENSION_NAME, XR_KHR_VISIBILITY_MASK_EXTENSION_NAME, @@ -70,12 +72,18 @@ ovrResult Runtime::CreateInstance(XrInstance* out_Instance, const ovrInitParams* CHK_XR(xrEnumerateInstanceExtensionProperties(nullptr, (uint32_t)properties.size(), &size, properties.data())); m_extensions.clear(); + const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + const bool isWine = ntdll && GetProcAddress(ntdll, "wine_get_version") != nullptr; for (const char* extension : s_required_extensions) m_extensions.push_back(extension); for (const char* extension : s_optional_extensions) { + if (isWine && + (strcmp(extension, "XR_KHR_D3D12_enable") == 0 || + strcmp(extension, "XR_KHR_vulkan_enable") == 0)) + continue; auto findExtension = [extension](XrExtensionProperties props) { return strcmp(props.extensionName, extension) == 0; diff --git a/runtime/windows-openxr/Swapchain.cpp b/runtime/windows-openxr/Swapchain.cpp index a2ad85f..8fb1633 100644 --- a/runtime/windows-openxr/Swapchain.cpp +++ b/runtime/windows-openxr/Swapchain.cpp @@ -60,6 +60,14 @@ ovrResult ovrTextureSwapChainData::Init(XrSession session, const ovrTextureSwapC createInfo.faceCount = desc->Type == ovrTexture_Cube ? desc->ArraySize : 1; createInfo.arraySize = desc->Type == ovrTexture_Cube ? 1 : desc->ArraySize; createInfo.mipCount = desc->MipLevels; + TraceOculusValue("xrCreateSwapchain.ovrFormat", desc->Format); + TraceOculusValue("xrCreateSwapchain.format", format); + TraceOculusValue("xrCreateSwapchain.usageFlags", createInfo.usageFlags); + TraceOculusValue("xrCreateSwapchain.mipCount", createInfo.mipCount); + TraceOculusValue("xrCreateSwapchain.width", createInfo.width); + TraceOculusValue("xrCreateSwapchain.height", createInfo.height); + TraceOculusValue("xrCreateSwapchain.arraySize", createInfo.arraySize); + TraceOculusValue("xrCreateSwapchain.sampleCount", createInfo.sampleCount); CHK_XR(xrCreateSwapchain(session, &createInfo, &Swapchain)); XrSwapchainImageAcquireInfo acqInfo = XR_TYPE(SWAPCHAIN_IMAGE_ACQUIRE_INFO); @@ -103,10 +111,7 @@ DXGI_FORMAT ovrTextureSwapChainData::TextureFormatToDXGIFormat(ovrTextureFormat // Depth formats case OVR_FORMAT_D16_UNORM: return DXGI_FORMAT_D16_UNORM; - // WineOpenXR maps D24S8 to VK_FORMAT_D24_UNORM_S8_UINT, which is not - // universally advertised by Monado's Vulkan device. D32S8 preserves stencil - // and maps to the broadly supported VK_FORMAT_D32_SFLOAT_S8_UINT. - case OVR_FORMAT_D24_UNORM_S8_UINT: return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; + case OVR_FORMAT_D24_UNORM_S8_UINT: return DXGI_FORMAT_D24_UNORM_S8_UINT; case OVR_FORMAT_D32_FLOAT: return DXGI_FORMAT_D32_FLOAT; case OVR_FORMAT_D32_FLOAT_S8X24_UINT: return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; @@ -148,6 +153,11 @@ DXGI_FORMAT ovrTextureSwapChainData::NegotiateFormat(ovrSession session, DXGI_FO { if (session->SupportsFormat(format)) return format; + // Preserve native D24S8 when available; Wine/Monado may require D32S8. + if (format == DXGI_FORMAT_D24_UNORM_S8_UINT && session->SupportsFormat(DXGI_FORMAT_D32_FLOAT_S8X24_UINT)) + return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; + if (format == DXGI_FORMAT_D32_FLOAT_S8X24_UINT && session->SupportsFormat(DXGI_FORMAT_D24_UNORM_S8_UINT)) + return DXGI_FORMAT_D24_UNORM_S8_UINT; // Upgrade R11G11B10F to RGBA16F if it's available if (format == DXGI_FORMAT_R11G11B10_FLOAT && session->SupportsFormat(DXGI_FORMAT_R16G16B16A16_FLOAT)) diff --git a/runtime/windows-openxr/main.cpp b/runtime/windows-openxr/main.cpp index ac63bc8..296d8b0 100644 --- a/runtime/windows-openxr/main.cpp +++ b/runtime/windows-openxr/main.cpp @@ -6,6 +6,8 @@ #include #include #include +#include "../PlatformCompat.h" +#include "Common.h" #include "OVR_CAPI.h" #include "OVR_Version.h" @@ -134,6 +136,8 @@ HANDLE WINAPI HookOpenEvent(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCWSTR HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -147,6 +151,8 @@ HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -162,6 +168,9 @@ HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpLibFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -174,6 +183,9 @@ HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpLibFileName, HANDLE hFile, DWORD dwFl HMODULE WINAPI HookLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpLibFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -230,7 +242,7 @@ void AttachDetours() DetourAttach((PVOID*)&TrueGetModuleHandleExA, HookGetModuleHandleExA); DetourAttach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourAttach(&(PVOID&)TrueOpenEvent, HookOpenEvent); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } void DetachDetours() @@ -246,7 +258,7 @@ void DetachDetours() DetourDetach((PVOID*)&TrueGetModuleHandleExA, HookGetModuleHandleExA); DetourDetach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourDetach(&(PVOID&)TrueOpenEvent, HookOpenEvent); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } BOOL APIENTRY DllMain(HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..420caf6 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory = $true)][string]$RuntimeDirectory, + [string]$Python = 'python', + [string]$Iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + [switch]$AppOnly +) +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$runtime = (Resolve-Path -LiteralPath $RuntimeDirectory).Path +$env:RIFTLIFT_PACKAGE_RUNTIME = $runtime +$env:RIFTLIFT_PACKAGE_ICON = Join-Path $root 'build\windows\riftlift.ico' +$originalPath = $env:PATH +$pythonExe = (Get-Command $Python -ErrorAction Stop).Source +# DLL discovery must not pull same-named libraries from unrelated developer tools. +$env:PATH = "$(Split-Path $pythonExe);$env:WINDIR\System32;$env:WINDIR" +Push-Location $root +try { + & $Python scripts/prepare-windows-package.py $runtime $env:RIFTLIFT_PACKAGE_ICON + if ($LASTEXITCODE -ne 0) { throw 'Windows package preparation failed' } + & $Python -m PyInstaller --noconfirm --clean --distpath dist/windows --workpath build/windows/pyinstaller scripts/windows.spec + if ($LASTEXITCODE -ne 0) { throw 'Windows application build failed' } + if ($AppOnly) { return } + $version = & $Python -c 'from riftlift import __version__; print(__version__)' + if ($LASTEXITCODE -ne 0) { throw 'Could not read RiftLift version' } + $bundle = Join-Path $root 'dist\windows\RiftLift' + & $Iscc "/DAppVersion=$version" "/DBundleDir=$bundle" "/O$root\dist\windows" scripts/windows-installer.iss + if ($LASTEXITCODE -ne 0) { throw 'Windows installer build failed' } + $installer = Join-Path $root "dist\windows\RiftLift-Setup-$version-x64.exe" + $hash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $(Split-Path $installer -Leaf)" | Set-Content -Encoding ascii "$installer.sha256" + Write-Host "Installer ready: $installer" +} finally { + $env:PATH = $originalPath + Pop-Location +} diff --git a/scripts/platform-exports.py b/scripts/platform-exports.py new file mode 100644 index 0000000..71baa8f --- /dev/null +++ b/scripts/platform-exports.py @@ -0,0 +1,29 @@ +"""Generate the native platform forwarders from dumpbin /exports output.""" + +import pathlib +import re +import subprocess +import sys + + +def generate_forwarders(symbols: str, source: str) -> str: + overrides = set(re.findall(r"__cdecl\s+(ovr\w+)\(", source)) + names = re.findall(r"^\s+\d+\s+[\dA-F]+\s+[\dA-F]+\s+(ovr\w+)", symbols, re.M) + if not names: + raise ValueError("No platform exports found") + return ( + "\n".join( + f'#pragma comment(linker, "/export:{name}=LibOVRPlatformImpl64_1_real.{name}")' + for name in names + if name not in overrides + ) + + "\n" + ) + + +if __name__ == "__main__": + original, source, output = map(pathlib.Path, sys.argv[1:4]) + symbols = subprocess.check_output(["dumpbin", "/exports", str(original)], text=True) + data = generate_forwarders(symbols, source.read_text()) + if not output.exists() or output.read_text() != data: + output.write_text(data) diff --git a/scripts/prepare-windows-package.py b/scripts/prepare-windows-package.py new file mode 100644 index 0000000..b11fe57 --- /dev/null +++ b/scripts/prepare-windows-package.py @@ -0,0 +1,38 @@ +"""Validate the native payload and derive the Windows icon from our SVG.""" + +import os +import sys +from pathlib import Path + +from riftlift.util import sha256 +from riftlift.windows import FILES, PLATFORM_FILES, SDK_RUNTIME_SHA256 + +runtime, icon = map(Path, sys.argv[1:]) +missing = sorted( + name for name in FILES | PLATFORM_FILES if not (runtime / name).is_file() +) +if missing: + raise SystemExit("Incomplete native runtime: " + ", ".join(missing)) +if not list((runtime / "Input").glob("*.json")): + raise SystemExit("Missing native controller bindings") +if sha256(runtime / "LibOVRRT64_1.dll") != SDK_RUNTIME_SHA256: + raise SystemExit("The signed SDK discovery DLL does not match the pinned version") + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +from PIL import Image # noqa: E402 +from PySide6 import QtCore, QtGui, QtSvg # noqa: E402 + +app = QtGui.QGuiApplication([]) +svg = Path(__file__).resolve().parents[1] / "assets/io.github.villagers654.RiftLift.svg" +renderer = QtSvg.QSvgRenderer(str(svg)) +image = QtGui.QImage(256, 256, QtGui.QImage.Format_RGBA8888) +image.fill(QtCore.Qt.transparent) +painter = QtGui.QPainter(image) +renderer.render(painter) +painter.end() +icon.parent.mkdir(parents=True, exist_ok=True) +png = icon.with_suffix(".png") +image.save(str(png)) +Image.open(png).save( + icon, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (256, 256)] +) diff --git a/scripts/test-windows-install.ps1 b/scripts/test-windows-install.ps1 new file mode 100644 index 0000000..349f9dc --- /dev/null +++ b/scripts/test-windows-install.ps1 @@ -0,0 +1,46 @@ +param([string]$Installer) +$ErrorActionPreference = 'Stop' +if ($env:CI -ne 'true') { throw 'Run installer lifecycle tests on an ephemeral CI worker' } +$root = Split-Path $PSScriptRoot -Parent +if (-not $Installer) { + $Installer = (Get-ChildItem "$root\dist\windows\RiftLift-Setup-*.exe" | Select-Object -First 1).FullName +} +if (-not $Installer) { throw 'Build the Windows installer first' } +$evidence = Join-Path $root 'build\install-check' +$appDir = Join-Path $evidence 'App with spaces' +$env:RIFTLIFT_HOME = Join-Path $evidence 'User data' +$env:QT_QPA_PLATFORM = 'offscreen' +New-Item -ItemType Directory -Force $evidence | Out-Null +function Run-Bounded([string]$File, [string[]]$Arguments) { + $process = Start-Process -FilePath $File -ArgumentList $Arguments -WindowStyle Hidden -PassThru + if (-not $process.WaitForExit(60000)) { + Stop-Process -Id $process.Id + throw "Timed out: $File" + } + if ($process.ExitCode -ne 0) { throw "$File exited $($process.ExitCode)" } +} +# Use only an isolated installation on an ephemeral CI worker. +Run-Bounded $Installer @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/TASKS=', ('/DIR="' + $appDir + '"'), ('/LOG="' + $evidence + '\install.log"')) +try { + $exe = Join-Path $appDir 'RiftLift.exe' + $env:PATH = "$env:WINDIR\System32;$env:WINDIR" + Run-Bounded $exe @('--package-check', ('"' + $evidence + '\first-launch"')) + $result = Get-Content "$evidence\first-launch\result.json" | ConvertFrom-Json + if (-not $result.success -or -not $result.icon_loaded) { throw 'Installed app check failed' } + $shortcutPath = Join-Path ([Environment]::GetFolderPath('Programs')) 'RiftLift.lnk' + $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcutPath) + if ($shortcut.TargetPath -ne $exe) { throw 'Start menu shortcut points to the wrong executable' } + $marker = Join-Path $env:RIFTLIFT_HOME 'preserve-on-upgrade.txt' + Set-Content $marker 'preserve games and settings' + Run-Bounded $Installer @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', ('/DIR="' + $appDir + '"'), ('/LOG="' + $evidence + '\upgrade.log"')) + if ((Get-Content $marker) -ne 'preserve games and settings') { throw 'Upgrade changed user data' } + Run-Bounded $exe @('--package-check', ('"' + $evidence + '\after-upgrade"')) +} finally { + if (Test-Path "$appDir\unins000.exe") { + Run-Bounded "$appDir\unins000.exe" @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', ('/LOG="' + $evidence + '\uninstall.log"')) + } +} +if (Test-Path "$appDir\RiftLift.exe") { throw 'Uninstall left the application executable' } +if (Test-Path $shortcutPath) { throw 'Uninstall left the Start menu shortcut' } +if ((Get-Content $marker) -ne 'preserve games and settings') { throw 'Uninstall changed user data' } +Write-Host 'Install, upgrade, launch, shortcut and uninstall checks passed.' diff --git a/scripts/windows-entry.py b/scripts/windows-entry.py new file mode 100644 index 0000000..f49ab53 --- /dev/null +++ b/scripts/windows-entry.py @@ -0,0 +1,3 @@ +from riftlift.windows_app import main + +raise SystemExit(main()) diff --git a/scripts/windows-installer.iss b/scripts/windows-installer.iss new file mode 100644 index 0000000..8ad37b8 --- /dev/null +++ b/scripts/windows-installer.iss @@ -0,0 +1,62 @@ +#ifndef AppVersion + #error AppVersion must be supplied by build-windows.ps1 +#endif +#ifndef BundleDir + #error BundleDir must be supplied by build-windows.ps1 +#endif + +[Setup] +AppId={{76D8E09B-371D-4723-BB67-AC3481EE7712} +AppName=RiftLift +AppVersion={#AppVersion} +AppPublisher=Villagers654 +AppPublisherURL=https://github.com/Villagers654/RiftLift +DefaultDirName={localappdata}\Programs\RiftLift +DefaultGroupName=RiftLift +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.17763 +WizardStyle=modern +SetupIconFile={#BundleDir}\_internal\assets\riftlift.ico +UninstallDisplayIcon={app}\RiftLift.exe +OutputBaseFilename=RiftLift-Setup-{#AppVersion}-x64 +Compression=lzma2 +SolidCompression=yes +CloseApplications=yes +RestartApplications=no + +[Tasks] +Name: desktopicon; Description: "Create a &desktop shortcut"; GroupDescription: "Shortcuts:"; Flags: unchecked + +[Files] +Source: "{#BundleDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{userprograms}\RiftLift"; Filename: "{app}\RiftLift.exe"; WorkingDir: "{app}"; AppUserModelID: "Villagers654.RiftLift" +Name: "{userdesktop}\RiftLift"; Filename: "{app}\RiftLift.exe"; WorkingDir: "{app}"; Tasks: desktopicon; AppUserModelID: "Villagers654.RiftLift" + +[Run] +Filename: "{app}\RiftLift.exe"; Description: "Open RiftLift"; Flags: nowait postinstall skipifsilent + +; User games, settings and sign-in live outside {app} and survive uninstall. + +[Code] +procedure RemoveOwnedProtocol(Scheme: String); +var + Command: String; +begin + if RegQueryStringValue(HKCU, 'Software\Classes\' + Scheme + '\shell\open\command', '', Command) then + if (Pos('"' + Lowercase(ExpandConstant('{app}\RiftLift.exe')) + '"', Lowercase(Command)) = 1) or + (Pos(Lowercase(ExpandConstant('{app}\RiftLift.exe')) + ' ', Lowercase(Command)) = 1) then + RegDeleteKeyIncludingSubkeys(HKCU, 'Software\Classes\' + Scheme); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then begin + RemoveOwnedProtocol('oculus'); + RemoveOwnedProtocol('oculus-client'); + end; +end; diff --git a/scripts/windows.spec b/scripts/windows.spec new file mode 100644 index 0000000..ea721c8 --- /dev/null +++ b/scripts/windows.spec @@ -0,0 +1,25 @@ +# Build with scripts/build-windows.ps1 after building the native x64 runtime. +import os +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, copy_metadata + +root = Path(SPECPATH).parent +native = Path(os.environ["RIFTLIFT_PACKAGE_RUNTIME"]).resolve() +icon = Path(os.environ["RIFTLIFT_PACKAGE_ICON"]).resolve() +a = Analysis( + [str(root / "scripts/windows-entry.py")], + pathex=[str(root / "src")], + binaries=[], + datas=[(str(native), "native"), (str(icon), "assets")] + + collect_data_files("meta_pcvr_downloader") + + copy_metadata("meta-pcvr-downloader"), + hiddenimports=["PySide6.QtSvg", "PySide6.QtNetwork"], + excludes=["tkinter", "pytest"], +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, a.scripts, [], exclude_binaries=True, name="RiftLift", + console=False, icon=str(icon), upx=False, +) +coll = COLLECT(exe, a.binaries, a.datas, name="RiftLift", upx=False) diff --git a/src/riftlift/auth.py b/src/riftlift/auth.py index 7620a84..5afbf3f 100644 --- a/src/riftlift/auth.py +++ b/src/riftlift/auth.py @@ -43,7 +43,7 @@ def login(paths: Paths) -> int: complete_browser_login(paths, session) print("RiftLift is signed in to Meta.") return 0 - if process.poll() is not None: + if process is not None and process.poll() is not None: raise RiftLiftError("the browser closed before Meta sign-in finished") time.sleep(1) finally: diff --git a/src/riftlift/auth_browser.py b/src/riftlift/auth_browser.py index 47b3865..4a8fff9 100644 --- a/src/riftlift/auth_browser.py +++ b/src/riftlift/auth_browser.py @@ -10,6 +10,7 @@ import shutil import signal import subprocess +import webbrowser from dataclasses import dataclass from pathlib import Path @@ -183,6 +184,8 @@ def _desktop_browser(desktop_id: str) -> Browser: def default_browser() -> Browser: + if os.name == "nt": + return Browser("windows", "your default browser", "native", ()) override = os.environ.get("RIFTLIFT_AUTH_BROWSER", "").strip().lower() if override: if override.endswith(".desktop"): @@ -265,8 +268,12 @@ def _prepare_chromium_profile(profile: Path) -> None: def launch_browser_login( paths: Paths, browser: Browser, url: str = META_LOGIN_URL -) -> subprocess.Popen[bytes]: +) -> subprocess.Popen[bytes] | None: """Open Meta's hosted login in a RiftLift-owned, isolated browser profile.""" + if browser.family == "native": + if not webbrowser.open(url): + raise RiftLiftError("Could not open the Windows default browser") + return None home = browser_home(paths, browser) home.mkdir(parents=True, exist_ok=True, mode=0o700) home.chmod(0o700) diff --git a/src/riftlift/auth_ui.py b/src/riftlift/auth_ui.py index adae6de..b30cd1f 100644 --- a/src/riftlift/auth_ui.py +++ b/src/riftlift/auth_ui.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from concurrent.futures import Future, ThreadPoolExecutor from PySide6 import QtCore, QtWidgets @@ -39,7 +40,10 @@ def __init__(self, paths: Paths, parent=None): title.setObjectName("game") layout.addWidget(title) explanation = QtWidgets.QLabel( - "RiftLift opens your default browser in a dedicated sign-in window " + "RiftLift opens your default browser and returns here automatically " + "when Meta finishes. Your password and security codes go only to Meta." + if os.name == "nt" + else "RiftLift opens your default browser in a dedicated sign-in window " "and returns here automatically when Meta finishes. Your password " "and security codes go only to Meta." ) diff --git a/src/riftlift/cli.py b/src/riftlift/cli.py index 7ef1233..0128899 100644 --- a/src/riftlift/cli.py +++ b/src/riftlift/cli.py @@ -1,27 +1,33 @@ from __future__ import annotations import argparse +import os import sys -from meta_pcvr_downloader.api import MetaApiError -from meta_pcvr_downloader.auth import AuthenticationError -from meta_pcvr_downloader.download import DownloadError - -from . import __version__ -from .auth import complete_login, login -from .config import Game, Paths, games -from .doctor import doctor -from .launch import launch -from .library import add, add_local -from .metadata import populate_game_metadata -from .playtime import playtime, playtime_label -from .runtime import setup -from .steam import sync_with_restart -from .steam_oculus import steam_oculus_game, steam_oculus_games -from .util import RiftLiftError +if os.name != "nt": + from meta_pcvr_downloader.api import MetaApiError + from meta_pcvr_downloader.auth import AuthenticationError + from meta_pcvr_downloader.download import DownloadError + + from . import __version__ + from .auth import complete_login, login + from .config import Game, Paths, games + from .doctor import doctor + from .launch import launch + from .library import add, add_local + from .metadata import populate_game_metadata + from .playtime import playtime, playtime_label + from .runtime import setup + from .steam import sync_with_restart + from .steam_oculus import steam_oculus_game, steam_oculus_games + from .util import RiftLiftError def parser() -> argparse.ArgumentParser: + if os.name == "nt": + from .windows import parser as windows_parser + + return windows_parser() root = argparse.ArgumentParser( prog="riftlift", description="Run owned Meta Rift games on Linux OpenXR/Monado.", @@ -245,6 +251,10 @@ def run(arguments: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: + if os.name == "nt": + from .windows import main as windows_main + + return windows_main(argv) values = list(sys.argv[1:] if argv is None else argv) try: return run(parser().parse_args(values)) diff --git a/src/riftlift/config.py b/src/riftlift/config.py index 2eaae17..ab610b5 100644 --- a/src/riftlift/config.py +++ b/src/riftlift/config.py @@ -62,6 +62,20 @@ class Paths: @classmethod def defaults(cls) -> Paths: + if os.name == "nt": + root = Path( + os.environ.get("RIFTLIFT_HOME") + or str(Path(os.environ["LOCALAPPDATA"]) / "RiftLift") + ) + root = root.expanduser().resolve() + return cls( + root / "data", + root / "cache", + root / "config", + root / "games", + root / "compatdata", + root / "tools", + ) home = Path.home() data = xdg_data_home() / "riftlift" cache = xdg_cache_home() / "riftlift" diff --git a/src/riftlift/library.py b/src/riftlift/library.py index 0e18998..85a985b 100644 --- a/src/riftlift/library.py +++ b/src/riftlift/library.py @@ -109,6 +109,7 @@ def add( arguments=launch_arguments, version=build.version, platform_offline=True, + platform_shim=True, source="meta", ) game.save(paths) diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index f0efb5e..ae24cbc 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -4,6 +4,7 @@ import contextlib import io +import os import threading from collections.abc import Callable @@ -18,10 +19,8 @@ games, set_debug_logging, ) -from .doctor import doctor from .game_ui import LocalGameDialog, StoreGameDialog -from .launch import launch -from .library import add, add_local +from .library import add from .metadata import populate_game_metadata from .playtime import playtime, playtime_label from .steam import sync_with_restart @@ -30,6 +29,14 @@ from .theme import STYLE from .util import RiftLiftError +if os.name == "nt": + from .windows import add_local + from .windows_ui_backend import doctor, launch +else: + from .doctor import doctor + from .launch import launch + from .library import add_local + class Events(QtCore.QObject): output = QtCore.Signal(str) @@ -118,6 +125,15 @@ def __init__(self, paths: Paths | None = None): self.setMinimumSize(1024, 637) self.setStyleSheet(STYLE) self._build() + if os.name == "nt": + for control in (self.steam_games,): + control.setEnabled(False) + control.setToolTip( + "This integration is pending native Windows support." + ) + self.debug_logging.setToolTip( + "Include native launcher diagnostics in View Activity." + ) self.refresh() def label(self, text="", name=""): @@ -146,7 +162,7 @@ def _build_header(self, outer: QtWidgets.QVBoxLayout) -> None: header.addWidget(self.debug_logging) self.check = self.button( "System", - lambda: self.run_task("Checking your system", lambda: doctor(self.paths)), + self.show_system, ) self.check.setObjectName("nav") self.signin = self.button( @@ -172,7 +188,11 @@ def _build_library(self) -> QtWidgets.QWidget: self.count = self.label("", "muted") heading.addWidget(self.count) heading.addStretch() - self.refresh_button = self.button("⟳", self.refresh_library) + self.refresh_button = self.button("", self.refresh_library) + self.refresh_button.setIcon( + self.style().standardIcon(QtWidgets.QStyle.SP_BrowserReload) + ) + self.refresh_button.setAccessibleName("Refresh library") self.refresh_button.setObjectName("refresh") self.refresh_button.setToolTip("Refresh library and game info") self.refresh_button.setFixedSize(34, 34) @@ -193,7 +213,7 @@ def _build_empty_state(self) -> QtWidgets.QWidget: title.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(title) hint = self.label( - "Add an owned Meta Rift title to download it and make it ready for OpenXR.", + "Add an owned Meta Rift title to download it and play with your headset.", "muted", ) hint.setAlignment(QtCore.Qt.AlignCenter) @@ -220,6 +240,7 @@ def _build_game_detail(self) -> HeroPanel: info.addSpacing(8) self.meta = self.label("", "muted") self.meta.setWordWrap(True) + self.meta.setMaximumWidth(340) info.addWidget(self.meta) info.addSpacing(14) actions = QtWidgets.QHBoxLayout() @@ -247,6 +268,7 @@ def _build_status_bar(self, outer: QtWidgets.QVBoxLayout) -> None: layout = QtWidgets.QHBoxLayout(bar) layout.setContentsMargins(0, 0, 0, 0) self.status = self.label("Ready", "muted") + self.status.setWordWrap(True) layout.addWidget(self.status, 1) activity = self.button("View Activity", self.show_activity) activity.setObjectName("nav") @@ -377,6 +399,50 @@ def show_game(self, game): def game(self): return next((g for g in self.installed if g.slug == self.slug), None) + def show_system(self): + if os.name != "nt": + self.run_task("Checking your system", lambda: doctor(self.paths)) + return + from . import windows + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("RiftLift system") + dialog.resize(820, 530) + dialog.setStyleSheet(STYLE) + layout = QtWidgets.QVBoxLayout(dialog) + layout.addWidget(self.label("Windows VR setup", "game")) + report, status = windows.doctor(self.paths) + heading = self.label( + "Setup needs attention" + if status + else "Runtime files are ready; launch a game to test VR output.", + "muted", + ) + heading.setWordWrap(True) + layout.addWidget(heading) + view = QtWidgets.QTextEdit(readOnly=True) + view.setPlainText(report) + layout.addWidget(view) + buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close) + buttons.rejected.connect(dialog.reject) + repair = buttons.addButton( + "Install / repair runtime", QtWidgets.QDialogButtonBox.ActionRole + ) + repair.setEnabled(not self.busy) + + def install(): + dialog.accept() + self.run_task( + "Installing native runtime", + lambda: windows.install_payload(self.paths), + "Native runtime installed", + ) + + repair.clicked.connect(install) + layout.addWidget(buttons) + self._append_log(report + "\n") + dialog.exec() + def launch_game(self): if g := self.game(): self.run_task( @@ -420,6 +486,9 @@ def open_store(self): def add_dialog(self): dialog = StoreGameDialog(self.local_dialog, self) + if os.name == "nt": + dialog.steam.setChecked(False) + dialog.steam.setEnabled(False) if dialog.exec() != QtWidgets.QDialog.Accepted: return @@ -433,6 +502,9 @@ def operation(): def local_dialog(self): dialog = LocalGameDialog(self) + if os.name == "nt": + dialog.steam.setChecked(False) + dialog.steam.setEnabled(False) if dialog.exec() != QtWidgets.QDialog.Accepted: return @@ -532,6 +604,11 @@ def main() -> int: app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) app.setApplicationName("RiftLift") app.setStyle("Fusion") + if os.name == "nt": + from .windows_desktop import activate_existing + + if activate_existing(app): + return 0 window = Window() app._riftlift_window = window window.show() diff --git a/src/riftlift/meta_auth.py b/src/riftlift/meta_auth.py index a41c2ef..4da9b94 100644 --- a/src/riftlift/meta_auth.py +++ b/src/riftlift/meta_auth.py @@ -4,9 +4,11 @@ import hashlib import json +import os import secrets import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path from urllib.error import HTTPError, URLError @@ -99,8 +101,44 @@ def record_callback(paths: Paths, callback_url: str) -> int: return 0 +def windows_callback_command(paths: Paths) -> tuple[Path, str]: + """Quote the callback for either a bundled app or a source installation.""" + if getattr(sys, "frozen", False): + python = Path(sys.executable) + entry = [str(python)] + else: + python = Path(sys.executable).with_name("pythonw.exe") + if not python.is_file(): + python = Path(sys.executable) + entry = [str(python), "-m", "riftlift.windows"] + command = ( + subprocess.list2cmdline( + [*entry, "--home", str(paths.config.parent), "callback"] + ) + + ' "%1"' + ) + return python, command + + def install_protocol_handler() -> Path: """Register RiftLift as the host handler for Meta's browser callback.""" + if os.name == "nt": + import winreg + + paths = Paths.defaults() + python, command = windows_callback_command(paths) + for scheme in ("oculus", "oculus-client"): + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, rf"Software\Classes\{scheme}" + ) as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, "URL:RiftLift Meta Login") + winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "") + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, + rf"Software\Classes\{scheme}\shell\open\command", + ) as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, command) + return python applications = xdg_data_home() / "applications" applications.mkdir(parents=True, exist_ok=True) desktop = applications / "riftlift-meta-login.desktop" diff --git a/src/riftlift/playtime.py b/src/riftlift/playtime.py index 955473f..c7077c1 100644 --- a/src/riftlift/playtime.py +++ b/src/riftlift/playtime.py @@ -1,6 +1,5 @@ from __future__ import annotations -import fcntl import json import os import threading @@ -14,6 +13,11 @@ from .config import Paths from .util import atomic_write_text +if os.name == "nt": + import msvcrt +else: + import fcntl + _VERSION = 1 _CHECKPOINT_SECONDS = 30.0 @@ -66,9 +70,16 @@ def _update( paths: Paths, slug: str, operation: Callable[[dict[str, Any]], None] ) -> None: paths.data.mkdir(parents=True, exist_ok=True) - with _lock_target(paths).open("a+", encoding="utf-8") as lock: - os.fchmod(lock.fileno(), 0o600) - fcntl.flock(lock, fcntl.LOCK_EX) + with _lock_target(paths).open("a+b") as lock: + if os.name == "nt": + if lock.tell() == 0: + lock.write(b"\0") + lock.flush() + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) + else: + os.fchmod(lock.fileno(), 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) value = _read(paths) games = value.setdefault("games", {}) record = games.setdefault(slug, {}) @@ -78,7 +89,7 @@ def _update( operation(record) value["version"] = _VERSION _write(paths, value) - fcntl.flock(lock, fcntl.LOCK_UN) + # Closing the descriptor releases either OS's lock, including on error. def playtime(paths: Paths, slug: str) -> Playtime: diff --git a/src/riftlift/util.py b/src/riftlift/util.py index 90fcb0f..475064a 100644 --- a/src/riftlift/util.py +++ b/src/riftlift/util.py @@ -41,8 +41,9 @@ def atomic_write_bytes(target: Path, payload: bytes, mode: int = 0o600) -> None: descriptor, name = tempfile.mkstemp(prefix=f".{target.name}-", dir=target.parent) temporary = Path(name) try: - os.fchmod(descriptor, mode) with os.fdopen(descriptor, "wb") as stream: + if os.name != "nt": + os.fchmod(stream.fileno(), mode) stream.write(payload) stream.flush() os.fsync(stream.fileno()) @@ -112,6 +113,7 @@ def download(url: str, target: Path, expected_sha256: str = "") -> Path: break except (OSError, urllib.error.URLError, TimeoutError) as error: if attempt == 3: + stream.close() temporary.unlink(missing_ok=True) raise RiftLiftError( f"could not download {target.name} after 4 attempts: {error}" diff --git a/src/riftlift/windows.py b/src/riftlift/windows.py new file mode 100644 index 0000000..d434df3 --- /dev/null +++ b/src/riftlift/windows.py @@ -0,0 +1,473 @@ +"""Native Windows backend for RiftLift's shared CLI and desktop application.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import subprocess +import sys +import urllib.request +import zipfile +from datetime import datetime +from pathlib import Path, PurePosixPath + +from meta_pcvr_downloader.api import MetaApiError +from meta_pcvr_downloader.download import DownloadError + +from . import __version__ +from .config import Game, Paths, debug_logging_enabled, games +from .detection import is_pe64 +from .util import RiftLiftError, atomic_write_bytes, download, sha256 + +RELEASE = "v0.10.2.2" +PAYLOAD_SHA256 = "90f9b1b5b26ba85a25ad2dcb3707b7a17540b0d40d310148dd98fa76c3a619eb" +PAYLOAD_URL = f"https://github.com/Villagers654/RiftLift/releases/download/{RELEASE}/riftlift-compat.zip" +FILES = { + "RiftLiftLauncher.exe", + "RiftLiftOpenXR64.dll", + "RiftLiftOpenVR64.dll", + "openvr_api64.dll", + "LICENSE", + "RIFTLIFT-LICENSE", +} +PLATFORM_FILES = { + "LibOVRPlatform64_1.dll", + "LibOVRPlatformImpl64_1.dll", + "LibOVRPlatformImpl64_1_real.dll", + "LibOVRP2P64_1.dll", + "LibOVRRT64_1.dll", +} +SDK_RUNTIME_SHA256 = "f6941275692026b18666bb856d71fe1b19462017b2b2e556fe8df82461f493f5" + + +def install_sdk_runtime(paths: Paths) -> Path: + """Keep Meta's signed discovery DLL intact for the static Oculus SDK loader.""" + native = runtime_dir(paths) + if ( + (native / "LibOVRRT64_1.dll").is_file() + and sha256(native / "LibOVRRT64_1.dll") == SDK_RUNTIME_SHA256 + ): + return native + directory = paths.tools / "meta-runtime" + target = directory / "LibOVRRT64_1.dll" + if target.is_file() and sha256(target) == SDK_RUNTIME_SHA256: + return directory + package = download( + "https://securecdn-atl3-3.oculus.com/binaries/download/?id=3766757683456363", + paths.cache / "oculus-runtime.zip", + "adbdc5f0285a2ac2ead6fdd34522de98de1bf6782017d9857ea4044b2d2fd009", + ) + with zipfile.ZipFile(package) as bundle: + payload = bundle.read(target.name) + if hashlib.sha256(payload).hexdigest() != SDK_RUNTIME_SHA256: + raise RiftLiftError("Meta SDK runtime checksum mismatch") + atomic_write_bytes(target, payload) + return directory + + +def runtime_dir(paths: Paths) -> Path: + if override := os.environ.get("RIFTLIFT_WINDOWS_RUNTIME"): + return Path(override).expanduser().resolve() + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "native" + return paths.tools / "windows-native" / RELEASE + + +def install_payload(paths: Paths, archive: Path | None = None) -> Path: + if getattr(sys, "frozen", False): + target = runtime_dir(paths) + if not all((target / name).is_file() for name in FILES | PLATFORM_FILES): + raise RiftLiftError( + "RiftLift's installation is incomplete. Please reinstall RiftLift." + ) + return target + if archive: + payload = archive.read_bytes() + else: + with urllib.request.urlopen(PAYLOAD_URL, timeout=60) as response: + payload = response.read(32 * 1024 * 1024 + 1) + if hashlib.sha256(payload).hexdigest() != PAYLOAD_SHA256: + raise RiftLiftError("Native payload SHA256 mismatch") + target = runtime_dir(paths) + # Validate the complete archive before writing. This pinned archive supplies + # the base bridge; the native platform layer is built with runtime/CMakeLists.txt. + with zipfile.ZipFile(io.BytesIO(payload)) as bundle: + names = {i.filename.replace("\\", "/") for i in bundle.infolist()} + if not FILES.issubset(names): + raise RiftLiftError("Native payload is missing required files") + selected = [] + for entry in bundle.infolist(): + name = entry.filename.replace("\\", "/") + relative = PurePosixPath(name) + if relative.is_absolute() or ".." in relative.parts or ":" in name: + raise RiftLiftError("Invalid native payload path") + if name in FILES or (name.startswith("Input/") and name.endswith(".json")): + selected.append((relative, bundle.read(entry))) + for relative, data in selected: + dest = target.joinpath(*relative.parts) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + (target / "local-build.json").unlink(missing_ok=True) + return target + + +def active_openxr() -> Path | None: + if override := os.environ.get("XR_RUNTIME_JSON"): + return Path(override) + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Khronos\OpenXR\1", + 0, + winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as key: + return Path(winreg.QueryValueEx(key, "ActiveRuntime")[0]) + except OSError: + return None + + +def active_openvr() -> Path | None: + config = Path( + os.environ.get("VR_PATHREG_OVERRIDE") + or str(Path(os.environ["LOCALAPPDATA"]) / "openvr/openvrpaths.vrpath") + ) + try: + roots = json.loads(config.read_text(encoding="utf-8"))["runtime"] + for root in roots: + candidate = Path(root) + if any( + (candidate / name).is_file() + for name in ("bin/vrclient_x64.dll", "bin/win64/vrclient_x64.dll") + ): + return candidate + except (OSError, ValueError, KeyError, TypeError): + pass + return None + + +def runtime_ready(backend: str) -> bool: + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Unknown native backend") + target = active_openxr() if backend == "openxr" else active_openvr() + return bool( + target and (target.is_file() if backend == "openxr" else target.is_dir()) + ) + + +def select_backend(game: Game, requested: str = "auto") -> str: + backend = requested + if backend == "auto": + if runtime_ready("openxr"): + xr, vr = active_openxr(), active_openvr() + # SteamVR's OpenXR path rejects Oculus cube and mipmapped textures. + # Use its OpenVR interface when both registrations refer to SteamVR. + if xr and vr and xr.resolve().parent == vr.resolve(): + return "openvr" + return "openxr" + return "openvr" + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Windows VR backend must be auto, openxr, or openvr") + return backend + + +def doctor(paths: Paths) -> tuple[str, int]: + native = runtime_dir(paths) + payload_ok = all((native / name).is_file() for name in FILES) + xr, vr = active_openxr(), active_openvr() + installed = games(paths) + platform_ok = all((native / name).is_file() for name in PLATFORM_FILES) + platform_required = any(game.platform_shim for game in installed) + text = "\n".join( + [ + f"RiftLift {__version__} on Windows", + f"Native payload: {'INSTALLED' if payload_ok else 'MISSING'} ({native})", + f"Build: {'local source build' if (native / 'local-build.json').is_file() else 'runtime payload'}", + f"OpenXR manifest: {xr or 'NOT REGISTERED'}", + f"OpenVR runtime: {vr or 'NOT REGISTERED'}", + f"Registered games: {len(installed)}", + *( + ["SIMULATED HEADSET: desktop rendering only; no physical headset test."] + if os.environ.get("RIFTLIFT_SIMULATOR") + else [] + ), + *[ + f" {game.name}: " + f"{'FOUND' if game.executable_path.is_file() else 'EXECUTABLE MISSING'}" + for game in installed + ], + f"Platform compatibility: {'INSTALLED' if platform_ok else 'MISSING'} (RiftLift offline mode)", + "Headset rendering/input/audio: NOT VERIFIED", + "Diagnostics remain local; no paste is uploaded.", + "", + "Next steps:", + *([] if payload_ok else ["Install or repair the RiftLift native runtime."]), + *( + [] + if runtime_ready("openxr") or runtime_ready("openvr") + else [ + "Connect your headset and configure its Windows OpenXR runtime,", + "or install SteamVR in Steam and complete headset setup.", + ] + ), + *( + [] + if installed + else [ + "Install your owned PC games, then use Add Game to select their .exe files.", + ] + ), + "RiftLift selects the Windows VR runtime automatically.", + f"Game logs: {paths.data / 'logs'}", + ] + ) + return text, 0 if payload_ok and (platform_ok or not platform_required) and ( + runtime_ready("openxr") or runtime_ready("openvr") + ) and all(game.executable_path.is_file() for game in installed) else 2 + + +def add_local( + paths: Paths, + executable: str, + name: str | None = None, + root: str | None = None, + arguments: str | None = None, + artwork: str | None = None, +) -> Game: + from .library import add_local as register + + if not is_pe64(Path(executable)): + raise RiftLiftError("Native Windows launcher requires an x64 PE executable") + game = register( + paths, executable, name=name, root=root, arguments=arguments, artwork=artwork + ) + game.platform_shim = False + game.platform_offline = False + game.save(paths) + return game + + +def launch_command( + paths: Paths, game: Game, backend: str, extra: list[str] | None = None +) -> list[str]: + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Unknown native backend") + native = runtime_dir(paths) + required = ( + ["RiftLiftLauncher.exe", "RiftLiftOpenXR64.dll"] + if backend == "openxr" + else ["RiftLiftLauncher.exe", "RiftLiftOpenVR64.dll", "openvr_api64.dll"] + ) + if not all((native / name).is_file() for name in required): + raise RiftLiftError("Run RiftLift setup to install the native payload") + executable = game.executable_path.resolve() + if not executable.is_relative_to(game.game_dir.resolve()) or not is_pe64( + executable + ): + raise RiftLiftError( + "Game executable must be an existing x64 PE inside its game folder" + ) + return [ + str(native / "RiftLiftLauncher.exe"), + f"/{backend}", + "/wait", + "/app", + game.app_key, + "/cwd", + str(game.game_dir), + str(executable), + *game.arguments, + *(extra or []), + ] + + +def launch( + paths: Paths, + game: Game, + backend: str, + dry_run: bool = False, + extra: list[str] | None = None, +) -> int: + command = launch_command(paths, game, backend, extra) + if dry_run: + print(subprocess.list2cmdline(command)) + return 0 + if not runtime_ready(backend): + raise RiftLiftError( + f"Configure a Windows {backend} runtime and connect the headset first" + ) + environment = os.environ.copy() + # Older Platform SDK loaders concatenate the DLL name directly to this value. + environment["LIBOVR_DLL_DIR"] = str(install_sdk_runtime(paths)) + os.sep + if game.platform_shim: + native = runtime_dir(paths) + if not all((native / name).is_file() for name in PLATFORM_FILES): + raise RiftLiftError("RiftLift native platform compatibility DLL is missing") + environment["PATH"] = str(native) + os.pathsep + environment.get("PATH", "") + environment["RIFTLIFT_PLATFORM_DLL"] = str( + native / "LibOVRPlatformImpl64_1.dll" + ) + environment["LIBOVR_DLL_DIR"] = str(native) + os.sep + if game.platform_offline: + environment["RIFTLIFT_PLATFORM_OFFLINE"] = "1" + if debug_logging_enabled(paths): + environment["RIFTLIFT_RUNTIME_TRACE"] = "1" + log = paths.data / "logs" / f"{game.slug}.log" + log.parent.mkdir(parents=True, exist_ok=True) + from .playtime import PlaytimeSession + from .windows_process import run_game + + print(f"Launching {game.name} through Windows {backend}; log: {log}") + with log.open("w", encoding="utf-8") as stream, PlaytimeSession(paths, game.slug): + stream.write(f"{datetime.now().isoformat()} | {game.name} | {backend}\n") + stream.write(f"Command: {subprocess.list2cmdline(command)}\n") + stream.flush() + returncode = run_game( + command, + cwd=game.game_dir, + stdout=stream, + env=environment, + ) + stream.write( + f"\nExit code: {returncode} (0x{returncode & 0xFFFFFFFF:08X})\n" + ) + launcher_log = ( + Path(os.environ["LOCALAPPDATA"]) / "RiftLift/RiftLiftLauncher.txt" + ) + if launcher_log.is_file(): + evidence = launcher_log.read_text(encoding="utf-8", errors="replace") + stream.write("\nNative launcher:\n" + evidence) + if debug_logging_enabled(paths): + print(evidence) + print(f"Launcher exit: {returncode}; log: {log}") + return returncode + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="riftlift", + description="Run Meta Rift games through native Windows VR runtimes.", + ) + p.add_argument("--home", type=Path, help=argparse.SUPPRESS) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + sub = p.add_subparsers(dest="command", required=True) + sub.add_parser("gui", help="open the Windows library") + simulator = sub.add_parser( + "simulate", help="open RiftLift with SteamVR's simulated headset" + ) + simulator.add_argument("--runtime", type=Path, required=True) + sub.add_parser("login", help="sign in to Meta in your default browser") + callback = sub.add_parser( + "callback", help="receive a Meta browser sign-in callback" + ) + callback.add_argument("url") + download = sub.add_parser("add", help="download an owned Meta Rift PC game") + download.add_argument("app") + download.add_argument("--build") + download.add_argument("--executable") + download.add_argument("--arguments") + download.add_argument("--jobs", type=int) + setup = sub.add_parser("setup", help="install checksum-verified native x64 runtime") + setup.add_argument("--archive", type=Path) + doc = sub.add_parser("doctor", help="local Windows runtime checks") + doc.add_argument("--no-paste", action="store_true") + sub.add_parser("list") + add = sub.add_parser( + "add-local", help="reference an installed game without changing its files" + ) + add.add_argument("executable") + add.add_argument("--name") + add.add_argument("--root") + add.add_argument("--arguments") + launch_parser = sub.add_parser("launch") + launch_parser.add_argument("slug") + launch_parser.add_argument( + "--backend", choices=["auto", "openxr", "openvr"], default="auto" + ) + launch_parser.add_argument("--dry-run", action="store_true") + return p + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + if args.home: + os.environ["RIFTLIFT_HOME"] = str(args.home) + paths = Paths.defaults() + try: + return _run_command(paths, args) + except ( + OSError, + ValueError, + RiftLiftError, + MetaApiError, + DownloadError, + zipfile.BadZipFile, + ) as error: + print(f"RiftLift: {error}", file=sys.stderr) + return 1 + + +def _run_command(paths: Paths, args: argparse.Namespace) -> int: + if args.command in {"gui", "simulate"}: + return _open_gui(paths, args) + if args.command == "login": + from .auth import login + + return login(paths) + if args.command == "callback": + from .auth import complete_login + + return complete_login(paths, args.url) + if args.command == "add": + from .library import add + + game = add( + paths, + args.app, + build_selector=args.build, + executable=args.executable, + arguments=args.arguments, + jobs=args.jobs, + ) + print(f"Installed: {game.name} ({game.slug})") + if args.command == "setup": + print(f"Native payload installed: {install_payload(paths, args.archive)}") + elif args.command == "doctor": + report, status = doctor(paths) + print(report) + return status + elif args.command == "list": + installed = games(paths) + print( + "\n".join(f"{g.slug}: {g.name}" for g in installed) + or "No games registered." + ) + elif args.command == "add-local": + game = add_local(paths, args.executable, args.name, args.root, args.arguments) + print(f"Registered: {game.slug}") + elif args.command == "launch": + game = Game.load(paths, args.slug) + return launch(paths, game, select_backend(game, args.backend), args.dry_run) + return 0 + + +def _open_gui(paths: Paths, args: argparse.Namespace) -> int: + if args.command == "simulate": + from .windows_simulator import start + + start(paths, args.runtime) + from .gui import main as gui + + return gui() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/windows_app.py b/src/riftlift/windows_app.py new file mode 100644 index 0000000..6de1326 --- /dev/null +++ b/src/riftlift/windows_app.py @@ -0,0 +1,49 @@ +"""Console-free Windows application entry point, also used by the installer.""" + +from __future__ import annotations + +import ctypes +import os +import sys +import traceback +from pathlib import Path + +from .config import Paths + + +def main() -> int: + # pythonw and PyInstaller's windowed bootloader have no standard streams. + # Keep startup failures accessible even before the Activity widget exists. + paths = Paths.defaults() + log = paths.data / "logs" / "riftlift.log" + log.parent.mkdir(parents=True, exist_ok=True) + if not sys.stdout or not sys.stderr: + if log.exists() and log.stat().st_size > 2 * 1024 * 1024: + log.replace(log.with_suffix(".previous.log")) + stream = log.open("a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = stream + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + "Villagers654.RiftLift" + ) + if len(sys.argv) == 3 and sys.argv[1] == "--package-check": + from .windows_package_check import run + + return run(Path(sys.argv[2])) + from .windows import main as command + + return command(sys.argv[1:] or ["gui"]) + except Exception: + traceback.print_exc() + if os.environ.get("QT_QPA_PLATFORM") != "offscreen": + ctypes.windll.user32.MessageBoxW( + None, + f"RiftLift could not start. Please reinstall RiftLift.\n\nDetails: {log}", + "RiftLift", + 0x10, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/windows_desktop.py b/src/riftlift/windows_desktop.py new file mode 100644 index 0000000..cc391d1 --- /dev/null +++ b/src/riftlift/windows_desktop.py @@ -0,0 +1,51 @@ +"""Windows taskbar identity and one library window per data directory.""" + +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +from PySide6 import QtGui, QtNetwork + +from .config import Paths + + +def activate_existing(app) -> bool: + name = ( + "riftlift-" + + hashlib.sha256(str(Paths.defaults().data).casefold().encode()).hexdigest()[ + :24 + ] + ) + socket = QtNetwork.QLocalSocket() + socket.connectToServer(name) + if socket.waitForConnected(300): + socket.write(b"activate") + socket.waitForBytesWritten(1000) + socket.disconnectFromServer() + return True + server = QtNetwork.QLocalServer(app) + server.setSocketOptions(QtNetwork.QLocalServer.UserAccessOption) + QtNetwork.QLocalServer.removeServer(name) + if not server.listen(name): + raise OSError("Could not open RiftLift's local application connection") + + def activate(): + while server.hasPendingConnections(): + client = server.nextPendingConnection() + client.close() + client.deleteLater() + window = getattr(app, "_riftlift_window", None) + if window: + window.showNormal() + window.raise_() + window.activateWindow() + app._riftlift_activations = getattr(app, "_riftlift_activations", 0) + 1 + + server.newConnection.connect(activate) + app._riftlift_server = server + if getattr(sys, "frozen", False): + icon = Path(sys._MEIPASS) / "assets/riftlift.ico" + app.setWindowIcon(QtGui.QIcon(str(icon))) + return False diff --git a/src/riftlift/windows_package_check.py b/src/riftlift/windows_package_check.py new file mode 100644 index 0000000..eeb842f --- /dev/null +++ b/src/riftlift/windows_package_check.py @@ -0,0 +1,54 @@ +"""Bounded, offscreen verification of the built Windows application.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + + +def run(output: Path) -> int: + # Never opens or drives the user's desktop. Uses the shipped Qt widgets. + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6 import QtCore, QtGui, QtWidgets + + from .config import Paths, games + from .gui import main + from .windows import FILES, PLATFORM_FILES, runtime_dir + + output.mkdir(parents=True, exist_ok=True) + app = QtWidgets.QApplication([]) + # Offscreen Qt on Windows does not automatically discover system fonts. + fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" + for name in ("segoeui.ttf", "segoeuib.ttf"): + QtGui.QFontDatabase.addApplicationFont(str(fonts / name)) + result = {} + + def finish(): + try: + window = app._riftlift_window + if not window.grab().save(str(output / "library.png")): + raise OSError("Could not save the library render") + paths = Paths.defaults() + native = runtime_dir(paths) + missing = sorted( + name for name in FILES | PLATFORM_FILES if not (native / name).is_file() + ) + result.update( + success=not missing, + native=str(native), + missing=missing, + games=len(games(paths)), + activations=getattr(app, "_riftlift_activations", 0), + width=window.width(), + height=window.height(), + icon_loaded=not app.windowIcon().isNull(), + ) + except Exception as error: + result.update(success=False, error=str(error)) + (output / "result.json").write_text(json.dumps(result, indent=2)) + app.quit() + + QtCore.QTimer.singleShot(5000, finish) + main() + return 0 if result.get("success") else 1 diff --git a/src/riftlift/windows_process.py b/src/riftlift/windows_process.py new file mode 100644 index 0000000..f59b1a0 --- /dev/null +++ b/src/riftlift/windows_process.py @@ -0,0 +1,58 @@ +"""Keep the packaged app's DLL search paths out of launched games.""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +from pathlib import Path + + +def run_game(command, *, cwd, stdout, env) -> int: + frozen = getattr(sys, "frozen", False) + if not frozen: + return subprocess.run( + command, + cwd=cwd, + stdout=stdout, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW, + env=env, + check=False, + ).returncode + + bundle = Path(sys._MEIPASS).resolve() + # Preserve RiftLift's explicit native runtime directory in PATH, while + # removing the Qt/Python directories added by PyInstaller's runtime hooks. + native = bundle / "native" + clean_env = env.copy() + clean_env["PATH"] = os.pathsep.join( + part + for part in env.get("PATH", "").split(os.pathsep) + if part + and ( + not Path(part).resolve().is_relative_to(bundle) + or Path(part).resolve() == native + ) + ) + for name in ("QT_PLUGIN_PATH", "QT_QPA_PLATFORM_PLUGIN_PATH"): + if name in clean_env and Path(clean_env[name]).resolve().is_relative_to(bundle): + del clean_env[name] + kernel = ctypes.windll.kernel32 + original = ctypes.create_unicode_buffer(32768) + length = kernel.GetDllDirectoryW(len(original), original) + if not kernel.SetDllDirectoryW(None): + raise ctypes.WinError() + try: + process = subprocess.Popen( + command, + cwd=cwd, + stdout=stdout, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW, + env=clean_env, + ) + finally: + kernel.SetDllDirectoryW(original.value if length else None) + return process.wait() diff --git a/src/riftlift/windows_simulator.py b/src/riftlift/windows_simulator.py new file mode 100644 index 0000000..a59ec32 --- /dev/null +++ b/src/riftlift/windows_simulator.py @@ -0,0 +1,94 @@ +"""Opt-in SteamVR null headset for desktop rendering tests on Windows.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +from .config import Paths +from .util import RiftLiftError, atomic_write_text + + +def configure(paths: Paths, runtime: Path) -> dict[str, str]: + runtime = runtime.expanduser().resolve() + required = ( + "bin/win64/vrstartup.exe", + "bin/vrclient_x64.dll", + "drivers/null/bin/win64/driver_null.dll", + "steamxr_win64.json", + ) + if not all((runtime / name).is_file() for name in required): + raise RiftLiftError( + "Choose a complete Windows SteamVR installation with its null driver" + ) + root = paths.data / "simulator" + config, logs = root / "config", root / "logs" + config.mkdir(parents=True, exist_ok=True) + logs.mkdir(parents=True, exist_ok=True) + registry = root / "openvrpaths.vrpath" + atomic_write_text( + registry, + json.dumps( + { + "jsonid": "vrpathreg", + "version": 1, + "runtime": [str(runtime)], + "config": [str(config)], + "log": [str(logs)], + "external_drivers": [], + }, + indent=2, + ), + ) + settings = config / "steamvr.vrsettings" + if not settings.exists(): + atomic_write_text( + settings, + json.dumps( + { + "steamvr": { + "forcedDriver": "null", + "activateMultipleDrivers": True, + "enableHomeApp": False, + "showMirrorView": True, + "renderTargetMultiplier": 1.0, + }, + "driver_null": { + "enable": True, + "serialNumber": "RiftLift Simulated HMD", + "modelNumber": "RiftLift desktop test headset", + "windowX": 100, + "windowY": 100, + "windowWidth": 1280, + "windowHeight": 720, + "renderWidth": 1024, + "renderHeight": 1024, + "displayFrequency": 90.0, + }, + "power": { + "pauseCompositorOnStandby": False, + "turnOffScreensTimeout": 86400.0, + }, + }, + indent=2, + ), + ) + return { + "VR_PATHREG_OVERRIDE": str(registry), + "XR_RUNTIME_JSON": str(runtime / "steamxr_win64.json"), + "RIFTLIFT_SIMULATOR": "1", + "RIFTLIFT_SIMULATED_CONTROLLERS": "1", + } + + +def start(paths: Paths, runtime: Path) -> None: + os.environ.update(configure(paths, runtime)) + subprocess.Popen( + [str(runtime.resolve() / "bin/win64/vrstartup.exe")], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + print( + "SteamVR simulated headset and static hand poses started. Physical tracking/controllers/audio are not verified." + ) diff --git a/src/riftlift/windows_ui_backend.py b/src/riftlift/windows_ui_backend.py new file mode 100644 index 0000000..540ec37 --- /dev/null +++ b/src/riftlift/windows_ui_backend.py @@ -0,0 +1,27 @@ +"""Adapt the native host to the existing main_window service signatures. + +No widgets, styles, or replacement windows live here. +""" + +from . import windows +from .config import Game, Paths +from .util import RiftLiftError + + +def doctor(paths: Paths) -> int: + report, status = windows.doctor(paths) + print(report) + if status: + raise RiftLiftError("Windows VR setup needs attention; see View Activity.") + return status + + +def launch(paths: Paths, game: Game, arguments: list[str]) -> int: + backend = windows.select_backend(game) + result = windows.launch(paths, game, backend, extra=arguments) + if result: + raise RiftLiftError( + f"Native game launch exited with code {result} " + f"(0x{result & 0xffffffff:08X}). Log: {paths.data / 'logs' / (game.slug + '.log')}" + ) + return result diff --git a/tests/test_gui.py b/tests/test_gui.py index 670ef21..7d575c8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -77,9 +77,10 @@ def test_gui_exposes_only_the_primary_library_actions(tmp_path: Path) -> None: "Sign In", "Steam Games", "Add Game", - "⟳", "View Activity", } <= buttons + assert window.refresh_button.accessibleName() == "Refresh library" + assert not window.refresh_button.icon().isNull() assert "Refresh Info" not in buttons assert "Store" not in buttons assert "Open in Rift Store ↗" in buttons @@ -235,13 +236,13 @@ def test_store_action_matches_the_selected_game_source(tmp_path: Path) -> None: paths.create() app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) window = Window(paths) - rift = Game("rift", "Rift Game", "123", "rift.game", "/tmp", "game.exe", []) + rift = Game("rift", "Rift Game", "123", "rift.game", str(tmp_path), "game.exe", []) steam = Game( "steam", "Steam Game", "456", "steam.app.456", - "/tmp", + str(tmp_path), "game.exe", [], store_url="https://store.steampowered.com/app/456/", @@ -258,7 +259,7 @@ def test_store_action_matches_the_selected_game_source(tmp_path: Path) -> None: "Local Game", "", "local.local-game", - "/tmp", + str(tmp_path), "game.exe", [], source="local", @@ -284,7 +285,7 @@ def test_selected_game_shows_local_playtime(tmp_path: Path) -> None: add_playtime(paths, "echo", 7380) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) window = Window(paths) - game = Game("echo", "Echo", "", "local.echo", "/tmp", "echo.exe", []) + game = Game("echo", "Echo", "", "local.echo", str(tmp_path), "echo.exe", []) window.show_game(game) diff --git a/tests/test_platform_exports.py b/tests/test_platform_exports.py new file mode 100644 index 0000000..c741aa1 --- /dev/null +++ b/tests/test_platform_exports.py @@ -0,0 +1,35 @@ +"""Native loader regressions: public SDK utility names have no underscore.""" + +import runpy +from pathlib import Path + +import pytest + +generate = runpy.run_path( + str(Path(__file__).parents[1] / "scripts/platform-exports.py") +)["generate_forwarders"] + + +def test_native_forwarders_include_utility_exports_and_preserve_overrides(): + symbols = """ + 1 0 00001000 ovrID_FromString + 2 1 00002000 ovrKeyValuePair_makeString + 3 2 00003000 ovr_PlatformInitializeWindows + 4 3 00004000 ovr_User_GetID +""" + output = generate( + symbols, + "__declspec(dllexport) int __cdecl ovr_PlatformInitializeWindows(const char* id)", + ) + assert "ovrID_FromString=LibOVRPlatformImpl64_1_real.ovrID_FromString" in output + assert ( + "ovrKeyValuePair_makeString=LibOVRPlatformImpl64_1_real.ovrKeyValuePair_makeString" + in output + ) + assert "ovr_User_GetID=LibOVRPlatformImpl64_1_real.ovr_User_GetID" in output + assert "ovr_PlatformInitializeWindows" not in output + + +def test_native_forwarder_generation_rejects_an_empty_export_table(): + with pytest.raises(ValueError, match="No platform exports"): + generate("invalid dumpbin output", "") diff --git a/tests/test_playtime.py b/tests/test_playtime.py index 89dbd27..500850c 100644 --- a/tests/test_playtime.py +++ b/tests/test_playtime.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from riftlift.config import Paths @@ -34,7 +35,8 @@ def test_playtime_accumulates_across_launches(tmp_path: Path) -> None: assert tracked.seconds == 3665.5 assert tracked.launches == 2 assert tracked.last_played_at == "2026-08-11T13:00:00+00:00" - assert (paths.data / "playtime.json").stat().st_mode & 0o777 == 0o600 + if os.name != "nt": + assert (paths.data / "playtime.json").stat().st_mode & 0o777 == 0o600 def test_session_records_exact_final_interval(tmp_path: Path) -> None: diff --git a/tests/test_windows_native.py b/tests/test_windows_native.py new file mode 100644 index 0000000..2a44539 --- /dev/null +++ b/tests/test_windows_native.py @@ -0,0 +1,305 @@ +import sys +from pathlib import Path + +import pytest + +from riftlift import windows +from riftlift.config import Game, Paths +from riftlift.util import RiftLiftError, atomic_write_text + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="Native Windows host tests" +) + + +@pytest.fixture +def paths(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path)) + return Paths.defaults() + + +def test_windows_paths_are_portable(paths, tmp_path): + assert paths.data == tmp_path / "data" + assert paths.config == tmp_path / "config" + + +def test_atomic_write_windows(tmp_path): + target = tmp_path / "atomic.txt" + atomic_write_text(target, "first") + atomic_write_text(target, "second") + assert target.read_text() == "second" + assert list(tmp_path.iterdir()) == [target] + + +def test_cli_help_has_native_backend(capsys): + from riftlift.cli import main + + with pytest.raises(SystemExit) as result: + main(["--help"]) + assert result.value.code == 0 + assert "native Windows" in capsys.readouterr().out + + +def test_windows_uses_the_shared_product_version(capsys): + from riftlift import __version__ + from riftlift.cli import main + + with pytest.raises(SystemExit) as result: + main(["--version"]) + assert result.value.code == 0 + assert capsys.readouterr().out == f"riftlift {__version__}\n" + + +def test_windows_metadata_is_part_of_the_main_package(): + root = Path(__file__).resolve().parents[1] + metadata = (root / "pyproject.toml").read_text() + assert '"Operating System :: Microsoft :: Windows"' in metadata + assert not (root / "WINDOWS.md").exists() + + +def test_add_local_preserves_game_binary(paths): + executable = Path(sys.executable) + before = executable.read_bytes() + game = windows.add_local(paths, str(executable), "Smoke probe") + assert Game.load(paths, game.slug).executable_path == executable + assert not game.platform_shim and not game.platform_offline + assert executable.read_bytes() == before + + +def test_add_local_rejects_non_pe(paths, tmp_path): + fake = tmp_path / "fake.exe" + fake.write_text("not a binary") + with pytest.raises(RiftLiftError, match="x64 PE"): + windows.add_local(paths, str(fake)) + + +def test_payload_checksum_failure_writes_nothing(paths, tmp_path): + fake = tmp_path / "fake.zip" + fake.write_bytes(b"bad") + with pytest.raises(RiftLiftError, match="SHA256"): + windows.install_payload(paths, fake) + assert not windows.runtime_dir(paths).exists() + + +def test_native_launch_builds_argv_without_shell_or_wine(paths): + game = windows.add_local(paths, sys.executable, "Probe", arguments='"two words"') + runtime = windows.runtime_dir(paths) + runtime.mkdir(parents=True) + for name in windows.FILES: + (runtime / name).touch() + argv = windows.launch_command(paths, game, "openxr", ["tail"]) + assert argv[1:6] == ["/openxr", "/wait", "/app", game.app_key, "/cwd"] + assert argv[-2:] == ["two words", "tail"] + assert "wine" not in argv and "proton" not in argv + assert "LibOVRPlatformImpl64_1.dll" not in " ".join(argv) + + +def test_missing_runtime_stops_before_launch(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + runtime = windows.runtime_dir(paths) + runtime.mkdir(parents=True) + for name in windows.FILES: + (runtime / name).touch() + monkeypatch.setattr(windows, "runtime_ready", lambda backend: False) + monkeypatch.setattr( + windows.subprocess, "run", lambda *a, **k: pytest.fail("started process") + ) + with pytest.raises(RiftLiftError, match="connect the headset"): + windows.launch(paths, game, "openxr") + + +def test_doctor_missing_runtime_is_not_success(paths, monkeypatch): + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + report, code = windows.doctor(paths) + assert code == 2 + assert "NOT REGISTERED" in report + assert "NOT VERIFIED" in report + + +def test_doctor_requires_platform_dependencies_for_downloaded_games(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + game.platform_shim = True + game.save(paths) + native = windows.runtime_dir(paths) + native.mkdir(parents=True) + for name in windows.FILES: + (native / name).touch() + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + report, status = windows.doctor(paths) + assert status == 2 + assert "Platform compatibility: MISSING" in report + + +def test_gui_constructs_without_linux_imports(paths, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from riftlift.main_window import Window + + app = QApplication.instance() or QApplication([]) + window = Window() + assert window.windowTitle() == "RiftLift" + assert Window.__module__ == "riftlift.main_window" + assert window.signin.isEnabled() + assert window.debug_logging.isEnabled() + assert not window.steam_games.isEnabled() + assert window.library.count() == 0 + window.close() + app.processEvents() + + +def test_shared_ui_backend_reports_failures(paths, monkeypatch): + from riftlift import windows_ui_backend + + monkeypatch.setattr(windows, "doctor", lambda p: ("Missing runtime", 2)) + with pytest.raises(RiftLiftError, match="View Activity"): + windows_ui_backend.doctor(paths) + game = windows.add_local(paths, sys.executable, "Probe") + monkeypatch.setattr(windows, "runtime_ready", lambda b: True) + monkeypatch.setattr(windows, "launch", lambda *a, **k: 7) + with pytest.raises(RiftLiftError, match="code 7"): + windows_ui_backend.launch(paths, game, []) + + +def test_windows_playtime_locks_across_processes(paths): + import subprocess + + from riftlift.playtime import playtime + + script = ( + "from riftlift.config import Paths; from riftlift.playtime import mark_launch; " + "[mark_launch(Paths.defaults(), 'probe') for _ in range(5)]" + ) + children = [subprocess.Popen([sys.executable, "-c", script]) for _ in range(3)] + assert all(child.wait(timeout=30) == 0 for child in children) + assert playtime(paths, "probe").launches == 15 + + +def test_native_runtime_selection_is_automatic(paths, monkeypatch): + monkeypatch.setenv("RIFTLIFT_WINDOWS_BACKEND", "openvr") + game = windows.add_local(paths, sys.executable, "Probe") + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + monkeypatch.setattr(windows, "runtime_ready", lambda backend: backend == "openvr") + assert windows.select_backend(game) == "openvr" + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + assert windows.select_backend(game) == "openxr" + assert windows.select_backend(game, "openvr") == "openvr" + with pytest.raises(RiftLiftError, match="backend"): + windows.select_backend(game, "invalid") + + +def test_automatic_steamvr_uses_its_openvr_interface(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + steamvr = paths.tools / "SteamVR" + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + monkeypatch.setattr( + windows, "active_openxr", lambda: steamvr / "steamxr_win64.json" + ) + monkeypatch.setattr(windows, "active_openvr", lambda: steamvr) + assert windows.select_backend(game) == "openvr" + monkeypatch.setattr( + windows, "active_openxr", lambda: paths.tools / "other/runtime.json" + ) + assert windows.select_backend(game) == "openxr" + + +def test_openvr_finds_current_steamvr_layout(tmp_path, monkeypatch): + import json + + runtime = tmp_path / "SteamVR" + (runtime / "bin").mkdir(parents=True) + (runtime / "bin/vrclient_x64.dll").touch() + registry = tmp_path / "openvrpaths.vrpath" + registry.write_text(json.dumps({"runtime": [str(runtime)]})) + monkeypatch.setenv("VR_PATHREG_OVERRIDE", str(registry)) + assert windows.active_openvr() == runtime + + +def test_simulator_uses_isolated_configuration(paths, tmp_path): + import json + + from riftlift.windows_simulator import configure + + runtime = tmp_path / "SteamVR" + for name in ( + "bin/win64/vrstartup.exe", + "bin/vrclient_x64.dll", + "drivers/null/bin/win64/driver_null.dll", + "steamxr_win64.json", + ): + target = runtime / name + target.parent.mkdir(parents=True, exist_ok=True) + target.touch() + environment = configure(paths, runtime) + registry = json.loads(Path(environment["VR_PATHREG_OVERRIDE"]).read_text()) + assert registry["runtime"] == [str(runtime)] + assert Path(registry["config"][0]).is_relative_to(paths.data) + assert environment["XR_RUNTIME_JSON"] == str(runtime / "steamxr_win64.json") + settings = Path(registry["config"][0]) / "steamvr.vrsettings" + assert json.loads(settings.read_text())["driver_null"]["enable"] + settings.write_text('{"custom": true}') + configure(paths, runtime) + assert json.loads(settings.read_text()) == {"custom": True} + + +def test_windows_browser_uses_os_default_without_owning_process(paths, monkeypatch): + from riftlift import auth_browser + + opened = [] + monkeypatch.setattr( + auth_browser.webbrowser, "open", lambda url: opened.append(url) or True + ) + browser = auth_browser.default_browser() + assert ( + auth_browser.launch_browser_login(paths, browser, "https://auth.meta.com/") + is None + ) + assert opened == ["https://auth.meta.com/"] + + +def test_download_error_is_concise_and_does_not_register_game( + paths, monkeypatch, capsys +): + from meta_pcvr_downloader.download import DownloadError + + def denied(*args, **kwargs): + raise DownloadError("Meta refused the manifest") + + monkeypatch.setattr("riftlift.library.add", denied) + assert windows.main(["add", "123456789"]) == 1 + assert "Meta refused the manifest" in capsys.readouterr().err + assert not list((paths.data / "games").glob("*.json")) + + +def test_windows_download_preserves_manifest_and_enables_offline_compat( + paths, monkeypatch +): + from types import SimpleNamespace + + from riftlift import library + + monkeypatch.setattr(library, "runtime_access_token", lambda paths: "test-token") + build = SimpleNamespace(app_name="Test Download", version="1.0") + monkeypatch.setattr(library, "list_builds", lambda *args: [build]) + monkeypatch.setattr(library, "select_build", lambda *args: build) + manifest = { + "canonicalName": "publisher.test", + "launchFile": "bin/game.exe", + "launchParameters": '"two words"', + } + monkeypatch.setattr(library, "fetch_manifest", lambda *args: manifest) + monkeypatch.setattr(library, "_best_executable", lambda *args: "bin/game.exe") + monkeypatch.setattr(library, "populate_game_metadata", lambda *args: None) + received = [] + monkeypatch.setattr( + library, "Downloader", lambda *args: SimpleNamespace(run=received.append) + ) + game = library.add(paths, "123456789") + assert received == [manifest] + assert game.app_key == "publisher.test" + assert game.arguments == ["two words"] + assert game.platform_shim and game.platform_offline diff --git a/tests/test_windows_package.py b/tests/test_windows_package.py new file mode 100644 index 0000000..c6a5ad4 --- /dev/null +++ b/tests/test_windows_package.py @@ -0,0 +1,69 @@ +import sys +from pathlib import Path + +import pytest + +from riftlift import windows +from riftlift.config import Paths +from riftlift.meta_auth import windows_callback_command +from riftlift.util import RiftLiftError + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows packaging") + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path / "user data")) + monkeypatch.delenv("RIFTLIFT_WINDOWS_RUNTIME", raising=False) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "app files"), raising=False) + paths = Paths.defaults() + native = windows.runtime_dir(paths) + native.mkdir(parents=True) + for name in windows.FILES | windows.PLATFORM_FILES: + (native / name).write_bytes(b"test payload") + return paths, native + + +def test_bundled_setup_is_offline_and_preserves_payload(bundle, monkeypatch): + paths, native = bundle + monkeypatch.setattr( + windows.urllib.request, "urlopen", lambda *a, **k: pytest.fail("network") + ) + assert windows.install_payload(paths) == native + assert (native / "RiftLiftOpenVR64.dll").read_bytes() == b"test payload" + + +def test_incomplete_bundle_requests_reinstall_without_download(bundle, monkeypatch): + paths, native = bundle + (native / "LibOVRPlatformImpl64_1.dll").unlink() + monkeypatch.setattr( + windows.urllib.request, "urlopen", lambda *a, **k: pytest.fail("network") + ) + with pytest.raises(RiftLiftError, match="reinstall"): + windows.install_payload(paths) + + +def test_bundled_discovery_needs_no_first_launch_download(bundle, monkeypatch): + paths, native = bundle + monkeypatch.setattr(windows, "sha256", lambda p: windows.SDK_RUNTIME_SHA256) + monkeypatch.setattr(windows, "download", lambda *a, **k: pytest.fail("network")) + assert windows.install_sdk_runtime(paths) == native + + +def test_packaged_callback_uses_exe_and_quotes_spaces(bundle, monkeypatch): + paths, _ = bundle + exe = Path("C:/Program Files/RiftLift/RiftLift.exe") + monkeypatch.setattr(sys, "executable", str(exe)) + target, command = windows_callback_command(paths) + assert target == exe + assert command.startswith(f'"{exe}" --home "') + assert command.endswith(' callback "%1"') + assert "-m" not in command + + +def test_source_callback_uses_python_module(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path)) + monkeypatch.setattr(sys, "frozen", False, raising=False) + _, command = windows_callback_command(Paths.defaults()) + assert "-m riftlift.windows" in command