On the Steam Frame, the fixed 1200x800 layout lost about its bottom quarter. Measured with --snapshot: fonts and DPI are as intended, but the controls ran in the Material style. The qt6 branch changed mainwidget.qml's "import QtQuick.Controls" into "import QtQuick.Controls.Material", which fixes Material for all controls and even overrides QT_QUICK_CONTROLS_STYLE. Material's insets and padding make the navigation buttons 65 px tall instead of 45 px (Fusion) or 49 px (Basic, Qt 5's default style renamed); nothing in the file uses Material. mainwidget.qml imports QtQuick.Controls again, and main.cpp sets Fusion explicitly, so the sizes are the same on every platform instead of depending on Qt 6's per-platform default. With Basic the last navigation button is still cut off on the Frame; with Fusion everything fits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
19 KiB
Markdown
347 lines
19 KiB
Markdown
# 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 <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:
|
||
|
||
```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-<mode>/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-<version>-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 <file>` (`setup.*`, `main.cpp`, `overlaycontroller.*`): while the
|
||
overlay is visible and rendered, `renderOverlay()` writes `RenderBackend::readback()` as
|
||
PNG to `<file>`, 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 `<file>.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.
|