# Steam Frame port: design Design of iteration 1 (R1–R4, R6, R7 in [REQUIREMENTS.md](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 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: ```cpp 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 trixie (Qt 6.8) with arm64 multiarch libraries, cross compiler | | `native.Dockerfile` | The same for the host's own architecture (on the Frame: native build); base of the test container | | `test.Dockerfile` | Mesa (incl. lavapipe), Vulkan validation layers, Xvfb | | `aarch64-linux-gnu.cmake` | Cross-compile toolchain | | `container-build.sh cross\|native` | Builds with `OVRAS_STEAM_FRAME=ON` → `build/steamframe-/Release/` | | `container-test.sh` | Render tests with both APIs, Vulkan with validation layers | | `package-release.sh` | Build output + `install.sh` / `uninstall.sh` / `run.sh` / `README.txt` → `dist/AdvancedSettings--linux-arm64(.tar.gz)` | | `deploy-to-frame.sh` | Copies the package to the Frame over SSH (`rsync`) and runs `install.sh` there | - **Scripts in the package:** - `install.sh` stops a running instance, registers it (`--force-install-manifest`, auto-start) and starts it with `DISPLAY=:0`. Over SSH there is no display, and Qt needs one. - `run.sh [opengl|vulkan]` restarts it with a given graphics API. - The scripts start the program in the package folder, because OVRAS writes `AdvancedSettings.log` into the working directory. - Packages use the **system Qt**: no Qt is bundled, because of section 3 the binary uses no private API. - RPATH is only `$ORIGIN` (`BUILD_WITH_INSTALL_RPATH`): the build tree gets packaged. ## Tests - **x86_64 locally:** the build succeeds; `objdump` check (R1). - **Render test `test/render/tst_render`** (`OVRAS_BUILD_TESTS=ON`): - Drives the backends and `QQuickRenderControl` in the same order as `OverlayController`. - Checks the texture description for SteamVR (type, Vulkan format) and the content and orientation via readback. - One CTest entry per API. - `container-test.sh` runs it with the Vulkan validation layers. It fails on validation messages and on skipped tests. - **OVRAS's keyboard parser test** (`test/keyboard_input`, qmake) is not included in CMake in iteration 1 (not run). - **On the Frame:** the checklist in `PROGRESS.md` per acceptance criterion R1–R4, R6, R7, with both graphics APIs. ## 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.