Files
OpenVR-AdvancedSettings-frame/docs/steamframe/DESIGN.md
T
Matthias JanetschekandClaude Opus 5.5 aac3edf7e3 Steam Frame port: requirements and design of iteration 1
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>
2026-10-07 13:29:53 +02:00

13 KiB
Raw Blame History

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 in OverlayController.

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:
    • GuiPrivate is not needed after section 3, so it is removed rather than added.
    • d3d11.h goes along with QRhi (section 3).
    • LOG(INFO|WARNING|ERROR) becomes qInfo() / qWarning() / qCritical().
    • OpenVR link path and Xtst are added.
  • QML as bytecode only: qt_add_qml_module(... ) gets the bytecode-only flag (QT_QMLCACHEGEN_ARGUMENTS --only-bytecode, as the template's VRDASH_QML_BYTECODE_ONLY). This removes QQmlPrivate::AOTCompiledContext. Code compiled from QML to C++ only fits the exact Qt version it was built with.
  • Qt modules:
    • Core5Compat is removed: it is unused, and it isn't on the Frame.
    • GuiPrivate is removed (section 3).
    • Widgets, QuickDialogs2, Svg, Multimedia and WebSockets stay. 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 from lib/linux64 or lib/linuxarm64 depending on CMAKE_SYSTEM_PROCESSOR. It is copied next to the binary, and the RPATH is $ORIGIN.
  • Check: objdump -T AdvancedSettings_app | grep Qt_6_PRIVATE_API may only show QVulkan*. Qt's Vulkan wrappers carry that tag but are generated from the Vulkan specification.

2. Manifest (R2)

  • manifest.vrmanifest: gets binary_path_linux and binary_path_linux_arm, both set to AdvancedSettings_app.
    • On Linux, qt6 names the binary that way (QTBUG-98568).
    • The Frame only reads binary_path_linux_arm; entries without it are skipped.
  • manifest::handleManifests (setup.cpp:255):
    1. First VR_Init(Utility), as before.
    2. On VRInitError_Init_NotAvailableToUtilityApps, VR_Init(Background).
    • Same behavior on every platform.
    • Background needs a running SteamVR and doesn't start it. Utility stays first, so that Windows' installer still works without SteamVR running.
    • It calls VR_Init directly, without initializeOpenVR's retry loop. That loop waits and retries on every error, including the one we expect here.

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 factory
  • OpenGLRenderBackend.*
  • 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_OPTIMAL after each frame

Changes in OVRAS:

  • main.cpp, in this order:

    1. parse the command line
    2. initializeOpenVR(Overlay)
    3. createRenderBackend(api) and initialize(renderInterop)
    4. QQuickWindow::setGraphicsApi(backend->graphicsApi())
    5. construct OverlayController(…, backend)

    setGraphicsApi must come before any QQuickWindow exists. OverlayController::m_window is a member, so the API is fixed before the controller is constructed.

  • openvr_init: gets renderInterop(). Its three functions query GetVulkanInstanceExtensionsRequired, GetVulkanDeviceExtensionsRequired and GetOutputDevice; the code is copied from the template's VrRuntime::renderInterop().

  • OverlayController::SetWidget: the QRhi block (newTexture, newTextureRenderTarget, QVulkanInstance creation) is replaced by:

    1. backend->configureWindow(&m_window)
    2. m_renderControl.initialize()
    3. backend->attachWindow(&m_window)
    4. m_window.setRenderTarget(backend->createRenderTarget(size))
  • OverlayController::renderOverlay: the frame is wrapped in backend->beginFrame() / finishFrame(). Submission is SetOverlayTexture(backend->vrTexture()) plus SetOverlayTextureBounds(backend->vrTextureBounds()), while makeCurrent() is active (the OpenGL submission requires it). SetOverlayFromQRhiTexture and 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 with exit(), so destructors don't run. Before that it calls vr::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_Shutdown in overlay mode.

4. Command-line option --graphics-api (R3)

  • Syntax: --graphics-api opengl|vulkan; the default is opengl.
    • It goes into CommandLineOptions and returnCommandLineParser (setup.*), next to the existing options.
  • 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)

  • getMousePositionForEvent already flips y everywhere, then flips it back on Linux in #ifdef __linux__. The Linux block goes, so y is 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_pumpEventsTimer fires 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 a Qt::PreciseTimer. The 1 ms counting is dropped. Same behavior, about 1/11 of the wake-ups at 11 ms.
    • setVsyncDisabled / setCustomTickRateMs restart the timer with the new interval.
    • The vsync mode stays unchanged.
  • Frame defaults through the platform option (section 7): vsyncDisabled = true and customTickRateMs = 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.
  • Measurement: pidstat -p <pid> 10 6 on the Frame, in three states: dashboard closed, OVRAS tab visible, another tab visible.

7. Platform option and capability constants

  • CMake option OVRAS_STEAM_FRAME: default OFF, ON in the Frame build. It sets the compile definition OVRAS_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.h use 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 when GetCalibrationState() != 200.
  • Otherwise m_pendingZeroOffsets stays set, and the commit repeats on every tick.

Change:

  • With !platform::k_chaperoneCommitSupported:
    • updateChaperoneResetData() still reads the working copy, but skips CommitWorkingCopy / 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; objdump check (R1).
  • Vulkan: container-test.sh with validation layers. It reports no messages, and the render path is checked by a snapshot test (tst_render from the template, reduced to the backends).
  • OVRAS: the keyboard parser test test/keyboard_input is 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

  1. Build fixes and bytecode-only QML. Linux x86_64 builds with QRhi (baseline).
  2. Platform option, R7, tick.
  3. Rendering via the template's backends, --graphics-api, mouse y, shutdown order. objdump check.
  4. Manifest and installation.
  5. 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 steamframe until then (2026-10-07).
  • D2 – Code style: OVRAS style, including for the template files in src/render/ (2026-10-07).
    • The repository's .clang-format dates 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; qt6 uses the same .clang-format.
    • The format check in CI is disabled (.travis.yml). Under clang-format 22, 12 of 86 files in src/ already deviate today (e.g. overlaycontroller.cpp with 138 places, master likewise).
    • 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.