# OpenXR Simulator Runtime — Design A Windows OpenXR **runtime** that pretends to be a headset. OpenXR applications (especially the `gl3-vulkan` / "Get Beaned" engine) run unmodified and render into runtime-owned swapchains; the runtime displays those swapchain images in a desktop window built with GLFW + Dear ImGui (docking). This document is the architecture of record for the scaffold in this repository. --- ## 1. What an OpenXR runtime is on Windows The application links `openxr_loader.dll` (from OpenXR-SDK). The loader is not the runtime; it locates one of the installed runtimes and forwards every `xr*` call to it. Resolution order for the active runtime: 1. `XR_RUNTIME_JSON` environment variable → path to a runtime manifest JSON. 2. Per-user registry `HKCU\SOFTWARE\Khronos\OpenXR\1\ActiveRuntime`. 3. Machine registry `HKLM\SOFTWARE\Khronos\OpenXR\1\ActiveRuntime`. The manifest points at a DLL that exports `xrNegotiateLoaderRuntimeInterface`. That function hands the loader an `XrNegotiateRuntimeRequest` containing a `PFN_xrGetInstanceProcAddr`. The loader uses that GIPA to resolve every core and extension entry point. ```json { "file_format_version": "1.0.0", "runtime": { "library_path": "openxr_simulator.dll", "name": "OpenXR Simulator" } } ``` The negotiation structs are in `third_party/openxr/openxr_loader_negotiation.h` (vendored from OpenXR-SDK-Source `src/common/loader_interfaces.h`). ### Development shortcut The `gl3-vulkan` engine already supports overriding the runtime without touching the registry: `ApplicationContext::createOpenXrInstance(customOpenXrRuntimePath)` sets `XR_RUNTIME_JSON` (`code/Engine/src/Engine/Core/ApplicationContext.cpp:904`). So the whole simulator can be tested by launching the game with that env var pointing at `openxr_simulator.json`. No system-wide install required. --- ## 2. How `gl3-vulkan` uses OpenXR (the contract we must satisfy) The engine is the primary target. Key facts, with source references: ### 2.1 The runtime owns Vulkan The engine does **not** create its own `VkInstance`/`VkDevice`. It asks the runtime to: | Call | Location | | --- | --- | | `xrCreateVulkanInstanceKHR` | `ApplicationContext.cpp:1442` | | `xrGetVulkanGraphicsDevice2KHR` | `ApplicationContext.cpp:177` | | `xrCreateVulkanDeviceKHR` | `ApplicationContext.cpp:478` | Consequence: the runtime creates the instance and device, so it can create a Win32 `VkSurfaceKHR`/present queue on the same objects and display the app's images with **zero cross-device copies**. This is the single most important design lever. ### 2.2 Graphics binding and swapchains - Session is created with `XrGraphicsBindingVulkan2KHR` (app's instance, physical device, device, graphics queue family/index): `Headset.cpp:59`. - One **multiview** swapchain, `arraySize = 2` (`Headset.cpp:657`). Two eye layers in one image, submitted as two projection views with `imageArrayIndex = 0/1` (`Headset.cpp:762`). - Format negotiated via `xrEnumerateSwapchainFormats`; engine prefers `VK_FORMAT_R8G8B8A8_SRGB` (`Headset.cpp:23`), fallback `R8G8B8A8_UNORM` + `MUTABLE_FORMAT` (`Headset.cpp:24,667`). - `recommendedSwapchainSampleCount`, `recommendedImageRectWidth/Height` come from our view configuration. - Reference space is `XR_REFERENCE_SPACE_TYPE_STAGE` (`Headset.cpp:20`). ### 2.3 Frame loop Per frame: `xrWaitFrame` → `xrBeginFrame` → `xrLocateViews` → `xrAcquireSwapchainImage` → `xrWaitSwapchainImage` → [app renders] → `xrReleaseSwapchainImage` → `xrEndFrame` with one `XrCompositionLayerProjection` (2 views). See `Headset.cpp:1070-1161`. ### 2.4 Required extensions and runtime behaviour - `XR_KHR_vulkan_enable2` is hard-required (`ApplicationContext.cpp:963,979`). - Debug builds also require `XR_EXT_debug_utils` and call `xrCreateDebugUtilsMessengerEXT` (`ApplicationContext.cpp:965-967,1090`). - `XR_ENVIRONMENT_BLEND_MODE_OPAQUE` must be advertised (`ApplicationContext.cpp:66,1194`). - Session state events drive startup: the engine waits for `XR_SESSION_STATE_READY` to call `xrBeginSession` (`Engine.cpp:284`, `Headset.cpp:925`), and only renders in READY/SYNCHRONIZED/VISIBLE/FOCUSED (`Headset.cpp:990`). We must enqueue `XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED`. - Input: the engine builds actions for ~16 interaction profiles and attaches them (`XrInputHandler.cpp`). We must implement the action API and at least one profile (`khr/simple_controller` and/or `oculus/touch_controller`). ### 2.5 Gotcha: Vulkan requirement version encoding `getVulkanGraphicsRequirements2KHR` must report `minApiVersionSupported` / `maxApiVersionSupported` using **`XR_MAKE_VERSION`** (major in bits 48-63), not `VK_MAKE_VERSION`. The engine decodes with `XR_VERSION_MAJOR/MINOR` (`ApplicationContext.cpp:1289-1297`) and rejects the runtime otherwise. Report `XR_MAKE_VERSION(1,0,0)` .. `XR_MAKE_VERSION(1,4,0)`. --- ## 3. Architecture ``` app process (Game.exe) ┌─────────────────────────────────────────────────────────────┐ │ engine ── xr* calls ──► openxr_loader.dll │ │ │ │ │ ▼ │ │ openxr_simulator.dll (this repo)│ │ ┌───────────────────────────────────────────────────────┐ │ │ │ xrNegotiateLoaderRuntimeInterface (DLL export) │ │ │ │ xrGetInstanceProcAddr (dispatch table) │ │ │ │ │ │ │ │ Instance ─ System ─ Session ─ Swapchain │ │ │ │ │ │ │ │ │ │ ▼ ▼ │ │ │ │ Vulkan backend (owns VkInstance/VkDevice) │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ Compositor (GLFW window + ImGui docking) │ │ │ │ - eye textures drawn in dockable ImGui windows │ │ │ └───────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### 3.1 Layers | Layer | Responsibility | | --- | --- | | Negotiation | Export the interface, build the GIPA table. | | Instance | State per `XrInstance`; registry of handles. | | System / View config | Advertise HMD system, stereo view config, resolution, formats, blend modes. | | Vulkan backend | Create/hold `VkInstance`/`VkDevice`, allocate swapchain images, own the present queue. | | Session / Frame loop | Session state events, `xrWaitFrame` pacing, `xrEndFrame` submission. | | Tracking | Head pose + controller poses (MVP: mouse/WASD/keyboard). | | Actions | Action sets, bindings, `xrSyncActions`, state queries. | | Compositor | GLFW+ImGui window; blit submitted eye layers into ImGui-visible textures. | ### 3.2 Object model and handles OpenXR handles are opaque pointers (`XR_DEFINE_HANDLE`). We allocate small structs and reinterpret-cast to the handle type, keeping them in a global registry guarded by a mutex: ```cpp struct RuntimeInstance; struct RuntimeSession; struct RuntimeSwapchain; ... RuntimeInstance* GetInstance(XrInstance); RuntimeSession* GetSession(XrSession); ``` ### 3.3 Vulkan ownership Because the engine delegates instance/device creation to us: - `xrCreateVulkanInstanceKHR`: call the app-supplied `pfnGetInstanceProcAddr` to get `vkCreateInstance`, create the instance (augmenting the create info with our own required extensions such as `VK_KHR_surface`, `VK_KHR_win32_surface`). - `xrGetVulkanGraphicsDevice2KHR`: enumerate devices, pick one that supports graphics + present to our window surface, return it. - `xrCreateVulkanDeviceKHR`: `vkCreateDevice` with the app's create info plus any queue families/extensions we need (`VK_KHR_swapchain`). - Swapchain images are allocated on this device; the compositor blits them on the same device/queue. No interop needed. ### 3.4 Compositor (GLFW + ImGui docking) Decision (MVP): the simulator window is a normal GLFW window rendered with the Dear ImGui Vulkan backend, using ImGui docking so the user can freely arrange windows. Rather than presenting to a raw swapchain, each submitted eye is shown in a dockable `ImGui::Image` window. **Thread affinity (important).** The `gl3-vulkan` engine calls `xrEndFrame` from a **background thread** (`EngineKern::completeFrameAsync`) and `xrWaitFrame` from a worker pool, but calls `xrBeginFrame` from its main thread. GLFW/ImGui are thread-affine, so the compositor splits into two phases: - `Present()` — called from `xrEndFrame` on any thread. Only *records* the resolved eye images (`ResolveSubImage`, using the swapchain's `lastReleasedIndex` because release already cleared the acquired index). No GLFW/ImGui/Vulkan work. - `Pump()` — called from `xrBeginFrame` and `xrWaitFrame`; does the real work only when the caller is the thread that created the window (guarded by `windowThreadId_`), otherwise returns immediately. Pump steps: 1. Wait for the app's rendering to finish on the shared queue (MVP: `vkQueueWaitIdle` on the app graphics queue; later: timeline/fence sync). 2. Copy the two array layers of the submitted XR swapchain image into two persistent runtime "display" images (`TRANSFER_DST | SAMPLED`, kept in `GENERAL` layout). 3. `ImGui::NewFrame()`; draw a menu + two image windows (`Eye L`, `Eye R`) sampling the display images, plus simulator controls (tracking sliders). 4. Render ImGui into the window swapchain and present. Registering two persistent display images as ImGui textures decouples the ImGui texture set from the XR swapchain image count and lets us scale each eye to fit its dock window. Window/queue notes: - The simulator window surface is created on the runtime-owned `VkInstance`. - Use the app's graphics queue (from `XrGraphicsBindingVulkan2KHR`) as the ImGui queue so ordering on the same queue provides implicit synchronization, and ensure that family supports present to our surface. If it does not, do a queue family ownership transfer (or fall back to a dedicated runtime present queue + explicit waits). - `gl3-vulkan` also creates its own GLFW mirror window on the same instance. GLFW is statically linked into both; each copy keeps independent state, so both can coexist. If this proves fragile, the ImGui **Win32** backend is the fallback (no GLFW dependency). ### 3.5 Frame pacing and events - `xrWaitFrame` throttles to a configurable virtual refresh rate (default 90 Hz) and returns `predictedDisplayTime` + `shouldRender`. - Session state event sequence we enqueue: `IDLE → READY` at session creation, then after `xrBeginSession` `SYNCHRONIZED → VISIBLE → FOCUSED`. This matches what the engine's event handler expects. ### 3.6 Tracking and input - Head: mouse look (yaw/pitch) + WASD translation, integrated into an `XrPosef` returned from `xrLocateViews`. STAGE space; y- is up. - Controllers: the runtime advertises an emulated `oculus/touch_controller` profile from `xrGetCurrentInteractionProfile` for both hands, so the app activates that profile and wires up its aim/grip pose spaces. - Action state (`xrGetActionState*`) is classified from the action name suffix (`trigger_value`, `grip_value`, `thumbstick`, `a_button`, …) and the subaction path (hand), then read from a global `InputState` updated by the compositor. `xrLocateSpace` on an action space returns the emulated hand pose. Keyboard mapping (shown in the ImGui `Controls` window): | Control | Left hand | Right hand | | --- | --- | --- | | Head look / move | right-drag, WASD, Q/E | | | Trigger | `F` | `H` | | Grip | `G` | `U` | | Thumbstick | `I`/`K`/`J`/`L` | arrow keys | | Stick click | `R` | `O` | | Face buttons | `C` (X), `V` (Y) | `N` (A), `M` (B) | | Menu | `Tab` | | The hand poses are placed relative to the head, so they follow look direction. A `TrackingProvider` interface is planned so a real HMD / OpenTrack / gamepad bridge can be plugged in later without touching the session code. ### 3.7 Configuration The runtime reads a JSON config (`src/Config.{h,cpp}`) from `XRSIM_CONFIG`, or else `openxr_simulator_config.json` next to the DLL. It drives the emulated headset: system name/vendor, per-eye resolution, refresh rate, FOV, IPD, swapchain image count, and the compositor window (title/size/present mode/display mode). The `configurator` tool (GLFW + ImGui, `tools/configurator/`) edits this file and ships presets for common headsets; it does not render — it only writes the config the DLL reads. Presentation mode matters for throughput: `fifo` presents with vsync and, because presentation runs on the app's thread inside `xrBeginFrame`, caps the app to the monitor refresh. The default `mailbox` (or `immediate`) is uncapped. `refreshRateHz` sets the simulated strobe used by `xrWaitFrame`, independent of presentation. --- ## 4. Phasing | Phase | Deliverable | Exit criterion | | --- | --- | --- | | 0 | Negotiation + GIPA + metadata functions (this scaffold) | Loader loads the DLL; `xrCreateInstance`/`xrGetSystem` succeed. | | 1 | Vulkan passthrough | Engine creates its `VkInstance`/`VkDevice` through us. | | 2 | Session, swapchain, frame loop, GLFW+ImGui compositor | Eye images (test pattern, then real) appear in the ImGui window. | | 3 | Tracking | Mouse/WASD head pose drives `xrLocateViews`. | | 4 | Actions/controllers | Game hands/inputs work; a profile is bound. | | 5 | Polish | Session state edges, visibility/focus, haptics no-op, settings UI. | Phases 0–4 are implemented. Remaining: phase 5 polish. ## 5. Risks / open questions - **Loader strictness**: GIPA must return non-null for every function the app loads. Extension functions the engine loads (`xrGetVulkanGraphicsDevice2KHR`, `xrGetVulkanGraphicsRequirements2KHR`, `xrCreateVulkanInstanceKHR`, `xrCreateVulkanDeviceKHR`) must be real, or `OpenXrHelper::LoadXrFunction` throws. - **GPU sync without app-provided semaphores**: core OpenXR gives no sync primitive for swapchain images; MVP uses `vkQueueWaitIdle`. - **Present family mismatch** between the app's graphics family and our window surface. - **Duplicate GLFW instances** in one process (engine + runtime); fallback to ImGui Win32 backend. - **Format negotiation**: only advertise formats we can actually create and sample. - **Version encoding** for Vulkan requirements (§2.5). ## 6. References - OpenXR-SDK `hello_xr` — what a runtime must answer. - Monado — reference open-source runtime (negotiation + compositor). - `openxr_loader_negotiation.h` — exact negotiation structs. - Engine source: `code/Engine/src/Engine/Core/ApplicationContext.cpp`, `code/Engine/src/Engine/Renderer/Headset.cpp`, `code/Engine/src/Engine/Input/XrInputHandler.cpp`.