diff --git a/.gitmodules b/.gitmodules index 9ac662a..db7a1c3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "third_party/Vulkan-Headers"] path = third_party/Vulkan-Headers url = https://github.com/KhronosGroup/Vulkan-Headers.git +[submodule "third_party/OpenXR-SDK"] + path = third_party/OpenXR-SDK + url = https://github.com/KhronosGroup/OpenXR-SDK.git diff --git a/CMakeLists.txt b/CMakeLists.txt index ae28540..a060d5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,8 @@ set(VULKAN_HEADERS_ENABLE_INSTALL OFF CACHE BOOL "Install vendored Vulkan-Headers" FORCE) add_subdirectory(third_party/Vulkan-Headers EXCLUDE_FROM_ALL) +include(${CMAKE_SOURCE_DIR}/cmake/MocktailVR.cmake) + include(CTest) if(BUILD_TESTING) # Package builds disable tests and do not fetch Google Test. @@ -627,6 +629,7 @@ target_compile_definitions(mocktail PRIVATE "MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST=\"${MOCKTAIL_DEFAULT_COMPATIBILITY_MANIFEST}\"" ) target_link_libraries(mocktail PRIVATE + Mocktail::VR mocktail_legacy_runtime mocktail_auth_runtime_composition mocktail_failure_dialog_runtime diff --git a/README.md b/README.md index 04120ea..99341c3 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,5 @@ -# Mocktail +This is a dev branch for vr support in Mocktail. -[![CI](https://github.com/komaruworld/mocktail/actions/workflows/ci.yml/badge.svg)](https://github.com/komaruworld/mocktail/actions/workflows/ci.yml) -[![Stars](https://img.shields.io/github/stars/komaruworld/mocktail?style=flat&logo=github)](https://github.com/komaruworld/mocktail/stargazers) -[![Downloads](https://img.shields.io/github/downloads/komaruworld/mocktail/total?logo=github)](https://github.com/komaruworld/mocktail/releases/latest) -[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Discord](https://img.shields.io/discord/1543955607869063269?label=discord)](https://discord.gg/rhgpfcmFSD) +idk if I can do this. -Mocktail runs the Android `x86_64` Roblox client on Linux, including a Linux -userspace hosted by FreeBSD's Linuxulator. It provides the Android ABI and JNI -pieces the client expects, then connects them to SDL3 and Vulkan or OpenGL on -the Linux side. - -Mocktail is an independent community project. It is not affiliated with Roblox -Corporation or VinegarHQ and does not distribute the Roblox client. - -## How it works - -``` -Roblox APK -> signature and ABI checks -> Bionic + JNI -> SDL3 + Vulkan/OpenGL -``` - -The APK is checked before any native code is loaded. It is downloaded on first -launch and is not bundled with Mocktail. The last working copy is kept in case -an update fails. - -## Install with Flatpak - -Install the stable release from Flathub: - -```bash -flatpak install flathub space.bigrat.mocktail -flatpak run space.bigrat.mocktail -``` - -Nightly builds are produced automatically from the latest `main` branch: - -```bash -flatpak install --user https://mocktail.bigrat.space/mocktail.flatpakref -flatpak run space.bigrat.mocktail -``` - -## Install from the AUR - -Arch Linux users can install either the pinned source release (`mocktail`) or -the current development version (`mocktail-git`) with an AUR helper: - -```bash -paru -S mocktail-git -# or -yay -S mocktail-git -``` - -Use `mocktail` instead of `mocktail-git` to build the pinned release from -source, or install the stable prebuilt package with `paru -S mocktail-bin` or -`yay -S mocktail-bin`. - -## Install with DNF - -Fedora 44 users can install either the stable `mocktail` package or the -`mocktail-nightly` package built from `main`: - -```bash -sudo curl -fsSL https://mocktail.bigrat.space/rpm/mocktail.repo \ - -o /etc/yum.repos.d/mocktail.repo -sudo dnf install mocktail -# or -sudo dnf install mocktail-nightly -``` - -## Install with APT - -Ubuntu 26.04 users can install either the stable `mocktail` package or the -`mocktail-nightly` package built from `main`: - -```bash -sudo install -d -m 0755 /etc/apt/keyrings -sudo curl -fsSL https://mocktail.bigrat.space/mocktail-packages.gpg \ - -o /etc/apt/keyrings/mocktail.gpg -echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/mocktail.gpg] https://mocktail.bigrat.space/apt mocktail main" | \ - sudo tee /etc/apt/sources.list.d/mocktail.list >/dev/null -sudo apt update -sudo apt install mocktail -# or -sudo apt install mocktail-nightly -``` - -## Direct downloads - -Direct AppImage, DEB, and RPM downloads are available from the -[Website](https://mocktail.bigrat.space/) and -[GitHub Releases](https://github.com/komaruworld/mocktail/releases). - -
-Screenshots - -![Roblox home in Mocktail](assets/screenshots/flatpak-home.png) - -![Roblox gameplay in Mocktail](assets/screenshots/flatpak-gameplay-tower.png) - -![Roblox experience in Mocktail](assets/screenshots/flatpak-gameplay-lobby.png) - -
- -## FFlag overrides - -Put a JSON object in `$XDG_CONFIG_HOME/mocktail/fflags.json` (usually -`~/.config/mocktail/fflags.json`). Mocktail applies it on the next launch. - -```json -{ - "FFlagExample": "True", - "DFIntExample": "120" -} -``` -## FAQ - -To see the most Frequently Asked Question go check [FAQ.md](FAQ.md) - - -## Building - -Linux `x86_64` is supported. Experimental FreeBSD 15.1 Linuxulator support has -been tested with an `x86_64` Fedora 44 userspace. On FreeBSD, Mocktail runs -inside Linuxulator; it is not a native FreeBSD binary. Building requires CMake -3.20+, Git, pkg-config, LLD, binutils, a C++17 compiler, SDL 3.4+, SDL3_ttf, -Vulkan, EGL, libplacebo, fontconfig, libcurl, OpenSSL, libelf, libyaml, minizip, -Capstone 5, utf8proc, nlohmann/json, GTK4, libadwaita 1.6+, and WebKitGTK 6.0. - -See the [FreeBSD Guide](packaging/freebsd/README.md) for setup and launch instructions. - -
-Ubuntu 26.04+ - -```bash -sudo apt update -sudo apt install build-essential cmake git ninja-build pkg-config lld \ - libsdl3-dev libsdl3-ttf-dev libcurl4-openssl-dev libssl-dev \ - nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \ - libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \ - libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \ - libplacebo-dev zlib1g-dev -``` -
- -
-Arch Linux - -```bash -sudo pacman -S --needed base-devel cmake git ninja pkgconf lld sdl3 sdl3_ttf \ - curl openssl nlohmann-json libyaml libelf minizip capstone gtk4 \ - libadwaita webkitgtk-6.0 libutf8proc fontconfig libglvnd \ - libplacebo vulkan-headers vulkan-icd-loader zlib -``` -
- -
-Fedora 44+ - -```bash -sudo dnf install gcc-c++ cmake git ninja-build pkgconf-pkg-config lld \ - SDL3-devel SDL3_ttf-devel libcurl-devel openssl-devel \ - nlohmann-json-devel libyaml-devel elfutils-libelf-devel minizip-ng-compat-devel \ - capstone-devel gtk4-devel libadwaita-devel webkitgtk6.0-devel \ - utf8proc-devel fontconfig-devel libglvnd-devel vulkan-headers \ - vulkan-loader-devel libplacebo-devel zlib-ng-compat-devel -``` -
- -```bash -git clone --recurse-submodules https://github.com/komaruworld/mocktail.git -cd mocktail -make build -./build/mocktail -``` - -## Known limitations - -Mocktail does not currently work with `hardened_malloc`. Using -`hardened_malloc` may cause Mocktail to fail to start or crash during runtime. -Run Mocktail without `hardened_malloc` enabled. - -## License - -[Apache License 2.0](LICENSE). Third-party components keep their own licenses. - -## Support - -You can support the project by giving it a star or with cryptocurrency: - -- USDT (TON): `UQCi6Yzcc9cOctoij6n_r1K90-OdVxAT0D_xo2UzGKkQaJDY` -- USDT (TRC20): `TNPMG9Vig2xiuo2r1QqnXRChPH7Vu28Jmx` +But it will be fun :) diff --git a/cmake/MocktailVR.cmake b/cmake/MocktailVR.cmake new file mode 100644 index 0000000..5c7f4b6 --- /dev/null +++ b/cmake/MocktailVR.cmake @@ -0,0 +1,60 @@ +# Copyright 2026 Mocktail Project Authors +# Licensed under the Apache License, Version 2.0. + +include_guard(GLOBAL) + +option(MOCKTAIL_ENABLE_VR "Build experimental native OpenXR support" OFF) + +add_library(mocktail_vr STATIC src/vr/diagnostics.cc) +add_library(Mocktail::VR ALIAS mocktail_vr) +target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include") +mocktail_apply_compile_options(mocktail_vr) + +if(MOCKTAIL_ENABLE_VR) + if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "Mocktail OpenXR support currently targets Linux") + endif() + if(NOT EXISTS "${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK/CMakeLists.txt") + message(FATAL_ERROR + "Initialize OpenXR: git submodule update --init third_party/OpenXR-SDK") + endif() + + # Scope upstream's generic option names to this dependency. Static linking + # packages the pinned loader without requiring a matching system loader. + function(mocktail_add_openxr_sdk) + set(BUILD_LOADER ON) + set(DYNAMIC_LOADER OFF) + set(BUILD_WITH_SYSTEM_JSONCPP OFF) + set(BUILD_TESTS OFF) + set(BUILD_API_LAYERS OFF) + set(BUILD_CONFORMANCE_TESTS OFF) + set(BUILD_SDK_TESTS OFF) + set(BUILD_FORCE_GENERATION OFF) + add_subdirectory("${CMAKE_SOURCE_DIR}/third_party/OpenXR-SDK" + "${CMAKE_BINARY_DIR}/third_party/OpenXR-SDK" EXCLUDE_FROM_ALL) + endfunction() + mocktail_add_openxr_sdk() + target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc) + # Error unwinding is confined to this native session implementation; its + # public entry point catches errors before returning to the guest runtime. + set_source_files_properties(src/vr/openxr_preview.cc PROPERTIES + COMPILE_OPTIONS -fexceptions) + target_compile_definitions(mocktail_vr PRIVATE XR_USE_GRAPHICS_API_VULKAN) + target_link_libraries(mocktail_vr PRIVATE OpenXR::openxr_loader Vulkan::Headers ${CMAKE_DL_LIBS}) +else() + target_sources(mocktail_vr PRIVATE src/vr/openxr_disabled.cc) +endif() + +add_executable(mocktail_vr_probe src/vr/probe_main.cc) +set_target_properties(mocktail_vr_probe PROPERTIES OUTPUT_NAME mocktail-vr-probe) +target_link_libraries(mocktail_vr_probe PRIVATE Mocktail::VR) +mocktail_apply_compile_options(mocktail_vr_probe) +install(TARGETS mocktail_vr_probe RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +# Development launcher for an independently built Monado runtime. --prepare +# keeps its shared library and a relative manifest under this build directory. +configure_file("${CMAKE_SOURCE_DIR}/scripts/vr_simulated.py" + "${CMAKE_BINARY_DIR}/mocktail-vr-simulated" COPYONLY) +file(CHMOD "${CMAKE_BINARY_DIR}/mocktail-vr-simulated" + PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE + WORLD_READ WORLD_EXECUTE) diff --git a/config/mocktail.example.yaml b/config/mocktail.example.yaml index 986bb65..aa10b5a 100644 --- a/config/mocktail.example.yaml +++ b/config/mocktail.example.yaml @@ -49,6 +49,13 @@ graphics: # Optional presentation synchronization override: auto, on, or off. # vsync: off +vr: + # Boolean (default: false): launch the Roblox experimental VR path. + # Only Build ID 2998 is currently accepted; eye resources and OpenXR output + # are NOT yet connected. Standalone scene: mocktail-vr-probe --scene. + # Use --no-vr to launch Roblox normally. + enabled: false + performance: # Boolean (default: false): size Roblox scheduler/render queues from every # physical CPU core. A place's Lua/main thread can still remain serial. diff --git a/include/mocktail/vr/openxr_preview.h b/include/mocktail/vr/openxr_preview.h new file mode 100644 index 0000000..c7e3810 --- /dev/null +++ b/include/mocktail/vr/openxr_preview.h @@ -0,0 +1,34 @@ +#ifndef MOCKTAIL_VR_OPENXR_PREVIEW_H_ +#define MOCKTAIL_VR_OPENXR_PREVIEW_H_ + +#include +#include +#include + +namespace mocktail::vr { + +struct PreviewOptions { + // Zero keeps the scene open until the runtime or user requests exit. + std::uint32_t frame_limit = 0; +}; + +struct PreviewResult { + bool success = false; + std::string message; + std::uint64_t frames = 0; + std::uint64_t rendered_frames = 0; + std::uint64_t tracked_frames = 0; + std::array head_position{}; + std::array head_orientation{0, 0, 0, 1}; +}; + +// Runs a native Vulkan/OpenXR scene with a panel fixed in LOCAL space. The +// compositor projects it into both eyes. Roblox images/input are not connected. +// Call on the main thread: SIGINT/SIGTERM request a graceful session exit, and +// their previous handlers are restored before returning. +PreviewResult RunOpenXrPreview(const PreviewOptions& options = {}); +std::string FormatPreviewResult(const PreviewResult& result); + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_OPENXR_PREVIEW_H_ diff --git a/include/mocktail/vr/openxr_probe.h b/include/mocktail/vr/openxr_probe.h new file mode 100644 index 0000000..c406ad2 --- /dev/null +++ b/include/mocktail/vr/openxr_probe.h @@ -0,0 +1,48 @@ +#ifndef MOCKTAIL_VR_OPENXR_PROBE_H_ +#define MOCKTAIL_VR_OPENXR_PROBE_H_ + +#include +#include +#include + +namespace mocktail::vr { + +enum class ProbeStatus { + kReady, + kBuildDisabled, + kRuntimeUnavailable, + kHeadsetUnavailable, + kIncompatibleRuntime, + kError, +}; + +struct ViewConfiguration { + std::uint32_t width = 0; + std::uint32_t height = 0; + std::uint32_t samples = 0; +}; + +struct ProbeReport { + ProbeStatus status = ProbeStatus::kError; + std::string message; + std::string sdk_version; + std::string runtime_name; + std::string runtime_version; + std::string system_name; + std::vector extensions; + std::vector views; + bool orientation_tracking = false; + bool position_tracking = false; + std::string minimum_vulkan_version; + std::string maximum_vulkan_version; +}; + +// Native host discovery only. Does not load Roblox, change the active runtime, +// create a graphics device/session, or claim that VR rendering is implemented. +ProbeReport ProbeOpenXr(); +std::string FormatProbeReport(const ProbeReport& report); +int ProbeExitCode(ProbeStatus status); + +} // namespace mocktail::vr + +#endif // MOCKTAIL_VR_OPENXR_PROBE_H_ diff --git a/include/runtime/command_line.h b/include/runtime/command_line.h index 8a491a9..f74291f 100644 --- a/include/runtime/command_line.h +++ b/include/runtime/command_line.h @@ -1,6 +1,7 @@ #ifndef MOCKTAIL_RUNTIME_COMMAND_LINE_H_ #define MOCKTAIL_RUNTIME_COMMAND_LINE_H_ +#include #include #include @@ -21,6 +22,7 @@ enum class WindowMode { struct CommandLineOptions { CommandMode mode = CommandMode::kRun; WindowMode window_mode = WindowMode::kUnspecified; + std::optional vr_enabled; std::string program_name = "mocktail"; std::string roblox_library_path; std::string graphics_backend; diff --git a/include/runtime/runtime_config.h b/include/runtime/runtime_config.h index 6c4ddbd..faa6f5c 100644 --- a/include/runtime/runtime_config.h +++ b/include/runtime/runtime_config.h @@ -77,6 +77,8 @@ class RuntimeConfig { static RuntimeConfig FromEnvironment(const Environment& environment); bool headless() const { return headless_; } + bool vr_enabled() const { return vr_enabled_; } + bool vr_valid() const { return vr_valid_; } const std::filesystem::path& roblox_library_path() const { return roblox_library_path_; } @@ -135,6 +137,8 @@ class RuntimeConfig { private: bool headless_ = false; + bool vr_enabled_ = false; + bool vr_valid_ = true; std::filesystem::path roblox_library_path_ = "rbx_bin/libroblox.so"; GraphicsBackend graphics_backend_ = GraphicsBackend::kVulkan; std::string graphics_backend_name_ = "direct-vulkan"; diff --git a/scripts/vr_simulated.py b/scripts/vr_simulated.py new file mode 100644 index 0000000..0d4c958 --- /dev/null +++ b/scripts/vr_simulated.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Launch the VR preview with a separately built Monado simulated runtime.""" + +import argparse +import json +import os +from pathlib import Path +import select +import shutil +import signal +import subprocess +import sys +import tempfile + + +def positive_integer(value): + number = int(value) + if not 0 < number <= 0xFFFFFFFF: + raise argparse.ArgumentTypeError("must be a positive 32-bit integer") + return number + + +def prepare_runtime(source, destination): + """Keep the development runtime beside the build, independent of /tmp.""" + source = source.resolve() + document = json.loads(source.read_text()) + library = Path(document["runtime"]["library_path"]) + if not library.is_absolute(): + library = source.parent / library + if not library.is_file(): + raise ValueError(f"Runtime library does not exist: {library}") + destination.mkdir(parents=True, exist_ok=True) + output_library = destination / "libopenxr_monado.so" + if library.resolve() != output_library.resolve(): + shutil.copy2(library, output_library) + output_manifest = destination / "runtime.json" + output_manifest.write_text(json.dumps({ + "file_format_version": "1.0.0", + "runtime": {"name": "Monado (Mocktail simulator)", + "library_path": "./libopenxr_monado.so"}, + }, indent=2) + "\n") + print(f"Prepared simulator: {output_manifest}") + + +def simulator_environment(parent, manifest, display, wayland, software): + environment = parent.copy() + # These overrides belong to this child process; no active_runtime.json is + # written and the user's WiVRn/system runtime selection remains intact. + environment.update( + XR_RUNTIME_JSON=str(manifest.resolve()), + SIMULATED_ENABLE="1", + XRT_COMPOSITOR_NULL="0", + XRT_COMPOSITOR_FORCE_XCB="1" if display else "0", + XRT_COMPOSITOR_FORCE_WAYLAND="1" if not display and wayland else "0", + XRT_COMPOSITOR_FORCE_NVIDIA="0", + XRT_COMPOSITOR_FORCE_RANDR="0", + XRT_COMPOSITOR_FORCE_WAYLAND_DIRECT="0", + XRT_COMPOSITOR_FORCE_VK_DISPLAY="-1", + XRT_COMPOSITOR_XCB_FULLSCREEN="0", + ) + environment.setdefault("XRT_COMPOSITOR_DEFAULT_FRAMERATE", "30") + if display: + environment["DISPLAY"] = display + environment.pop("WAYLAND_DISPLAY", None) + if software: + candidates = [directory / "lvp_icd.json" for directory in + (Path("/usr/share/vulkan/icd.d"), Path("/usr/local/share/vulkan/icd.d"))] + manifest_path = next((path for path in candidates if path.is_file()), None) + if manifest_path is None: + raise ValueError("Mesa Lavapipe is not installed; omit --software to use your GPU") + environment["VK_DRIVER_FILES"] = str(manifest_path) + environment.pop("VK_ICD_FILENAMES", None) + environment["XRT_COMPOSITOR_FORCE_GPU_INDEX"] = "0" + environment["XRT_COMPOSITOR_FORCE_CLIENT_GPU_INDEX"] = "0" + return environment + + +def main(): + location = Path(__file__).resolve().parent + default_build = location.parent / "build" if location.name == "scripts" else location + parser = argparse.ArgumentParser( + description="Show the Mocktail VR test scene on the desktop using a simulated Monado headset.") + parser.add_argument("--build-dir", type=Path, default=default_build, + help="Mocktail build directory (defaults to the directory of this launcher)") + parser.add_argument("--runtime", type=Path, + help="Manifest of an in-process Monado build with the simulated driver enabled") + parser.add_argument("--prepare", type=Path, metavar="MONADO_MANIFEST", + help="Copy an already built Monado runtime next to this build, then exit") + parser.add_argument("--frames", type=positive_integer, + help="Request exit after this many frames; otherwise run until Ctrl+C") + parser.add_argument("--software", action="store_true", help="Use Mesa Lavapipe instead of a physical GPU") + parser.add_argument("--headless", action="store_true", + help="Use a private Xvfb display for automated checks (defaults to 180 frames)") + args = parser.parse_args() + build = args.build_dir.resolve() + if args.prepare: + prepare_runtime(args.prepare, build / "vr-simulator") + return 0 + manifest = args.runtime or build / "vr-simulator/runtime.json" + if not manifest.is_file(): + parser.error("No simulated Monado runtime is prepared. Use --runtime /path/to/openxr_monado-dev.json " + "or --prepare /path/to/openxr_monado-dev.json with a separately built Monado runtime.") + probe = build / "mocktail-vr-probe" + if not probe.is_file(): + parser.error(f"Build mocktail_vr_probe with MOCKTAIL_ENABLE_VR=ON first: {probe}") + display = os.environ.get("DISPLAY") + wayland = os.environ.get("WAYLAND_DISPLAY") + if not args.headless and not (display or wayland): + parser.error("Run from a graphical desktop terminal to see the scene, or use --headless for an automated check.") + + xvfb = None + try: + if args.headless: + if not shutil.which("Xvfb"): + parser.error("--headless requires Xvfb") + read_fd, write_fd = os.pipe() + try: + xvfb = subprocess.Popen( + ["Xvfb", "-displayfd", str(write_fd), "-screen", "0", "1280x720x24", "-nolisten", "tcp"], + pass_fds=(write_fd,), stdout=subprocess.DEVNULL, start_new_session=True) + except OSError: + os.close(read_fd) + raise + finally: + os.close(write_fd) + with os.fdopen(read_fd) as number: + if not select.select([number], [], [], 10)[0]: + raise ValueError("Xvfb did not start within 10 seconds") + display_number = number.readline().strip() + if not display_number.isdigit(): + raise ValueError("Xvfb failed to allocate a display") + display = ":" + display_number + wayland = None + env = simulator_environment(os.environ, manifest, display, wayland, args.software) + # Keep simulator settings and runtime files private and temporary. The + # desktop's XDG_RUNTIME_DIR is retained for Wayland authentication. + with tempfile.TemporaryDirectory(prefix="mocktail-vr-simulated-") as directory: + root = Path(directory) + env["XDG_CONFIG_HOME"] = str(root / "config") + env["XDG_DATA_HOME"] = str(root / "data") + if not env.get("XDG_RUNTIME_DIR"): + env["XDG_RUNTIME_DIR"] = str(root) + command = [str(probe), "--scene"] + frames = args.frames or (180 if args.headless else None) + if frames: + command += ["--frames", str(frames)] + print(f"Simulated Monado headset: {manifest.resolve()}", flush=True) + print("The test scene will appear in a desktop window. Ctrl+C exits." if not args.headless + else "Checking the test scene on a private Xvfb display.", flush=True) + child = None + previous = {} + + def forward_signal(signum, _frame): + if child is not None and child.poll() is None: + child.send_signal(signum) + + try: + child = subprocess.Popen(command, env=env, start_new_session=True) + for signum in (signal.SIGINT, signal.SIGTERM): + previous[signum] = signal.signal(signum, forward_signal) + return child.wait() + finally: + if child is not None and child.poll() is None: + child.terminate() + try: + child.wait(timeout=10) + except subprocess.TimeoutExpired: + child.kill() + child.wait() + for signum, handler in previous.items(): + signal.signal(signum, handler) + finally: + if xvfb is not None: + xvfb.terminate() + try: + xvfb.wait(timeout=5) + except subprocess.TimeoutExpired: + xvfb.kill() + xvfb.wait() + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, ValueError, KeyError) as error: + print(f"VR simulator: {error}", file=sys.stderr) + sys.exit(1) diff --git a/src/legacy/legacy_runtime.cc b/src/legacy/legacy_runtime.cc index fe45a91..4c93dc3 100644 --- a/src/legacy/legacy_runtime.cc +++ b/src/legacy/legacy_runtime.cc @@ -2476,7 +2476,8 @@ jobject BuildAppBridgeInitParams(JNIEnv* env, jstring client_settings, SetStringField(env, params, "userAgent", user_agent.c_str()); SetBooleanField(env, params, "isTablet", JNI_FALSE); SetBooleanField(env, params, "isPotato", JNI_FALSE); - SetBooleanField(env, params, "isVrDevice", JNI_FALSE); + SetBooleanField(env, params, "isVrDevice", + IsEnabled("MOCKTAIL_VR_ENABLED") ? JNI_TRUE : JNI_FALSE); SetStringField(env, params, "buildVariant", "googleProdRelease"); SetObjectField(env, params, "vrContext", "Landroid/app/Activity;", activity); @@ -4507,6 +4508,18 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options, const mocktail::compat::BuildProfile& build_profile = *profile_result.profile; + // The experimental VR ABI has only been observed on the active 2998 + // payload. Do not pass VR device parameters to unrelated builds, even when + // they have a general-purpose compatibility profile. Eye initialization and + // OpenXR projection are not yet implemented; this is a diagnostic launch. + if (IsEnabled("MOCKTAIL_VR_ENABLED") && + build_profile.elf_build_id != + "ade08266c67aee88ec9c1d00902150e1684dad3a") { + std::cerr << "[FATAL] Experimental Roblox VR requires exact Build ID " + "ade08266c67aee88ec9c1d00902150e1684dad3a; loaded " + << build_profile.elf_build_id << "\n"; + return EXIT_FAILURE; + } const mocktail::compat::HostAbiProfile* host_abi_profile = mocktail::compat::FindHostAbiProfile(build_profile.elf_build_id); const bool experiment_allowed = diff --git a/src/main.cc b/src/main.cc index c3899af..1f9352c 100644 --- a/src/main.cc +++ b/src/main.cc @@ -308,6 +308,36 @@ int main(int argc, char* argv[]) { } mocktail::runtime::ScrubCommandLineLaunchArguments(&command_line.options, argc, argv); + + const mocktail::runtime::RuntimeConfigBootstrapResult config_bootstrap = + mocktail::runtime::EnsureRuntimeConfigFile(paths.config_file()); + if (!config_bootstrap) { + std::cerr << "[FATAL] Cannot prepare " << paths.config_file() << ": " + << config_bootstrap.error << '\n'; + if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { + (void)mocktail::runtime::ShowFailureDialog( + environment, "Mocktail could not prepare its configuration."); + } + return EXIT_FAILURE; + } + mocktail::runtime::RuntimeConfigLoadResult runtime_config = + mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file()); + if (!runtime_config) { + std::cerr << "[FATAL] Cannot load " << paths.config_file() << ": " + << runtime_config.error << '\n'; + if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { + (void)mocktail::runtime::ShowFailureDialog( + environment, "Mocktail could not load its configuration."); + } + return EXIT_FAILURE; + } + // The standalone OpenXR scene belongs to mocktail-vr-probe --scene. + // Continue through Roblox startup to inspect its native VR device. + if (runtime_config.config.vr_enabled()) { + std::cerr << " [vr] Roblox VR initialization requested (experimental); " + "stereo eye resources and OpenXR output are not yet connected. " + "Standalone scene: mocktail-vr-probe --scene\n"; + } mocktail::runtime::SessionLog session_log; mocktail::runtime::FailureSupportBundleGuard support_bundle_guard( environment, paths, @@ -355,28 +385,6 @@ int main(int argc, char* argv[]) { return EXIT_FAILURE; } } - const mocktail::runtime::RuntimeConfigBootstrapResult config_bootstrap = - mocktail::runtime::EnsureRuntimeConfigFile(paths.config_file()); - if (!config_bootstrap) { - std::cerr << "[FATAL] Cannot prepare " << paths.config_file() << ": " - << config_bootstrap.error << '\n'; - if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { - (void)mocktail::runtime::ShowFailureDialog( - environment, "Mocktail could not prepare its configuration."); - } - return EXIT_FAILURE; - } - mocktail::runtime::RuntimeConfigLoadResult runtime_config = - mocktail::runtime::LoadRuntimeConfig(environment, paths.config_file()); - if (!runtime_config) { - std::cerr << "[FATAL] Cannot load " << paths.config_file() << ": " - << runtime_config.error << '\n'; - if (command_line.options.mode == mocktail::runtime::CommandMode::kRun) { - (void)mocktail::runtime::ShowFailureDialog( - environment, "Mocktail could not load its configuration."); - } - return EXIT_FAILURE; - } if (runtime_config.config.use_system_proxy()) { const mocktail::runtime::SystemProxyResult system_proxy = mocktail::runtime::ResolveSystemProxy(); diff --git a/src/runtime/command_line.cc b/src/runtime/command_line.cc index 42dcc9d..08b9acf 100644 --- a/src/runtime/command_line.cc +++ b/src/runtime/command_line.cc @@ -74,6 +74,8 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) { &result.options.graphics_backend, &result.error)) { return result; } + } else if (argument == "--vr" || argument == "--no-vr") { + result.options.vr_enabled = argument == "--vr"; } else if (argument == "--allow-unverified-build") { result.options.allow_unverified_build = true; } else if (argument == "--force-run-latest") { @@ -141,6 +143,7 @@ CommandLineParseResult ParseCommandLine(int argc, const char* const argv[]) { (!result.options.roblox_library_path.empty() || result.options.window_mode != WindowMode::kUnspecified || !result.options.graphics_backend.empty() || + result.options.vr_enabled.has_value() || result.options.allow_unverified_build || !result.options.launch_request_json.empty())) { result.error = "--force-run-latest must be used on its own"; @@ -218,6 +221,11 @@ void ScrubCommandLineLaunchArguments(CommandLineOptions* options, int argc, bool ApplyCommandLineEnvironment(const CommandLineOptions& options, std::string* error) { + if (options.vr_enabled.has_value() && + !SetEnvironment("MOCKTAIL_VR_ENABLED", *options.vr_enabled ? "1" : "0", + error)) { + return false; + } if (!options.roblox_library_path.empty() && !SetEnvironment("ROBLOX_LIB_PATH", options.roblox_library_path, error)) { return false; @@ -248,6 +256,8 @@ std::string CommandLineUsage(const std::string& program_name) { "x86_64 libroblox.so\n" << " --headless Run without creating an SDL window\n" << " --windowed Force windowed startup (default)\n" + << " --vr / --no-vr Launch Roblox in experimental VR mode or " + "disable vr.enabled\n" << " --graphics direct-vulkan | opengl | system | " "angle-vulkan (default: direct-vulkan)\n" << " --allow-unverified-build Run a known but unverified Build-ID " diff --git a/src/runtime/runtime_config.cc b/src/runtime/runtime_config.cc index f3e9d9a..18e0913 100644 --- a/src/runtime/runtime_config.cc +++ b/src/runtime/runtime_config.cc @@ -170,6 +170,8 @@ std::string BuildNetworkProxyUrl(const NetworkProxyConfig& proxy) { RuntimeConfig RuntimeConfig::FromEnvironment(const Environment& environment) { RuntimeConfig config; config.headless_ = LegacyEnabled(environment, "MOCKTAIL_HEADLESS"); + config.vr_enabled_ = + ReadBoolean(environment, "MOCKTAIL_VR_ENABLED", false, &config.vr_valid_); config.roblox_library_path_ = environment.GetOr( "ROBLOX_LIB_PATH", config.roblox_library_path_.string()); config.graphics_backend_name_ = environment.GetOr( diff --git a/src/runtime/runtime_config_bootstrap.cc b/src/runtime/runtime_config_bootstrap.cc index f0d5386..3905077 100644 --- a/src/runtime/runtime_config_bootstrap.cc +++ b/src/runtime/runtime_config_bootstrap.cc @@ -67,6 +67,13 @@ graphics: # Optional presentation synchronization override: auto, on, or off. # vsync: off +vr: + # Boolean (default: false): launch the Roblox experimental VR path. + # Only Build ID 2998 is currently accepted; eye resources and OpenXR output + # are NOT yet connected. Standalone scene: mocktail-vr-probe --scene. + # Use --no-vr to launch Roblox normally. + enabled: false + performance: # Boolean (default: false): size Roblox scheduler/render queues from every # physical CPU core. A place's Lua/main thread can still remain serial. diff --git a/src/runtime/runtime_config_file.cc b/src/runtime/runtime_config_file.cc index faee55b..9ad3422 100644 --- a/src/runtime/runtime_config_file.cc +++ b/src/runtime/runtime_config_file.cc @@ -168,6 +168,7 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, "device.keyboard", "runtime.headless", "runtime.roblox_library", + "vr.enabled", "appearance.theme", "graphics.backend", "graphics.frame_rate_limit", @@ -308,6 +309,14 @@ bool ValidateAndMap(const ValueMap& yaml, ValueMap* environment, } (*environment)["MOCKTAIL_THEME"] = *theme; } + if (const auto enabled = value("vr.enabled"); enabled.has_value()) { + bool parsed = false; + if (!ParseBoolean(*enabled, &parsed)) { + *error = "vr.enabled must be true or false"; + return false; + } + (*environment)["MOCKTAIL_VR_ENABLED"] = parsed ? "1" : "0"; + } if (const auto backend = value("graphics.backend"); backend.has_value()) { if (RuntimeConfig::ParseGraphicsBackend(*backend) == GraphicsBackend::kUnknown) { @@ -656,7 +665,7 @@ bool LoadYaml(const std::filesystem::path& path, ValueMap* values, bool* loaded, *error = "device must be a preset scalar or detailed mapping"; valid = false; } - } else if (key == "runtime" || key == "appearance" || + } else if (key == "runtime" || key == "appearance" || key == "vr" || key == "graphics" || key == "performance" || key == "audio" || key == "window" || key == "input" || key == "compatibility" || key == "network") { @@ -725,6 +734,9 @@ RuntimeConfigLoadResult LoadRuntimeConfig( result.config = RuntimeConfig::FromEnvironment(layered); if (!result.config.frame_rate().valid()) { result.error = "frame-rate policy is invalid"; + } else if (!result.config.vr_valid()) { + result.error = "VR configuration is invalid: MOCKTAIL_VR_ENABLED must be " + "1/0, true/false, or on/off"; } else if (!result.config.device_profile_valid()) { result.error = "device profile is invalid"; } else if (!result.config.input_capabilities().touch_enabled && @@ -761,6 +773,10 @@ RuntimeConfigLoadResult LoadRuntimeConfig( bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config, std::string* error) { + if (!config.vr_valid()) { + if (error != nullptr) *error = "cannot export an invalid VR configuration"; + return false; + } if (!config.device_profile_valid()) { if (error != nullptr) { *error = "cannot export an invalid device profile"; @@ -813,6 +829,8 @@ bool ExportRuntimeConfigEnvironment(const RuntimeConfig& config, const bool base_exported = SetEnvironmentValue("MOCKTAIL_HEADLESS", config.headless() ? "1" : "0", error) && + SetEnvironmentValue("MOCKTAIL_VR_ENABLED", config.vr_enabled() ? "1" : "0", + error) && SetEnvironmentValue("MOCKTAIL_DEVICE_PROFILE", std::string(device.name), error) && SetEnvironmentValue("MOCKTAIL_DEVICE_CLASS", diff --git a/src/vr/diagnostics.cc b/src/vr/diagnostics.cc new file mode 100644 index 0000000..2d37435 --- /dev/null +++ b/src/vr/diagnostics.cc @@ -0,0 +1,75 @@ +#include "mocktail/vr/openxr_probe.h" +#include "mocktail/vr/openxr_preview.h" + +#include + +namespace mocktail::vr { + +std::string FormatPreviewResult(const PreviewResult& result) { + std::ostringstream output; + output << "[VR] " << result.message << '\n' + << "Frames: " << result.frames + << ", rendered: " << result.rendered_frames + << ", tracked: " << result.tracked_frames << '\n'; + if (result.tracked_frames) { + output << "Head position (metres): " << result.head_position[0] << ", " + << result.head_position[1] << ", " << result.head_position[2] << '\n' + << "Head orientation (xyzw): " << result.head_orientation[0] << ", " + << result.head_orientation[1] << ", " << result.head_orientation[2] + << ", " << result.head_orientation[3] << '\n'; + } + return output.str(); +} + +int ProbeExitCode(ProbeStatus status) { + switch (status) { + case ProbeStatus::kReady: + return 0; + case ProbeStatus::kBuildDisabled: + return 2; + case ProbeStatus::kRuntimeUnavailable: + return 3; + case ProbeStatus::kHeadsetUnavailable: + return 4; + case ProbeStatus::kIncompatibleRuntime: + return 5; + case ProbeStatus::kError: + return 1; + } + return 1; +} + +std::string FormatProbeReport(const ProbeReport& report) { + std::ostringstream output; + output << "Mocktail OpenXR discovery\n"; + if (!report.sdk_version.empty()) + output << "SDK: " << report.sdk_version << '\n'; + if (!report.runtime_name.empty()) { + output << "Runtime: " << report.runtime_name << " (" + << report.runtime_version << ")\n"; + } + if (!report.system_name.empty()) { + output << "System: " << report.system_name << '\n' + << "Tracking: orientation=" << report.orientation_tracking + << " position=" << report.position_tracking << '\n'; + } + for (std::size_t eye = 0; eye < report.views.size(); ++eye) { + const auto& view = report.views[eye]; + output << "Eye " << eye << ": " << view.width << 'x' << view.height + << ", samples=" << view.samples << '\n'; + } + if (!report.minimum_vulkan_version.empty()) { + output << "Vulkan: minimum=" << report.minimum_vulkan_version + << ", highest runtime-tested=" << report.maximum_vulkan_version + << '\n'; + } + output << "Extensions (" << report.extensions.size() << "):\n"; + for (const auto& extension : report.extensions) + output << " " << extension << '\n'; + output << "Discovery: " << report.message << '\n' + << "Roblox VR rendering: not implemented yet.\n" + << "This probe does not create an XR session or render frames.\n"; + return output.str(); +} + +} // namespace mocktail::vr diff --git a/src/vr/openxr_disabled.cc b/src/vr/openxr_disabled.cc new file mode 100644 index 0000000..f967e10 --- /dev/null +++ b/src/vr/openxr_disabled.cc @@ -0,0 +1,23 @@ +#include "mocktail/vr/openxr_probe.h" +#include "mocktail/vr/openxr_preview.h" + +namespace mocktail::vr { + +PreviewResult RunOpenXrPreview(const PreviewOptions&) { + PreviewResult result; + result.message = + "This build does not include OpenXR support. Use a VR-enabled build of " + "Mocktail."; + return result; +} + +ProbeReport ProbeOpenXr() { + ProbeReport report; + report.status = ProbeStatus::kBuildDisabled; + report.message = + "This build does not include OpenXR support. Use a " + "VR-enabled build of Mocktail."; + return report; +} + +} // namespace mocktail::vr diff --git a/src/vr/openxr_preview.cc b/src/vr/openxr_preview.cc new file mode 100644 index 0000000..2644d5b --- /dev/null +++ b/src/vr/openxr_preview.cc @@ -0,0 +1,729 @@ +#include "mocktail/vr/openxr_preview.h" + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mocktail::vr { +namespace { + +using Clock = std::chrono::steady_clock; +volatile sig_atomic_t interrupted = 0; +void Interrupt(int) { interrupted = 1; } + +class SignalScope { + public: + SignalScope() { + interrupted = 0; + struct sigaction action{}; + action.sa_handler = Interrupt; + sigemptyset(&action.sa_mask); + have_int_ = sigaction(SIGINT, &action, &old_int_) == 0; + have_term_ = sigaction(SIGTERM, &action, &old_term_) == 0; + } + ~SignalScope() { + if (have_int_) sigaction(SIGINT, &old_int_, nullptr); + if (have_term_) sigaction(SIGTERM, &old_term_, nullptr); + } + + private: + struct sigaction old_int_{}, old_term_{}; + bool have_int_ = false, have_term_ = false; +}; + +template +T XrInfo(XrStructureType type) { + T value{}; + value.type = type; + return value; +} + +template +T VkInfo(VkStructureType type) { + T value{}; + value.sType = type; + return value; +} + +void Require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +void CheckVk(VkResult result, const char* operation) { + Require(result == VK_SUCCESS, + std::string(operation) + " failed: " + std::to_string(result)); +} + +// Every Vulkan call uses the host loader explicitly. Linking global vk* +// symbols here could accidentally enter Mocktail's Android Vulkan adapter. +struct Vulkan { + void* library = nullptr; + PFN_vkGetInstanceProcAddr get_instance_proc = nullptr; + PFN_vkGetDeviceProcAddr get_device_proc = nullptr; + VkInstance instance = VK_NULL_HANDLE; + VkPhysicalDevice physical_device = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + VkQueue queue = VK_NULL_HANDLE; + std::uint32_t queue_family = 0; + PFN_vkDestroyInstance DestroyInstance = nullptr; + +#define MOCKTAIL_PREVIEW_VK_FUNCTIONS(X) \ + X(DestroyDevice) \ + X(GetDeviceQueue) \ + X(DeviceWaitIdle) \ + X(CreateImageView) \ + X(DestroyImageView) \ + X(CreateRenderPass) \ + X(DestroyRenderPass) \ + X(CreateFramebuffer) \ + X(DestroyFramebuffer) \ + X(CreateCommandPool) \ + X(DestroyCommandPool) \ + X(AllocateCommandBuffers) \ + X(ResetCommandBuffer) \ + X(BeginCommandBuffer) \ + X(EndCommandBuffer) \ + X(CmdBeginRenderPass) \ + X(CmdEndRenderPass) \ + X(CmdClearAttachments) \ + X(CreateFence) \ + X(DestroyFence) \ + X(ResetFences) \ + X(WaitForFences) \ + X(QueueSubmit) +#define DECLARE(name) PFN_vk##name name = nullptr; + MOCKTAIL_PREVIEW_VK_FUNCTIONS(DECLARE) +#undef DECLARE + + Vulkan() = default; + Vulkan(const Vulkan&) = delete; + Vulkan& operator=(const Vulkan&) = delete; + ~Vulkan() { + if (device && DestroyDevice) DestroyDevice(device, nullptr); + if (instance && DestroyInstance) DestroyInstance(instance, nullptr); + if (library) dlclose(library); + } + + void OpenLoader() { + library = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL); + Require(library != nullptr, + "Cannot load the host Vulkan loader libvulkan.so.1"); + get_instance_proc = reinterpret_cast( + dlsym(library, "vkGetInstanceProcAddr")); + Require(get_instance_proc != nullptr, + "Host vkGetInstanceProcAddr is missing"); + } + + template + T InstanceFunction(const char* name) { + const auto function = + reinterpret_cast(get_instance_proc(instance, name)); + Require(function != nullptr, + std::string("Missing Vulkan function: ") + name); + return function; + } + + void LoadDeviceFunctions() { +#define LOAD(name) \ + name = reinterpret_cast(get_device_proc(device, "vk" #name)); \ + Require(name != nullptr, "Missing Vulkan function: vk" #name); + MOCKTAIL_PREVIEW_VK_FUNCTIONS(LOAD) +#undef LOAD + GetDeviceQueue(device, queue_family, 0, &queue); + } +}; +#undef MOCKTAIL_PREVIEW_VK_FUNCTIONS + +class Preview { + public: + Preview() = default; + Preview(const Preview&) = delete; + Preview& operator=(const Preview&) = delete; + ~Preview() { + // The runtime still needs our VkDevice when destroying its + // swapchain/session. + if (vk_.device && vk_.DeviceWaitIdle) (void)vk_.DeviceWaitIdle(vk_.device); + for (auto framebuffer : framebuffers_) + vk_.DestroyFramebuffer(vk_.device, framebuffer, nullptr); + for (auto view : image_views_) + vk_.DestroyImageView(vk_.device, view, nullptr); + if (render_pass_) vk_.DestroyRenderPass(vk_.device, render_pass_, nullptr); + if (fence_) vk_.DestroyFence(vk_.device, fence_, nullptr); + if (pool_) vk_.DestroyCommandPool(vk_.device, pool_, nullptr); + if (swapchain_) (void)xrDestroySwapchain(swapchain_); + if (view_space_) (void)xrDestroySpace(view_space_); + if (local_space_) (void)xrDestroySpace(local_space_); + if (session_) (void)xrDestroySession(session_); + if (instance_) (void)xrDestroyInstance(instance_); + } + + void Initialize() { + const char* extension = XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME; + auto info = XrInfo(XR_TYPE_INSTANCE_CREATE_INFO); + std::strcpy(info.applicationInfo.applicationName, "Mocktail VR preview"); + std::strcpy(info.applicationInfo.engineName, "Mocktail"); + info.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0); + info.enabledExtensionCount = 1; + info.enabledExtensionNames = &extension; + Check(xrCreateInstance(&info, &instance_), "xrCreateInstance"); + auto system = XrInfo(XR_TYPE_SYSTEM_GET_INFO); + system.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + Check(xrGetSystem(instance_, &system, &system_), "xrGetSystem"); + + const auto views = Enumerate( + XR_TYPE_VIEW_CONFIGURATION_VIEW, + [&](auto capacity, auto* count, auto* data) { + return xrEnumerateViewConfigurationViews( + instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + capacity, count, data); + }); + Require(views.size() == 2, + "The VR preview requires PRIMARY_STEREO support"); + const auto blends = Enumerate( + XR_TYPE_UNKNOWN, [&](auto capacity, auto* count, auto* data) { + return xrEnumerateEnvironmentBlendModes( + instance_, system_, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + capacity, count, data); + }); + Require(!blends.empty(), "The runtime exposes no environment blend modes"); + blend_ = std::find(blends.begin(), blends.end(), + XR_ENVIRONMENT_BLEND_MODE_OPAQUE) != blends.end() + ? XR_ENVIRONMENT_BLEND_MODE_OPAQUE + : blends.front(); + CreateGraphics(); + auto binding = XrInfo( + XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR); + binding.instance = vk_.instance; + binding.physicalDevice = vk_.physical_device; + binding.device = vk_.device; + binding.queueFamilyIndex = vk_.queue_family; + auto session_info = + XrInfo(XR_TYPE_SESSION_CREATE_INFO); + session_info.next = &binding; + session_info.systemId = system_; + Check(xrCreateSession(instance_, &session_info, &session_), + "xrCreateSession"); + auto space_info = + XrInfo(XR_TYPE_REFERENCE_SPACE_CREATE_INFO); + space_info.poseInReferenceSpace.orientation.w = 1; + space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_LOCAL; + Check(xrCreateReferenceSpace(session_, &space_info, &local_space_), + "xrCreateReferenceSpace LOCAL"); + space_info.referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW; + Check(xrCreateReferenceSpace(session_, &space_info, &view_space_), + "xrCreateReferenceSpace VIEW"); + CreateImages(); + } + + void Run(const PreviewOptions& options, PreviewResult* report) { + const auto ready_deadline = Clock::now() + std::chrono::seconds(30); + auto exit_deadline = Clock::time_point::max(); + bool running = false, exit_requested = false; + while (true) { + while (true) { + auto event = XrInfo(XR_TYPE_EVENT_DATA_BUFFER); + const auto result = xrPollEvent(instance_, &event); + if (result == XR_EVENT_UNAVAILABLE) break; + Check(result, "xrPollEvent"); + if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) + throw std::runtime_error("The OpenXR runtime was lost"); + if (event.type != XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) continue; + const auto& changed = + reinterpret_cast(event); + if (changed.session != session_) continue; + switch (changed.state) { + case XR_SESSION_STATE_READY: { + auto begin = XrInfo(XR_TYPE_SESSION_BEGIN_INFO); + begin.primaryViewConfigurationType = + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO; + Check(xrBeginSession(session_, &begin), "xrBeginSession"); + running = true; + std::cerr << "[VR] Session started. A test panel is fixed two " + "metres ahead.\n"; + break; + } + case XR_SESSION_STATE_STOPPING: + if (running) Check(xrEndSession(session_), "xrEndSession"); + return; + case XR_SESSION_STATE_EXITING: + return; + case XR_SESSION_STATE_LOSS_PENDING: + throw std::runtime_error( + "The OpenXR session was lost; reconnect and launch again"); + default: + break; + } + } + if (interrupted || + (options.frame_limit && report->frames >= options.frame_limit)) { + if (!running) return; + if (!exit_requested) { + Check(xrRequestExitSession(session_), "xrRequestExitSession"); + exit_requested = true; + exit_deadline = Clock::now() + std::chrono::seconds(5); + // Consume an immediate STOPPING event before starting another frame. + continue; + } + } + Require(Clock::now() < exit_deadline, + "The runtime did not stop the VR session after an exit request"); + if (!running) { + Require(Clock::now() < ready_deadline, + "Timed out waiting for the VR session to become READY"); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + Frame(report); + } + } + + private: + void Check(XrResult result, const char* operation) const { + if (XR_SUCCEEDED(result)) return; + char text[XR_MAX_RESULT_STRING_SIZE]{}; + if (instance_) (void)xrResultToString(instance_, result, text); + if (!text[0] && result == XR_ERROR_RUNTIME_UNAVAILABLE) + std::strcpy(text, "XR_ERROR_RUNTIME_UNAVAILABLE"); + std::string message = + std::string(operation) + + " failed: " + (text[0] ? std::string(text) : std::to_string(result)); + if (result == XR_ERROR_RUNTIME_UNAVAILABLE) + message += ". No active OpenXR runtime was found. Start WiVRn for a real " + "headset. Without a headset, run mocktail-vr-simulated from " + "this build directory."; + else if (result == XR_ERROR_FORM_FACTOR_UNAVAILABLE) + message += ". Connect a headset or select a simulated runtime."; + else if (result == XR_ERROR_EXTENSION_NOT_PRESENT) + message += ". XR_KHR_vulkan_enable2 is required."; + throw std::runtime_error(message); + } + + template + T Function(const char* name) { + PFN_xrVoidFunction function = nullptr; + Check(xrGetInstanceProcAddr(instance_, name, &function), name); + Require(function != nullptr, + std::string("Missing OpenXR function: ") + name); + return reinterpret_cast(function); + } + + template + std::vector Enumerate(XrStructureType type, F function) { + for (int retry = 0; retry < 3; ++retry) { + std::uint32_t count = 0; + Check(function(0, &count, static_cast(nullptr)), + "OpenXR enumeration count"); + Require(count <= 4096, "OpenXR enumeration exceeds the supported size"); + std::vector values(count); + if constexpr (std::is_class_v) + for (auto& value : values) value.type = type; + if (!count) return values; + const auto result = function(count, &count, values.data()); + if (result == XR_ERROR_SIZE_INSUFFICIENT) continue; + Check(result, "OpenXR enumeration data"); + Require(count <= values.size(), + "OpenXR enumeration returned an invalid size"); + values.resize(count); + return values; + } + throw std::runtime_error("OpenXR enumeration kept changing size"); + } + + void CreateGraphics() { + auto requirements = XrInfo( + XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR); + Check(Function( + "xrGetVulkanGraphicsRequirements2KHR")(instance_, system_, + &requirements), + "xrGetVulkanGraphicsRequirements2KHR"); + vk_.OpenLoader(); + auto application = + VkInfo(VK_STRUCTURE_TYPE_APPLICATION_INFO); + application.pApplicationName = "Mocktail VR preview"; + const auto version = std::max( + requirements.minApiVersionSupported, XR_MAKE_VERSION(1, 0, 0)); + application.apiVersion = VK_MAKE_API_VERSION(0, XR_VERSION_MAJOR(version), + XR_VERSION_MINOR(version), 0); + auto instance_info = + VkInfo(VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO); + instance_info.pApplicationInfo = &application; + auto xr_instance_info = XrInfo( + XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR); + xr_instance_info.systemId = system_; + xr_instance_info.pfnGetInstanceProcAddr = vk_.get_instance_proc; + xr_instance_info.vulkanCreateInfo = &instance_info; + VkResult result = VK_ERROR_INITIALIZATION_FAILED; + Check(Function("xrCreateVulkanInstanceKHR")( + instance_, &xr_instance_info, &vk_.instance, &result), + "xrCreateVulkanInstanceKHR"); + CheckVk(result, "vkCreateInstance"); + vk_.DestroyInstance = + vk_.InstanceFunction("vkDestroyInstance"); + vk_.get_device_proc = + vk_.InstanceFunction("vkGetDeviceProcAddr"); + auto device_info = XrInfo( + XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR); + device_info.systemId = system_; + device_info.vulkanInstance = vk_.instance; + Check(Function( + "xrGetVulkanGraphicsDevice2KHR")(instance_, &device_info, + &vk_.physical_device), + "xrGetVulkanGraphicsDevice2KHR"); + VkPhysicalDeviceProperties properties{}; + vk_.InstanceFunction( + "vkGetPhysicalDeviceProperties")(vk_.physical_device, &properties); + Require(properties.apiVersion >= application.apiVersion, + "The runtime-selected GPU does not support the required Vulkan " + "version"); + const auto get_families = + vk_.InstanceFunction( + "vkGetPhysicalDeviceQueueFamilyProperties"); + std::uint32_t count = 0; + get_families(vk_.physical_device, &count, nullptr); + Require(count > 0 && count <= 256, + "The runtime-selected GPU has no usable queue families"); + std::vector families(count); + get_families(vk_.physical_device, &count, families.data()); + const auto family = + std::find_if(families.begin(), families.end(), [](const auto& value) { + return value.queueCount && (value.queueFlags & VK_QUEUE_GRAPHICS_BIT); + }); + Require(family != families.end(), + "The runtime-selected GPU has no graphics queue"); + vk_.queue_family = static_cast(family - families.begin()); + const float priority = 1; + auto queue = VkInfo( + VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO); + queue.queueFamilyIndex = vk_.queue_family; + queue.queueCount = 1; + queue.pQueuePriorities = &priority; + auto create = + VkInfo(VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO); + create.queueCreateInfoCount = 1; + create.pQueueCreateInfos = &queue; + auto xr_create = XrInfo( + XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR); + xr_create.systemId = system_; + xr_create.pfnGetInstanceProcAddr = vk_.get_instance_proc; + xr_create.vulkanPhysicalDevice = vk_.physical_device; + xr_create.vulkanCreateInfo = &create; + Check(Function("xrCreateVulkanDeviceKHR")( + instance_, &xr_create, &vk_.device, &result), + "xrCreateVulkanDeviceKHR"); + CheckVk(result, "vkCreateDevice"); + vk_.LoadDeviceFunctions(); + std::cerr << "[VR] Vulkan device: " << properties.deviceName << '\n'; + } + + void CreateImages() { + const auto formats = Enumerate( + XR_TYPE_UNKNOWN, [&](auto capacity, auto* count, auto* data) { + return xrEnumerateSwapchainFormats(session_, capacity, count, data); + }); + VkFormat format = VK_FORMAT_UNDEFINED; + for (auto candidate : + {VK_FORMAT_R8G8B8A8_SRGB, VK_FORMAT_B8G8R8A8_SRGB, + VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_B8G8R8A8_UNORM}) { + if (std::find(formats.begin(), formats.end(), candidate) != + formats.end()) { + format = candidate; + break; + } + } + Require(format != VK_FORMAT_UNDEFINED, + "The runtime exposes no supported RGBA swapchain format"); + auto system_properties = + XrInfo(XR_TYPE_SYSTEM_PROPERTIES); + Check(xrGetSystemProperties(instance_, system_, &system_properties), + "xrGetSystemProperties"); + width_ = std::min( + 512u, system_properties.graphicsProperties.maxSwapchainImageWidth); + height_ = std::min( + 512u, system_properties.graphicsProperties.maxSwapchainImageHeight); + Require(width_ >= 64 && height_ >= 64 && + system_properties.graphicsProperties.maxLayerCount > 0, + "The runtime cannot display the preview panel"); + auto swapchain = + XrInfo(XR_TYPE_SWAPCHAIN_CREATE_INFO); + swapchain.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT; + swapchain.format = format; + swapchain.sampleCount = 1; + swapchain.width = width_; + swapchain.height = height_; + swapchain.faceCount = swapchain.arraySize = swapchain.mipCount = 1; + Check(xrCreateSwapchain(session_, &swapchain, &swapchain_), + "xrCreateSwapchain"); + const auto images = Enumerate( + XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR, + [&](auto capacity, auto* count, auto* data) { + return xrEnumerateSwapchainImages( + swapchain_, capacity, count, + reinterpret_cast(data)); + }); + Require(!images.empty(), "The runtime returned an empty swapchain"); + + VkAttachmentDescription attachment{}; + attachment.format = format; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + attachment.finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference reference{0, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL}; + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &reference; + VkSubpassDependency dependency{}; + dependency.srcSubpass = VK_SUBPASS_EXTERNAL; + dependency.dstSubpass = 0; + dependency.srcStageMask = dependency.dstStageMask = + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependency.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + auto pass = VkInfo( + VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO); + pass.attachmentCount = 1; + pass.pAttachments = &attachment; + pass.subpassCount = 1; + pass.pSubpasses = &subpass; + pass.dependencyCount = 1; + pass.pDependencies = &dependency; + CheckVk(vk_.CreateRenderPass(vk_.device, &pass, nullptr, &render_pass_), + "vkCreateRenderPass"); + for (const auto& image : images) { + auto view_info = VkInfo( + VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO); + view_info.image = image.image; + view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; + view_info.format = format; + view_info.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VkImageView view = VK_NULL_HANDLE; + CheckVk(vk_.CreateImageView(vk_.device, &view_info, nullptr, &view), + "vkCreateImageView"); + image_views_.push_back(view); + auto framebuffer_info = VkInfo( + VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO); + framebuffer_info.renderPass = render_pass_; + framebuffer_info.attachmentCount = 1; + framebuffer_info.pAttachments = &image_views_.back(); + framebuffer_info.width = width_; + framebuffer_info.height = height_; + framebuffer_info.layers = 1; + VkFramebuffer framebuffer = VK_NULL_HANDLE; + CheckVk(vk_.CreateFramebuffer(vk_.device, &framebuffer_info, nullptr, + &framebuffer), + "vkCreateFramebuffer"); + framebuffers_.push_back(framebuffer); + } + auto pool = VkInfo( + VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO); + pool.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool.queueFamilyIndex = vk_.queue_family; + CheckVk(vk_.CreateCommandPool(vk_.device, &pool, nullptr, &pool_), + "vkCreateCommandPool"); + auto allocate = VkInfo( + VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO); + allocate.commandPool = pool_; + allocate.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocate.commandBufferCount = 1; + CheckVk(vk_.AllocateCommandBuffers(vk_.device, &allocate, &command_), + "vkAllocateCommandBuffers"); + auto fence = VkInfo(VK_STRUCTURE_TYPE_FENCE_CREATE_INFO); + CheckVk(vk_.CreateFence(vk_.device, &fence, nullptr, &fence_), + "vkCreateFence"); + } + + void Render(std::uint32_t image, std::uint64_t frame) { + Require(image < framebuffers_.size(), + "The runtime acquired an invalid swapchain image index"); + CheckVk(vk_.ResetCommandBuffer(command_, 0), "vkResetCommandBuffer"); + auto begin = VkInfo( + VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO); + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + CheckVk(vk_.BeginCommandBuffer(command_, &begin), "vkBeginCommandBuffer"); + VkClearValue background{}; + background.color = {{0.018f, 0.024f, 0.04f, 1.f}}; + auto pass = + VkInfo(VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO); + pass.renderPass = render_pass_; + pass.framebuffer = framebuffers_[image]; + pass.renderArea.extent = {width_, height_}; + pass.clearValueCount = 1; + pass.pClearValues = &background; + vk_.CmdBeginRenderPass(command_, &pass, VK_SUBPASS_CONTENTS_INLINE); + auto rectangle = [&](std::uint32_t x, std::uint32_t y, std::uint32_t w, + std::uint32_t h, VkClearColorValue color) { + VkClearAttachment attachment{}; + attachment.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + attachment.clearValue.color = color; + VkClearRect rect{ + {{static_cast(x), static_cast(y)}, + {w, h}}, + 0, + 1}; + vk_.CmdClearAttachments(command_, 1, &attachment, 1, &rect); + }; + for (std::uint32_t y = 0; y < 8; ++y) + for (std::uint32_t x = 0; x < 8; ++x) + if ((x + y) % 2 == 0) + rectangle(x * width_ / 8, y * height_ / 8, width_ / 8, height_ / 8, + x < 4 ? VkClearColorValue{{0.025f, 0.3f, 0.38f, 1.f}} + : VkClearColorValue{{0.23f, 0.08f, 0.4f, 1.f}}); + rectangle(width_ / 2 - 1, height_ / 4, 2, height_ / 2, + {{0.9f, 0.9f, 0.9f, 1.f}}); + rectangle(width_ / 4, height_ / 2 - 1, width_ / 2, 2, + {{0.9f, 0.9f, 0.9f, 1.f}}); + rectangle(static_cast(frame % (width_ - 16)), height_ - 12, + 16, 6, {{0.8f, 0.55f, 0.08f, 1.f}}); + vk_.CmdEndRenderPass(command_); + CheckVk(vk_.EndCommandBuffer(command_), "vkEndCommandBuffer"); + CheckVk(vk_.ResetFences(vk_.device, 1, &fence_), "vkResetFences"); + auto submit = VkInfo(VK_STRUCTURE_TYPE_SUBMIT_INFO); + submit.commandBufferCount = 1; + submit.pCommandBuffers = &command_; + CheckVk(vk_.QueueSubmit(vk_.queue, 1, &submit, fence_), "vkQueueSubmit"); + // One frame in flight for the first prototype: all GPU writes complete + // before release and command-buffer reuse. No runtime-owned image is freed. + CheckVk( + vk_.WaitForFences(vk_.device, 1, &fence_, VK_TRUE, 5'000'000'000ULL), + "vkWaitForFences"); + } + + void Frame(PreviewResult* report) { + auto wait = XrInfo(XR_TYPE_FRAME_WAIT_INFO); + auto state = XrInfo(XR_TYPE_FRAME_STATE); + Check(xrWaitFrame(session_, &wait, &state), "xrWaitFrame"); + auto begin = XrInfo(XR_TYPE_FRAME_BEGIN_INFO); + Check(xrBeginFrame(session_, &begin), "xrBeginFrame"); + auto end = XrInfo(XR_TYPE_FRAME_END_INFO); + end.displayTime = state.predictedDisplayTime; + end.environmentBlendMode = blend_; + bool frame_open = true, image_waited = false; + try { + auto head = XrInfo(XR_TYPE_SPACE_LOCATION); + Check(xrLocateSpace(view_space_, local_space_, state.predictedDisplayTime, + &head), + "xrLocateSpace"); + constexpr auto valid = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + const bool pose_valid = (head.locationFlags & valid) == valid; + if (pose_valid) { + ++report->tracked_frames; + report->head_position = {head.pose.position.x, head.pose.position.y, + head.pose.position.z}; + report->head_orientation = { + head.pose.orientation.x, head.pose.orientation.y, + head.pose.orientation.z, head.pose.orientation.w}; + } + auto layer = + XrInfo(XR_TYPE_COMPOSITION_LAYER_QUAD); + const XrCompositionLayerBaseHeader* layer_pointer = + reinterpret_cast(&layer); + if (state.shouldRender && pose_valid) { + auto acquire = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO); + std::uint32_t image = 0; + Check(xrAcquireSwapchainImage(swapchain_, &acquire, &image), + "xrAcquireSwapchainImage"); + auto image_wait = + XrInfo(XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO); + image_wait.timeout = 50'000'000; + const auto deadline = Clock::now() + std::chrono::seconds(5); + while (true) { + const auto result = xrWaitSwapchainImage(swapchain_, &image_wait); + if (result != XR_TIMEOUT_EXPIRED) { + Check(result, "xrWaitSwapchainImage"); + break; + } + Require(!interrupted && Clock::now() < deadline, + "Timed out waiting for an OpenXR swapchain image"); + } + image_waited = true; + Render(image, report->frames); + auto release = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO); + image_waited = false; + Check(xrReleaseSwapchainImage(swapchain_, &release), + "xrReleaseSwapchainImage"); + layer.space = local_space_; + layer.eyeVisibility = XR_EYE_VISIBILITY_BOTH; + layer.subImage.swapchain = swapchain_; + layer.subImage.imageRect.extent = {static_cast(width_), + static_cast(height_)}; + layer.pose.orientation.w = 1; + layer.pose.position.z = -2; + layer.size = {1.4f, 1.4f}; + end.layerCount = 1; + end.layers = &layer_pointer; + } + frame_open = false; + Check(xrEndFrame(session_, &end), "xrEndFrame"); + ++report->frames; + if (end.layerCount) ++report->rendered_frames; + } catch (...) { + if (image_waited) { + if (vk_.DeviceWaitIdle) (void)vk_.DeviceWaitIdle(vk_.device); + auto release = XrInfo( + XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO); + (void)xrReleaseSwapchainImage(swapchain_, &release); + } + if (frame_open) { + end.layerCount = 0; + end.layers = nullptr; + (void)xrEndFrame(session_, &end); + } + throw; + } + } + + Vulkan vk_; + XrInstance instance_ = XR_NULL_HANDLE; + XrSystemId system_ = XR_NULL_SYSTEM_ID; + XrSession session_ = XR_NULL_HANDLE; + XrSpace local_space_ = XR_NULL_HANDLE, view_space_ = XR_NULL_HANDLE; + XrSwapchain swapchain_ = XR_NULL_HANDLE; + XrEnvironmentBlendMode blend_ = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + std::uint32_t width_ = 512, height_ = 512; + VkRenderPass render_pass_ = VK_NULL_HANDLE; + VkCommandPool pool_ = VK_NULL_HANDLE; + VkCommandBuffer command_ = VK_NULL_HANDLE; + VkFence fence_ = VK_NULL_HANDLE; + std::vector image_views_; + std::vector framebuffers_; +}; + +} // namespace + +PreviewResult RunOpenXrPreview(const PreviewOptions& options) { + PreviewResult result; + SignalScope signals; + try { + Preview preview; + preview.Initialize(); + std::cerr << "[VR] Experimental test scene. Roblox rendering is not " + "connected. Ctrl+C exits.\n"; + preview.Run(options, &result); + result.success = true; + result.message = "VR preview closed"; + } catch (const std::exception& error) { + result.message = error.what(); + } + return result; +} + +} // namespace mocktail::vr diff --git a/src/vr/openxr_probe.cc b/src/vr/openxr_probe.cc new file mode 100644 index 0000000..1963979 --- /dev/null +++ b/src/vr/openxr_probe.cc @@ -0,0 +1,209 @@ +#include "mocktail/vr/openxr_probe.h" + +#include +#include +#include + +#include +#include +#include + +namespace mocktail::vr { +namespace { + +std::string Version(XrVersion version) { + return std::to_string(XR_VERSION_MAJOR(version)) + "." + + std::to_string(XR_VERSION_MINOR(version)) + "." + + std::to_string(XR_VERSION_PATCH(version)); +} + +struct InstanceScope { + XrInstance instance = XR_NULL_HANDLE; + ~InstanceScope() { + if (instance != XR_NULL_HANDLE) (void)xrDestroyInstance(instance); + } +}; + +void Failure(ProbeReport* report, XrInstance instance, const char* operation, + XrResult result) { + report->status = ProbeStatus::kError; + if (result == XR_ERROR_RUNTIME_UNAVAILABLE) { + report->status = ProbeStatus::kRuntimeUnavailable; + } else if (result == XR_ERROR_FORM_FACTOR_UNAVAILABLE) { + report->status = ProbeStatus::kHeadsetUnavailable; + } else if (result == XR_ERROR_FORM_FACTOR_UNSUPPORTED || + result == XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED) { + report->status = ProbeStatus::kIncompatibleRuntime; + } + char name[XR_MAX_RESULT_STRING_SIZE] = {}; + if (instance != XR_NULL_HANDLE) + (void)xrResultToString(instance, result, name); + report->message = std::string(operation) + " failed: " + + (name[0] ? std::string(name) : std::to_string(result)); + if (report->status == ProbeStatus::kRuntimeUnavailable) { + report->message += ". Start an OpenXR runtime such as WiVRn or Monado."; + } else if (report->status == ProbeStatus::kHeadsetUnavailable) { + report->message += + ". Connect a headset, or use a simulated runtime for development."; + } +} + +// Counts can change between the two enumeration calls. Retry a bounded number +// of times and never consume an output array after XR_ERROR_SIZE_INSUFFICIENT. +template +XrResult EnumerateProperties(XrStructureType type, Enumerate enumerate, + std::vector* values) { + for (int attempt = 0; attempt < 3; ++attempt) { + std::uint32_t count = 0; + XrResult result = enumerate(0, &count, nullptr); + if (XR_FAILED(result)) return result; + if (count > 4096) return XR_ERROR_LIMIT_REACHED; + values->assign(count, T{}); + for (auto& value : *values) value.type = type; + if (count == 0) return XR_SUCCESS; + result = enumerate(count, &count, values->data()); + if (result == XR_ERROR_SIZE_INSUFFICIENT) continue; + if (XR_FAILED(result)) return result; + if (count > values->size()) return XR_ERROR_RUNTIME_FAILURE; + values->resize(count); + return XR_SUCCESS; + } + return XR_ERROR_SIZE_INSUFFICIENT; +} + +} // namespace + +ProbeReport ProbeOpenXr() { + ProbeReport report; + report.sdk_version = Version(XR_CURRENT_API_VERSION); + std::vector extensions; + XrResult result = EnumerateProperties( + XR_TYPE_EXTENSION_PROPERTIES, + [](std::uint32_t capacity, std::uint32_t* count, + XrExtensionProperties* data) { + return xrEnumerateInstanceExtensionProperties(nullptr, capacity, count, + data); + }, + &extensions); + if (XR_FAILED(result)) { + Failure(&report, XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties", + result); + return report; + } + for (const auto& extension : extensions) { + report.extensions.emplace_back(extension.extensionName); + } + const bool vulkan = + std::find(report.extensions.begin(), report.extensions.end(), + XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME) != + report.extensions.end(); + const char* enabled_extension = XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME; + XrInstanceCreateInfo create_info{}; + create_info.type = XR_TYPE_INSTANCE_CREATE_INFO; + std::strcpy(create_info.applicationInfo.applicationName, "Mocktail VR probe"); + std::strcpy(create_info.applicationInfo.engineName, "Mocktail"); + // Use the latest SDK while requiring only the core API this probe actually + // uses, so runtimes exposing OpenXR 1.0 can also be diagnosed. + create_info.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0); + create_info.enabledExtensionCount = vulkan ? 1 : 0; + create_info.enabledExtensionNames = vulkan ? &enabled_extension : nullptr; + InstanceScope scope; + result = xrCreateInstance(&create_info, &scope.instance); + if (XR_FAILED(result)) { + Failure(&report, XR_NULL_HANDLE, "xrCreateInstance", result); + return report; + } + XrInstanceProperties properties{}; + properties.type = XR_TYPE_INSTANCE_PROPERTIES; + result = xrGetInstanceProperties(scope.instance, &properties); + if (XR_FAILED(result)) { + Failure(&report, scope.instance, "xrGetInstanceProperties", result); + return report; + } + report.runtime_name = properties.runtimeName; + report.runtime_version = Version(properties.runtimeVersion); + + XrSystemGetInfo system_info{}; + system_info.type = XR_TYPE_SYSTEM_GET_INFO; + system_info.formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY; + XrSystemId system = XR_NULL_SYSTEM_ID; + result = xrGetSystem(scope.instance, &system_info, &system); + if (XR_FAILED(result)) { + Failure(&report, scope.instance, "xrGetSystem", result); + return report; + } + XrSystemProperties system_properties{}; + system_properties.type = XR_TYPE_SYSTEM_PROPERTIES; + result = xrGetSystemProperties(scope.instance, system, &system_properties); + if (XR_FAILED(result)) { + Failure(&report, scope.instance, "xrGetSystemProperties", result); + return report; + } + report.system_name = system_properties.systemName; + report.orientation_tracking = + system_properties.trackingProperties.orientationTracking == XR_TRUE; + report.position_tracking = + system_properties.trackingProperties.positionTracking == XR_TRUE; + if (!vulkan) { + report.status = ProbeStatus::kIncompatibleRuntime; + report.message = "The runtime does not expose XR_KHR_vulkan_enable2."; + return report; + } + + std::vector views; + result = EnumerateProperties( + XR_TYPE_VIEW_CONFIGURATION_VIEW, + [&](std::uint32_t capacity, std::uint32_t* count, + XrViewConfigurationView* data) { + return xrEnumerateViewConfigurationViews( + scope.instance, system, XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, + capacity, count, data); + }, + &views); + if (XR_FAILED(result)) { + Failure(&report, scope.instance, "xrEnumerateViewConfigurationViews", + result); + return report; + } + for (const auto& view : views) { + report.views.push_back({view.recommendedImageRectWidth, + view.recommendedImageRectHeight, + view.recommendedSwapchainSampleCount}); + } + if (views.size() != 2 || std::any_of(report.views.begin(), report.views.end(), + [](const ViewConfiguration& view) { + return !view.width || !view.height || + !view.samples; + })) { + report.status = ProbeStatus::kIncompatibleRuntime; + report.message = "The runtime did not provide two usable stereo views."; + return report; + } + + PFN_xrVoidFunction function = nullptr; + result = xrGetInstanceProcAddr( + scope.instance, "xrGetVulkanGraphicsRequirements2KHR", &function); + if (XR_FAILED(result) || function == nullptr) { + Failure(&report, scope.instance, + "xrGetVulkanGraphicsRequirements2KHR lookup", + XR_FAILED(result) ? result : XR_ERROR_FUNCTION_UNSUPPORTED); + return report; + } + XrGraphicsRequirementsVulkan2KHR requirements{}; + requirements.type = XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR; + result = reinterpret_cast(function)( + scope.instance, system, &requirements); + if (XR_FAILED(result)) { + Failure(&report, scope.instance, "xrGetVulkanGraphicsRequirements2KHR", + result); + return report; + } + report.minimum_vulkan_version = Version(requirements.minApiVersionSupported); + report.maximum_vulkan_version = Version(requirements.maxApiVersionSupported); + report.status = ProbeStatus::kReady; + report.message = + "OpenXR HMD and Vulkan requirements discovered successfully."; + return report; +} + +} // namespace mocktail::vr diff --git a/src/vr/probe_main.cc b/src/vr/probe_main.cc new file mode 100644 index 0000000..a8b6a85 --- /dev/null +++ b/src/vr/probe_main.cc @@ -0,0 +1,50 @@ +#include "mocktail/vr/openxr_probe.h" +#include "mocktail/vr/openxr_preview.h" + +#include +#include +#include + +int main(int argc, char* argv[]) { + if (argc == 2 && (std::string_view(argv[1]) == "--help" || + std::string_view(argv[1]) == "-h")) { + std::cout + << "Usage: mocktail-vr-probe [--scene [--frames N]]\n" + "Inspect the host OpenXR runtime and HMD capabilities.\n" + "Uses the active runtime, or XR_RUNTIME_JSON if set.\n" + "Without arguments: discovery only, no XR session.\n" + "--scene: render a tracked test panel in a real Vulkan/XR session.\n" + "--frames N: request exit after N frames (positive integer).\n" + "Ctrl+C or the runtime closes the scene. Does not load Roblox.\n" + "Exit codes: 0=discovery ready, 1=API error, 2=build disabled,\n" + "3=runtime unavailable, 4=HMD unavailable, 5=incompatible " + "runtime.\n"; + return 0; + } + if (argc >= 2 && std::string_view(argv[1]) == "--scene") { + mocktail::vr::PreviewOptions options; + if (argc == 4 && std::string_view(argv[2]) == "--frames") { + const std::string_view value(argv[3]); + const auto parsed = std::from_chars( + value.data(), value.data() + value.size(), options.frame_limit); + if (parsed.ec != std::errc() || + parsed.ptr != value.data() + value.size() || !options.frame_limit) { + std::cerr << "--frames requires a positive integer.\n"; + return 1; + } + } else if (argc != 2) { + std::cerr << "Use --scene [--frames N].\n"; + return 1; + } + const auto result = mocktail::vr::RunOpenXrPreview(options); + std::cout << mocktail::vr::FormatPreviewResult(result); + return result.success ? 0 : 1; + } + if (argc != 1) { + std::cerr << "Use mocktail-vr-probe --help for supported options.\n"; + return 1; + } + const auto report = mocktail::vr::ProbeOpenXr(); + std::cout << mocktail::vr::FormatProbeReport(report); + return mocktail::vr::ProbeExitCode(report.status); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ceb6ee2..21bb1e0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,6 +2,34 @@ # # Licensed under the Apache License, Version 2.0 (the "License") +find_package(Python3 COMPONENTS Interpreter REQUIRED) +add_test(NAME OpenXrSimulatorLauncher + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/simulator_launcher_test.py" + "${CMAKE_BINARY_DIR}/mocktail-vr-simulated") +if(MOCKTAIL_ENABLE_VR) + add_library(mocktail_test_openxr_runtime MODULE + vr/fake_openxr_runtime.cc vr/fake_openxr_preview.cc) + target_link_libraries(mocktail_test_openxr_runtime PRIVATE + OpenXR::headers Vulkan::Headers) + target_compile_definitions(mocktail_test_openxr_runtime PRIVATE + XR_USE_GRAPHICS_API_VULKAN) + set_target_properties(mocktail_test_openxr_runtime PROPERTIES + CXX_VISIBILITY_PRESET hidden) + mocktail_apply_compile_options(mocktail_test_openxr_runtime) + add_test(NAME OpenXrProbeContract + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/probe_contract_test.py" + "$" "$") + add_test(NAME OpenXrPreviewContract + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/preview_contract_test.py" + "$" "$" + "$") + set_tests_properties(OpenXrPreviewContract PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 90) +else() + add_test(NAME OpenXrProbeDisabled + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/vr/probe_contract_test.py" + "$" --disabled "$") +endif() + add_executable(jnivm_test jnivm_test.cc ) diff --git a/tests/command_line_test.cc b/tests/command_line_test.cc index 75fdfe9..ee69e95 100644 --- a/tests/command_line_test.cc +++ b/tests/command_line_test.cc @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -11,6 +12,52 @@ namespace mocktail { namespace runtime { namespace { +TEST(CommandLineTest, VrOverrideSurvivesReexecAndCanDisableEnvironmentDefault) { + const std::array arguments = {"mocktail", "--vr", + "--headless", "--no-vr"}; + const auto parsed = ParseCommandLine(arguments.size(), arguments.data()); + ASSERT_TRUE(parsed) << parsed.error; + ASSERT_TRUE(parsed.options.vr_enabled.has_value()); + EXPECT_FALSE(*parsed.options.vr_enabled); + std::vector reexec; + std::string error; + ASSERT_TRUE(BuildCommandLineReexecArguments( + parsed.options, arguments.size(), arguments.data(), &reexec, &error)) + << error; + EXPECT_EQ(reexec, + (std::vector{"--vr", "--headless", "--no-vr"})); + + const char* previous = std::getenv("MOCKTAIL_VR_ENABLED"); + const std::optional saved = + previous ? std::optional(previous) : std::nullopt; + const char* previous_headless = std::getenv("MOCKTAIL_HEADLESS"); + const std::optional saved_headless = + previous_headless ? std::optional(previous_headless) + : std::nullopt; + setenv("MOCKTAIL_VR_ENABLED", "true", 1); + EXPECT_TRUE(ApplyCommandLineEnvironment(parsed.options, &error)) << error; + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0"); + if (saved) + setenv("MOCKTAIL_VR_ENABLED", saved->c_str(), 1); + else + unsetenv("MOCKTAIL_VR_ENABLED"); + if (saved_headless) + setenv("MOCKTAIL_HEADLESS", saved_headless->c_str(), 1); + else + unsetenv("MOCKTAIL_HEADLESS"); +} + +TEST(CommandLineTest, ParsesExplicitVrAndKeepsUnspecifiedDistinctFromFalse) { + const char* enabled[] = {"mocktail", "--vr"}; + const auto parsed = ParseCommandLine(2, enabled); + ASSERT_TRUE(parsed) << parsed.error; + EXPECT_EQ(parsed.options.vr_enabled, std::optional(true)); + const char* normal[] = {"mocktail"}; + EXPECT_FALSE(ParseCommandLine(1, normal).options.vr_enabled.has_value()); + const char* incompatible[] = {"mocktail", "--force-run-latest", "--vr"}; + EXPECT_FALSE(ParseCommandLine(3, incompatible)); +} + TEST(CommandLineTest, ParsesCompleteRunContractOnce) { const std::array arguments = { "mocktail", diff --git a/tests/runtime_config_file_test.cc b/tests/runtime_config_file_test.cc index cb4b555..e4ce0b2 100644 --- a/tests/runtime_config_file_test.cc +++ b/tests/runtime_config_file_test.cc @@ -99,6 +99,7 @@ TEST(RuntimeConfigBootstrapTest, CreatesCompletePrivateFirstRunFile) { LoadRuntimeConfig(MapEnvironment(), file); ASSERT_TRUE(loaded) << loaded.error; EXPECT_TRUE(loaded.file_loaded); + EXPECT_FALSE(loaded.config.vr_enabled()); EXPECT_EQ(loaded.config.graphics_backend(), GraphicsBackend::kVulkan); EXPECT_EQ(loaded.config.theme_mode(), "roblox"); EXPECT_EQ(loaded.config.frame_rate().mode, FrameRateLimitMode::kUnmanaged); @@ -115,6 +116,58 @@ TEST(RuntimeConfigBootstrapTest, CreatesCompletePrivateFirstRunFile) { EXPECT_EQ(loaded.config.device_profile().name, "pc-windows-11"); } +TEST(RuntimeConfigFileTest, + VrEnvironmentOverridesYamlIncludingExplicitDisable) { + TemporaryDirectory temporary; + const auto file = temporary.Write("version: 1\nvr:\n enabled: true\n"); + const auto enabled = LoadRuntimeConfig(MapEnvironment(), file); + ASSERT_TRUE(enabled) << enabled.error; + EXPECT_TRUE(enabled.config.vr_enabled()); + const auto disabled = + LoadRuntimeConfig(MapEnvironment({{"MOCKTAIL_VR_ENABLED", "0"}}), file); + ASSERT_TRUE(disabled) << disabled.error; + EXPECT_FALSE(disabled.config.vr_enabled()); + const auto invalid = LoadRuntimeConfig( + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "maybe"}}), file); + EXPECT_FALSE(invalid); + EXPECT_NE(invalid.error.find("VR configuration"), std::string::npos); +} + +TEST(RuntimeConfigFileTest, RejectsMalformedVrSectionAndUnsupportedKeys) { + TemporaryDirectory temporary; + for (const auto* yaml : + {"vr: true\n", "vr:\n enabled: perhaps\n", "vr:\n enabled: [true]\n", + "vr:\n enable: true\n", "vr:\n enabled: true\n enabled: false\n"}) { + const auto result = + LoadRuntimeConfig(MapEnvironment(), temporary.Write(yaml)); + EXPECT_FALSE(result) << yaml; + } +} + +TEST(RuntimeConfigFileTest, ExportsResolvedVrStateAndRejectsInvalidState) { + const char* previous = std::getenv("MOCKTAIL_VR_ENABLED"); + const std::optional saved = + previous ? std::optional(previous) : std::nullopt; + std::string error; + const auto enabled = RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "on"}})); + EXPECT_TRUE(ExportRuntimeConfigEnvironment(enabled, &error)) << error; + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "1"); + EXPECT_TRUE(ExportRuntimeConfigEnvironment( + RuntimeConfig::FromEnvironment(MapEnvironment()), &error)) + << error; + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0"); + EXPECT_FALSE(ExportRuntimeConfigEnvironment( + RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "invalid"}})), + &error)); + EXPECT_STREQ(std::getenv("MOCKTAIL_VR_ENABLED"), "0"); + if (saved) + setenv("MOCKTAIL_VR_ENABLED", saved->c_str(), 1); + else + unsetenv("MOCKTAIL_VR_ENABLED"); +} + TEST(RuntimeConfigBootstrapTest, MatchesShippedExampleAndDocumentsEveryDefault) { const std::filesystem::path example = diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 357f224..74ac297 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -37,6 +37,8 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) { const RuntimeConfig config = RuntimeConfig::FromEnvironment(environment); EXPECT_FALSE(config.headless()); + EXPECT_FALSE(config.vr_enabled()); + EXPECT_TRUE(config.vr_valid()); EXPECT_EQ(config.roblox_library_path(), "rbx_bin/libroblox.so"); EXPECT_EQ(config.graphics_backend(), GraphicsBackend::kVulkan); EXPECT_EQ(config.graphics_backend_name(), "direct-vulkan"); @@ -89,9 +91,17 @@ TEST(RuntimeConfigTest, UsesSupportedDefaults) { EXPECT_TRUE(config.unsafe_detached_thread_overrides().empty()); } +TEST(RuntimeConfigTest, RejectsMalformedVrEnvironmentInsteadOfEnablingIt) { + const auto config = RuntimeConfig::FromEnvironment( + MapEnvironment({{"MOCKTAIL_VR_ENABLED", "maybe"}})); + EXPECT_FALSE(config.vr_valid()); + EXPECT_FALSE(config.vr_enabled()); +} + TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) { const MapEnvironment environment({ {"MOCKTAIL_HEADLESS", "1"}, + {"MOCKTAIL_VR_ENABLED", "true"}, {"ROBLOX_LIB_PATH", "/tmp/libroblox.so"}, {"MOCKTAIL_GRAPHICS_BACKEND", "vulkan"}, {"MOCKTAIL_WIN_WIDTH", "1920"}, @@ -122,6 +132,8 @@ TEST(RuntimeConfigTest, ReadsTypedRuntimeValues) { const RuntimeConfig config = RuntimeConfig::FromEnvironment(environment); EXPECT_TRUE(config.headless()); + EXPECT_TRUE(config.vr_enabled()); + EXPECT_TRUE(config.vr_valid()); EXPECT_EQ(config.roblox_library_path(), "/tmp/libroblox.so"); EXPECT_EQ(config.graphics_backend(), GraphicsBackend::kVulkan); EXPECT_EQ(config.window().width, 1920); diff --git a/tests/vr/fake_openxr_preview.cc b/tests/vr/fake_openxr_preview.cc new file mode 100644 index 0000000..eb9d673 --- /dev/null +++ b/tests/vr/fake_openxr_preview.cc @@ -0,0 +1,508 @@ +// Session contract fixture. Vulkan devices, images, rendering and readback are +// real; OpenXR scheduling/tracking are scripted to exercise error paths. +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool Scenario(const char* name) { + const char* value = std::getenv("MOCKTAIL_TEST_XR_SCENARIO"); + return value && std::strcmp(value, name) == 0; +} + +void Trace(const char* event) { + if (const char* path = std::getenv("MOCKTAIL_TEST_XR_TRACE")) { + if (auto* file = std::fopen(path, "a")) { + std::fprintf(file, "%s\n", event); + std::fclose(file); + } + } +} + +#define REQUIRE(value) \ + if (!(value)) { \ + Trace("contract-error: " #value); \ + return XR_ERROR_VALIDATION_FAILURE; \ + } + +struct Fixture { + PFN_vkGetInstanceProcAddr get_instance_proc = nullptr; + PFN_vkGetDeviceProcAddr get_device_proc = nullptr; + VkInstance instance = VK_NULL_HANDLE; + VkPhysicalDevice physical = VK_NULL_HANDLE; + VkDevice device = VK_NULL_HANDLE; + VkQueue queue = VK_NULL_HANDLE; + std::uint32_t queue_family = 0; + std::array images{}; + std::array memory{}; + std::uint32_t width = 0, height = 0, index = 0, frames = 0; + bool running = false, begun = false, acquired = false, waited = false; + bool timeout_returned = false, pixels_verified = false; + int local = 0, view = 0, swapchain = 0; + XrSessionState state = XR_SESSION_STATE_IDLE; + std::deque events; +} fixture; + +XrSession Session() { return reinterpret_cast(&fixture); } +XrSwapchain Swapchain() { + return reinterpret_cast(&fixture.swapchain); +} +XrSpace Local() { return reinterpret_cast(&fixture.local); } +XrSpace View() { return reinterpret_cast(&fixture.view); } + +template +T Device(const char* name) { + return reinterpret_cast(fixture.get_device_proc(fixture.device, name)); +} +#define VK(name, ...) Device("vk" #name)(__VA_ARGS__) + +std::uint32_t MemoryType(std::uint32_t bits, VkMemoryPropertyFlags flags) { + VkPhysicalDeviceMemoryProperties memory{}; + reinterpret_cast( + fixture.get_instance_proc(fixture.instance, + "vkGetPhysicalDeviceMemoryProperties"))( + fixture.physical, &memory); + for (std::uint32_t i = 0; i < memory.memoryTypeCount; ++i) + if ((bits & (1u << i)) && + (memory.memoryTypes[i].propertyFlags & flags) == flags) + return i; + return UINT32_MAX; +} + +// Independently inspect submitted pixels, so a successful xrEndFrame alone +// cannot make an empty or unrendered texture pass the integration test. +bool VerifyPixels() { + struct Resources { + VkBuffer buffer = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkCommandPool pool = VK_NULL_HANDLE; + ~Resources() { + VK(DeviceWaitIdle, fixture.device); + if (pool) VK(DestroyCommandPool, fixture.device, pool, nullptr); + if (buffer) VK(DestroyBuffer, fixture.device, buffer, nullptr); + if (memory) VK(FreeMemory, fixture.device, memory, nullptr); + } + } resources; + VkBufferCreateInfo buffer{}; + buffer.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + buffer.size = fixture.width * fixture.height * 4; + buffer.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + if (VK(CreateBuffer, fixture.device, &buffer, nullptr, &resources.buffer) != + VK_SUCCESS) + return false; + VkMemoryRequirements requirements{}; + VK(GetBufferMemoryRequirements, fixture.device, resources.buffer, + &requirements); + VkMemoryAllocateInfo allocate{}; + allocate.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocate.allocationSize = requirements.size; + allocate.memoryTypeIndex = MemoryType( + requirements.memoryTypeBits, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + if (allocate.memoryTypeIndex == UINT32_MAX || + VK(AllocateMemory, fixture.device, &allocate, nullptr, + &resources.memory) != VK_SUCCESS || + VK(BindBufferMemory, fixture.device, resources.buffer, resources.memory, + 0) != VK_SUCCESS) + return false; + VkCommandPoolCreateInfo pool{}; + pool.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool.queueFamilyIndex = fixture.queue_family; + if (VK(CreateCommandPool, fixture.device, &pool, nullptr, &resources.pool) != + VK_SUCCESS) + return false; + VkCommandBufferAllocateInfo commands{}; + commands.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + commands.commandPool = resources.pool; + commands.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + commands.commandBufferCount = 1; + VkCommandBuffer command = VK_NULL_HANDLE; + if (VK(AllocateCommandBuffers, fixture.device, &commands, &command) != + VK_SUCCESS) + return false; + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + if (VK(BeginCommandBuffer, command, &begin) != VK_SUCCESS) return false; + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = + VK_QUEUE_FAMILY_IGNORED; + barrier.image = fixture.images[fixture.index]; + barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + VK(CmdPipelineBarrier, command, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkBufferImageCopy copy{}; + copy.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + copy.imageExtent = {fixture.width, fixture.height, 1}; + VK(CmdCopyImageToBuffer, command, barrier.image, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, resources.buffer, 1, ©); + barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VK(CmdPipelineBarrier, command, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, 0, nullptr, + 1, &barrier); + if (VK(EndCommandBuffer, command) != VK_SUCCESS) return false; + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &command; + if (VK(QueueSubmit, fixture.queue, 1, &submit, VK_NULL_HANDLE) != + VK_SUCCESS || + VK(QueueWaitIdle, fixture.queue) != VK_SUCCESS) + return false; + void* mapped = nullptr; + if (VK(MapMemory, fixture.device, resources.memory, 0, VK_WHOLE_SIZE, 0, + &mapped) != VK_SUCCESS) + return false; + const auto* bytes = static_cast(mapped); + const auto* cyan = + bytes + (fixture.height / 16 * fixture.width + fixture.width / 16) * 4; + const auto* purple = cyan + fixture.width / 2 * 4; + const bool valid = cyan[1] > 60 && cyan[2] > 80 && cyan[0] < 15 && + purple[0] > 40 && purple[1] < 35 && purple[2] > 80; + VK(UnmapMemory, fixture.device, resources.memory); + if (valid) Trace("pixels-verified"); + return valid; +} + +XrResult XRAPI_CALL +CreateVulkanInstance(XrInstance, const XrVulkanInstanceCreateInfoKHR* info, + VkInstance* instance, VkResult* result) { + fixture.get_instance_proc = info->pfnGetInstanceProcAddr; + *result = reinterpret_cast( + fixture.get_instance_proc(VK_NULL_HANDLE, "vkCreateInstance"))( + info->vulkanCreateInfo, info->vulkanAllocator, instance); + if (*result == VK_SUCCESS) { + fixture.instance = *instance; + fixture.get_device_proc = reinterpret_cast( + fixture.get_instance_proc(*instance, "vkGetDeviceProcAddr")); + } + return XR_SUCCESS; +} + +XrResult XRAPI_CALL +GetVulkanDevice(XrInstance, const XrVulkanGraphicsDeviceGetInfoKHR* info, + VkPhysicalDevice* device) { + const auto enumerate = reinterpret_cast( + fixture.get_instance_proc(info->vulkanInstance, + "vkEnumeratePhysicalDevices")); + std::uint32_t count = 0; + if (enumerate(info->vulkanInstance, &count, nullptr) != VK_SUCCESS || !count) + return XR_ERROR_GRAPHICS_DEVICE_INVALID; + std::vector devices(count); + if (enumerate(info->vulkanInstance, &count, devices.data()) != VK_SUCCESS) + return XR_ERROR_RUNTIME_FAILURE; + *device = fixture.physical = devices.front(); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL CreateVulkanDevice(XrInstance, + const XrVulkanDeviceCreateInfoKHR* info, + VkDevice* device, VkResult* result) { + if (Scenario("device_failure")) { + *result = VK_ERROR_FEATURE_NOT_PRESENT; + return XR_SUCCESS; + } + *result = reinterpret_cast(fixture.get_instance_proc( + fixture.instance, "vkCreateDevice"))(info->vulkanPhysicalDevice, + info->vulkanCreateInfo, + info->vulkanAllocator, device); + if (*result == VK_SUCCESS) fixture.device = *device; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EnumerateBlends(XrInstance, XrSystemId, + XrViewConfigurationType, + std::uint32_t capacity, + std::uint32_t* count, + XrEnvironmentBlendMode* blends) { + *count = 1; + if (capacity) blends[0] = XR_ENVIRONMENT_BLEND_MODE_OPAQUE; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL CreateSession(XrInstance, const XrSessionCreateInfo* info, + XrSession* session) { + if (Scenario("session_failure")) return XR_ERROR_INITIALIZATION_FAILED; + const auto* binding = + static_cast(info->next); + REQUIRE(binding && binding->type == XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR); + REQUIRE(binding->device == fixture.device && + binding->physicalDevice == fixture.physical); + fixture.queue_family = binding->queueFamilyIndex; + VK(GetDeviceQueue, fixture.device, binding->queueFamilyIndex, + binding->queueIndex, &fixture.queue); + *session = Session(); + fixture.events.push_back(XR_SESSION_STATE_READY); + Trace("session-create"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL DestroySession(XrSession) { + REQUIRE(fixture.images[0] == VK_NULL_HANDLE); + Trace("session-destroy"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL PollEvent(XrInstance, XrEventDataBuffer* buffer) { + if (fixture.events.empty()) return XR_EVENT_UNAVAILABLE; + XrEventDataSessionStateChanged event{}; + event.type = XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED; + event.session = Session(); + event.state = fixture.state = fixture.events.front(); + fixture.events.pop_front(); + std::memcpy(buffer, &event, sizeof(event)); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL BeginSession(XrSession, const XrSessionBeginInfo* begin) { + REQUIRE(fixture.state == XR_SESSION_STATE_READY && !fixture.running); + REQUIRE(begin->primaryViewConfigurationType == + XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO); + fixture.running = true; + fixture.events.push_back(XR_SESSION_STATE_FOCUSED); + Trace("session-begin"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL RequestExit(XrSession) { + REQUIRE(fixture.running); + fixture.events.push_back(XR_SESSION_STATE_STOPPING); + Trace("session-exit-request"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EndSession(XrSession) { + REQUIRE(fixture.state == XR_SESSION_STATE_STOPPING && fixture.running); + fixture.running = false; + Trace("session-end"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL CreateSpace(XrSession, + const XrReferenceSpaceCreateInfo* info, + XrSpace* space) { + REQUIRE(info->poseInReferenceSpace.orientation.w == 1); + REQUIRE(info->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_LOCAL || + info->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_VIEW); + *space = info->referenceSpaceType == XR_REFERENCE_SPACE_TYPE_LOCAL ? Local() + : View(); + Trace("space-create"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL DestroySpace(XrSpace) { + Trace("space-destroy"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL LocateSpace(XrSpace space, XrSpace base, XrTime, + XrSpaceLocation* location) { + REQUIRE(space == View() && base == Local()); + location->locationFlags = Scenario("invalid_tracking") && fixture.frames == 0 + ? 0 + : XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | + XR_SPACE_LOCATION_POSITION_VALID_BIT; + location->pose.orientation.w = 1; + location->pose.position.x = static_cast(fixture.frames) / 10; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EnumerateFormats(XrSession, std::uint32_t capacity, + std::uint32_t* count, + std::int64_t* formats) { + *count = 1; + if (capacity) formats[0] = VK_FORMAT_R8G8B8A8_UNORM; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL CreateSwapchain(XrSession, + const XrSwapchainCreateInfo* info, + XrSwapchain* swapchain) { + if (Scenario("swapchain_failure")) return XR_ERROR_RUNTIME_FAILURE; + REQUIRE(info->sampleCount == 1 && info->arraySize == 1 && + info->faceCount == 1 && info->mipCount == 1); + fixture.width = info->width; + fixture.height = info->height; + VkImageCreateInfo create{}; + create.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + create.imageType = VK_IMAGE_TYPE_2D; + create.format = static_cast(info->format); + create.extent = {info->width, info->height, 1}; + create.mipLevels = create.arrayLayers = 1; + create.samples = VK_SAMPLE_COUNT_1_BIT; + create.usage = + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + for (std::size_t i = 0; i < fixture.images.size(); ++i) { + REQUIRE(VK(CreateImage, fixture.device, &create, nullptr, + &fixture.images[i]) == VK_SUCCESS); + VkMemoryRequirements requirements{}; + VK(GetImageMemoryRequirements, fixture.device, fixture.images[i], + &requirements); + VkMemoryAllocateInfo memory{}; + memory.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + memory.allocationSize = requirements.size; + memory.memoryTypeIndex = MemoryType(requirements.memoryTypeBits, 0); + REQUIRE(memory.memoryTypeIndex != UINT32_MAX); + REQUIRE(VK(AllocateMemory, fixture.device, &memory, nullptr, + &fixture.memory[i]) == VK_SUCCESS); + REQUIRE(VK(BindImageMemory, fixture.device, fixture.images[i], + fixture.memory[i], 0) == VK_SUCCESS); + } + *swapchain = Swapchain(); + Trace("swapchain-create"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL DestroySwapchain(XrSwapchain) { + for (std::size_t i = 0; i < fixture.images.size(); ++i) { + if (fixture.images[i]) + VK(DestroyImage, fixture.device, fixture.images[i], nullptr); + if (fixture.memory[i]) + VK(FreeMemory, fixture.device, fixture.memory[i], nullptr); + fixture.images[i] = VK_NULL_HANDLE; + } + Trace("swapchain-destroy"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EnumerateImages(XrSwapchain, std::uint32_t capacity, + std::uint32_t* count, + XrSwapchainImageBaseHeader* base) { + *count = fixture.images.size(); + if (!capacity) return XR_SUCCESS; + if (capacity < *count) return XR_ERROR_SIZE_INSUFFICIENT; + auto* images = reinterpret_cast(base); + for (std::uint32_t i = 0; i < *count; ++i) { + REQUIRE(images[i].type == XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR); + images[i].image = fixture.images[i]; + } + return XR_SUCCESS; +} + +XrResult XRAPI_CALL WaitFrame(XrSession, const XrFrameWaitInfo*, + XrFrameState* state) { + REQUIRE(fixture.running && !fixture.begun); + state->predictedDisplayTime = (fixture.frames + 1) * 16'666'666; + state->predictedDisplayPeriod = 16'666'666; + state->shouldRender = !(Scenario("skip_render") && fixture.frames == 0); + Trace("frame-wait"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL BeginFrame(XrSession, const XrFrameBeginInfo*) { + REQUIRE(fixture.running && !fixture.begun); + fixture.begun = true; + Trace("frame-begin"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL AcquireImage(XrSwapchain, + const XrSwapchainImageAcquireInfo*, + std::uint32_t* index) { + REQUIRE(fixture.begun && !fixture.acquired); + fixture.acquired = true; + fixture.index = fixture.frames % fixture.images.size(); + *index = Scenario("bad_image_index") ? 1000 : fixture.index; + Trace("image-acquire"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL WaitImage(XrSwapchain, const XrSwapchainImageWaitInfo*) { + REQUIRE(fixture.acquired && !fixture.waited); + if (Scenario("image_timeout") && !fixture.timeout_returned) { + fixture.timeout_returned = true; + Trace("image-timeout"); + return XR_TIMEOUT_EXPIRED; + } + fixture.waited = true; + Trace("image-wait"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL ReleaseImage(XrSwapchain, + const XrSwapchainImageReleaseInfo*) { + REQUIRE(fixture.acquired && fixture.waited); + fixture.acquired = fixture.waited = false; + Trace("image-release"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EndFrame(XrSession, const XrFrameEndInfo* end) { + REQUIRE(fixture.begun && !fixture.acquired); + REQUIRE(end->displayTime == (fixture.frames + 1) * 16'666'666); + REQUIRE(end->environmentBlendMode == XR_ENVIRONMENT_BLEND_MODE_OPAQUE); + if (end->layerCount) { + REQUIRE(end->layerCount == 1 && + end->layers[0]->type == XR_TYPE_COMPOSITION_LAYER_QUAD); + const auto* layer = + reinterpret_cast(end->layers[0]); + REQUIRE(layer->space == Local() && + layer->eyeVisibility == XR_EYE_VISIBILITY_BOTH); + REQUIRE(layer->pose.position.z == -2 && layer->pose.orientation.w == 1); + REQUIRE(layer->subImage.swapchain == Swapchain()); + if (!fixture.pixels_verified) { + REQUIRE(VerifyPixels()); + fixture.pixels_verified = true; + } + Trace("layer-submit"); + } else { + Trace("empty-submit"); + } + fixture.begun = false; + ++fixture.frames; + if (Scenario("preview_auto_stop") && fixture.frames == 3) + fixture.events.push_back(XR_SESSION_STATE_STOPPING); + if (Scenario("session_loss")) + fixture.events.push_back(XR_SESSION_STATE_LOSS_PENDING); + Trace("frame-end"); + return XR_SUCCESS; +} + +} // namespace + +PFN_xrVoidFunction MocktailTestPreviewFunction(const char* name) { +#define MAP(xr_name, implementation) \ + if (std::strcmp(name, #xr_name) == 0) \ + return reinterpret_cast(implementation); + MAP(xrCreateVulkanInstanceKHR, CreateVulkanInstance) + MAP(xrGetVulkanGraphicsDevice2KHR, GetVulkanDevice) + MAP(xrCreateVulkanDeviceKHR, CreateVulkanDevice) + MAP(xrEnumerateEnvironmentBlendModes, EnumerateBlends) + MAP(xrCreateSession, CreateSession) + MAP(xrDestroySession, DestroySession) + MAP(xrPollEvent, PollEvent) + MAP(xrBeginSession, BeginSession) + MAP(xrRequestExitSession, RequestExit) + MAP(xrEndSession, EndSession) + MAP(xrCreateReferenceSpace, CreateSpace) + MAP(xrDestroySpace, DestroySpace) + MAP(xrLocateSpace, LocateSpace) + MAP(xrEnumerateSwapchainFormats, EnumerateFormats) + MAP(xrCreateSwapchain, CreateSwapchain) + MAP(xrDestroySwapchain, DestroySwapchain) + MAP(xrEnumerateSwapchainImages, EnumerateImages) + MAP(xrWaitFrame, WaitFrame) + MAP(xrBeginFrame, BeginFrame) + MAP(xrEndFrame, EndFrame) + MAP(xrAcquireSwapchainImage, AcquireImage) + MAP(xrWaitSwapchainImage, WaitImage) + MAP(xrReleaseSwapchainImage, ReleaseImage) +#undef MAP + return nullptr; +} diff --git a/tests/vr/fake_openxr_runtime.cc b/tests/vr/fake_openxr_runtime.cc new file mode 100644 index 0000000..fd6c53e --- /dev/null +++ b/tests/vr/fake_openxr_runtime.cc @@ -0,0 +1,170 @@ +// Test-only runtime for exercising the real Khronos loader without hardware. +// Implements discovery only; it is neither a compositor nor an XR emulator. +#include +#include +#include +#include + +#include +#include +#include +#include + +PFN_xrVoidFunction MocktailTestPreviewFunction(const char* name); + +namespace { + +bool Scenario(const char* expected) { + const char* value = std::getenv("MOCKTAIL_TEST_XR_SCENARIO"); + return value && std::strcmp(value, expected) == 0; +} + +void Trace(const char* event) { + const char* path = std::getenv("MOCKTAIL_TEST_XR_TRACE"); + if (path) { + if (FILE* file = std::fopen(path, "a")) { + std::fprintf(file, "%s\n", event); + std::fclose(file); + } + } +} + +XrResult XRAPI_CALL EnumerateExtensions(const char*, std::uint32_t capacity, + std::uint32_t* count, + XrExtensionProperties* properties) { + *count = Scenario("no_vulkan") ? 0 : 1; + if (capacity == 0 || *count == 0) return XR_SUCCESS; + if (properties[0].type != XR_TYPE_EXTENSION_PROPERTIES) + return XR_ERROR_VALIDATION_FAILURE; + std::strcpy(properties[0].extensionName, + XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME); + properties[0].extensionVersion = XR_KHR_vulkan_enable2_SPEC_VERSION; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL CreateInstance(const XrInstanceCreateInfo* info, + XrInstance* instance) { + if (Scenario("create_failure")) return XR_ERROR_INITIALIZATION_FAILED; + if (info->type != XR_TYPE_INSTANCE_CREATE_INFO || info->next != nullptr) + return XR_ERROR_VALIDATION_FAILURE; + static int handle; + *instance = reinterpret_cast(&handle); + Trace("create"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL DestroyInstance(XrInstance) { + Trace("destroy"); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL GetInstanceProperties(XrInstance, + XrInstanceProperties* properties) { + if (Scenario("api_error")) return XR_ERROR_RUNTIME_FAILURE; + std::strcpy(properties->runtimeName, "Mocktail discovery fixture"); + properties->runtimeVersion = XR_MAKE_VERSION(1, 0, 42); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL GetSystem(XrInstance, const XrSystemGetInfo* info, + XrSystemId* system) { + if (info->formFactor != XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY) + return XR_ERROR_FORM_FACTOR_UNSUPPORTED; + if (Scenario("no_headset")) return XR_ERROR_FORM_FACTOR_UNAVAILABLE; + *system = 1; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL GetSystemProperties(XrInstance, XrSystemId, + XrSystemProperties* properties) { + properties->systemId = 1; + std::strcpy(properties->systemName, "Simulated test HMD"); + properties->graphicsProperties = {4096, 4096, 16}; + properties->trackingProperties = {XR_TRUE, XR_TRUE}; + return XR_SUCCESS; +} + +XrResult XRAPI_CALL EnumerateViews(XrInstance, XrSystemId, + XrViewConfigurationType type, + std::uint32_t capacity, std::uint32_t* count, + XrViewConfigurationView* views) { + if (type != XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO) + return XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED; + *count = Scenario("bad_stereo") ? 1 : 2; + if (capacity == 0) return XR_SUCCESS; + static bool grew = false; + if (Scenario("enumeration_changed") && !grew) { + grew = true; + return XR_ERROR_SIZE_INSUFFICIENT; + } + if (capacity < *count) return XR_ERROR_SIZE_INSUFFICIENT; + for (std::uint32_t eye = 0; eye < *count; ++eye) { + if (views[eye].type != XR_TYPE_VIEW_CONFIGURATION_VIEW || views[eye].next) + return XR_ERROR_VALIDATION_FAILURE; + views[eye].recommendedImageRectWidth = 1440; + views[eye].recommendedImageRectHeight = 1600; + views[eye].maxImageRectWidth = 4096; + views[eye].maxImageRectHeight = 4096; + views[eye].recommendedSwapchainSampleCount = 1; + views[eye].maxSwapchainSampleCount = 4; + } + return XR_SUCCESS; +} + +XrResult XRAPI_CALL GetGraphicsRequirements( + XrInstance, XrSystemId, XrGraphicsRequirementsVulkan2KHR* requirements) { + if (Scenario("graphics_error")) return XR_ERROR_RUNTIME_FAILURE; + if (requirements->type != XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR || + requirements->next) + return XR_ERROR_VALIDATION_FAILURE; + requirements->minApiVersionSupported = XR_MAKE_VERSION(1, 1, 0); + requirements->maxApiVersionSupported = XR_MAKE_VERSION(1, 3, 0); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL ResultToString(XrInstance, XrResult result, char* buffer) { + std::snprintf(buffer, XR_MAX_RESULT_STRING_SIZE, "fixture_result_%d", result); + return XR_SUCCESS; +} + +XrResult XRAPI_CALL GetProcAddr(XrInstance, const char* name, + PFN_xrVoidFunction* function) { + *function = nullptr; +#define MOCKTAIL_TEST_XR_PROC(xr_name, implementation) \ + if (std::strcmp(name, #xr_name) == 0) { \ + *function = reinterpret_cast(implementation); \ + return XR_SUCCESS; \ + } + MOCKTAIL_TEST_XR_PROC(xrGetInstanceProcAddr, GetProcAddr) + MOCKTAIL_TEST_XR_PROC(xrEnumerateInstanceExtensionProperties, + EnumerateExtensions) + MOCKTAIL_TEST_XR_PROC(xrCreateInstance, CreateInstance) + MOCKTAIL_TEST_XR_PROC(xrDestroyInstance, DestroyInstance) + MOCKTAIL_TEST_XR_PROC(xrGetInstanceProperties, GetInstanceProperties) + MOCKTAIL_TEST_XR_PROC(xrGetSystem, GetSystem) + MOCKTAIL_TEST_XR_PROC(xrGetSystemProperties, GetSystemProperties) + MOCKTAIL_TEST_XR_PROC(xrEnumerateViewConfigurationViews, EnumerateViews) + MOCKTAIL_TEST_XR_PROC(xrGetVulkanGraphicsRequirements2KHR, + GetGraphicsRequirements) + MOCKTAIL_TEST_XR_PROC(xrResultToString, ResultToString) +#undef MOCKTAIL_TEST_XR_PROC + *function = MocktailTestPreviewFunction(name); + if (*function) return XR_SUCCESS; + return XR_ERROR_FUNCTION_UNSUPPORTED; +} + +} // namespace + +extern "C" __attribute__((visibility("default"))) XrResult XRAPI_CALL +xrNegotiateLoaderRuntimeInterface(const XrNegotiateLoaderInfo* loader, + XrNegotiateRuntimeRequest* request) { + if (loader->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO || + request->structType != XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST || + loader->minInterfaceVersion > XR_CURRENT_LOADER_RUNTIME_VERSION || + loader->maxInterfaceVersion < XR_CURRENT_LOADER_RUNTIME_VERSION) + return XR_ERROR_INITIALIZATION_FAILED; + request->runtimeInterfaceVersion = XR_CURRENT_LOADER_RUNTIME_VERSION; + request->runtimeApiVersion = XR_MAKE_VERSION(1, 0, 42); + request->getInstanceProcAddr = GetProcAddr; + return XR_SUCCESS; +} diff --git a/tests/vr/preview_contract_test.py b/tests/vr/preview_contract_test.py new file mode 100644 index 0000000..ce67dc3 --- /dev/null +++ b/tests/vr/preview_contract_test.py @@ -0,0 +1,141 @@ +"""Real Vulkan rendering/readback with a scripted OpenXR session lifecycle.""" + +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import time + + +probe, runtime, mocktail = map(Path, sys.argv[1:4]) +with tempfile.TemporaryDirectory(prefix="mocktail-xr-preview-") as directory: + root = Path(directory) + manifest = root / "runtime.json" + manifest.write_text(json.dumps({"file_format_version": "1.0.0", + "runtime": {"library_path": str(runtime.resolve())}})) + env = {k: v for k, v in os.environ.items() + if not k.startswith(("XR_", "MOCKTAIL_", "VK_"))} + env.update(XR_RUNTIME_JSON=str(manifest), XR_LOADER_DEBUG="none", + XDG_CONFIG_HOME=str(root / "config"), XDG_DATA_HOME=str(root / "data"), + VK_LOADER_LAYERS_DISABLE="~implicit~") + lavapipe = Path("/usr/share/vulkan/icd.d/lvp_icd.json") + if lavapipe.exists(): + env["VK_DRIVER_FILES"] = str(lavapipe) + if Path("/usr/share/vulkan/explicit_layer.d/VkLayer_khronos_validation.json").exists(): + env["VK_INSTANCE_LAYERS"] = "VK_LAYER_KHRONOS_validation" + trace = root / "trace" + env["MOCKTAIL_TEST_XR_TRACE"] = str(trace) + + def check(result, code): + output = result.stdout + result.stderr + assert result.returncode == code, (result.returncode, output) + assert "Validation Error" not in output and "VUID-" not in output, output + events = trace.read_text().splitlines() if trace.exists() else [] + assert not any(event.startswith("contract-error") for event in events), events + assert events.count("create") == events.count("destroy"), events + assert events.count("space-create") == events.count("space-destroy"), events + assert events.count("session-create") == events.count("session-destroy"), events + assert events.count("swapchain-create") == events.count("swapchain-destroy"), events + assert events.count("frame-begin") == events.count("frame-end"), events + assert events.count("image-acquire") == events.count("image-release"), events + if "swapchain-destroy" in events: + assert events.index("swapchain-destroy") < events.index("session-destroy") < events.index("destroy") + return output, events + + cases = [ + ("preview_ready", 0, "rendered: 3, tracked: 3"), + ("skip_render", 0, "rendered: 2, tracked: 3"), + ("invalid_tracking", 0, "rendered: 2, tracked: 2"), + ("image_timeout", 0, "rendered: 3, tracked: 3"), + ("device_failure", 1, "vkCreateDevice failed"), + ("session_failure", 1, "xrCreateSession failed"), + ("swapchain_failure", 1, "xrCreateSwapchain failed"), + ("bad_image_index", 1, "invalid swapchain image index"), + ("session_loss", 1, "OpenXR session was lost"), + ] + for scenario, code, message in cases: + trace.unlink(missing_ok=True) + env["MOCKTAIL_TEST_XR_SCENARIO"] = scenario + result = subprocess.run([str(probe), "--scene", "--frames", "3"], env=env, + capture_output=True, text=True, timeout=15) + if scenario == "preview_ready" and result.returncode != 0: + output = result.stdout + result.stderr + if "Cannot load the host Vulkan loader" in output or "vkCreateInstance failed: -9" in output: + print("SKIP: no Vulkan implementation available for GPU contract checks") + sys.exit(77) + output, events = check(result, code) + assert message in output, (scenario, output) + if code == 0: + assert "pixels-verified" in events, (scenario, events) + assert events.count("session-exit-request") == events.count("session-end") == 1 + assert "Head position (metres): 0.2, 0, 0" in output, output + if scenario in ("skip_render", "invalid_tracking", "bad_image_index"): + assert events.count("empty-submit") == 1, events + if scenario == "image_timeout": + assert events.count("image-timeout") == 1, events + print(scenario + ": passed") + + # VR must traverse the real Roblox startup, never fall back to this scene. + # Exercise configuration/CLI precedence with a deliberately missing payload. + for scenario, yaml_value, switches, env_value in [ + ("yaml", "true", [], None), + ("cli", "false", ["--vr"], "0"), + ("environment", "false", [], "1"), + ]: + case = root / scenario + config = case / "config" + config.mkdir(parents=True) + contents = f"version: 1\nvr:\n enabled: {yaml_value}\n" + (config / "config.yaml").write_text(contents) + env.update(MOCKTAIL_CONFIG_ROOT=str(config), MOCKTAIL_DATA_ROOT=str(case / "data"), + MOCKTAIL_STATE_ROOT=str(case / "state"), MOCKTAIL_TEST_XR_SCENARIO="preview_auto_stop") + if env_value is None: + env.pop("MOCKTAIL_VR_ENABLED", None) + else: + env["MOCKTAIL_VR_ENABLED"] = env_value + trace.unlink(missing_ok=True) + result = subprocess.run([str(mocktail), "--headless", "--roblox-lib", + str(case / "missing-libroblox.so"), *switches], + env=env, capture_output=True, text=True, timeout=15) + output = result.stdout + result.stderr + assert result.returncode != 0, (scenario, output) + assert "missing-libroblox.so" in output, (scenario, output) + assert not trace.exists(), (scenario, trace.read_text() if trace.exists() else "") + assert (config / "config.yaml").read_text() == contents + print("Mocktail VR rejects missing Roblox via " + scenario + ": passed") + + env["MOCKTAIL_TEST_XR_SCENARIO"] = "preview_ready" + trace.unlink(missing_ok=True) + child = subprocess.Popen([str(probe), "--scene"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + deadline = time.monotonic() + 10 + while not trace.exists() or "layer-submit" not in trace.read_text(): + assert child.poll() is None and time.monotonic() < deadline, "Scene failed to start for interrupt test" + time.sleep(0.01) + child.send_signal(signal.SIGINT) + stdout, stderr = child.communicate(timeout=10) + _, events = check(subprocess.CompletedProcess(child.args, child.returncode, stdout, stderr), 0) + assert "session-exit-request" in events and "session-end" in events, events + finally: + if child.poll() is None: + child.kill() + child.wait() + print("SIGINT session shutdown: passed") + + for count in ("0", "-1", "abc", "4294967296", "1x"): + result = subprocess.run([str(probe), "--scene", "--frames", count], env=env, + capture_output=True, text=True, timeout=5) + assert result.returncode == 1 and "positive integer" in result.stderr, (count, result) + print("frame limit validation: passed") + + env["XR_RUNTIME_JSON"] = str(root / "missing-runtime.json") + result = subprocess.run([str(probe), "--scene", "--frames", "1"], env=env, + capture_output=True, text=True, timeout=5) + assert result.returncode == 1, result + assert "XR_ERROR_RUNTIME_UNAVAILABLE" in result.stdout, result.stdout + assert "mocktail-vr-simulated" in result.stdout, result.stdout + print("missing runtime diagnostic offers simulator: passed") diff --git a/tests/vr/probe_contract_test.py b/tests/vr/probe_contract_test.py new file mode 100644 index 0000000..9cff3d7 --- /dev/null +++ b/tests/vr/probe_contract_test.py @@ -0,0 +1,82 @@ +"""Run the actual OpenXR loader against a discovery-only test runtime.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +probe = Path(sys.argv[1]) +if sys.argv[2] == "--disabled": + result = subprocess.run([str(probe)], text=True, capture_output=True, timeout=15) + assert result.returncode == 2, (result.returncode, result.stdout, result.stderr) + assert "does not include OpenXR support" in result.stdout, result.stdout + result = subprocess.run([str(probe), "--scene", "--frames", "1"], + text=True, capture_output=True, timeout=15) + assert result.returncode == 1 and "does not include OpenXR support" in result.stdout, result + with tempfile.TemporaryDirectory(prefix="mocktail-xr-disabled-") as directory: + root = Path(directory) + env = {k: v for k, v in os.environ.items() if not k.startswith(("XR_", "MOCKTAIL_"))} + for kind in ("CONFIG", "DATA", "STATE"): + env["MOCKTAIL_" + kind + "_ROOT"] = str(root / kind.lower()) + result = subprocess.run([sys.argv[3], "--headless", "--vr"], env=env, + text=True, capture_output=True, timeout=15) + assert result.returncode == 1 and "does not include OpenXR support" in result.stderr, result + assert not (root / "data").exists() and not (root / "state").exists() + print("OpenXR-disabled build: passed") + sys.exit(0) + +runtime = Path(sys.argv[2]) +with tempfile.TemporaryDirectory(prefix="mocktail-xr-probe-") as directory: + root = Path(directory) + manifest = root / "runtime.json" + manifest.write_text(json.dumps({ + "file_format_version": "1.0.0", + "runtime": {"library_path": str(runtime.resolve())}, + })) + environment = {k: v for k, v in os.environ.items() + if not k.startswith(("XR_", "MOCKTAIL_"))} + environment["XR_RUNTIME_JSON"] = str(manifest) + environment["XR_LOADER_DEBUG"] = "none" + environment["XDG_CONFIG_HOME"] = str(root / "config") + environment["XDG_DATA_HOME"] = str(root / "data") + environment["XR_API_LAYER_PATH"] = str(root / "layers") + (root / "layers").mkdir() + trace = root / "trace" + environment["MOCKTAIL_TEST_XR_TRACE"] = str(trace) + + cases = [ + ("ready", 0, "discovered successfully"), + ("no_headset", 4, "Connect a headset"), + ("no_vulkan", 5, "does not expose XR_KHR_vulkan_enable2"), + ("bad_stereo", 5, "two usable stereo views"), + ("api_error", 1, "xrGetInstanceProperties failed"), + ("create_failure", 1, "xrCreateInstance failed"), + ("enumeration_changed", 0, "discovered successfully"), + ("graphics_error", 1, "xrGetVulkanGraphicsRequirements2KHR failed"), + ] + for scenario, code, message in cases: + trace.unlink(missing_ok=True) + environment["MOCKTAIL_TEST_XR_SCENARIO"] = scenario + result = subprocess.run([str(probe)], env=environment, + text=True, capture_output=True, timeout=15) + assert result.returncode == code, (scenario, result.returncode, result.stdout, result.stderr) + assert message in result.stdout, (scenario, result.stdout, result.stderr) + assert "SDK: 1.1.63" in result.stdout, result.stdout + assert "Roblox VR rendering: not implemented yet" in result.stdout + events = trace.read_text().splitlines() if trace.exists() else [] + assert events == ([] if scenario == "create_failure" else ["create", "destroy"]), (scenario, events) + if code == 0: + assert "Eye 0: 1440x1600" in result.stdout + assert "Eye 1: 1440x1600" in result.stdout + assert "minimum=1.1.0" in result.stdout + print(scenario + ": passed") + + environment["XR_RUNTIME_JSON"] = str(root / "does-not-exist.json") + result = subprocess.run([str(probe)], env=environment, + text=True, capture_output=True, timeout=15) + assert result.returncode == 3, (result.returncode, result.stdout, result.stderr) + assert "runtime" in result.stdout.lower(), result.stdout + print("missing runtime: passed") diff --git a/tests/vr/simulator_launcher_test.py b/tests/vr/simulator_launcher_test.py new file mode 100644 index 0000000..3a43bc1 --- /dev/null +++ b/tests/vr/simulator_launcher_test.py @@ -0,0 +1,89 @@ +"""Check process-local runtime selection and preparation independently of VR hardware.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + + +launcher = Path(sys.argv[1]).resolve() +with tempfile.TemporaryDirectory(prefix="mocktail-simulator-launcher-") as directory: + root = Path(directory) + build = root / "build with spaces" + build.mkdir() + source = root / "source" + source.mkdir() + library = source / "runtime.so" + library.write_bytes(b"runtime fixture for preparation only") + manifest = source / "manifest.json" + manifest.write_text(json.dumps({"runtime": {"library_path": "./runtime.so"}})) + command = [sys.executable, str(launcher), "--build-dir", str(build)] + result = subprocess.run([*command, "--prepare", str(manifest)], + capture_output=True, text=True, timeout=10) + assert result.returncode == 0, result.stderr + prepared = build / "vr-simulator/runtime.json" + document = json.loads(prepared.read_text()) + relative_library = Path(document["runtime"]["library_path"]) + assert not relative_library.is_absolute() + assert (prepared.parent / relative_library).read_bytes() == library.read_bytes() + + # Moving the prepared build must not depend on the original /tmp/source path. + relocated = root / "relocated build" + build.rename(relocated) + build = relocated + prepared = build / "vr-simulator/runtime.json" + command = [sys.executable, str(launcher), "--build-dir", str(build)] + probe = build / "mocktail-vr-probe" + probe.write_text("""#!/usr/bin/env python3 +import json, os, sys +names = ('XR_RUNTIME_JSON', 'SIMULATED_ENABLE', 'XRT_COMPOSITOR_NULL', + 'XRT_COMPOSITOR_FORCE_XCB', 'XRT_COMPOSITOR_FORCE_WAYLAND', + 'XDG_CONFIG_HOME', 'XDG_RUNTIME_DIR') +print(json.dumps({'args': sys.argv[1:], 'env': {key: os.environ.get(key) for key in names}})) +sys.exit(7) +""") + probe.chmod(0o755) + env = os.environ.copy() + env.update(DISPLAY=":123", WAYLAND_DISPLAY="wayland-test", + XR_RUNTIME_JSON=str(root / "real-headset.json"), + XRT_COMPOSITOR_NULL="1", XRT_COMPOSITOR_FORCE_WAYLAND="1", + XDG_CONFIG_HOME=str(root / "user-config"), XDG_RUNTIME_DIR=str(root / "desktop-runtime")) + original_selection = env["XR_RUNTIME_JSON"] + original_config = Path(env["XDG_CONFIG_HOME"]) + original_config.mkdir() + marker = original_config / "keep" + marker.write_text("preserve user settings") + + for graphical_backend in ("xcb", "wayland"): + if graphical_backend == "wayland": + env.pop("DISPLAY") + result = subprocess.run([*command, "--frames", "4"], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 7, (result.stdout, result.stderr) + child = json.loads(result.stdout.splitlines()[-1]) + assert child["args"] == ["--scene", "--frames", "4"] + selected = child["env"] + assert selected["XR_RUNTIME_JSON"] == str(prepared) + assert selected["SIMULATED_ENABLE"] == "1" and selected["XRT_COMPOSITOR_NULL"] == "0" + assert selected["XRT_COMPOSITOR_FORCE_XCB"] == ("1" if graphical_backend == "xcb" else "0") + assert selected["XRT_COMPOSITOR_FORCE_WAYLAND"] == ("0" if graphical_backend == "xcb" else "1") + assert selected["XDG_CONFIG_HOME"] != str(original_config) + assert selected["XDG_RUNTIME_DIR"] == env["XDG_RUNTIME_DIR"] + assert env["XR_RUNTIME_JSON"] == original_selection + assert marker.read_text() == "preserve user settings" + assert not (original_config / "openxr").exists() + print(graphical_backend + " process-local selection: passed") + + env.pop("WAYLAND_DISPLAY") + result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10) + assert result.returncode == 2 and "graphical desktop" in result.stderr, result + result = subprocess.run([*command, "--runtime", str(root / "missing.json")], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 2 and "No simulated Monado runtime" in result.stderr, result + for count in ("0", "-1", "4294967296"): + result = subprocess.run([*command, "--frames", count], env=env, + capture_output=True, text=True, timeout=10) + assert result.returncode == 2 and "positive 32-bit integer" in result.stderr, result + print("missing runtime/display and frame limit diagnostics: passed") diff --git a/third_party/OpenXR-SDK b/third_party/OpenXR-SDK new file mode 160000 index 0000000..f2448a8 --- /dev/null +++ b/third_party/OpenXR-SDK @@ -0,0 +1 @@ +Subproject commit f2448a8797c85814aa892efc1ab8707900fbcc78