Internal fork based on upstream's qt6 branch (2d4db7e). Goal: OVRAS runs
natively on the Steam Frame (SteamOS, aarch64) with a minimal change set
against qt6 and only public Qt API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 KiB
Steam Frame port: design
Design of iteration 1 (R1–R4, R6, R7 in REQUIREMENTS.md). Status: approved 2026-10-07.
Principle: minimal change set against qt6. Existing OVRAS code is changed only where
the Frame or the "public API only" rule requires it. New parts come in as self-contained
files. Every change gets comments that explain what it does and why.
Starting point
qt6 (2d4db7e) doesn't build on Linux. Checked in a scratch copy with Qt 6.11; with the
fixes from section 1 it builds:
| Error | Place |
|---|---|
Qt::GuiPrivate not found (Qt ≥ 6.10 requires it in find_package) |
CMakeLists.txt |
d3d11.h and QRhi::D3D11 used on Linux too |
overlaycontroller.cpp |
8 × easylogging LOG(...) left over |
overlaycontroller.cpp (6), ChaperoneTabController.cpp, MoveCenterTabController.cpp |
No link path to libopenvr_api.so on Linux |
CMakeLists.txt |
libXtst not linked (XTestFakeKeyEvent) |
CMakeLists.txt |
Private Qt symbols in the binary built that way (objdump -T, Qt_6_PRIVATE_API):
QQmlPrivate::AOTCompiledContext(2), from compiling QML to C++.- 7 ×
QRhi…, from the rendering inOverlayController.
Changes at a glance
| # | Area | Files | Kind |
|---|---|---|---|
| 1 | Build fixes, bytecode-only QML, OpenVR per architecture | CMakeLists.txt, 3 × .cpp |
Change |
| 2 | Manifest for Linux; installation tries Utility, then Background |
manifest.vrmanifest, setup.cpp |
Change |
| 3 | Rendering through the template's backends instead of QRhi | src/render/* (new), overlaycontroller.*, main.cpp |
New + change |
| 4 | Command-line option --graphics-api |
setup.*, main.cpp |
Change |
| 5 | Mouse y the right way up | overlaycontroller.cpp |
Change (−4 lines) |
| 6 | Tick: the timer runs at the tick interval instead of counting 1 ms ticks | overlaycontroller.cpp |
Change |
| 7 | Platform option OVRAS_STEAM_FRAME with capability constants |
CMakeLists.txt, src/utils/platform_capabilities.h (new) |
New |
| 8 | No play-area commit at start (R7) | MoveCenterTabController.cpp |
Change |
| 9 | Frame build, packaging, deployment | build_scripts/steamframe/* (new) |
New |
1. Build (R1)
- The five errors in the table above:
GuiPrivateis not needed after section 3, so it is removed rather than added.d3d11.hgoes along with QRhi (section 3).LOG(INFO|WARNING|ERROR)becomesqInfo()/qWarning()/qCritical().- OpenVR link path and
Xtstare added.
- QML as bytecode only:
qt_add_qml_module(... )gets the bytecode-only flag (QT_QMLCACHEGEN_ARGUMENTS --only-bytecode, as the template'sVRDASH_QML_BYTECODE_ONLY). This removesQQmlPrivate::AOTCompiledContext. Code compiled from QML to C++ only fits the exact Qt version it was built with. - Qt modules:
Core5Compatis removed: it is unused, and it isn't on the Frame.GuiPrivateis removed (section 3).Widgets,QuickDialogs2,Svg,MultimediaandWebSocketsstay. They are present on the Frame (SteamOS 0.4.3, system Qt 6.8.0).
- OpenVR: the repository's
third-party/openvr. The library comes fromlib/linux64orlib/linuxarm64depending onCMAKE_SYSTEM_PROCESSOR. It is copied next to the binary, and the RPATH is$ORIGIN. - Check:
objdump -T AdvancedSettings_app | grep Qt_6_PRIVATE_APImay only showQVulkan*. Qt's Vulkan wrappers carry that tag but are generated from the Vulkan specification.
2. Manifest (R2)
manifest.vrmanifest: getsbinary_path_linuxandbinary_path_linux_arm, both set toAdvancedSettings_app.- On Linux,
qt6names the binary that way (QTBUG-98568). - The Frame only reads
binary_path_linux_arm; entries without it are skipped.
- On Linux,
manifest::handleManifests(setup.cpp:255):- First
VR_Init(Utility), as before. - On
VRInitError_Init_NotAvailableToUtilityApps,VR_Init(Background).
- Same behavior on every platform.
Backgroundneeds a running SteamVR and doesn't start it.Utilitystays first, so that Windows' installer still works without SteamVR running.- It calls
VR_Initdirectly, withoutinitializeOpenVR's retry loop. That loop waits and retries on every error, including the one we expect here.
- First
3. Rendering through public API (R1, R3)
From the template (4a5b090, verified on the Frame), adopted largely unchanged:
RenderBackend.h/.cpp: the interface,RenderInterop, the factoryOpenGLRenderBackend.*VulkanRenderBackend.*
They depend only on openvr.h and public Qt API:
- textures via
QQuickRenderTarget::fromOpenGLTexture/fromVulkanImage - Qt's device via
QSGRendererInterface::getResource
The Vulkan backend already handles what qt6's Vulkan path lacks:
- the instance/device extensions SteamVR requires
- the HMD's GPU (
GetOutputDevice) - the correct
VkFormat - the layout transition to
TRANSFER_SRC_OPTIMALafter each frame
Changes in OVRAS:
-
main.cpp, in this order:- parse the command line
initializeOpenVR(Overlay)createRenderBackend(api)andinitialize(renderInterop)QQuickWindow::setGraphicsApi(backend->graphicsApi())- construct
OverlayController(…, backend)
setGraphicsApimust come before anyQQuickWindowexists.OverlayController::m_windowis a member, so the API is fixed before the controller is constructed. -
openvr_init: getsrenderInterop(). Its three functions queryGetVulkanInstanceExtensionsRequired,GetVulkanDeviceExtensionsRequiredandGetOutputDevice; the code is copied from the template'sVrRuntime::renderInterop(). -
OverlayController::SetWidget: the QRhi block (newTexture,newTextureRenderTarget,QVulkanInstancecreation) is replaced by:backend->configureWindow(&m_window)m_renderControl.initialize()backend->attachWindow(&m_window)m_window.setRenderTarget(backend->createRenderTarget(size))
-
OverlayController::renderOverlay: the frame is wrapped inbackend->beginFrame()/finishFrame(). Submission isSetOverlayTexture(backend->vrTexture())plusSetOverlayTextureBounds(backend->vrTextureBounds()), whilemakeCurrent()is active (the OpenGL submission requires it).SetOverlayFromQRhiTextureand the QRhi members are removed. -
Frame-rate cap: the render delay timer goes from 5 to 16 ms. A frame starts at most 16 ms after a change request, which is about 60 fps.
-
Shutdown:
exitApp()ends withexit(), so destructors don't run. Before that it callsvr::VR_Shutdown().- Why: OpenVR has to shut down while the graphics device still exists. Otherwise its client
frees objects on a dead
VkDevice, which crashed on the Frame with Vulkan. - Today OVRAS never calls
VR_Shutdownin overlay mode.
- Why: OpenVR has to shut down while the graphics device still exists. Otherwise its client
frees objects on a dead
4. Command-line option --graphics-api (R3)
- Syntax:
--graphics-api opengl|vulkan; the default isopengl.- It goes into
CommandLineOptionsandreturnCommandLineParser(setup.*), next to the existing options.
- It goes into
- Unknown value, or Vulkan not compiled in: error message, exit code
GENERAL_FAILURE. - No automatic fallback. OpenGL is the default anyway, and an explicit choice shouldn't be changed silently (as in the template).
- Vulkan in the build: compiled in when the Vulkan headers and a Qt with Vulkan support are present. The cross container has both.
5. Mouse y (R4)
getMousePositionForEventalready flipsyeverywhere, then flips it back on Linux in#ifdef __linux__. The Linux block goes, soyis flipped on every platform.- Why: SteamVR delivers mouse coordinates in GL convention (y from the bottom), regardless of platform and graphics API. This was verified with the template on the Frame for OpenGL and Vulkan.
- The "
TODO temp mouse is negative??" comment is replaced by this explanation.
6. Tick (R6)
- Today:
m_pumpEventsTimerfires every 1 ms.- With "vsync" on, it checks for a new compositor frame.
- With "Disable App Vsync", it counts 1 ms ticks up to
customTickRateMs.
- Change:
- With "Disable App Vsync", the timer runs directly at
customTickRateMs, as aQt::PreciseTimer. The 1 ms counting is dropped. Same behavior, about 1/11 of the wake-ups at 11 ms. setVsyncDisabled/setCustomTickRateMsrestart the timer with the new interval.- The vsync mode stays unchanged.
- With "Disable App Vsync", the timer runs directly at
- Frame defaults through the platform option (section 7):
vsyncDisabled = trueandcustomTickRateMs = 11, about 90 Hz as in the template's event polling. Tick-counted intervals in OVRAS (designed for ~90 fps) keep their meaning.- Upstream defaults stay
false/20. - Both can still be changed in the OVRAS settings.
- Upstream defaults stay
- Measurement:
pidstat -p <pid> 10 6on the Frame, in three states: dashboard closed, OVRAS tab visible, another tab visible.
7. Platform option and capability constants
- CMake option
OVRAS_STEAM_FRAME: defaultOFF,ONin the Frame build. It sets the compile definitionOVRAS_STEAM_FRAME=1. - New
src/utils/platform_capabilities.h: the only place that evaluates the definition.- Everything else asks for named constants and never for the platform.
- Each constant is commented with why it differs on the Frame.
- Iteration 1:
namespace platform
{
// IVRChaperoneSetup::CommitWorkingCopy changes the live play area. On the
// Steam Frame (inside-out tracking) this shifts the boundary.
constexpr bool k_chaperoneCommitSupported = !OVRAS_STEAM_FRAME;
// Fixed-interval tick instead of 1 ms vsync polling (fewer wake-ups).
constexpr bool k_defaultVsyncDisabled = OVRAS_STEAM_FRAME;
constexpr int k_defaultCustomTickRateMs = OVRAS_STEAM_FRAME ? 11 : 20;
} // namespace platform
- The setting defaults in
settings_controller.huse the two default constants.
8. No play-area commit at start (R7)
Cause (MoveCenterTabController.cpp):
zeroOffsets()→updateChaperoneResetData()→CommitWorkingCopy(Live)(:1185).- At init it creates an autosave profile (
:1109), but only whenGetCalibrationState() != 200. - Otherwise
m_pendingZeroOffsetsstays set, and the commit repeats on every tick.
Change:
- With
!platform::k_chaperoneCommitSupported:updateChaperoneResetData()still reads the working copy, but skipsCommitWorkingCopy/RevertWorkingCopy.- The same applies to
applyChaperoneResetData(). - The init autosave profile is skipped.
- Each skip is logged once.
- What the playspace features do when the user operates them is R5 (iteration 2).
9. Frame build, packaging, deployment (R1, R2)
New files under build_scripts/steamframe/, adapted from the template. They change no
existing code:
| File | Purpose |
|---|---|
cross-arm64.Dockerfile |
Debian container with Qt 6.8 (aarch64 multiarch), CMake, Vulkan headers, X11/Xtst, libpulse |
aarch64-linux-gnu.cmake |
Cross-compile toolchain |
container-build.sh |
Configures the build (OVRAS_STEAM_FRAME=ON) and builds it in the container |
package-release.sh |
Folder with the binary, libopenvr_api.so, res/, manifests, install.sh / uninstall.sh → .tar.gz |
deploy-to-frame.sh |
Copies the package to the Frame over SSH and runs install.sh |
install.sh / uninstall.sh |
--force-install-manifest / --force-remove-manifest (section 2) and start / stop |
- Packages use the system Qt: no Qt is bundled, because of section 3 the binary uses no private API.
- The template's Qt-bundling fallback is not adopted for now.
Tests
- x86_64 locally: the build succeeds;
objdumpcheck (R1). - Vulkan:
container-test.shwith validation layers. It reports no messages, and the render path is checked by a snapshot test (tst_renderfrom the template, reduced to the backends). - OVRAS: the keyboard parser test
test/keyboard_inputis included in CMake if that is easy; otherwise it is noted as not run. - On the Frame: a checklist per acceptance criterion R1–R4, R6, R7, with both graphics
APIs. Results go to
docs/steamframe/PROGRESS.md.
Implementation order
- Build fixes and bytecode-only QML. Linux x86_64 builds with QRhi (baseline).
- Platform option, R7, tick.
- Rendering via the template's backends,
--graphics-api, mouse y, shutdown order.objdumpcheck. - Manifest and installation.
- Frame build scripts, package, deployment, tests on the Frame (OpenGL, then Vulkan).
Decisions (formerly open)
- D1 – Windows render path: (c). Windows stays open until the upstream evaluation. The
Windows build is broken on
steamframeuntil then (2026-10-07). - D2 – Code style: OVRAS style, including for the template files in
src/render/(2026-10-07).- The repository's
.clang-formatdates from 2018-12-27 (one fix the day after). The whole code base was reformatted once on 2018-12-28 (51057c1). The style hasn't changed since;qt6uses the same.clang-format. - The format check in CI is disabled (
.travis.yml). Under clang-format 22, 12 of 86 files insrc/already deviate today (e.g.overlaycontroller.cppwith 138 places,masterlikewise). - Hence: only lines we change get formatted (
git clang-format), never whole existing files. Otherwise the diff would contain unrelated formatting changes. New files are formatted completely.
- The repository's