feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menu #204

Open
airdamien wants to merge 9 commits from airdamien/Longshot:feat/vr-openxr into main
First-time contributor

Why

Longshot can only be played on a monitor. This lets it be played in a PC VR headset (tested with a Meta Quest 3 over Quest Link): either on a big flat screen in front of you, or in stereo 3D with the game's camera at your eyes.

What changed

  • vr: gpu/vk/vk_xr.cpp / vk_xr.h (new). The renderer's instance and device are made through OpenXR (XR_KHR_vulkan_enable2) when VR is on. Flat-screen mode shows every presented picture on a quad layer. Stereo mode draws one eye a frame (xr_view), matches frames to eyes by the scene camera (xr_note_scene_draw), holds a pair's first eye until its second and gives both to the headset together (xr_copy_acquire, publish_held), one pair per xrWaitFrame (xr_thread). The HUD is redirected to a head-locked panel (xr_hud_draw), with its middle (the aiming reticle) shown where the game aims. A first-person view (left-stick tap or F9). The Touch controllers as a virtual SDL gamepad (pad_attach). On Linux the file is stubs (xr_built() false): it is Win32 only for now.
  • vk: hooks in vk_main.cpp (instance, device, shutdown), vk_replay.cpp (present_image: the copies to the headset, the window's mirror), vk_draw.cpp (HUD and scene-camera notes), vk_temporal.cpp (a DLSS/FSR history per eye), vk_framegen.cpp (no made frames with a headset).
  • menu: a VR tab in gpu/vk/vk_menu.cpp. Row::apply is new: a row kept outside the options file (these are kept in longshot_vr.ini, written by xr_opt_changed).
  • runtime: host/hos_main.c hos_oot_free_view hands the game the headset's view and field of view; host/nn_vi.c realtime_step steps the game in pairs of frames while stereo runs (both eyes one moment); host/rx_backend.c looks up the renderer's optional mkwvk_xr_* exports.
  • lift: nx2/oot_wide.py (the projection's field of view at the five matrix-update sites), nx2/oot_patches.py (the step hook records the step a frame read).
  • build: nx2/build.py adds third_party/openxr/include, and copies Khronos' openxr_loader.dll beside the game when the tools have it (LONGSHOT_OPENXR_LOADER); nx2/make_game.py carries it into the game folder (BESIDE). quest/build_vk.ps1 builds the renderer alone and installs it with the loader.
  • tools: tools.lock has a line for Khronos' loader (openxr-loader, Khronos' own release archive, Apache-2.0, SHA-256 pinned), so the Builder downloads it with its other tools; builder/LongshotBuilder/Runner.cs passes its folder to the build. Nothing of it is committed or packaged in a release.
  • vr: no headset for a run with no game window (--prepare-shaders, HOS_HEADLESS): xr_wanted() in gpu/vk/vk_xr.cpp.
  • third_party: third_party/openxr (Khronos' headers, Apache-2.0, unmodified, with PROVENANCE.md).
  • docs: quest/README.md, the three switches in docs/development.md.
  • tests: stubs for the new calls in the fixtures that read the changed functions.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
longshot_vr.ini vr= / VR tab "VR headset" The game in an OpenXR headset Off (no file: off) Off by default Windows with a PC VR headset Everyone else: nothing runs Headsets other than a Quest 3 over Link
stereo= / VR tab "View" Stereo 3D, or the flat screen Flat screen Flat screen until more headsets are tested - - SteamVR, Virtual Desktop, WMR
VR tab: First person, World scale, Eye separation, Level horizon, Head movement, Fill the view, Upscaler in 3D, Desktop window, HUD panel and its size, Reticle at the aim, Touch controllers, Paired frames, Headset pacing, Camera smoothing Stereo's settings, applied at once As quest/longshot_vr.ini As they are - - -
RX_VR=0 / 1, RX_VR_STEREO=0 / 1 vr= and stereo= for one run Unset Unset Test runs - -
LONGSHOT_OPENXR_LOADER=<dir> Build: the folder with Khronos' openxr_loader.dll, copied beside the game Unset (the Builder sets it to its openxr-loader tool) As it is Windows builds - -
RX_VR_TEST=1 Stereo's cameras without a headset, for screenshots of each eye Off Off Developing without a headset - -
RX_VR_TRACE=1 Logs the HUD's draws once every 10 s Off Off - - -

Measurements

main: 0c74253d (2026-11-13)
this PR: feat/vr-openxr @ 7b2120c7. Rebased since onto main @ dd02c6cc, and the numbers still hold: main's two commits after 0c74253d are docs only (the README, CONTRIBUTING.md), and this PR's commits after 7b2120c7 are the README and its check, docs, tools.lock and the build's copy of the loader, and one early return in xr_wanted() for runs with no game window, none of which a game with VR off runs. Rebased again onto main @ 514591a3 (the head is now 10d2a376) and not measured again: main's commits since dd02c6cc are the website, docs, the forge script, a Damage taken row, an opt-in HDR route for FSR 4, occlusion query numbering, quick pipelines, two input fixes and the menu's layout presets; the one conflict was row_shown's line in the menu (the Damage taken row's condition beside the VR rows'), and with VR off none of this change's code runs in a frame. The rebased branch was built whole (nx2/make_game.py) and its checks run. Rebased again onto main @ cf605c85 (the head is now 40fae344) and not measured again: main's 34 commits since 514591a3 are renderer, host and docs work none of which is in this change's path (sparse VRAM mirrors, late readbacks, the recorder thread, Intel fixes, hold-to-toggle, a Mono choice, the caches' and logs' folders); the conflicts were row_shown's line in the menu (main's Intel row beside the VR rows'), the options the host reads (hold-to-toggle beside the headset's pads), and the measured runs, now two files under docs/tested/.
Hardware: NVIDIA GeForce RTX 5080 (Vulkan driver 0x93cec000), Windows 11
Scene: the save's load point, Link standing still; 3840x2160 output, DLSS, frame generation Auto (the display's rate), the game's frame rate cap 60
Method: VR off on both (RX_VR=0: what this PR must not cost). 150 s runs, the first 60 s left out, two interleaved pairs (main, this PR, main, this PR). The numbers are RX_VK_FRAME_LOG's frame times, a line per present (made frames included); the game's own rate is longshot_log.txt's vi: line.
How to reproduce: both builds from the same dump with nx2/make_game.py, the same longshot_options.ini and save, RX_VR=0 HOS_AUDIO_MUTE=1 HOS_INPUT_SCRIPT=12:A,15:A,...,42:A RX_VK_FRAME_LOG=<csv>.

main @ 0c74253d this PR @ 7b2120c7
presents per second (run 1 / run 2) 236.26 / 237.88 236.42 / 236.93
median frame time (run 1 / run 2) 4.148 / 4.143 ms 4.147 / 4.147 ms
p99 frame time (run 1 / run 2) 8.045 / 5.816 ms 7.934 / 7.757 ms
the game's own rate 60.0 fps 60.0 fps
main1: 21194 frames in 89.7 s after 60 s warm-up: 236.26 fps, median 4.148 ms, p99 8.045 ms, max 17.49 ms
vr1:   21184 frames in 89.6 s after 60 s warm-up: 236.42 fps, median 4.147 ms, p99 7.934 ms, max 30.70 ms
main2: 21334 frames in 89.7 s after 60 s warm-up: 237.88 fps, median 4.143 ms, p99 5.816 ms, max 13.66 ms
vr2:   21245 frames in 89.7 s after 60 s warm-up: 236.93 fps, median 4.147 ms, p99 7.757 ms, max 12.22 ms
[hos Presentation Thread] vi: 8344 frames presented (60.0 fps over the last 5.0 s, interval 1)   (main, run 2)
[hos Presentation Thread] vi: 8348 frames presented (60.0 fps over the last 5.0 s, interval 1)   (this PR, run 2)

An earlier pair of runs, before the rebase (main @ 7097361f against this PR @ 5c269eda, frame generation off): 60.00 fps on both, median 16.669 / 16.658 ms against 16.683 / 16.685 ms. With a headset on there is no main to compare with.

Testing

Tested:

  • python -I -B tests/run_all.py on Windows 11 (llvm-mingw), at the rebased head: 477 passed, 7 failed, at the rebased head. The same 7 fail on main @ cf605c85 on this PC (test_bake_portability, test_fpx_sticky, test_module_ids, test_oot_state_tap, test_pad_layout, test_spawn_emit, test_tested_configurations): local tools and packages, line endings in a pipe, and a fixture of main's that does not compile here, not this change.
  • The same before this rebase, at 7b2120c7: 443 passed, 7 failed. The same 7 fail on main on this PC (test_bake_portability, test_fpx_sticky, test_spawn_emit, test_nup_paths, test_check_third_party, test_release, and test_display_pointer, checked on main @ 0c74253d): missing local tools or packages, or failing on main itself, not this change.
  • The same on Linux (WSL, Ubuntu 24.04, clang), before the rebase (5c269eda): 496 passed, 3 failed (test_bake_portability, test_bake_ds: no zstandard; test_check_third_party: the Windows prebuilt files copied into third_party for the build). RX_VK_ONLY=1 nx2/build.py --standalone compiles and links longshot_vk.so there.
  • In the game, this PR's build, no headset (RTX 5080, Windows 11): starts and plays on the desktop with vr=1 and no headset connected, and with RX_VR=0. The VR tab opens, a row's change applies and is written to longshot_vr.ini (xr: menu: fp_height=0.30), the options file is left alone. RX_VR_TEST=1: the game hands out a view per frame and each frame is matched to its eye, 76 s without a crash.
  • The loader's way in, at the head: tools.py install --only openxr-loader with an empty cache downloads Khronos' archive, checks it and unpacks openxr_loader.dll (SHA-256 b0d0eed4...); a make_game.py build with LONGSHOT_OPENXR_LOADER set puts it beside longshot.exe, and its shader preparation ends normally with vr=1 (before the xr_wanted() change that run opened a headset session and died on exit, 0xc0000409).
  • With a headset: Quest 3 over Quest Link (Meta's OpenXR runtime 1.208.0), RTX 5080, the build of 7b2120c7 (the head's code; the later commits are the README, its check and a docs table), played in stereo 3D by the author: it looks fine. That run had Ultrawide menus at Centred, blurred sides, the setting that put the sides' pass into the headset's pictures at 5c269eda: with 7b2120c7 the log has no ultrawide menu sides on line and the sides did not show. The session closed cleanly on exit (xr: the session and the instance closed). Before the port, on Hookshot v2.1.0, the flat screen, stereo, the HUD panel and reticle and the Touch controllers were played over several sessions.

Not tested:

  • The flat-screen view on this base in a headset (stereo was the view played); it was played on Hookshot v2.1.0.
  • AMD and Intel GPUs, and any headset or runtime other than a Quest 3 over Link.
  • Linux at run time: the VR code is stubs there, so the game should behave as main; only the build and the game-free checks ran.
  • Cutscenes, pre-rendered movies and most areas in stereo.

Needs other testers:

  • A SteamVR or Virtual Desktop headset: the game shows in the headset in both views, expecting no crash on exit (T-N5H4)
  • AMD or Intel GPU on Windows, no headset: the game plays as on main with the VR tab present (T-VZY2)
  • Linux, any GPU: the game plays as on main; the VR tab shows one row saying it is not in the Linux build (T-W3YQ)

Screenshots

The Insert menu's VR tab, this PR @ 5c269eda (the tab is the same at the head; main has no such tab):

The VR tab's first rows

The VR tab further down: the 3D view's rows and the HUD's

Risk and rollback

  • With VR off (the default) the new code is a handful of calls that return at once; the measured cost at the cap is none.
  • The host changes are in every build: realtime_step and the camera hook ask the renderer for a view each frame. They do nothing unless the renderer says a headset runs in stereo.
  • The lifted projection patch (nx2/oot_wide.py) reads host globals that are zero without a headset; it needs the game rebuilt.
  • Known in stereo: an occasional repeated frame on fast turns; first person shows no body.
  • tools.lock's new line: see the reviewer notes (mirror it before a release).
  • To turn it off: vr=0 (or RX_VR=0). Reverting the commit is enough; nothing is migrated.

Reviewer notes

  • This is large for one PR. If it is wanted in pieces, the natural split is: flat-screen mode (renderer only, no host or lift change), then stereo, then the menu tab.
  • Before this merges, a maintainer needs to mirror the loader: python -I scripts/tools.py mirror openxr-loader. tools.lock's new line names its copies in Longshot's store first, as tests/test_tools_lock.py asks, and they are not there yet. tools.py falls through to Khronos' URL (checked here: a fresh cache installs it), but the Windows setup (builder/setup/setup.c) reads only a line's first two URLs, so a Builder release cut from an unmirrored lock could not install; tools.py check --require refuses such a release. If a 32 MB download for every Builder install is not wanted for a feature that is off by default, say so and I will make it a build option instead (as --fetch-fsr-runtime is).
  • MkwVkHost / MkwVkRenderer are unchanged: the host finds the new calls as optional exports.
  • The work was not claimed in #dev-work beforehand; it started on Hookshot before the move.

Issues

None.

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • Screenshots for a change that shows on screen.
  • A row in docs/tested-configurations.md for each measured run (now a file under docs/tested/: 2026-11-13-vr-openxr-airdamien-run01.md and -run02.md).
  • A check under tests/ that reads changed source updated in this PR, not weakened.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths.
  • Docs updated.

🤖 Generated with Claude Code

## Why Longshot can only be played on a monitor. This lets it be played in a PC VR headset (tested with a Meta Quest 3 over Quest Link): either on a big flat screen in front of you, or in stereo 3D with the game's camera at your eyes. ## What changed - vr: `gpu/vk/vk_xr.cpp` / `vk_xr.h` (new). The renderer's instance and device are made through OpenXR (`XR_KHR_vulkan_enable2`) when VR is on. Flat-screen mode shows every presented picture on a quad layer. Stereo mode draws one eye a frame (`xr_view`), matches frames to eyes by the scene camera (`xr_note_scene_draw`), holds a pair's first eye until its second and gives both to the headset together (`xr_copy_acquire`, `publish_held`), one pair per `xrWaitFrame` (`xr_thread`). The HUD is redirected to a head-locked panel (`xr_hud_draw`), with its middle (the aiming reticle) shown where the game aims. A first-person view (left-stick tap or F9). The Touch controllers as a virtual SDL gamepad (`pad_attach`). On Linux the file is stubs (`xr_built()` false): it is Win32 only for now. - vk: hooks in `vk_main.cpp` (instance, device, shutdown), `vk_replay.cpp` (`present_image`: the copies to the headset, the window's mirror), `vk_draw.cpp` (HUD and scene-camera notes), `vk_temporal.cpp` (a DLSS/FSR history per eye), `vk_framegen.cpp` (no made frames with a headset). - menu: a **VR** tab in `gpu/vk/vk_menu.cpp`. `Row::apply` is new: a row kept outside the options file (these are kept in `longshot_vr.ini`, written by `xr_opt_changed`). - runtime: `host/hos_main.c` `hos_oot_free_view` hands the game the headset's view and field of view; `host/nn_vi.c` `realtime_step` steps the game in pairs of frames while stereo runs (both eyes one moment); `host/rx_backend.c` looks up the renderer's optional `mkwvk_xr_*` exports. - lift: `nx2/oot_wide.py` (the projection's field of view at the five matrix-update sites), `nx2/oot_patches.py` (the step hook records the step a frame read). - build: `nx2/build.py` adds `third_party/openxr/include`, and copies Khronos' `openxr_loader.dll` beside the game when the tools have it (`LONGSHOT_OPENXR_LOADER`); `nx2/make_game.py` carries it into the game folder (`BESIDE`). `quest/build_vk.ps1` builds the renderer alone and installs it with the loader. - tools: `tools.lock` has a line for Khronos' loader (`openxr-loader`, Khronos' own release archive, Apache-2.0, SHA-256 pinned), so the Builder downloads it with its other tools; `builder/LongshotBuilder/Runner.cs` passes its folder to the build. Nothing of it is committed or packaged in a release. - vr: no headset for a run with no game window (`--prepare-shaders`, `HOS_HEADLESS`): `xr_wanted()` in `gpu/vk/vk_xr.cpp`. - third_party: `third_party/openxr` (Khronos' headers, Apache-2.0, unmodified, with `PROVENANCE.md`). - docs: `quest/README.md`, the three switches in `docs/development.md`. - tests: stubs for the new calls in the fixtures that read the changed functions. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `longshot_vr.ini` `vr=` / VR tab "VR headset" | The game in an OpenXR headset | Off (no file: off) | Off by default | Windows with a PC VR headset | Everyone else: nothing runs | Headsets other than a Quest 3 over Link | | `stereo=` / VR tab "View" | Stereo 3D, or the flat screen | Flat screen | Flat screen until more headsets are tested | - | - | SteamVR, Virtual Desktop, WMR | | VR tab: First person, World scale, Eye separation, Level horizon, Head movement, Fill the view, Upscaler in 3D, Desktop window, HUD panel and its size, Reticle at the aim, Touch controllers, Paired frames, Headset pacing, Camera smoothing | Stereo's settings, applied at once | As `quest/longshot_vr.ini` | As they are | - | - | - | | `RX_VR=0` / `1`, `RX_VR_STEREO=0` / `1` | `vr=` and `stereo=` for one run | Unset | Unset | Test runs | - | - | | `LONGSHOT_OPENXR_LOADER=<dir>` | Build: the folder with Khronos' `openxr_loader.dll`, copied beside the game | Unset (the Builder sets it to its `openxr-loader` tool) | As it is | Windows builds | - | - | | `RX_VR_TEST=1` | Stereo's cameras without a headset, for screenshots of each eye | Off | Off | Developing without a headset | - | - | | `RX_VR_TRACE=1` | Logs the HUD's draws once every 10 s | Off | Off | - | - | - | ## Measurements main: 0c74253d (2026-11-13) this PR: feat/vr-openxr @ 7b2120c7. Rebased since onto main @ dd02c6cc, and the numbers still hold: main's two commits after 0c74253d are docs only (the README, CONTRIBUTING.md), and this PR's commits after 7b2120c7 are the README and its check, docs, tools.lock and the build's copy of the loader, and one early return in `xr_wanted()` for runs with no game window, none of which a game with VR off runs. Rebased again onto main @ 514591a3 (the head is now 10d2a376) and not measured again: main's commits since dd02c6cc are the website, docs, the forge script, a Damage taken row, an opt-in HDR route for FSR 4, occlusion query numbering, quick pipelines, two input fixes and the menu's layout presets; the one conflict was `row_shown`'s line in the menu (the Damage taken row's condition beside the VR rows'), and with VR off none of this change's code runs in a frame. The rebased branch was built whole (`nx2/make_game.py`) and its checks run. Rebased again onto main @ cf605c85 (the head is now 40fae344) and not measured again: main's 34 commits since 514591a3 are renderer, host and docs work none of which is in this change's path (sparse VRAM mirrors, late readbacks, the recorder thread, Intel fixes, hold-to-toggle, a Mono choice, the caches' and logs' folders); the conflicts were `row_shown`'s line in the menu (main's Intel row beside the VR rows'), the options the host reads (hold-to-toggle beside the headset's pads), and the measured runs, now two files under `docs/tested/`. Hardware: NVIDIA GeForce RTX 5080 (Vulkan driver 0x93cec000), Windows 11 Scene: the save's load point, Link standing still; 3840x2160 output, DLSS, frame generation Auto (the display's rate), the game's frame rate cap 60 Method: VR off on both (`RX_VR=0`: what this PR must not cost). 150 s runs, the first 60 s left out, two interleaved pairs (main, this PR, main, this PR). The numbers are `RX_VK_FRAME_LOG`'s frame times, a line per present (made frames included); the game's own rate is `longshot_log.txt`'s `vi:` line. How to reproduce: both builds from the same dump with `nx2/make_game.py`, the same `longshot_options.ini` and save, `RX_VR=0 HOS_AUDIO_MUTE=1 HOS_INPUT_SCRIPT=12:A,15:A,...,42:A RX_VK_FRAME_LOG=<csv>`. | | main @ 0c74253d | this PR @ 7b2120c7 | |---|---|---| | presents per second (run 1 / run 2) | 236.26 / 237.88 | 236.42 / 236.93 | | median frame time (run 1 / run 2) | 4.148 / 4.143 ms | 4.147 / 4.147 ms | | p99 frame time (run 1 / run 2) | 8.045 / 5.816 ms | 7.934 / 7.757 ms | | the game's own rate | 60.0 fps | 60.0 fps | ``` main1: 21194 frames in 89.7 s after 60 s warm-up: 236.26 fps, median 4.148 ms, p99 8.045 ms, max 17.49 ms vr1: 21184 frames in 89.6 s after 60 s warm-up: 236.42 fps, median 4.147 ms, p99 7.934 ms, max 30.70 ms main2: 21334 frames in 89.7 s after 60 s warm-up: 237.88 fps, median 4.143 ms, p99 5.816 ms, max 13.66 ms vr2: 21245 frames in 89.7 s after 60 s warm-up: 236.93 fps, median 4.147 ms, p99 7.757 ms, max 12.22 ms [hos Presentation Thread] vi: 8344 frames presented (60.0 fps over the last 5.0 s, interval 1) (main, run 2) [hos Presentation Thread] vi: 8348 frames presented (60.0 fps over the last 5.0 s, interval 1) (this PR, run 2) ``` An earlier pair of runs, before the rebase (main @ 7097361f against this PR @ 5c269eda, frame generation off): 60.00 fps on both, median 16.669 / 16.658 ms against 16.683 / 16.685 ms. With a headset on there is no `main` to compare with. ## Testing **Tested:** - `python -I -B tests/run_all.py` on Windows 11 (llvm-mingw), at the rebased head: 477 passed, 7 failed, at the rebased head. The same 7 fail on `main` @ cf605c85 on this PC (`test_bake_portability`, `test_fpx_sticky`, `test_module_ids`, `test_oot_state_tap`, `test_pad_layout`, `test_spawn_emit`, `test_tested_configurations`): local tools and packages, line endings in a pipe, and a fixture of main's that does not compile here, not this change. - The same before this rebase, at 7b2120c7: 443 passed, 7 failed. The same 7 fail on `main` on this PC (`test_bake_portability`, `test_fpx_sticky`, `test_spawn_emit`, `test_nup_paths`, `test_check_third_party`, `test_release`, and `test_display_pointer`, checked on main @ 0c74253d): missing local tools or packages, or failing on `main` itself, not this change. - The same on Linux (WSL, Ubuntu 24.04, clang), before the rebase (5c269eda): 496 passed, 3 failed (`test_bake_portability`, `test_bake_ds`: no `zstandard`; `test_check_third_party`: the Windows prebuilt files copied into `third_party` for the build). `RX_VK_ONLY=1 nx2/build.py --standalone` compiles and links `longshot_vk.so` there. - In the game, this PR's build, no headset (RTX 5080, Windows 11): starts and plays on the desktop with `vr=1` and no headset connected, and with `RX_VR=0`. The VR tab opens, a row's change applies and is written to `longshot_vr.ini` (`xr: menu: fp_height=0.30`), the options file is left alone. `RX_VR_TEST=1`: the game hands out a view per frame and each frame is matched to its eye, 76 s without a crash. - The loader's way in, at the head: `tools.py install --only openxr-loader` with an empty cache downloads Khronos' archive, checks it and unpacks `openxr_loader.dll` (SHA-256 b0d0eed4...); a `make_game.py` build with `LONGSHOT_OPENXR_LOADER` set puts it beside `longshot.exe`, and its shader preparation ends normally with `vr=1` (before the `xr_wanted()` change that run opened a headset session and died on exit, 0xc0000409). - With a headset: Quest 3 over Quest Link (Meta's OpenXR runtime 1.208.0), RTX 5080, the build of 7b2120c7 (the head's code; the later commits are the README, its check and a docs table), played in stereo 3D by the author: it looks fine. That run had Ultrawide menus at Centred, blurred sides, the setting that put the sides' pass into the headset's pictures at 5c269eda: with 7b2120c7 the log has no `ultrawide menu sides on` line and the sides did not show. The session closed cleanly on exit (`xr: the session and the instance closed`). Before the port, on Hookshot v2.1.0, the flat screen, stereo, the HUD panel and reticle and the Touch controllers were played over several sessions. **Not tested:** - The flat-screen view on this base in a headset (stereo was the view played); it was played on Hookshot v2.1.0. - AMD and Intel GPUs, and any headset or runtime other than a Quest 3 over Link. - Linux at run time: the VR code is stubs there, so the game should behave as `main`; only the build and the game-free checks ran. - Cutscenes, pre-rendered movies and most areas in stereo. **Needs other testers:** - A SteamVR or Virtual Desktop headset: the game shows in the headset in both views, expecting no crash on exit (T-N5H4) - AMD or Intel GPU on Windows, no headset: the game plays as on `main` with the VR tab present (T-VZY2) - Linux, any GPU: the game plays as on `main`; the VR tab shows one row saying it is not in the Linux build (T-W3YQ) ## Screenshots The Insert menu's VR tab, this PR @ 5c269eda (the tab is the same at the head; `main` has no such tab): ![The VR tab's first rows](https://git.magicbean.dev/attachments/7fcd8013-e636-43f3-8421-98697303fe7a) ![The VR tab further down: the 3D view's rows and the HUD's](https://git.magicbean.dev/attachments/91a74e2b-5902-4fba-8865-4a61ee255995) ## Risk and rollback - With VR off (the default) the new code is a handful of calls that return at once; the measured cost at the cap is none. - The host changes are in every build: `realtime_step` and the camera hook ask the renderer for a view each frame. They do nothing unless the renderer says a headset runs in stereo. - The lifted projection patch (`nx2/oot_wide.py`) reads host globals that are zero without a headset; it needs the game rebuilt. - Known in stereo: an occasional repeated frame on fast turns; first person shows no body. - `tools.lock`'s new line: see the reviewer notes (mirror it before a release). - To turn it off: `vr=0` (or `RX_VR=0`). Reverting the commit is enough; nothing is migrated. ## Reviewer notes - This is large for one PR. If it is wanted in pieces, the natural split is: flat-screen mode (renderer only, no host or lift change), then stereo, then the menu tab. - **Before this merges, a maintainer needs to mirror the loader:** `python -I scripts/tools.py mirror openxr-loader`. `tools.lock`'s new line names its copies in Longshot's store first, as `tests/test_tools_lock.py` asks, and they are not there yet. `tools.py` falls through to Khronos' URL (checked here: a fresh cache installs it), but the Windows setup (`builder/setup/setup.c`) reads only a line's first two URLs, so a Builder release cut from an unmirrored lock could not install; `tools.py check --require` refuses such a release. If a 32 MB download for every Builder install is not wanted for a feature that is off by default, say so and I will make it a build option instead (as `--fetch-fsr-runtime` is). - `MkwVkHost` / `MkwVkRenderer` are unchanged: the host finds the new calls as optional exports. - The work was not claimed in `#dev-work` beforehand; it started on Hookshot before the move. ## Issues None. ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] Screenshots for a change that shows on screen. - [x] A row in `docs/tested-configurations.md` for each measured run (now a file under `docs/tested/`: `2026-11-13-vr-openxr-airdamien-run01.md` and `-run02.md`). - [x] A check under `tests/` that reads changed source updated in this PR, not weakened. - [x] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. - [x] Docs updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
First-time contributor

Rebased onto main @ 0c74253d and measured again there (VR off: 236.4 / 236.9 presents a second against main's 236.3 / 237.9, the same median frame time). Added: the Ultrawide menus fix found in a headset run (7b2120c7), the README's VR rows, two tested-configurations rows, and the three test requests (T-N5H4, T-VZY2, T-W3YQ). Still WIP: the head has not been run in a headset yet, and the screenshots are to attach.

Rebased onto main @ 0c74253d and measured again there (VR off: 236.4 / 236.9 presents a second against main's 236.3 / 237.9, the same median frame time). Added: the Ultrawide menus fix found in a headset run (7b2120c7), the README's VR rows, two tested-configurations rows, and the three test requests (T-N5H4, T-VZY2, T-W3YQ). Still WIP: the head has not been run in a headset yet, and the screenshots are to attach.
Author
First-time contributor

The README has a line for each VR row, and tests/test_readme_menu_rows.py now covers the VR tab (it failed on the grouped lines of the commit before). run_all on Windows: 443 passed, the 7 failures are main's on this PC.

The README has a line for each VR row, and tests/test_readme_menu_rows.py now covers the VR tab (it failed on the grouped lines of the commit before). run_all on Windows: 443 passed, the 7 failures are main's on this PC.
Author
First-time contributor

Run in the headset at the head's code (the build of 7b2120c7, Quest 3 over Link, stereo 3D, Ultrawide menus at Centred, blurred sides): it looks fine, the sides no longer reach the headset, and the session closes cleanly. Screenshots of the VR tab attached. Marking it ready.

Run in the headset at the head's code (the build of 7b2120c7, Quest 3 over Link, stereo 3D, Ultrawide menus at Centred, blurred sides): it looks fine, the sides no longer reach the headset, and the session closes cleanly. Screenshots of the VR tab attached. Marking it ready.
airdamien changed title from WIP: feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menu to feat(vr): play in a PC VR headset over OpenXR, with a VR tab in the Insert menu 2026-11-13 14:45:10 +00:00
Author
First-time contributor

menu_0
menu_1

![menu_0](/attachments/47acd91a-08b8-48f4-8391-56640b8f3f56) ![menu_1](/attachments/405423b1-3b95-4992-8fd5-186e0767243a)
airdamien force-pushed feat/vr-openxr from 843ab23c10
All checks were successful
PR description / Description follows the template (pull_request_target) Successful in 3s
to 3f6d4ef86f
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 11s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-13 14:57:24 +00:00
Compare
Author
First-time contributor

The loader no longer needs packaging or a script: tools.lock has a line for Khronos' release archive (openxr-loader), the Builder passes its folder to the build, and the build copies openxr_loader.dll beside the game. A maintainer needs to mirror it before a release (tools.py mirror openxr-loader): the reviewer notes say why. Also: a run with no game window (--prepare-shaders, HOS_HEADLESS) no longer opens a headset session; the preparation after a build died on exit with vr=1. Rebased onto main's head; run_all on Windows: 443 passed, the 7 failures are main's on this PC.

The loader no longer needs packaging or a script: tools.lock has a line for Khronos' release archive (openxr-loader), the Builder passes its folder to the build, and the build copies openxr_loader.dll beside the game. A maintainer needs to mirror it before a release (tools.py mirror openxr-loader): the reviewer notes say why. Also: a run with no game window (--prepare-shaders, HOS_HEADLESS) no longer opens a headset session; the preparation after a build died on exit with vr=1. Rebased onto main's head; run_all on Windows: 443 passed, the 7 failures are main's on this PC.
airdamien force-pushed feat/vr-openxr from 3f6d4ef86f
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 11s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 10d2a376c5
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 8s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
2026-11-13 19:55:49 +00:00
Compare
Author
First-time contributor

Rebased onto main @ 514591a3 (head 10d2a376). One conflict, row_shown's line in the menu (main's Damage taken row beside the VR rows' condition); tests/test_damage_debug_menu.py, new on main, gets a stub for vr_row_shown. Built whole and the checks run (454 passed; the 7 that fail also fail on main here). Not measured again: the description says why.

Rebased onto main @ 514591a3 (head 10d2a376). One conflict, `row_shown`'s line in the menu (main's Damage taken row beside the VR rows' condition); `tests/test_damage_debug_menu.py`, new on main, gets a stub for `vr_row_shown`. Built whole and the checks run (454 passed; the 7 that fail also fail on main here). Not measured again: the description says why.
airdamien force-pushed feat/vr-openxr from 10d2a376c5
Some checks failed
PR description / Description follows the template (pull_request_target) Successful in 8s
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request) Has been cancelled
to 40fae344ce
Some checks failed
Website / Website on Cloudflare (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
PR description / Description follows the template (pull_request_target) Successful in 7s
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
2026-11-14 03:30:30 +00:00
Compare
Author
First-time contributor

Rebased onto main @ cf605c85 (head 40fae344). Conflicts: row_shown in the menu (main's Intel shader compiles row), the host's options (hold-to-toggle), and the measured runs, which are now two files under docs/tested/ as #96 asks. python -I -B tests/run_all.py: 477 passed; the 7 that fail also fail on main here. Built whole. Not measured again: the description says why.

Rebased onto main @ cf605c85 (head 40fae344). Conflicts: `row_shown` in the menu (main's Intel shader compiles row), the host's options (hold-to-toggle), and the measured runs, which are now two files under `docs/tested/` as #96 asks. `python -I -B tests/run_all.py`: 477 passed; the 7 that fail also fail on main here. Built whole. Not measured again: the description says why.
airdamien force-pushed feat/vr-openxr from 40fae344ce
Some checks failed
Website / Website on Cloudflare (pull_request) Has been cancelled
Website / Website on Cloudflare (pull_request_target) Has been skipped
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
PR description / Description follows the template (pull_request_target) Successful in 7s
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
to 0d30abedef
Some checks are pending
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 14s
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
2026-11-14 05:37:27 +00:00
Compare
Some checks are pending
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
Website / Website on Cloudflare (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 14s
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
This pull request has changes conflicting with the target branch.
  • docs/development.md
  • gpu/vk/vk_menu.cpp
  • gpu/vk/vk_replay.cpp
Some workflows are waiting to be reviewed.
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'feat/vr-openxr:airdamien-feat/vr-openxr'
git switch 'airdamien-feat/vr-openxr'
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!204
No description provided.