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>
This commit is contained in:
Matthias Janetschek
2026-10-07 13:29:53 +02:00
co-authored by Claude Opus 5.5
parent 2d4db7ef4b
commit aac3edf7e3
2 changed files with 336 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
# 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 <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 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.
+77
View File
@@ -0,0 +1,77 @@
# Steam Frame port: requirements
Goal: OVRAS runs natively on the Steam Frame (SteamOS, aarch64) as a SteamVR dashboard tab.
- The work happens on the internal branch `steamframe`, based on upstream's `qt6` branch
(commit `2d4db7e`).
- Changes may later go back upstream as pull requests. That decision is made once
something runs.
- **Hence: the change set against `qt6` stays minimal.**
## Background
- **Proton:** The Windows build runs on the Frame through Proton. Its laser y-axis is
mirrored, which makes it unusable. Running x86 code under emulation plus Wine is also
inefficient.
- **State of `qt6`:** Upstream's `qt6` branch (Qt 6.8, CMake) doesn't build on Linux yet.
Its rendering uses QRhi, which is private Qt API (`Qt_6_PRIVATE_API`).
- **Template:** The author's vrdashboard template (FrameAssistant commit `4a5b090`) is
verified on the Frame. It serves as a source of knowledge and of individual,
self-contained parts: render backends, build container, packaging scripts. It does not
replace OVRAS's platform layer.
## Decisions
| Date | Decision |
|---|---|
| 2026-10-07 | Internal fork, Frame only. Windows and upstream pull requests are evaluated once something runs. |
| 2026-10-07 | Minimal change set against `qt6`, rather than rebuilding OVRAS on the template. |
| 2026-10-07 | Only public Qt API: rendering without QRhi, QML compiled to bytecode only. |
| 2026-10-07 | Manifest installation tries `Utility` first, then `Background`, on every platform. |
| 2026-10-07 | Vulkan is an additional render path, selected by a command-line option. OpenGL stays the default. |
| 2026-10-07 | Frame differences are switched by one build option. In the code they are capability constants, named by capability. |
| 2026-10-07 | OVRAS's code style everywhere, including template code that comes in. Only changed lines get formatted. |
| 2026-10-07 | Windows (render path, build) stays open until the upstream evaluation. |
| 2026-10-07 | Every code change gets comments that explain it fully, but directly and concisely. |
| 2026-10-07 | The app key stays `steam.overlay.1009850`; the Proton build gets uninstalled. |
## Iteration 1: native start on the Frame
| # | Requirement | Acceptance criterion |
|---|---|---|
| R1 | Linux-only build: cross-build for aarch64 against the Frame's system Qt 6.8, plus an x86_64 build for development. Only public Qt API. | The container build succeeds. The package contains no Qt. `objdump -T` shows no `Qt_6_PRIVATE_API` symbols except `QVulkan*`. |
| R2 | Runs natively on the Frame as a dashboard tab, with install, auto-start and uninstall. | After `deploy-to-frame.sh` the tab appears and starts by itself after a reboot. Quitting doesn't crash. |
| R3 | Rendering: OpenGL by default, Vulkan as an additional path via a command-line option. Render only on change, capped at 60 fps. | Both paths show every page correctly. With Vulkan, `container-test.sh` reports no validation messages. |
| R4 | Input: laser the right way up, scrolling, text fields via OVRAS's existing SteamVR keyboard handling. OVRAS's SteamVR Input actions work. | Every page can be operated. An OVRAS action (e.g. push-to-talk) arrives. |
| R6 | Throttled tick: a fixed 11 ms timer (as in the template) instead of 1 ms polling. | Idle CPU load is measured and documented. There is no comparison with the Proton build. |
| R7 | OVRAS doesn't change the play area at start-up. Today `MoveCenterTabController::zeroOffsets()` commits the working copy as the live chaperone (`CommitWorkingCopy(Live)` in `updateChaperoneResetData()`) and creates a chaperone autosave profile. On the Frame this shifts the play area at start. | After starting OVRAS on the Frame, the play area is where it was before. The log shows no `CommitWorkingCopy`. |
R5 (hiding features) was moved to iteration 2; the number stays reserved.
**Not in iteration 1:**
- hiding or disabling features
- checking features one by one
- replacements for the playspace features
- the template's D-pad navigation
- Windows
- upstream pull requests
## Iteration 2 (planned)
| # | Requirement | Acceptance criterion |
|---|---|---|
| R5 | Hide features per the feature classification below. Features that don't work make no `IVRChaperoneSetup` zero-pose calls. | The affected pages/groups are hidden; no chaperone-setup calls appear in the log. |
Then check features one by one and re-enable them.
## Feature classification (basis for R5)
Native in SteamVR: according to SteamVR's release notes up to 2.17 / 2.18 beta and Valve's
Steam Frame guide (researched 2026-10-07).
| Group | Features | Plan |
|---|---|---|
| Native in SteamVR | Wall height, fade distance, style, center marker, brightness, supersampling, motion smoothing, supersample filtering, audio devices and mirror, performance graph, do not disturb, pairing, tracker battery, recenter, native chaperone import; also Oculus SDK block and restart SteamVR (Windows only), multiple drivers | Hide |
| Only in OVRAS, useful on the Frame | Proximity warnings, dim when crouching, push-to-talk/-mute, mic mute when the HMD is taken off, audio and video profiles, color tint, media keys (MPRIS), alarm clock, statistics, per-game bindings | Keep; check one by one |
| Doesn't work, needs a replacement | Space drag/turn, gravity, height toggle, offsets, fix floor, chaperone profiles (geometry), all rotation features | Disable for now |
| Questionable | Keyboard input; cord untangling and rotation counter (the Frame is wireless) | Disable for now |