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.
-[](https://github.com/komaruworld/mocktail/actions/workflows/ci.yml)
-[](https://github.com/komaruworld/mocktail/stargazers)
-[](https://github.com/komaruworld/mocktail/releases/latest)
-[](LICENSE)
-[](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
-
-
-
-
-
-
-
-
-
-## 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