# 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. ### 2a. App key already registered by Steam (approved 2026-10-07) **Finding (Frame test T1):** Steam writes `~/.local/share/Steam/config/steamapps.vrmanifest` (`"source": "steam"`) with every VR app the user **owns**, installed or not (Frame: 154 entries, 25 installed). It is regenerated by Steam. Whoever owns OVRAS on Steam therefore always has `steam.overlay.1009850` registered, as `launch_type: url` (`steam://launch/1009850/openvroverlay`), `is_hidden`. Such an entry has no working directory. `reinstallApplicationManifest()` treats that as an error ("Could not find working directory of already installed application: VRApplicationError_PropertyNotSet") and stops. **Open question:** does SteamVR accept a second manifest for the same key, and which entry wins? `AddApplicationManifest` may return `VRApplicationError_AppKeyAlreadyExists`. If Steam's entry wins, auto-start and the dashboard would launch the Steam (Proton) version. **Change (`setup.cpp`, platform-neutral, also useful upstream):** - `reinstallApplicationManifest()`: if the key is installed but has no working directory (`VRApplicationError_PropertyNotSet`), there is no manifest file to remove. Log it with the entry's source and don't remove anything. Then call `AddApplicationManifest` directly (`installApplicationManifest()` skips installed keys) and fail with the error's name if it is refused. - New helper `logApplicationEntry()`, called at the end of `--force-install-manifest`: logs what SteamVR now reports for the key (source, launch type, working directory, binary path, URL, auto-launch). This answers the open question on the Frame. - Unchanged: the path for a regular earlier installation (with working directory), and `installApplicationManifest()` on normal starts. **Follow-up depending on the result:** if SteamVR refuses our manifest or Steam's entry wins, the native build needs its own app key (option 2, separate design: the key is also in the manifest, 7 default action manifests and `SteamVRTabController.h`). ## 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. ## 10. Layout diagnosis: font log and `--snapshot` (approved 2026-10-08) **Why:** on the Frame the fixed 1200×800 layout overflows: the bottom quarter is cut off (left: Statistics, Bindings, Settings; right: Microphone, Push-to-Talk), and the navigation icons overlap their text. `QT_FONT_DPI=96`, `QT_QUICK_CONTROLS_STYLE=Basic` and the Material "Dense" variant didn't fix it. Font metrics (Noto Sans instead of Segoe UI) are the main suspect; measuring needs the unwarped rendered image. **Changes (platform-neutral):** - `OverlayController::SetWidget()`: logs once the application font (family, pt, px), what 20 pt (`MyText`) is in px, the screen's logical/physical DPI and the window's device pixel ratio. - Option `--snapshot ` (`setup.*`, `main.cpp`, `overlaycontroller.*`): while the overlay is visible and rendered, `renderOverlay()` writes `RenderBackend::readback()` as PNG to ``, at most once per second (OVRAS only renders when the scene changes, so a frame count could never trigger on a still page). Written to `.tmp` and renamed. `main.cpp` passes it through a setter, not a new constructor parameter, to keep the diff small. Without the option nothing changes. - `run.sh`: arguments after the graphics API go to the program (`./run.sh opengl --snapshot /tmp/ovras.png`). ## 11. Quick Controls style: Fusion, no Material import (approved 2026-10-08) **Finding (measured with `--snapshot`, DESIGN.md section 10):** the font is as intended (20 pt = 27 px, 96 DPI, device pixel ratio 1). The controls run in the Material style: `qt6` (`93c0851`) changed `mainwidget.qml`'s `import QtQuick.Controls` into `import QtQuick.Controls.Material`. That import fixes Material for all controls and even overrides `QT_QUICK_CONTROLS_STYLE`. Material's insets and padding make the navigation buttons 65 px tall (75 px pitch), so the fixed 1200×800 root page loses about its bottom quarter. Nothing in the file uses Material. | Style (root page, navigation buttons) | Button | Pitch | Result | |---|---|---|---| | Material (`qt6` as is) | 65 px | 75 px | 9 of 12 buttons visible | | Basic (Qt 5's "Default" renamed) | 49 px | 59 px | last button cut off (18 of 49 px) | | Fusion (Qt 6's Linux default) | 45 px | 55 px | everything fits | **Changes:** - `mainwidget.qml`: `import QtQuick.Controls` again, as before `93c0851`, with a comment. - `main.cpp`: `QQuickStyle::setStyle("Fusion")` after `QQuickWindow::setGraphicsApi()`, before any QML is loaded. Explicit, so the look and sizes are the same on every platform (without it Qt 6 picks Windows' own style there). Links `Qt6QuickControls2` directly (already a dependency; present on the Frame: `/usr/lib/libQt6QuickControls2.so.6.8.0`). - Upstream note: with Material, the layout overflows on Windows too. ## 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.