Rewrite the README and docs in plainer prose

The README drops the bold headlines on every item and reads as prose. The
reference is brought up to date and loses its bold labels. The design doc
is reorganized by topic instead of as a dated work log, keeping the
technical findings. The setup guide uses subheadings for troubleshooting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-09-26 19:06:36 -06:00
1 parent 3fb8c717e4
commit b0036cdeae
5 files changed
+219 -287

No files matched your search

+3 -3
View File
@@ -1,13 +1,13 @@
# Working on Frametop
Rules for people and coding agents changing this repo. The README covers what Frametop is and how to install it; `docs/reference.md` covers each component, and `docs/design.md` records how SteamVR on the Frame behaves and why things are built the way they are. Read the findings there before changing how the screens, the pointer, or the input relay talk to SteamVR.
Rules for people and coding agents changing this repo. The README covers what Frametop is and how to install it; `docs/reference.md` covers each component, and `docs/design.md` records how SteamVR on the Frame behaves and why things are built the way they are. Read its notes on SteamVR before changing how the screens, the pointer, or the input relay talk to SteamVR.
## Two ways to run the scripts
Every script works in both modes, and must keep working in both:
- **On the Frame** (SteamOS, VR variant): commands run locally, in this checkout.
- **From a PC over SSH:** the repo is synced to `~/dev/frametop` on the Frame (`scripts/sync.sh`), and commands run there. `scripts/_env.sh` works out which mode applies (`FRAME_LOCAL`, `FRAME_HOST`, `FRAME_REPO`).
- On the Frame (SteamOS, VR variant), commands run locally, in this checkout.
- From a PC over SSH, the repo is synced to `~/dev/frametop` on the Frame (`scripts/sync.sh`), and commands run there. `scripts/_env.sh` works out which mode applies (`FRAME_LOCAL`, `FRAME_HOST`, `FRAME_REPO`).
```
scripts/sync.sh # PC -> ~/dev/frametop on the Frame
+33 -39
View File
@@ -1,22 +1,22 @@
# Frametop
A multi-monitor desktop and a universal 3D mouse for the Valve Steam Frame, installed and run on the headset itself.
Frametop puts a multi-monitor KDE Plasma desktop into SteamVR on the Valve Steam Frame, and lets a Bluetooth mouse drive all of SteamVR. It installs and runs on the headset itself.
- **Several desktop screens floating in SteamVR.** A full KDE Plasma desktop where every screen is a real monitor of its own: any resolution and shape (ultrawide, portrait, 4K) and any size in the room. They appear in your saved layout when the desktop starts. You move, resize, curve, and roll them by hand, or pin one to your wrist, and one shortcut puts them all back.
- **A universal 3D mouse.** A Bluetooth mouse runs all of SteamVR (the dashboard, Steam, overlays, the desktop) as a small dot anchored in the room. It snaps onto panels, drags and tilts them, and hands the laser back to your controllers when you pick one up.
- **Frametop Display Settings**, an app for the screens: how many, each one's resolution, size, scale, and curve, which has the taskbar, the layout they float in, and when they show.
- **Frametop Input Settings**, an app to choose devices, map mouse buttons (for example to open the SteamVR dashboard), and tune the pointer.
- **Bluetooth fixes** so LE mice and keyboards, like the Swiftpoint Z3, reconnect after they sleep or the headset reboots.
Each screen is its own monitor with its own resolution, so you can have an ultrawide in the middle and two portrait screens beside it, at whatever size and distance you like. The screens come back to your saved layout when the desktop starts. You can move, resize, curve, and roll them, pin one to your wrist, and put them all back with a shortcut.
Frametop is an independent project. It isn't made by or affiliated with Valve.
The mouse shows up as a small dot anchored in the room. It works on the SteamVR dashboard, Steam, overlays, and the desktop, and it hands the laser back to your controllers when you pick one up.
It comes with two settings apps, Frametop Display Settings for the screens and Frametop Input Settings for mice, keyboards, and button mappings, plus fixes that let Bluetooth LE mice and keyboards like the Swiftpoint Z3 reconnect after they sleep.
Frametop is an independent project, not made by or affiliated with Valve.
## Install on the headset
You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or the on-screen one), and about 3 GB of free space.
1. **Open a desktop.** In the launcher, choose **Launch a program → Desktop**.
2. **Open a terminal.** In the application menu, open **System → Konsole**.
3. **Clone and install:**
1. In the launcher, choose Launch a program → Desktop.
2. In the application menu, open System → Konsole.
3. Clone the repo and run the installer:
```
git clone https://github.com/DeeJanuz/frametop.git ~/frametop
@@ -24,21 +24,15 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
./install.sh
```
The installer sets up distrobox (in your home folder; the system files stay untouched), a Fedora build container, and everything below. The first run downloads about 1–2 GB. It asks before the two steps that affect you:
The installer sets up distrobox in your home folder (the system files aren't touched), a Fedora build container, and everything else. The first run downloads 1–2 GB. It asks you two things along the way. The Bluetooth fixes need your `sudo` password; if you've never set one, run `passwd` first, or skip them for now. SteamVR has to restart once at the end, which closes everything open in VR, including the terminal. Rebooting the headset works too.
- **Bluetooth fixes.** These need your password for `sudo`. If you've never set one, run `passwd` first. You can also skip them and install later.
- **Restarting SteamVR.** This is needed once, and it closes everything open in VR, including the terminal. Rebooting the headset works too.
After the restart:
- **Launch a program → Desktop** opens the multi-screen desktop. Its screens arrange themselves around where you're facing.
- **Frametop Display Settings** and **Frametop Input Settings** are in the desktop's application menu, under Settings.
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings.
### Add a Bluetooth mouse or keyboard
1. Pair it in Steam: **Settings → Bluetooth**.
2. If you installed the Bluetooth fixes, apply them once for the new device: **Frametop Input Settings → Bluetooth → Apply Bluetooth fixes**. Or, in the repo: `setup/bluetooth/install.sh run`. After that it reconnects on its own.
3. Move the mouse. The dot appears where you're looking.
1. Pair it in Steam, under Settings → Bluetooth.
2. If you installed the Bluetooth fixes, apply them once for the new device with Frametop Input Settings → Bluetooth → Apply Bluetooth fixes (or `setup/bluetooth/install.sh run`). After that it reconnects on its own.
3. Move the mouse, and the dot appears where you're looking.
## Use
@@ -53,23 +47,23 @@ After the restart:
| Click the curve button (next to the bar) | Curves the screen around you, or flattens it |
| Drag the roll button sideways, or scroll on it | Rolls the screen; it snaps level near straight |
| While carrying a screen, sweep its laser across your other controller's ring, then let go | Pins it to that wrist, at its size and distance, as you hold it when you let go; it shows while you see its front. Grab its bar to adjust it (it stays pinned); sweep across the ring again to take it off |
| Meta+Shift+R in the desktop | Puts the screens back in their layout (also the **Reset Screen Layout** menu entry, and a button you can map) |
| Meta+Shift+H in the desktop | Hides or shows all screens (also **Hide/Show Screens** and a mappable button). **Frametop Display Settings → Visibility & wrist** can instead show them only with the dashboard open, or while you look at your wrist |
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard (or press Meta+Shift+H) to see and use them. **Visibility & wrist → During VR games** can keep them visible over the game instead; the controllers still stay in the game, and the 3D mouse or the dashboard works the screens. |
| Meta+Shift+R in the desktop | Puts the screens back in their layout (also in the menu as Reset Screen Layout, and mappable to a mouse button) |
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & wrist tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
| Play a VR game | The screens hide and your controllers stay in the game. Open the SteamVR dashboard, or press Meta+Shift+H, to see and use them. To keep them visible over games, change During VR games on the Visibility & wrist tab; the controllers still stay in the game, and you use the screens with the mouse or the dashboard |
Map the mouse's extra buttons to actions such as **Toggle SteamVR dashboard** or **Recenter pointer** in **Frametop Input Settings → Buttons**. Speed, dot size, and the rest are on its **Pointer** page and apply immediately.
You can map the mouse's extra buttons to actions such as Toggle SteamVR dashboard or Recenter pointer on the Buttons page of Frametop Input Settings. Pointer speed, dot size, and the rest are on its Pointer page and take effect immediately.
Restarting the desktop (**Frametop Display Settings → Restart desktop**) closes its windows, but background work started in it, like servers, tmux, and builds, keeps running.
Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running.
## Known limitations
This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 20260922, SteamVR 2.17.10).
- A SteamOS or SteamVR update can break parts of it until Frametop catches up. If something stops working after an update, please report it (below).
- The first install downloads 1–2 GB (a Fedora build container) and builds everything on the headset. It takes several minutes.
- During a VR game, a controller button can't show the screens (the game owns the buttons). Open the SteamVR dashboard, press Meta+Shift+H, or use a mapped mouse button.
- Flatscreen games don't count as VR games. If the controllers work the screens instead of such a game, set **Frametop Display Settings → Visibility & wrist → Controllers on the screens → Only with the SteamVR dashboard open**.
- The screens show no mouse cursor of their own: the 3D mouse's dot, or SteamVR's laser, is the cursor.
- A SteamOS or SteamVR update can break parts of it until Frametop catches up. If something stops working after an update, please report it.
- The first install downloads 1–2 GB for the build container and compiles everything on the headset, which takes several minutes.
- During a VR game you can't show the screens with a controller button, because the game owns the buttons. Open the SteamVR dashboard, press Meta+Shift+H, or use a mapped mouse button instead.
- Flatscreen games aren't detected as games. If your controllers end up working the screens instead of the game, set Controllers on the screens to "Only with the SteamVR dashboard open" (Frametop Display Settings, Visibility & wrist tab).
- The screens don't draw a mouse cursor of their own. The 3D mouse's dot or SteamVR's laser shows where you're pointing.
- Remote desktop over VNC (`./desktops.sh remote on`) needs Tailscale on the Frame.
## Reporting problems
@@ -80,7 +74,7 @@ In a terminal on the headset, run:
cd ~/frametop && scripts/report.sh
```
It writes `frametop-report-<date>.txt` with the versions, service states, settings, and recent logs (Bluetooth addresses and the headset's serial number are masked). [Open an issue](https://github.com/DeeJanuz/frametop/issues) with what you did, what you expected, and what happened, and attach the file.
This writes `frametop-report-<date>.txt` with version numbers, service states, settings, and recent logs. Bluetooth addresses and the headset's serial number are masked. Then [open an issue](https://github.com/DeeJanuz/frametop/issues), describe what you did, what you expected, and what happened, and attach the file.
## Update
@@ -102,7 +96,7 @@ setup/bluetooth/install.sh uninstall # if you installed the Bluetooth fixes
## How it works
A nested Plasma session runs inside ft-screens (`screens/`), a small Wayland compositor. KWin opens a window per screen, ft-screens gives each its own size, and hands every frame to SteamVR as its own overlay without copying it. An input relay (`input/`) keeps Bluetooth mice working in SteamVR and turns the mouse into the 3D pointer, which drives a virtual SteamVR controller (`pointer/`). The details, and everything we learned about SteamVR on the Frame, are in [docs/reference.md](docs/reference.md) and [docs/design.md](docs/design.md).
A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland compositor. KWin opens one window per screen, ft-screens sets each window's size, and each frame goes to SteamVR as an overlay without being copied. An input relay (`input/`) keeps Bluetooth mice working in SteamVR and feeds the mouse to the 3D pointer, which drives a virtual SteamVR controller (`pointer/`). [docs/reference.md](docs/reference.md) covers each piece, and [docs/design.md](docs/design.md) explains the design and what we learned about SteamVR on the Frame.
| Folder | What it is |
| --- | --- |
@@ -119,10 +113,10 @@ A nested Plasma session runs inside ft-screens (`screens/`), a small Wayland com
## Developing from a PC
Everything also works from a Linux (or WSL) PC over SSH, which is handier for editing code. The scripts detect where they're running: on the Frame they work on the local checkout, and on a PC they sync the repo to `~/dev/frametop` on the Frame and run there.
The scripts also work from a Linux or WSL PC over SSH, which is easier for editing code. On the Frame they use the local checkout; on a PC they sync the repo to `~/dev/frametop` on the Frame and run there.
1. **On the Frame:** turn on developer mode, set a password (`passwd`), and enable SSH (`sudo systemctl enable --now sshd`). Add your public key to `~/.ssh/authorized_keys`. [deck-tailscale](https://github.com/tailscale-dev/deck-tailscale) gives access from anywhere.
2. **On the PC:** add the Frame to `~/.ssh/config` as host `frame` (or set `FRAME_HOST`):
1. On the Frame, turn on developer mode, set a password with `passwd`, and enable SSH with `sudo systemctl enable --now sshd`. Add your public key to `~/.ssh/authorized_keys`. [deck-tailscale](https://github.com/tailscale-dev/deck-tailscale) lets you reach it from anywhere.
2. On the PC, add the Frame to `~/.ssh/config` as host `frame`, or set `FRAME_HOST`:
```
Host frame
@@ -131,7 +125,7 @@ Everything also works from a Linux (or WSL) PC over SSH, which is handier for ed
IdentityFile ~/.ssh/<your-key>
```
3. For the Bluetooth fixes, which need `sudo` without a terminal on the Frame, put the password in `.env` at the repo root. It's gitignored and never synced:
3. The Bluetooth fixes need `sudo`, and there's no terminal on the Frame to type the password into, so put it in `.env` at the repo root. It's gitignored and never synced:
```
steamos_root_pwd="<password>"
@@ -147,9 +141,9 @@ scripts/frame.sh -C <dir> '<cmd>' # same, in a folder of the repo
scripts/frame.sh --host '<cmd>' # run on the SteamOS host
```
The sync is one-way: it makes the Frame's copy match this repo and deletes files there that no longer exist here. It skips `.git`, `build/`, `.env`, and anything gitignored. **Edit on the PC only.** Changes made in `~/dev/frametop` on the Frame are overwritten by the next sync.
The sync only goes one way. It makes the Frame's copy match your checkout, deleting files there that you've removed, and skips `.git`, `build/`, `.env`, and anything gitignored. Edit on the PC only, since the next sync overwrites changes made in `~/dev/frametop` on the Frame.
Programs built in the `dev` container use its glibc, which is newer than the host's, so they run in the container. The SteamVR driver is the exception: it's built to run on the host (see `pointer/driver/build.sh`). See [AGENTS.md](AGENTS.md) for the working rules, including what not to restart on a headset someone is using.
Programs built in the `dev` container link against its glibc, which is newer than the host's, so they run inside the container. The SteamVR driver is the exception and is built to run on the host (see `pointer/driver/build.sh`). [AGENTS.md](AGENTS.md) has the working rules, including what not to restart while someone is using the headset.
## License
+66 -150
View File
@@ -1,205 +1,121 @@
# Frametop design
Last updated: 2026-09-25.
Why Frametop is built the way it is, and what we learned about SteamVR on the Steam Frame while building it. [reference.md](reference.md) describes the parts and how to run them. Read the SteamVR notes here before changing how the screens, the pointer, or the input relay talk to SteamVR.
## Goal
## Goals
- Two or more desktop screens shown as panels in SteamVR, placed anywhere around the user (arc, stacked, tilted, behind).
- One physical mouse drives a cursor through that 3D layout. Moving the mouse moves the cursor across panels in the direction you expect from where they are in space.
- Works with a Bluetooth mouse. The keyboard should follow the focused panel.
- Desktops come from Linux on the Frame itself. PC streaming is out of scope for now.
- Several desktop screens in SteamVR, each a real monitor with its own resolution and shape, placed anywhere around you.
- One physical mouse that drives a cursor through that 3D arrangement, and through the rest of SteamVR too: the dashboard, Steam, and other overlays.
- Controllers stay fully usable, and VR games aren't disturbed.
- Everything runs on the Frame itself, installed from a terminal on the headset.
## Approach: nested Plasma in ft-screens (2026-09-26; before: a PerWindow gamescope)
## Screens
Since 2026-09-26 the nested KWin runs inside ft-screens (`screens/`), our own wlroots compositor, instead of gamescope: gamescope draws every window into one canvas of at most 1920x1080 pixels and leaves the panels to the dashboard, which caps their size. ft-screens sizes each KWin screen on its own and shows it as its own SteamVR overlay that we place and size (grab bar, resize handle, pin to a hand, hide/show). The findings log (2026-09-26) has the details. The gamescope notes below still describe `BACKEND=gamescope`.
### Why our own compositor
Everything it uses ships with SteamOS.
SteamOS already shows a single-screen Plasma desktop in VR by running KWin nested inside gamescope. The first version of Frametop did the same with gamescope's `PerWindow` mode and `kwin --output-count N`, which gives each KWin output its own SteamVR overlay. It worked, but gamescope has two limits that ruled it out:
- `session/frametop-session.sh` starts its own `gamescope --backend openvr --virtual-connector-strategy PerWindow`. Inside it runs a full Plasma session whose KWin has `--output-count N`. The nested KWin opens one window per output, and PerWindow makes each window its own SteamVR overlay.
- It's modeled on `/usr/bin/steamos-nested-desktop`, SteamOS's single-screen desktop in VR, and runs alongside it. It has its own `XDG_RUNTIME_DIR` (`/run/user/1000/frametop`), config (`~/.config/frametop`), and state (`~/.local/state/frametop`).
- It's controlled from the PC with `desktops.sh`.
- It draws every window into one shared canvas of a single size, letterboxed, and never tells a window what size to be. So every screen had the same resolution and shape; portrait screens had to be rotated outputs on rolled panels.
- Its OpenVR backend allocates an upload buffer of exactly 1920 × 1080 × 4 bytes, so anything larger, such as 3440 × 1440, aborts it at start.
Verified in the headset on 2026-09-25:
- Two 1920x1080 panels, each with wallpaper, sharp.
- A taskbar, and apps can be launched.
- Panels can be moved in VR.
It also leaves the panels to SteamVR's dashboard, which places them and doesn't expose their position to other programs.
Still open:
- Do windows drag between screens? This needs a mouse or keyboard connected.
- What overlay keys does gamescope give each window? Read gamescope's OpenVR backend source (ValveSoftware/gamescope).
- Does the `Gamescope WSI Layer Error: Creating swapchain for non-Gamescope swapchain` in the session log affect any apps? Apps inside the nested desktop inherit `ENABLE_GAMESCOPE_WSI=1`.
ft-screens replaces it. It's a small wlroots compositor that hosts the nested KWin. KWin's nested Wayland backend opens one window per output and resizes an output when the host configures its window, so ft-screens can give each screen any size, live. KWin needs `wl_compositor` v4 or later, `wl_shm`, `wl_seat`, `xdg_wm_base`, and `zwp_linux_dmabuf_v1` v4 with feedback from its host; the rest is optional.
## Launcher, config, and panel placement
Frames go to SteamVR the same way gamescope sends them: OpenVR's `IVRIPCResourceManagerClient` imports each DMA-BUF (`ImportDmabuf`), and the overlay shows it as a `TextureType_SharedTextureHandle` texture. There's no copy and no size limit. The Frame's SteamVR supports this interface, but the OpenVR header bundled with SteamVR's samples predates it, so the build fetches a pinned header from Valve's openvr repository.
- Launcher: `desktops.sh install` writes `~/.local/share/applications/deckard-nested-desktop.desktop`, which overrides the stock `/usr/share/applications/deckard-nested-desktop.desktop` (`Exec=steamos-nested-desktop`) by filename. It keeps `X-Steam-Special=Desktop`, which the Frame's non-Steam app launcher uses to pick out the Desktop entry. `uninstall` removes it.
- Config: `~/.config/frametop.conf` (`SCREENS`, `WIDTH`, `HEIGHT`, `PHYS_WIDTH`).
- Placement (researched 2026-09-25): gamescope's OpenVR backend (upstream `src/Backends/OpenVRBackend.cpp`) creates every panel with `CreateDashboardOverlay`, keyed `gamescope.<wl_display>.window.<n>` for non-Steam windows, and never sets a transform. The SteamVR dashboard (web UI, `resources/webinterface/dashboard`) owns placement. Each panel is a "frame" with a dock location: `Dashboard`, `Theater`, `World` (the "Float In World" menu action), `LeftHand`, `RightHand`, or `Boot`. No `/settings/dashboard/*` key sets the initial dock location, and dock state and transforms aren't persisted, only `lastAccessedExternalOverlayKey`.
- Default floating and a layout (decided 2026-09-26: automating the dashboard won over a patched gamescope, which would have lost SteamVR's window controls): `layout/ft-layout` floats each screen with `vrcmd --dock-overlay` and the pointer helper carries it into place with the invisible controller. See the 2026-09-26 findings and `README.md`. With `--virtual-connector-strategy PerWindow` and `--vr-overlay-key frametop`, the keys are `frametop.app.<window seq>`; `.app.0` is gamescope's default connector and never has a window.
- Resolution per screen isn't possible with stock gamescope: it never sets its windows' sizes (no `xdg_toplevel` configure sizes), and draws each window into the one `-W`×`-H` composition, letterboxed. KWin 6.2 would resize a nested output on a configure, and `kscreen-doctor` offers only the one mode. Per-screen scale (KWin output scale, kept in the session's `kwinoutputconfig.json`) stands in.
Before settling on this, we tried KWin's screencast virtual outputs. KWin 6.2.5 crashed (`std::out_of_range` in `textureForOutput`) streaming one while nested in gamescope, and its headless backend doesn't implement virtual outputs at all.
## The universal 3D mouse
A few wlroots details worth knowing: `wlr_shm_create` wants DRM format codes, not `wl_shm` ones, and KWin asks for server-side decorations before its first commit, when setting the mode would assert, so ft-screens answers on the first commit.
Goal (decided 2026-09-25): run all of SteamVR from inside the headset with a Bluetooth mouse and keyboard. That covers the dashboard, Steam, overlays, the Frametop desktop, and flatscreen apps. It works like the Apple Vision Pro's mouse: a small cursor floats in the room and "collides" with any panel, then acts like a controller laser on it.
### Placing and sizing
Decisions:
- Universal, not only Frametop panels.
- The cursor is anchored in the room (world space), with recenter on demand.
- Controllers stay fully usable. Last used wins.
- Hands off VR games. A scene app with the dashboard closed gets nothing from the pointer. Flatscreen mouse games in Theater get the raw mouse passed through.
Because the overlays are ours, ft-screens places them exactly with `SetOverlayTransformAbsolute` and draws its own controls under each screen. OpenVR has no overlay-relative transforms, so the controls are repositioned whenever their screen moves.
### Cursor model
The curved layout chains screens edge to edge, like monitors on a desk: the middle screen (or the seam between two) straight ahead, and each neighbour hinged at the previous screen's outer edge and turned to face you. An earlier version spaced screens by angle, which assumes every screen sits on the circle; a flat 3.6 m screen's edges are much farther away than its centre, so its neighbours landed in front of it. Solving for the hinge angle needs a scan and bisection, because a simple fixed-point iteration diverges for screens nearly as wide as twice their distance.
1. The relay grabs the mouse (done: `input/input-relay.py`).
2. Relative motion becomes yaw and pitch of a direction anchored in the room. Recenter puts it straight ahead of the current head pose. Sensitivity is in degrees per count, with optional acceleration.
3. The pointer ray starts at the head (or a point just below the eyes) and runs along that direction. Anything it hits is the target.
4. The cursor sits on the hit surface when there is one. Otherwise it sits on a sphere at a set distance (1.5 m default).
5. Settings: distance, cursor size, snap margin (with hysteresis so it doesn't flicker at panel edges), sensitivity and acceleration, recenter key.
`SetOverlayCurvature` takes the fraction of a full cylinder that the overlay's width covers, so the radius is width / (2π × curvature). The stated width is the arc length, and the cylinder bends toward the viewer: at a 2 m radius, a 3.57 m wide screen's corners sit about 75 cm closer to you and 23 cm further in than a flat screen's would. Controls on a curved screen are placed on that cylinder, and the bar gets the same curvature.
### Delivery: a virtual SteamVR controller
A resize handle has to be able to shrink a screen from any direction, so the dragged corner follows the laser along the screen's diagonal rather than taking the larger of its horizontal and vertical reach. Pushing and pulling a carried screen moves it along the line from your head, because the 3D mouse's virtual controller sits just in front of the bar, below the screen's centre, so the line from the device points mostly upward.
SteamVR's dashboard, and every overlay it hosts, is driven by the vrcompositor "lasermouse" action set:
`ComputeOverlayIntersection` ignores `SetOverlayIntersectionMask`, and a control can't be allowed to cover part of its screen, so the resize tab sits entirely outside the corner.
- `Pointer`: the `/pose/tip` pose.
- `leftclick`, `rightclick`, `middleclick`, `back`, and `home`.
- `scroll_discrete` and `scroll_smooth`: `scroll`.
- `system`: `ToggleDashboard`.
- `quickrecenter`: `Recenter`.
### Wrist pinning
The Frame controller's defaults are in `/opt/steamvr/drivers/frame_controller/resources/input/vrcompositor_bindings_frame_controller.json`.
Pinning started as "bring the screen to your wrist", which doesn't work for big screens, because their centre is far from the edge you bring close. It became aiming: while a screen is carried, the line from the carrying device to its bar is tested against the other hand controllers. Crossing a controller's 6 cm ring arms the pin (leaving past 9 cm, so it doesn't flicker), and crossing it again disarms it. The pin happens on release, with the screen's pose at that moment, so you can arm it and then turn the screen. An earlier version pinned the moment the laser touched the wrist, which left the screen at whatever angle the carrying hand had while pointing there.
A small OpenVR driver (`ft_pointer`) adds a virtual controller with no render model or laser of its own:
- Pose: the pointer ray (origin at the head, aimed at the cursor). A laser that starts at the eye and runs along your line of sight shows up as a dot, so SteamVR's own hit cursor becomes the floating mouse on panels.
- Inputs: mouse left to `trigger` (leftclick), right to rightclick, middle to middleclick, wheel to scroll, side buttons to back. A keyboard shortcut maps to system (ToggleDashboard) and to recenter.
- Its own `controller_type` (`ft_pointer`) with default bindings for `openvr.component.vrcompositor` and `steam.client`.
A pinned screen's alpha follows the angle between its front and the direction to your head, fully visible inside the wrist angle and fading over the last 10°.
Open questions (spike 1):
- Role. The right hand collides with the real controller. The stylus role (`TrackedControllerRole_Stylus`, path `/user/stylus`) might be bindable without taking a hand.
- Does the Frame's dashboard follow a third pointer device? Is last used wins automatic (`lasermouse_secondary/switchlaserhand` exists)?
- Hiding the device from VR games: report the pose as invalid, or deactivate, whenever a scene app has focus and the dashboard is closed.
### Visibility and VR games
### Processes
`VROverlayFlags_MakeOverlaysInteractiveIfVisible` keeps SteamVR's laser mouse on while an overlay with that flag is visible. Without it, the laser is off whenever the dashboard is closed: the first click on a panel only turns it on, and the laser turns off again as soon as it leaves every panel. With it, controllers work the screens normally, but the laser also takes the controllers away from a VR game.
- `input-relay.py` (host, user service): in pointer mode it sends mouse deltas and buttons to the helper over a Unix socket. In passthrough mode (pointer off, or a flatscreen game in Theater) it forwards to the virtual uinput devices as now.
- `ft_pointer` driver (inside vrserver, host): the virtual controller. It takes its pose and buttons from the helper over a Unix socket. It's built for the host ABI (glibc 2.39; the `dev` container has 2.43) in a Fedora 40 build container.
- `ft-pointer` helper (OpenVR client, in the container): cursor state, recenter, mode switching (IVROverlay `IsDashboardVisible` and the scene app's focus), the free-space cursor overlay, and settings.
`IVRApplications::GetCurrentSceneProcessId()` is 0 when no game is running (the Frame's home environment isn't a scene app) and the game's process ID while one is. ft-screens checks it twice a second, turns the flag off while a game runs, and by default hides the screens unless the dashboard is open. Flatscreen games run inside Steam's gamescope overlay and aren't scene apps, which is why "only with the dashboard open" is offered as a controller setting.
Keyboard: still open. SteamVR opens keyboards (vrserver held the Z3 Keyboard), but routing physical keys into dashboard text fields and gamescope panels needs its own spike.
## The 3D mouse
### Spikes
The mouse works like the pointer on the Apple Vision Pro: a small cursor floats in the room, lands on whatever panel it meets, and acts on it like a controller's laser.
1. Driver: a minimal `ft_pointer` virtual controller with a fixed pose in front of the HMD and a scripted trigger. Does the dashboard laser follow it, and do clicks work? Try the right-hand and stylus roles.
2. Mouse-driven pose: the relay feeds the helper, the helper feeds the driver. Room-anchored cursor, recenter.
3. Free-space cursor overlay, and hiding it when SteamVR's hit dot is on a panel.
4. Modes: VR game focus (off), Theater flatscreen (passthrough), dashboard and overlays (pointer).
5. Keyboard routing.
### A virtual controller
## Phases
SteamVR's dashboard and every overlay it hosts are driven by the vrcompositor `lasermouse` action set: a pointer pose, left, right, and middle click, back, home, scrolling, and a system button that toggles the dashboard. So the 3D mouse is a virtual controller. The `ft_pointer` driver adds an invisible controller (its render model is a single transparent triangle) with its own controller type and default bindings for vrcompositor and the Steam client. Its `/input/a` button is bound to `lasermouse_secondary/switchlaserhand`, which moves the laser to it without clicking. `/pose/tip` didn't work for the laser, because tip is defined by a render model; `/pose/raw` does.
1. MVP: two panels. The mouse cursor crosses between them in 3D, with click and scroll working.
2. Layout: N panels, save and restore panel placement, recenter.
3. Daily use: keyboard focus follows the cursor, launch apps onto a chosen panel, cursor visuals, sensitivity settings, autostart.
Driver poses are in SteamVR's raw tracking space, and client programs work in the standing universe, which on the Frame is about 1.6 m above raw. Mixing them up put the laser's origin 1.6 m above your head. The helper converts using the headset's pose in both spaces every frame.
## Rejected approaches
The driver starts disconnected, because holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. It connects when the mouse is used and claims the right hand. SteamVR keeps a hand role reserved for a disconnected device that still asks for it, so the driver switches its role hint between right hand (connected) and opt-out (not connected).
- WayVR (wayvr-org/wayvr), tried 2026-09-25 and removed. It built for aarch64 in the `dev` container and connected to SteamVR, but we couldn't see or open it in the headset. It has no bindings for `frame_controller`, so it relies on SteamVR's Oculus remap, and its KDE screen capture needs `xdg-desktop-portal-kde`, which the Frame lacks. A GitHub fork, `DeeJanuz/wayvr`, was created for it.
- A custom capture app (headless KWin, then `zkde_screencast`, then DMA-BUF, then OpenVR overlays). It's workable, but gamescope PerWindow does the same job with no code.
### The cursor
## Findings log
Mouse motion turns into yaw and pitch around an anchor, the head position at the last recenter. A ray from the anchor is tested against every visible overlay with `ComputeOverlayIntersection`. On a hit, the cursor sits on that surface; otherwise it floats at `POINTER_DISTANCE`. Since the anchor isn't your current eye position, a second test runs along your line of sight to the cursor point, and anything nearer wins, so the cursor always lands on what you see under it.
- 2026-09-26: The headset's displays didn't sleep when it came off (SteamVR reports the HMD idle the moment it's removed, and turns the screens off 5 s later, `power.turnOffScreensTimeout`). A removal test that stopped one piece every 20 s found the pointer helper: standby 5 s after it stopped, nothing from hiding the screens or stopping the relay. Two causes in the helper: an awake pointer (connected controller, laser mode forced on) until 30 s without mouse input, and, even with the pointer released, its background `vrcmd --overlays` every second, a new SteamVR client connection each time (the per-second "External connection from /run/host/opt/steamvr/.../vrcmd" lines in vrcompositor.txt; `/run/host` is the container's view of the host). Now the helper releases the pointer when `GetTrackedDeviceActivityLevel(HMD)` is Idle, Standby, or Idle_Timeout, won't wake until it's worn again, and pauses the overlay list while the pointer is off. Separately: a podman container's monitor (conmon) stays in the cgroup of whatever started it, and `distrobox enter` from a systemd service starts the container on demand, so stopping frametop-pointer (which had started it at boot) killed the container, and with it the desktop's compositor. `scripts/container-up.sh` starts the container in a scope of its own before every `distrobox enter` (services, session, apps, build scripts).
OpenVR has no call to list other programs' overlays, so the helper runs `vrcmd --overlays` in the background. It includes hidden overlays, because a floating window's controls only appear while something hovers the window, and the cursor has to find them immediately.
- 2026-09-26: Screens over VR games. Visible screens with VROverlayFlags_MakeOverlaysInteractiveIfVisible keep SteamVR's laser mouse on, which takes the controllers from a VR game. `IVRApplications::GetCurrentSceneProcessId()` is 0 with no game (the Frame's home isn't a scene app) and the game's pid while one runs, so ft-screens clears the flag while it's nonzero (checked twice a second; the `controllers` setting, default outside_games). Tested in a VR game: the screens stay up over it, the controllers stay in the game, and the screens take the lasers again while the SteamVR dashboard is open.
The laser starts partway along your line of sight to the cursor rather than at your eye. SteamVR sizes its hit dot by distance from the laser's origin, and a laser from the eye still shows a beam in each eye. Starting it close to the target makes the beam and the dot tiny, while `POINTER_ORIGIN_MARGIN` keeps the origin in front of the small window controls, which float a few centimetres in front of their panels. The helper's own white dot is the visible cursor. In empty space it's an interactive overlay that the laser lands on, so SteamVR never draws a laser into nothing.
- 2026-09-26: Programs that stop their own helpers when their window closes still lose them on a desktop restart, even after keep-apps.sh moved them out of the unit (22 processes moved, all gone 7 s later when the compositor went): nothing outside the app can keep them. Run such work somewhere independent of the desktop, like a systemd user service. Controls: shown only while some controller ray passes within max(1.5 x button, 12% of the bar) of a control (5 points along the bar, each button, the tab), 0.4 s linger; a zone covering the lower quarter of the screen showed them whenever a screen was in use.
A few overlays need special handling:
- 2026-09-26: Restarting the desktop killed everything started in it, including background servers and the jobs they ran: `systemctl --user stop frametop-desktop` kills the unit's whole cgroup, and apps launched in the nested session never get scopes of their own (KIO uses systemd scopes only when systemd is on the session bus, and the session runs on a private dbus-run-session bus). Now `desktops.sh stop` first runs session/keep-apps.sh, which moves every process in the unit except the session's own (session script, dbus, KWin, Plasma, the session services it starts; matched by comm name) into a new transient scope (StartTransientUnit with PIDs, via busctl). GUI apps still exit when the compositor goes; whether an app's children outlive it is up to the app. Controls now fade in only while some controller-class device's ray (the 3D mouse's virtual controller included) meets the screen's plane in its lower quarter or just below it, and linger ~0.8 s.
- The dashboard's dock and the floating windows' controls are scene-graph overlays with no texture (0 × 0) and a placeholder width, so `ComputeOverlayIntersection` never hits them. For those the helper tests the overlay's plane within `POINTER_SCENE_RADIUS` of its origin.
- Just off a panel, the cursor stays on that panel's plane within `POINTER_EDGE_REACH`, so resize margins and window controls just outside the panel are reachable.
- While the left button is held, the cursor keeps the distance it had at the press and stops re-testing collisions, so dragging past a panel's edge doesn't make it jump.
- 2026-09-26: Roll button and quieter controls (feedback: tilting screens sideways was hard; the new corner tab was too big; controls should be smaller and translucent like SteamVR's). Roll is a knob: at the press the laser's angle around the screen's centre (in the plane of the pose at the press) is recorded, and the screen turns by how far that angle has moved (RollZ about its own front axis; pinned screens roll their controller->screen transform). Within 2.5° of level (the right edge's slope) it snaps level; scrolling on the button steps 5°. Controls: bar sqrt(0.012 x distance x width), buttons and tab max(13% of the bar, 1.8% of the distance); textures are a translucent light pill and dark translucent discs with white glyphs (3x supersampled), and every control sits at 55% overlay alpha until a laser is on it (VREvent_MouseMove / FocusEnter / FocusLeave on the control) or it's being dragged.
`dashboard.laserRayWidthScale` controls the beam's width, but SteamVR only applies a change from its own settings screen or at restart, so it can't be switched per device while running.
- 2026-09-26: Screen controls and pointer depth (feedback: the wrist ring was too big; the bar and corner handle only followed distance, looked detached from flat screens; the pointer was unreliable between things close together in view at different depths). Wrist ring 6 cm (leave at 9 cm). Controls: bar width = sqrt(0.02 x distance x width), at least 4% of the distance, at most 60% of the width; the resize control is a quarter-disc tab whose corner sits on the screen's corner (the old L floated 5 cm off it); on curved screens the bar gets the screen's radius and the button and tab are placed on the cylinder (angle u/r, facing the axis). They're re-sized every half second when the distance changes by more than 8%. OpenVR's intersection mask (SetOverlayIntersectionMask) doesn't affect ComputeOverlayIntersection, so it can't be checked from code; the tab stays entirely outside the screen instead. ft-pointer: the cursor's ray starts at the recenter anchor, not the eye, so after leaning it could land on a panel that a nearer one covers from the eye; a second test along the eye's line of sight to the cursor point now takes the nearer thing.
### Handing the laser back and forth
- 2026-09-26: Wrist pinning, third version (in testing, the target wasn't visible, and a screen that pinned mid-carry was stuck at the angle the carrying hand had while pointing at the wrist). Now the laser entering a controller's 10 cm ring (leaving it past 14 cm, so it doesn't flicker) toggles an armed state, and the pin happens on release with the pose at that moment. Grabbing a pinned screen starts armed for its wrist, so moving it re-pins it. While a screen is carried, every other hand controller gets a ring overlay (its zone, facing the head, blue when armed) and a dot at the laser's closest point to it (within 35 cm, blue inside the ring); both are non-interactive overlays above the screens (sort order 20/21). Frametop Display Settings' pages moved from a collapsed side drawer, which was easy to miss, to tabs.
The dashboard follows whichever device summoned it or last pressed its trigger. Frametop adds "last used wins": moving a real controller releases the pointer at once, and the next mouse movement takes the laser back. Small movements don't count; waking needs `POINTER_WAKE_COUNTS` of mouse motion within a second, so desk jitter doesn't steal the laser. While the pointer is awake, a tiny transparent overlay with `MakeOverlaysInteractiveIfVisible` keeps SteamVR's laser mouse on, since otherwise the first click would only switch the laser on.
- 2026-09-26: Wrist pinning, second version (feedback: pinning should work by aiming, not touching, so big and far screens can ride on a wrist, and a pinned screen should only show from the front). While a screen is carried, the segment from the carrying device to its bar is tested against the other hand controllers (not ft_pointer); within 10 cm for 0.3 s, the screen pins as it is (controller->screen transform kept). A pinned screen's alpha follows the angle between its front and the direction to the head: 1 inside the wrist angle minus 10°, 0 beyond it (then hidden). Visibility modes (always / dashboard / gesture: gaze within N° of a controller / toggle) in ft-screens, with g_manual as "hidden" in always and "shown anyway" in the others. Poses are read once per tick. `get` reports the pin (hand and transform) so `ft-layout capture` keeps pins and `apply` restores them.
When the headset comes off, SteamVR reports its activity level as idle at once and turns the displays off 5 seconds later (`power.turnOffScreensTimeout`), unless something keeps it awake. An awake pointer did, and so did the helper's `vrcmd` runs: each is a new SteamVR client, and a new client every second kept SteamVR out of standby. The helper now releases the pointer as soon as the headset is idle, ignores the mouse until you're wearing it again, and pauses the overlay list whenever the pointer is off.
- 2026-09-26: ft-screens fixes after the first session in the headset (moving windows between screens worked; text is as sharp as the headset allows, so screens need to be bigger and nearer).
- The curved preset spaced screens by angle (2 atan(w/2d)), which assumes every screen sits on the circle. A flat 3.6 m screen's edges are 2.7 m away when its centre is 2 m, so its neighbours landed in front of its edges. Now each row is chained edge to edge like monitors on a desk: the middle screen (or seam) straight ahead at the distance, each neighbour hinged at the previous one's outer edge plus the gap and turned until it faces the eye (dot(centre, right) = 0, solved by scan and bisection; a fixed-point iteration diverges for screens nearly as wide as twice their distance).
- Resize took the larger of the ray's reach in x and y (y scaled by the aspect). The handle sits below the bottom edge, so moving inward without moving up never shrank a screen. Now the corner follows the ray along the diagonal, keeping the grab offset; minimum 15 cm.
- Push/pull scaled the device-to-screen offset. The 3D mouse's device sits just in front of the bar (the laser origin is near the cursor), below the screen's centre, so that offset points mostly up. Now it moves along the head-to-screen line.
- Pinning checked the screen's centre within 35 cm of a controller, but a big screen's centre is far from the edge you bring to your wrist. Now it's the nearest point of the screen's rectangle within 20 cm; the pinned screen shrinks to 32 cm, 12 cm from the controller, and gets its width back when grabbed.
- Curvature: `SetOverlayCurvature` (the fraction of a full cylinder the width covers, width / 2πr). The curve button uses the head's distance as the radius, so the screen wraps around you; the width can change and the radius stays. ComputeOverlayIntersection and the mouse coordinates follow the curve.
- A button release is delivered to the panel under the laser, maybe another screen's, so any release on any of our panels ends that device's drags.
### Moving floating windows
- 2026-09-26: ft-screens, a gamescope replacement (decided: our own panels, always visible with a hide hotkey, switch as soon as it works; acceptance test: an ultrawide between two portrait screens). Spike works: three native panels, 1080×1920 / 3440×1440 / 1080×1920.
- OpenVR's public `IVRIPCResourceManagerClient_003` (`VRIPCResourceManager()`: `GetDmabufModifiers`, `ImportDmabuf`, `UnrefResource`) is how gamescope hands SteamVR its frames; a texture of type `TextureType_SharedTextureHandle` then shows it. No size limit. The Frame's runtime supports it (and `IVROverlay_028`), but SteamVR's bundled `hellovr` header predates it, so builds fetch Valve's public header (v2.15.6). SteamVR imports XRGB8888/ARGB8888 with `LINEAR` and `0x0500000000000001` (Qualcomm compressed).
- First try, KWin screencast virtual outputs (`zkde_screencast_unstable_v1.stream_virtual_output`, v3 in KWin 6.2.5): KWin crashed in `WorkspaceSceneOpenGL::textureForOutput` (std::out_of_range) streaming one while nested in gamescope, three times (the wrapper restarted it, plasmashell didn't come back until restarted by hand). In 6.2.5 only the DRM and nested Wayland backends implement `createVirtualOutput`, not `--virtual`. The nested backend's version is just another host window: `createOutput(name, size * scale, scale)`.
- So ft-screens is a minimal wlroots 0.20 compositor (`screens/compositor.c`, C) that hosts the nested KWin, plus an OpenVR side (`screens/vr.cpp`). KWin's nested backend needs `wl_compositor` v4+, `wl_shm`, `wl_seat`, `xdg_wm_base`, and `zwp_linux_dmabuf_v1` v4 (feedback: main device, format table); pointer constraints/gestures, relative pointer, and xdg-decoration are optional. We configure each KWin window's size on its first commit (`wlr_xdg_toplevel_set_size`) and KWin sizes that screen to match; each committed DMA-BUF goes straight to SteamVR, held until the next one. Frame callbacks at 90 Hz. Idle cost: ft-screens 1% CPU, KWin 1%.
- wlroots details: `wlr_shm_create` wants DRM format codes; KWin asks for server-side decorations before its first commit, and setting the mode then asserts (`surface->initialized`), so it's answered on the first commit.
For SteamVR's own floating windows, the dashboard does the moving. A press on a window's grab bar, 7.5 cm below its bottom edge, parents the window to the pressing device with the relative transform at the press. The scroll wheel pushes it along its normal in steps of about 7 cm. The dashboard finishes the move up to 150 ms after the release and reads the device's pose again then, so the helper holds the drag pose for half a second after the button comes up. Tilting works by rotating the virtual controller around the grab point while both buttons are held.
- 2026-09-26: Three screens (3440×1440 centre, portrait sides) meet two gamescope limits.
- One size and shape for every screen: a 540×960 test window opened straight in Frametop's gamescope (`WAYLAND_DISPLAY=/run/user/1000/gamescope-1`) became a landscape 16:9 panel (0.59 × 0.33 m): gamescope composes each window into the shared `-W`×`-H` canvas, letterboxed. Portrait screens are rotated KWin outputs (`kscreen-doctor output.N.rotation.left`) with their panels rolled 90° in VR (`place ... roll`); a rolled placement lands exactly (it took two moves).
- At most 1920×1080 worth of pixels: `upload_buffer_size = 1920 * 1080 * 4` (rendervulkan.hpp), and the OpenVR backend uploads a flat texture of the whole output at start (`vulkan_create_flat_texture(g_nOutputWidth, g_nOutputHeight, ...)`). 3440×1440 aborted gamescope at start (`uploadBufferData: Assertion 'size <= upload_buffer_size' failed`). The largest 21:9-ish size is about 2224×928. Beyond that needs a patched gamescope.
- A new window starts docked in the dashboard, where `--dock-overlay dashboard` is ignored as redundant and doesn't open the dashboard, so `world` then fails. `float_screen` docks to theater first.
- For scale: a 1.18 m panel at 1.2 m spans about 52°, and the Frame resolves roughly 20-25 pixels per degree, so about 1,200 pixels across; more pixels only help on bigger or nearer panels.
## Input relay
- 2026-09-26: Screens float and arrange themselves (tested with one screen; two screens pending).
- `vrcmd --dock-overlay <dashboard|world|theater|lefthand|righthand> <key>` sends the dashboard `vrcmd_dock_overlay`. An unknown key moves the dashboard's active frame instead (`GetFramesWithAssociatedSummonKeys(key)[0] ?? activeFrame`), and a key already at that location is ignored ("redundant"). `world` takes its first transform from the dashboard's position (`setInitialTransformForLocation` → `requestSGTransform(GetDockLocationTransformID(Dashboard))`), which fails while the dashboard is closed ("Invalid transform ID"): the panel floats with no position and shows only with the dashboard. Working order: dock `dashboard` (opens the dashboard), dock `world`, `--hidedashboard`.
- Floating panels' transforms can't be read (DashboardTab, type 5), but `ComputeOverlayIntersection` works on them and returns UVs (v bottom to top). Casting rays from the head over the sphere and fitting point = O + u·U + v·V gives centre, size, and frame exactly; 230,000 rays take 0.1 s (`md::ScanPanel` in `pointer/common/vrmath.h`, `vrprobe --scan`). A floating 16:9 panel measured 1.181 × 0.664 m with `PHYS_WIDTH=1.6`.
- The grab bar: below a floating panel SteamVR's laser hits three bands, 2-4, 6-9, and 14-26 cm below the bottom edge; 7.5 cm is the grab bar (`UndockedOverlay`: `onMouseDown: startFloatingWindowMove` on a 350 px bar at `{y: -0.26}` from the frame controls). `valve.steam.gamepadui.floatingfooter` never became visible there.
- The move (`startFloatingWindowMove`): the panel is parented to the device that clicked (head, left, or right hand by the mouse event's input path) with the relative transform at the press, and at the release it's `device × relative × pushTransform` (push = scroll, along the panel normal, whole notches of ~7 cm; fractional scroll does nothing). Drops within 0.3 m of the open dashboard or 0.4 m of the other hand snap there, so the dashboard is closed first. Rotations about the device origin carry the panel exactly (±20° yaw, ±8° pitch: 0.0° error). Slow translations do too (0.1 m in 1.5 s: 9.9 cm, no rotation). The first try, 0.1 m in 12 jerky 25 ms steps right after the press, moved it 0.19 m and turned it 8.5°. With a hover before the press, rotation first, then a smooth 60 Hz slide, placement was exact (0.0 cm, 0.0°) at every slide speed tried, 0.07 to 1 m/s, including a 66° swing and a panel facing away from the eye. `place` takes 2.5-3.5 s for a floating screen and about 8 s from docked.
- The helper answers `place`/`measure`/`head` by datagram to the sender's abstract address (`recvfrom`). The first version reset the sender length before replying, so replies went nowhere.
SteamVR opens every input device only when it starts. When a Bluetooth mouse sleeps and reconnects, it gets new device nodes, and SteamVR keeps holding the old, deleted ones, so the mouse stops working until SteamVR restarts. The relay creates permanent virtual devices through uinput before SteamVR starts and forwards the real devices' events into them. systemd keeps the virtual devices' file descriptors across relay restarts, so SteamVR never sees them disappear.
- 2026-09-26: The pointer with the dashboard closed (user-tested: "exactly how I want the pointer to work").
- gamescope's app panels (`frametop.app.N`, the desktops) report a 0x0 texture like scene-graph overlays, but their transform type is DashboardTab (5), so `GetOverlayTransformAbsolute` fails and the plane test skipped them. `ComputeOverlayIntersection` hits them normally. Only absolutely placed 0x0 overlays are scene-graph now. They stay visible when the dashboard closes.
- Off a panel, the cursor jumped to `POINTER_DISTANCE` (1.5 m), behind the panel (~1 m), and the laser started behind the panel's resize margins and window controls. It now stays on the last panel's plane within `POINTER_EDGE_REACH` (0.3 m). The floating-window controls only show while the panel is hovered, so the overlay list includes hidden overlays, and visibility is re-read every 50 ms.
- With the dashboard closed, SteamVR's laser mouse is off until a click, even while our device is the primary dashboard device (`GetPrimaryDashboardDevice` stayed ours; `system.pointer` stayed hidden until a trigger press). So the first click on a panel only turned the laser on, and leaving every panel turned it off again. A held Frame controller keeps it on by itself. Fix: `VROverlayFlags_MakeOverlaysInteractiveIfVisible` ("the system-wide laser mouse mode will be activated whenever this overlay is visible") on a transparent 1 mm overlay, `frametop.pointer.lasermode`, 50 m below the head, shown only while the pointer is awake. vrcompositor's strings (`overlaysForcingLaserMouseOn`, `force_activate_laser_mouse`) led to it. Re-sending the claim pulse when the primary device went invalid didn't help and was removed.
Keyboards aren't grabbed by default, because a grabbed keyboard's keys went into a virtual keyboard nothing typed from; the relay forwards them to ft-screens instead.
- 2026-09-25: The tilt was also lost on the left release. SteamVR's dashboard finishes a floating move up to 150 ms after mouseup (`endFloatingWindowMove` races `updatePushDistance` against `s_flFinalPushMeasurementMS` = 150) and re-reads the controller pose, which had already gone back to plain pointing. The helper now holds the drag pose (tilt and frozen distance) for 0.5 s after the left release.
## The desktop session
- 2026-09-25: Tilt works (user-tested), but it reverted when the right button was released: the device pose went back to plain pointing and the still-grabbed panel followed. Now the tilt angles accumulate per drag and stay applied (about the current cursor point) until left is released. They reset on each left press and release.
The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it. It has its own runtime directory, config (`~/.config/frametop`), and state, so it never disturbs the stock desktop's layout or panels. It runs on a private D-Bus from `dbus-run-session`, which has two consequences. KDE only launches apps in systemd scopes when systemd is on the session bus, so everything started in the desktop lands in its systemd unit, and stopping the unit would kill all of it; `session/keep-apps.sh` moves those programs out first. And tools that need the real user bus, like podman and `distrobox-host-exec`, have to be pointed at it explicitly.
- 2026-09-25: Small controls explained by the helper debug log (`debug` command). Over the undock-type controls the helper saw FREE space, put the catcher at 1.5 m and the laser origin at 1.39 m, while the controls were about 1.1-1.2 m away. `valve.steam.gamepadui.bar` (dock) and `valve.steam.gamepadui.floatingfooter` (floating-window controls) are visible absolute overlays with texture 0x0 and a placeholder width of 1.0 m. The dashboard draws them through its scene graph, so `ComputeOverlayIntersection` never hits them. The helper now plane-tests texture-less overlays within `POINTER_SCENE_RADIUS` (0.5 m) and puts the catcher 5 cm behind that plane. The drag lock (freeze the cursor distance while left is held) fixed resize snap-back; the user confirmed it.
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
- 2026-09-25: User feedback round.
- The Buttons page hid bindings whenever the Z3 slept, because it listed only connected devices. The app now also lists devices with saved rules or bindings.
- "Toggle dashboard" did nothing: the relay sent the system button's press and release together. It now wakes the pointer and holds the button for 0.12 s.
- Small floating controls (undock, frame buttons) sit a few cm in front of their panel. With the laser origin at 0.95-0.98 of the way, the laser started behind them. `POINTER_ORIGIN_MARGIN` (0.15 m) keeps the origin in front.
- Tilt: the driver takes `posq` (full quaternion), and the helper rotates the device pose around the grab point while left and right are held. SteamVR's floating move (`UndockedOverlay.startFloatingWindowMove`) keeps the panel rigid with the controller (`m_sMoveDevicePath`), so this should turn the panel. Not yet tested; it needs a SteamVR restart to load the driver.
A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it.
- 2026-09-25: Input relay v2 and the settings app. The relay has per-device roles (pointer, passthrough, ignore) keyed by Bluetooth address (EVIOCGUNIQ) or USB ids. It no longer grabs keyboards by default: they used to be swallowed into the virtual keyboard, which nothing types from. It has per-device button maps to named actions and a control socket `@frametop_relay` (devices, watch, reload; reload also reaches the helper, which now re-reads `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, and `POINTER_ORIGIN_FRACTION` live). `input-settings` (Kirigami and PySide6, in the container) was tested headless against the live relay with fake uinput devices: listing, roles, capture, mapping, role change, settings, and Bluetooth all work. The UI was checked through screenshots of the VNC display. Clicking through VNC, then RDP, then KWin fake input is too lossy for scripted UI tests.
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
- 2026-09-25: Controller handoff works for all three devices (left, right, mouse). The driver switches its role hint (Right while connected, OptOut while not), because SteamVR keeps a hand role reserved for a disconnected device that still hints it. The helper releases the pointer when a real controller moves, or when ours hasn't got the hand role within 1 s: SteamVR gives a contested role to the most recently used device, and a held Frame controller counts as used through its touch sensors.
- 2026-09-25: The beam width can't be switched live. `dashboard.laserRayWidthScale` set by any client (IVRSettings, `vrcmd`, even followed by a `laserLength` nudge or `VREvent_DashboardSectionSettingChanged`) is saved but not applied. Only the dashboard's own Settings screen, or a SteamVR restart, applies it. So the width stays at whatever it was set to. The helper starts the laser at 0.95 of the eye-to-cursor line (a few cm of beam along the line of sight, and SteamVR's hit dot is tiny), and draws its own white dot everywhere: `frametop.pointer.marker` (not interactive) on panels, `frametop.pointer.cursor` (interactive, catches the laser) in free space.
## Approaches we dropped
- 2026-09-25, spike 3 (helper) works. It looks right: only a dot, anchored to panel surfaces, with a floating white dot in free space.
- Bug fixed: driver poses are in raw tracking space, and client math is in the standing universe (on the Frame, standing is ~1.6 m above raw). Sending standing coordinates put the laser origin 1.6 m above the head, which also inflated SteamVR's hit dot. The helper now converts via the HMD pose in both universes each frame (`TrackingUniverseRawAndUncalibrated`).
- SteamVR's hit dot (`system.pointer`, transform type 4, not readable as absolute) is sized by distance from the laser origin. The origin sits `POINTER_ORIGIN_FRACTION` (0.5) along the eye-to-cursor line, which stays invisible and halves the dot.
- Controller laser not returning: the dashboard pointer flipped 2 → 1. Suspected cause: the relay re-claimed the laser on tiny mouse movement after a pause (sensor jitter). Fix: `POINTER_WAKE_COUNTS` (40 counts in 1 s) before waking or re-claiming. Clicks and scroll wake immediately.
- WayVR, an existing Wayland desktop for VR. It built and connected to SteamVR on the Frame, but nothing showed in the headset. It has no bindings for the Frame's controllers, and its KDE screen capture needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship.
- gamescope in `PerWindow` mode, for the reasons above. Frametop still supports it as `BACKEND=gamescope`. In that mode SteamVR's dashboard owns the panels, so `ft-layout` floats each one with `vrcmd --dock-overlay` and the pointer helper carries it into place with the virtual controller, hovering first and then sliding at 0.5 m/s, because the dashboard exaggerates fast movements.
- A capture pipeline from a headless KWin through KWin's screencast protocol. It's workable, but ft-screens gets the frames directly with less code.
- 2026-09-25, spike 2 (the mouse drives the pointer): it works end to end. The Z3's motion aims SteamVR's laser, and left click, right click, and scroll act on the dashboard. The invisible render model works: `{ft_pointer}/rendermodels/ft_pointer_invisible` is one tiny triangle with a transparent texture. The claim button (`/input/a` bound to `lasermouse_secondary/switchlaserhand`) takes the laser without clicking.
- Laser looks: `dashboard.laserRayWidthScale` (not in the Settings UI; default 1.0) = 0 hides the beam while it hits a panel. `dashboard.laserLength` is the Settings UI's "Laser Pointer Length" (0.5 = 50%, default). Neither hides the laser when it hits nothing. With an eye-origin ray the beam is still visible, because of stereo (each eye is ~31 mm off the ray).
- `vrcmd --overlays` lists every overlay (key, visibility, type, flags, handle): `system.systemui` (Steam UI), `valve.steam.desktopgame.*`, `gamescope.*`, `system.pointer` (the laser hit dot), and so on. `vrcmd --compositorcmd dump_laser_overlays` isn't handled by this build.
- Next (spike 3): the pointer helper. Relay → helper → driver. The helper does collision (enumerate visible overlays, `ComputeOverlayIntersection` from the anchor, cursor snaps onto the surface). It adds a laser-catching dot overlay at the cursor point in empty space, so the laser always hits something. It toggles the laser width by who owns the dashboard pointer.
## Open questions
- 2026-09-25, spike 1 results:
- Holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. So the driver starts disconnected (`deviceIsConnected=false`) and only connects on `show`.
- Once connected it becomes SteamVR's right hand (`GetTrackedDeviceIndexForControllerRole(Right)` = our device, even with the real controllers on). The real controllers still had their lasers.
- The dashboard's pointer device (`IVROverlay::GetPrimaryDashboardDevice`) goes to whichever device summoned the dashboard or last pressed its trigger. The headset's side button selects the HMD head pointer (device 0).
- With `/pose/raw` bound as `lasermouse/Pointer` and `lasermouse_secondary/switchlaserhand` on the trigger: a virtual trigger press moved the pointer to our device, and our system button closed and reopened the dashboard with ours as the pointer. `/pose/tip` didn't work, because tip comes from a render model and ours has none.
- Still to see in the headset: our dot anchored in the room (`install.sh aimhere`), and a click landing on a target.
- `pointer/probe/vrprobe` (OpenVR background client) prints devices, roles, the dashboard pointer, and head yaw and pitch. `vrcmd --info` also prints "Dashboard pointer device".
- 2026-09-25: Spike 1 prep. `pointer/driver/` holds the `ft_pointer` driver: a virtual controller with room-anchored `aim`/`gaze` pose from the head, buttons over the abstract datagram socket `@ft_pointer`, and default vrcompositor bindings to the lasermouse actions for both hands. It builds in `dev` for the host with `-static-libstdc++ -static-libgcc -Wl,--exclude-libs,ALL -fno-math-errno`: libm's `sqrtf` is versioned `GLIBC_2.43` in the container, which is newer than the host's 2.39. Its only export is `HmdDriverFactory`. Installed to `~/.local/share/frametop/ft_pointer` and registered with `vrpathreg adddriver`. Not loaded yet: that needs a SteamVR restart.
- 2026-09-25: The Z3 stopped reaching the desktop after it reconnected at 18:00:20 (sleep), seven minutes after SteamVR started. `vrserver` and `vrcompositor` held `/dev/input/event5`-`event8` as `(deleted)`. Restarting SteamVR fixed it once. The durable fix is `input/input-relay.py`: uinput virtual mouse and keyboard, created before SteamVR, plus EVIOCGRAB relay of USB and Bluetooth mice and keyboards with hotplug. Tested on the Frame with `--no-grab` and a fake USB uinput mouse: motion and BTN_LEFT relayed, unplug released cleanly, Z3 Mouse and Z3 Keyboard picked up, and the Z3 joystick nodes ignored. Enabled as a user service, not yet started.
- 2026-09-25: Chromium (Flatpak `org.chromium.Chromium`, system install) didn't launch from the taskbar. Plasma ran `kde-open appstream://org.chromium.Chromium`, which opens Discover, because the nested session had no `XDG_DATA_DIRS` and so no Flatpak exports. Fix: the session script sources `/etc/profile.d/flatpak.sh` (with a default `XDG_DATA_DIRS` first, since the script runs `set -u`). With the right env, Chromium runs fine in the nested session over Xwayland. Harmless log noise: `vaInitialize failed` (no VA-API) and a GCM `DEPRECATED_ENDPOINT`.
- 2026-09-25: VNC only. RealVNC Viewer can't speak RDP. Bridge: krdp on `127.0.0.1:3390`, then `xfreerdp` full screen inside `Xvnc :20`, then VNC on `<tailnet ip>:5900`. Verified with a screenshot of `:20`.
- 2026-09-25: Changing `SCREENS` can orphan Plasma panels. With 2 screens the taskbar panels were saved with `lastScreen=1`. After switching to 1 screen, no taskbar showed. Fixed by moving `~/.config/frametop/plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` aside (`*.bak-2screens`). TODO: handle this in the session script when the screen count shrinks.
- 2026-09-25: Remote desktop. `krfb` needs `xdg-desktop-portal-kde` (plugins `pw` and `xdp` only on Wayland), and `wayvnc` is wlroots-only. `krdpserver --plasma` (krdp 6.7.5, Fedora) uses KWin's screencast and fake-input protocols directly. It works against the nested KWin 6.2.5 once `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` is set, which is needed because KWin can't match a container binary to a desktop file. Tested with `xvfb-run xfreerdp`: the right password connects and streams H.264 (OpenH264, no VA-API), and a wrong password is rejected at PostConnect. Reachable from the Mac over the tailnet.
- 2026-09-25: gamescope doesn't always exit on SIGTERM when started from the Steam launcher. `desktops.sh stop` waits 10 s, then sends SIGKILL.
- 2026-09-25: A test of gamescope PerWindow with a bare nested `kwin_wayland --output-count 2` produced two separate overlays. The full Plasma version then showed two sharp 1080p desktops with wallpaper, a taskbar, and movable panels.
- 2026-09-25: A headless `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 kwin_wayland --virtual --output-count 2` runs next to the VR session. With the env var it exposes `zkde_screencast_unstable_v1` and `org_kde_kwin_fake_input`. Without it, KWin hides them. That's useful if the helper injects input through fake_input.
- 2026-09-25: SteamOS runs its own nested Plasma session inside gamescope (`/run/user/1000/nested_plasma`, socket `wayland-0`, X display `:2`). This is the built-in single-screen desktop in VR. Don't touch it.
- 2026-09-25: On the Frame, `gamescope --virtual-connector-strategy` accepts `SingleApplication`, `SteamControlled`, `PerAppId`, and `PerWindow`. The main VR session uses `PerAppId`.
- A head-locked screen, like a HUD.
- A controller button that shows the screens during a game. Games own the controllers, so this needs SteamVR input actions for ft-screens.
- Drawing KWin's cursor on the screens.
- Plasma can lose its panels when the number of screens goes down, because they're saved against a screen that no longer exists. Removing `plasma-org.kde.plasma.desktop-appletsrc` and `plasmashellrc` from `~/.config/frametop` brings the default panels back.
- Frame pacing and GPU cost with several busy screens haven't been measured.
+101 -85
View File
@@ -1,132 +1,148 @@
# Frametop
# Frametop reference
Several desktop screens floating in SteamVR on the Steam Frame, driven by a physical mouse (Bluetooth or USB) whose cursor moves through 3D space. The cursor crosses from one screen to the next based on where the screens actually sit around you, not on a flat monitor layout.
How each part of Frametop works, where its settings live, and the commands for running parts of it by hand. For why it's built this way, see [design.md](design.md).
How it works: a nested Plasma desktop runs inside ft-screens (`screens/`), our own small Wayland compositor. KWin opens a window per screen; ft-screens gives each one its own size, so every screen is a real monitor of any resolution and shape (ultrawide, portrait, 4K), and shows each as its own SteamVR panel, with KWin's frames passed to SteamVR as they are (no copy). The panels are ours: any size in metres, placed exactly by the layout, with a grab bar to move them, a handle to resize them, and pinning to a hand. See `docs/design.md`. (The older gamescope backend is still there: `BACKEND=gamescope`.)
## The desktop
Status: the multi-screen desktop works on ft-screens (acceptance test: a 3440 × 1440 ultrawide between two 1080 × 1920 portrait screens, wallpaper on each, the taskbar on the ultrawide). The universal 3D mouse works with the SteamVR dashboard, Steam, overlays, and the screens.
## Run
From the headset: open "Launch a program", then "Desktop". After `install`, that entry starts Frametop instead of the stock single-screen desktop.
From the headset, open Launch a program → Desktop. The installer replaces that launcher entry with Frametop's (`~/.local/share/applications/deckard-nested-desktop.desktop`), and `desktops.sh uninstall` gives the stock single-screen desktop back.
From a terminal, on the Frame or from a PC over SSH:
```
desktops.sh install # launcher "Desktop" starts Frametop (writes ~/.local/share/applications/deckard-nested-desktop.desktop)
desktops.sh uninstall # launcher gets the stock SteamOS desktop back
desktops.sh screens 3 # default screen count
desktops.sh start [screens] | stop | restart | status | log [lines]
desktops.sh install # the launcher's Desktop entry starts Frametop
desktops.sh uninstall # back to the stock SteamOS desktop
desktops.sh start | stop | restart | status | log [lines]
```
Settings: the screens (resolution, width in metres, scale, taskbar screen) and the layout are in `~/.config/frametop-layout.json`; `BACKEND`, `REMOTE`, and the pointer settings in `~/.config/frametop.conf` (see `session/frametop.conf.example`). Frametop Display Settings (below) edits both.
`session/frametop-session.sh` runs the desktop. It starts ft-screens in the `dev` container (log: `/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one desktop runs at a time. `desktops.sh start` runs it in its own systemd unit, `frametop-desktop`. It keeps its Plasma config in `~/.config/frametop`, separate from the stock desktop's.
The session script is `session/frametop-session.sh`. It runs on the Frame host and starts ft-screens in the `dev` container (`/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one instance runs at a time, and `desktops.sh start` runs it in its own systemd unit (`frametop-desktop`). It keeps its own Plasma config in `~/.config/frametop`, separate from the stock desktop.
Settings are in two files, and Frametop Display Settings edits both. The screens (resolution, width in metres, scale, curve, which one has the taskbar) and their layout are in `~/.config/frametop-layout.json`. The backend, remote desktop, and pointer settings are in `~/.config/frametop.conf`; `session/frametop.conf.example` lists every key.
Restarting the desktop (`desktops.sh restart`, or Restart desktop in Frametop Display Settings) closes its windows, but programs started in it keep running when they can: `session/keep-apps.sh` moves them out of the desktop's systemd unit first. Background work like servers, tmux, and builds survives. An app that stops its own helpers when its window closes still loses them; for work that must survive, run it outside the desktop, for example as a systemd user service.
Restarting the desktop closes its windows. Before the unit stops, `session/keep-apps.sh` moves every program started in the desktop into a systemd scope of its own, so background work such as servers, tmux, and builds keeps running. An app that shuts down its own helper processes when its window closes will still lose them; run that kind of work outside the desktop, for example as a systemd user service.
## ft-screens (the compositor)
## ft-screens, the compositor
`screens/compositor.c` (wlroots 0.20) hosts the nested KWin; `screens/vr.cpp` is the SteamVR side. Build: `screens/build.sh` (the installer does it).
`screens/compositor.c` is a small wlroots 0.20 compositor that hosts the nested KWin, and `screens/vr.cpp` is its SteamVR side. `screens/build.sh` builds it; the installer runs that for you.
- Screens: each KWin window is a screen. ft-screens sizes it (`xdg_toplevel` configure) and KWin resizes that screen to match, live. Frames arrive as DMA-BUFs and go to SteamVR with `ImportDmabuf` (OpenVR's `IVRIPCResourceManagerClient`), no copy, no size limit.
- Panels: `frametop.screen.N`, with `.bar` (move), `.curve` (the round button next to it), `.roll` (the next one: drag it sideways like a knob to roll the screen, snapping level within 2.5°, or scroll on it for 5° steps), and `.resize` (the tab on the bottom right corner; screens go down to 15 cm wide). The controls are sized from both the screen's width and its distance from you, sit on its surface when it's curved, and are translucent like SteamVR's own until a laser is on them. They're invisible until a laser or the 3D mouse's cursor comes very close to one of them (about 1.5 times a button's size), and fade out a moment after it leaves. Drag the bar with any laser (a controller, or the 3D mouse, whose right-drag tilt works too); scroll while dragging to push it away or pull it closer (along the line from your head). The curve button bends the screen into a cylinder around you (radius: your distance to it), or flat again. Pin to a wrist: while carrying a screen, sweep the laser (the line from whatever carries it to its bar) across your other controller. A ring around that controller shows the target and a dot shows where the laser passes; entering the ring arms the pin (ring and bar turn blue), entering it again disarms it. Let go while armed and the screen rides on that controller as it is then, at its size and distance (a 3.6 m screen 5 m away works; so does pinning all screens), so you can arm it first and then turn it the way you want. Grab a pinned screen's bar to adjust it: it comes back to the same wrist when you let go, unless you sweep across the ring to disarm. A pinned screen shows only while you see its front within the wrist angle, fading over the last 10°.
- Visibility (Frametop Display Settings → Visibility & wrist tab): always (Meta+Shift+H, the Hide/Show Screens menu entry, or a mapped mouse button hides them), only with the SteamVR dashboard open, while you look at a chosen controller (the wrist gesture), or only when shown with the hotkey. In the last three, the hotkey shows them anyway. Controllers on the screens (same tab): visible screens can keep SteamVR's laser mouse on so controllers work them with the dashboard closed, which also takes the controllers from a VR game. By default that's off while a VR game (a scene app) runs: the screens stay over the game, the controllers stay in it, and the 3D mouse or the dashboard works the screens. The other choices are always and only with the dashboard open (also right for flatscreen games, which aren't scene apps). Control socket: `controllers always|outside_games|dashboard`. During VR games (same tab): with Always, the screens hide unless the dashboard is open (default; `ingames hide`), or stay visible (`ingames visible`); the hotkey still shows them, and a game starting or stopping resets it. (A controller button to show them isn't there yet: in a game the game owns the buttons.)
- Input: pointer from the panels to KWin through our seat; keys from the input relay (every keyboard it doesn't grab, and keys a pointer device passes through) to the screen that was clicked last, but not while the SteamVR dashboard is open.
- Control socket `@ft_screens` (datagrams, replies to the sender): `place N x y z yaw pitch roll`, `width N metres`, `curve N radius|on|off`, `pin N|all left|right [12 numbers]`, `unpin N|all`, `size N w h` (live resolution), `get N`, `screens`, `head`, `visibility always|dashboard|gesture|toggle`, `wrist degrees`, `gesture left|right degrees`, `hide | show | toggle`, `controllers always|outside_games|dashboard`, `ingames hide|visible`, `state`, `key code value`.
Each KWin window is one screen. ft-screens sets its size with an `xdg_toplevel` configure and KWin resizes the output to match, live. Frames arrive as DMA-BUFs and go to SteamVR through OpenVR's `IVRIPCResourceManagerClient::ImportDmabuf`, with no copy and no size limit.
## Input relay (Bluetooth mice and keyboards)
Every screen is an overlay named `frametop.screen.N` with four controls:
SteamVR opens input devices only when it starts. A Bluetooth mouse that sleeps and reconnects gets new device nodes, and SteamVR keeps reading the dead ones, so the mouse stops working until SteamVR restarts. `input/input-relay.py` fixes that:
- `.bar` moves the screen. Drag it with any laser or with the 3D mouse, whose right-drag tilts. Scrolling while you drag pushes the screen away or pulls it closer, along the line from your head.
- `.curve` bends the screen into a cylinder around you, using your current distance as the radius, or makes it flat again.
- `.roll` rolls the screen when you drag it sideways, like a knob. It snaps level within 2.5°, and scrolling on it turns 5° per notch.
- `.resize`, the tab on the bottom right corner, sets the width. Screens go down to 15 cm wide.
- It creates `frametop virtual mouse` and `frametop virtual keyboard` through `/dev/uinput` before SteamVR starts.
- It grabs every USB or Bluetooth mouse and keyboard as they come and go, and forwards their events. SteamVR only sees the virtual devices, which never go away.
- Service: `frametop-input-relay.service` (user unit, `Before=steamvr.service`, wanted by `steamvr.service` and `default.target`).
The controls are sized from both the screen's width and its distance from you, follow the surface of a curved screen, and stay invisible until a laser or the 3D mouse's cursor comes within about 1.5 times a button's size of one. They're translucent until a laser is on them, like SteamVR's own window controls.
To pin a screen to a wrist, carry it by its bar and sweep the laser across your other controller. A ring around that controller marks the target, and a dot shows where the laser passes. Crossing the ring arms the pin, and the ring and bar turn blue; crossing it again disarms it. When you let go while armed, the screen rides on that controller at the size, distance, and angle it had, so you can arm the pin first and then turn the screen the way you want. Grab a pinned screen's bar to adjust it; it goes back to the same wrist when you let go unless you disarm it. A pinned screen shows only while you're looking at its front, within the wrist angle, and fades out over the last 10°.
The Visibility & wrist tab of Frametop Display Settings decides when the screens show:
- Always. Meta+Shift+H, the Hide/Show Screens menu entry, or a mapped mouse button hides them.
- Only while the SteamVR dashboard is open.
- While you look at a chosen controller (the wrist gesture).
- Only after you show them with the hotkey.
In the last three modes the hotkey shows the screens anyway. Two more settings on the same tab cover VR games, which ft-screens detects as SteamVR scene apps:
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from any keyboard it doesn't grab and any key a pointer device passes through, and go to the screen you clicked last, except while the SteamVR dashboard is open.
ft-screens listens for datagrams on the abstract socket `@ft_screens` and replies to the sender:
```
desktops.sh relay install # enable (starts on the next reboot or SteamVR start)
place N x y z yaw pitch roll width N metres curve N radius|on|off
pin N|all left|right [matrix] unpin N|all size N w h
get N screens head state key code value
visibility always|dashboard|gesture|toggle wrist degrees gesture left|right degrees
hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible
```
## Input relay
SteamVR opens input devices only when it starts. A Bluetooth mouse that sleeps and reconnects gets new device nodes, SteamVR keeps reading the dead ones, and the mouse stops working until SteamVR restarts. `input/input-relay.py` avoids this. It creates two virtual devices, `frametop virtual mouse` and `frametop virtual keyboard`, through `/dev/uinput` before SteamVR starts. It then grabs USB and Bluetooth mice and keyboards as they come and go and forwards their events, so SteamVR only ever sees the virtual devices, which never go away.
It runs as the user service `frametop-input-relay.service`, ordered before `steamvr.service`.
```
desktops.sh relay install # enable it (starts with the next reboot or SteamVR start)
desktops.sh relay status | log | uninstall
input/input-relay.py --no-grab # try it without taking devices from SteamVR
```
- The first time, start it before SteamVR (reboot, or restart SteamVR after `relay install`), so SteamVR opens its virtual devices. After that, restarting the relay is safe: systemd keeps the virtual devices in its file descriptor store (`FileDescriptorStorePreserve=yes`), so SteamVR keeps the same devices.
- Test without disturbing SteamVR: `input-relay.py --no-grab`.
- This is where the 3D mouse will hook in.
The first time, the relay has to start before SteamVR, so reboot or restart SteamVR after installing it. After that it's safe to restart on its own: systemd keeps the virtual devices open in its file descriptor store (`FileDescriptorStorePreserve=yes`), so SteamVR keeps the same devices.
## Universal 3D mouse
## The 3D mouse
A Bluetooth mouse drives SteamVR like a controller laser, but it looks like a small dot floating in the room. It snaps onto panels and works on the dashboard, Steam, overlays, and this desktop. Details and findings are in `docs/design.md`, "The universal 3D mouse".
A mouse drives SteamVR the way a controller's laser does, but shows up as a small dot anchored in the room. It snaps onto panels and works on the dashboard, Steam, overlays, and the desktop. Three pieces make it work:
- `input/input-relay.py` (pointer mode, `POINTER=1`) sends mouse motion, clicks, and scroll to the helper. Deliberate movement or a click wakes it; 30 s idle releases it.
- `pointer/helper/ft-pointer` (`frametop-pointer.service`, runs in the `dev` container, starts with SteamVR) holds the room-anchored cursor. It does collision against every visible overlay, draws the white dot, and sends the driver an exact pose.
- `pointer/driver/` (`ft_pointer`, loaded by SteamVR) is an invisible virtual right-hand controller whose laser follows the cursor.
- Last used wins: picking up a controller hands the laser back at once, and moving the mouse takes it again. While you hold a controller the mouse steps aside.
- Moving panels: left-drag a floating panel's grab bar, and it follows the pointer around you (SteamVR's own move). The scroll wheel during a drag pushes and pulls it. **Tilt**: while left-dragging, hold the right button and move the mouse to rotate the panel around the grab point. The right press isn't sent as a right-click. The tilt stays for the rest of the drag: release right and keep moving the tilted panel, press right again to tilt further. Releasing left drops the panel as it is.
- Toggle dashboard (a mapped button or a Meta tap) wakes the pointer if needed and holds the virtual system button for 0.12 s. SteamVR ignores a press and release in the same instant.
- The input relay, in pointer mode (`POINTER=1`), sends mouse motion, clicks, and scrolling to the helper. A deliberate movement or a click wakes the pointer, and 30 seconds without mouse input releases it.
- The helper, `pointer/helper/ft-pointer`, runs in the `dev` container as `frametop-pointer.service` and starts with SteamVR. It keeps the cursor, tests it against every visible overlay, draws the dot, and sends the driver an exact pose.
- The driver, `pointer/driver/` (`ft_pointer`), is loaded by SteamVR. It's an invisible virtual right-hand controller whose laser follows the cursor.
Whichever device you used last wins. Picking up a controller hands the laser back at once, and moving the mouse takes it again. When the headset comes off, the pointer lets go, so the displays can sleep, and it stays off until you're wearing the headset again.
To move a floating panel, left-drag its grab bar. The scroll wheel pushes and pulls it while you drag. Hold the right button while dragging and move the mouse to tilt the panel around the grab point; the right press isn't sent as a click. The tilt stays for the rest of the drag, and releasing the left button drops the panel as it is. A mapped Toggle dashboard button (or a Meta tap) wakes the pointer if needed and holds the virtual system button for 0.12 s, because SteamVR ignores a press and release in the same instant.
```
pointer/driver/build.sh && pointer/driver/install.sh install # then restart SteamVR
pointer/helper/build.sh && pointer/helper/run.sh install # user service
pointer/helper/build.sh && pointer/helper/run.sh install
pointer/helper/run.sh status | log | restart
pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer
```
Settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, and `POINTER_LASER_WIDTH`. See `session/frametop.conf.example`. Restart the relay or the helper after changing them.
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, and `POINTER_LASER_WIDTH`. The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper.
## Frametop Input Settings (app)
## Frametop Input Settings
A Plasma app (Kirigami, Python backend) to choose and map input devices. It's in the Plasma menu under Settings on the Frametop desktop (and the stock desktop). It runs in the `dev` container and talks to the relay's control socket `@frametop_relay`.
A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has four pages:
- **Devices**: every USB or Bluetooth mouse and keyboard, with a live activity light (move or press a device to find its row). Roles: **3D pointer** (grabbed, drives the pointer; default for anything with a mouse node), **Pass through** (not grabbed; default for keyboards; a Meta tap still toggles the dashboard), **Ignore**. A physical device is identified by its Bluetooth address (or USB ids and name), so all its nodes share a role.
- **Buttons**: pick a pointer device (devices with saved bindings are listed even while asleep or disconnected, marked "not connected"; **Forget** on the Devices page drops all of a device's saved settings), press **Capture a button**, press the button or key, then choose an action: left, right, or middle click, back, scroll up or down, toggle dashboard, recenter, pointer on or off, faster or slower, pass through as key, or do nothing. The Z3's extra buttons arrive as keys from its keyboard node. Each row has **Remove** (your own binding; the button passes through again), **Reset** (a changed built-in button goes back to its default), or **Unbind** (a built-in button does nothing). **Remove all** clears the device's custom bindings. `FT_INPUT_PAGE=buttons` opens the app on that page.
- **Pointer**: sliders for the `POINTER_*` settings, applied live (the relay and helper reload), plus Recenter.
- **Bluetooth**: paired devices, and **Apply Bluetooth fixes** (runs `/etc/steamframe/bt-fixups.sh` through `pkexec`) after pairing an LE device. Pair new devices in Steam.
- Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (not grabbed; the default for keyboards, where a Meta tap still toggles the dashboard), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device.
- Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, faster or slower, pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep.
- Pointer has sliders for the pointer settings, which apply immediately, and a Recenter button.
- Bluetooth lists paired devices and has Apply Bluetooth fixes, which runs `/etc/steamframe/bt-fixups.sh` through `pkexec`. Pair new devices in Steam.
Rules are saved to `~/.config/frametop-input.json` and pointer settings to `~/.config/frametop.conf`.
Device rules are saved in `~/.config/frametop-input.json`. `input-settings/install.sh` installs the menu entry. Its launcher hands podman the real `XDG_RUNTIME_DIR` and user bus and gives the app the session's Wayland socket, because the desktop session runs on a private D-Bus and podman fails on it.
## Frametop Display Settings and ft-layout
When the desktop starts, its screens arrange themselves around where you're facing. You can move them by hand at any time and put them back with Meta+Shift+R, the Reset Screen Layout menu entry, Arrange now in the app, or a mouse button mapped to Reset desktop screen layout.
Frametop Display Settings has three tabs:
- Screens: add and remove screens, and set each one's resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), its width in VR (0.5 to 6 m), its scale, whether it's curved, and whether it has the taskbar. Resolution, width, and curve apply at once. Adding or removing a screen takes a desktop restart, which the app offers.
- Layout: a curve around you, with the screens hinged edge to edge like monitors on a desk and each turned to face you, or a flat wall. Both take rows, distance, gap, and height. Save current arrangement keeps the positions and sizes you set by hand instead. A preview shows the layout from above and from the front, and a switch turns auto-arrange at startup on or off.
- Visibility & wrist: the visibility, game, and controller settings described above, the wrist angle, and buttons to pin all screens to a wrist or unpin them.
`layout/ft-layout` does the arranging. It's a Python script that uses only the standard library and runs on the host:
```
input-settings/install.sh # menu entry (ft-input-settings.desktop) -> host launcher ft-input-settings
```
The launcher gives podman the real `XDG_RUNTIME_DIR` and user bus, and gives the app the session's Wayland socket (absolute path) and bus. Without the real user bus, podman fails with `crun: ... cgroup.procs: Permission denied`, because the Frametop session runs on a private bus from `dbus-run-session`.
## Screens and layout (Frametop Display Settings, ft-layout)
When the desktop starts, its screens arrange themselves around where you're facing. Move them by hand any time; put them back with **Meta+Shift+R** in the desktop, the **Reset Screen Layout** menu entry, **Arrange now** in the app, or a mouse button mapped to **Reset desktop screen layout** (Frametop Input Settings → Buttons).
**Frametop Display Settings** (Plasma menu, Settings; Kirigami app in the `dev` container, like Frametop Input Settings):
- **Screens**: add and remove screens; each has a resolution (presets from 1080p to 4K, ultrawide, super ultrawide, portrait, or custom), a width in VR in metres (0.5 to 6), a scale, **Curved**, and **Taskbar here**. Resolution, width, and curve apply at once; adding or removing a screen when the desktop starts again (the app offers the restart).
- **Layout**: curved around you (screens hinged edge to edge like monitors on a desk, each turned to face you) or a flat wall, with rows, distance, gap, and height; or **Save current arrangement** to keep where you put the screens by hand (and their sizes). A preview shows it from above and from the front. **When the desktop starts** turns auto-arrange on or off.
`layout/ft-layout` does the work (Python standard library, on the host):
```
layout/ft-layout apply # arrange every screen (instant with ft-screens)
layout/ft-layout capture # save the current arrangement (and sizes) as the layout
layout/ft-layout plan # the arrangement as JSON (no VR needed)
layout/ft-layout scale # per-screen scale, side-by-side positions, taskbar screen to KWin
layout/ft-layout apply # arrange every screen
layout/ft-layout capture # save the current arrangement and sizes as the layout
layout/ft-layout plan # print the arrangement as JSON (no VR needed)
layout/ft-layout scale # per-screen scale, positions, and taskbar screen, to KWin
layout/ft-layout toggle # hide or show all screens
display-settings/install.sh # menu entries and the Meta+Shift+R / Meta+Shift+H shortcuts
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
```
The layout is saved in `~/.config/frametop-layout.json`, relative to your head when it's applied. `/tmp/frametop-layout.log` has the startup run. With `BACKEND=gamescope`, `ft-layout` floats each dashboard panel with `vrcmd --dock-overlay` and the pointer helper carries it into place (`place`), since SteamVR's dashboard owns those panels.
The layout is stored relative to your head when it's applied. `/tmp/frametop-layout.log` has the run from the last desktop start.
## Remote desktop (VNC)
## Remote desktop over VNC
With `REMOTE=1` in the config (`desktops.sh remote on`), the session also serves the VR desktop over VNC. Use RealVNC Viewer or macOS Screen Sharing.
With `REMOTE=1` in the config (`desktops.sh remote on`), the desktop is also served over VNC, for RealVNC Viewer or macOS Screen Sharing. `desktops.sh remote info` prints the address and password.
- Address: the Frame's tailnet name or address, port 5900 (`desktops.sh remote info` prints it). It listens on the tailnet address only, not the LAN. It needs Tailscale on the Frame ([deck-tailscale](https://github.com/tailscale-dev/deck-tailscale)).
- Password: in `~/.config/frametop-remote/vnc-password` on the Frame. VNC limits it to 8 characters. `desktops.sh remote info` prints it.
- Encryption: VNC auth has none of its own, so viewers warn about an unencrypted connection. The traffic is still encrypted by the tailnet (WireGuard), which is why it listens only there.
- How it works: no VNC server can capture KWin on SteamOS. `krfb` needs `xdg-desktop-portal-kde`, which SteamOS lacks, and `wayvnc` is wlroots-only. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390` (never reachable from outside). `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a full-screen FreeRDP client connected to it, and serves that over VNC. Everything runs in the `dev` container. The extra hop adds some latency.
- Security trade-off: with `REMOTE=1` the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`, so any app inside the Frametop desktop can capture its screen or inject input. This applies to that nested session only, not the stock desktop.
- To rotate the password, delete `~/.config/frametop-remote/` on the Frame and restart the desktop.
- Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session, not the VR desktop.
- Check what a viewer sees: `import -window root -display :20 /tmp/vnc.png` in the container.
It listens on port 5900 on the Frame's Tailscale address only, not the LAN, so it needs Tailscale on the Frame ([deck-tailscale](https://github.com/tailscale-dev/deck-tailscale)). VNC authentication has no encryption of its own, so viewers warn about it, but the tailnet encrypts the traffic. The password is in `~/.config/frametop-remote/vnc-password` and VNC limits it to 8 characters. To change it, delete that folder and restart the desktop.
No VNC server can capture KWin on SteamOS directly: `krfb` needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship, and `wayvnc` only works with wlroots compositors. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390`, and `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a full-screen FreeRDP client inside it and serves that. Both run in the `dev` container, and the extra hop adds a little latency.
With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`, so any app in the Frametop desktop could capture its screen or inject input. This applies only to that desktop, not the stock one. Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session rather than showing the VR desktop.
## Limits
- Keyboard typing into the screens is wired (relay → ft-screens) but not yet tested with a real keyboard.
- There's no pin to the head (HUD) yet, and no controller button to show hidden screens (a mapped mouse or keyboard button works).
- The KWin cursor isn't drawn on the screens (KWin draws it as a host cursor, which ft-screens ignores); the 3D mouse's dot and SteamVR's laser dot show where you point.
- With `BACKEND=gamescope`: one resolution for all screens, at most 1920 × 1080 worth of pixels; arranging borrows the pointer for a few seconds; a SteamVR update that moves the floating window's grab bar would break arranging (`LAYOUT_GRAB_OFFSET`, the helper's `grabprobe`).
- There's no way yet to pin a screen to your head like a HUD.
- A controller button can't show hidden screens; a mapped mouse or keyboard button can.
- KWin's cursor isn't drawn on the screens, because KWin draws it as a host cursor, which ft-screens doesn't render. The 3D mouse's dot and SteamVR's laser dot show where you're pointing.
- The old gamescope backend (`BACKEND=gamescope`) still works, but it gives every screen the same resolution, at most 1920×1080 pixels' worth, and arranging screens borrows the pointer for a few seconds.
+16 -10
View File
@@ -21,8 +21,8 @@ It creates the `dev` distrobox if it's missing and installs the packages listed
On stock SteamOS 0.3.0, Bluetooth LE mice and keyboards that use private addresses, such as the Swiftpoint Z3, pair but never reconnect. After the device sleeps or the Frame reboots, it stays disconnected. Two separate problems cause this:
1. **BlueZ 5.79 never sets the `ADDRESS_RESOLUTION` device flag.** Without it, kernel 6.18 doesn't load the device's identity key into the Bluetooth controller, so the controller can't recognize the device's rotating private address and ignores it. This is fixed upstream in BlueZ commit `f1fb4f95f4` ("core: Fix not resolving addresses"), but SteamOS doesn't ship that fix yet.
2. **Some devices only know the Frame's public address.** The Z3 doesn't accept the Frame's identity key during pairing, so it can only reconnect to the Frame's fixed public address. SteamOS sets `Privacy = device` in `/etc/bluetooth/main.conf`, and bluetoothd turns privacy back on at every start.
1. BlueZ 5.79 never sets the `ADDRESS_RESOLUTION` device flag. Without it, kernel 6.18 doesn't load the device's identity key into the Bluetooth controller, so the controller can't recognize the device's rotating private address and ignores it. This is fixed upstream in BlueZ commit `f1fb4f95f4` ("core: Fix not resolving addresses"), but SteamOS doesn't ship that fix yet.
2. Some devices only know the Frame's public address. The Z3 doesn't accept the Frame's identity key during pairing, so it can only reconnect to the Frame's fixed public address. SteamOS sets `Privacy = device` in `/etc/bluetooth/main.conf`, and bluetoothd turns privacy back on at every start.
Both settings reset whenever bluetoothd restarts or the Frame reboots, so they have to be reapplied every time. That's what this setup installs.
@@ -33,14 +33,14 @@ Both settings reset whenever bluetoothd restarts or the Frame reboots, so they h
| `/etc/steamframe/bt-fixups.sh` | Turns controller privacy off, then sets the `ADDRESS_RESOLUTION` flags (`0x6`) on every bonded LE device that has an identity key |
| `/etc/systemd/system/steamframe-bt-fixups.service` | Runs the script after every Bluetooth start |
The service runs **after** Bluetooth has started and never makes Bluetooth wait for it. That matters: SteamOS's `set-bluetooth-mac-address.service` gives the Bluetooth chip its address, and it needs `bluetooth.service` to finish starting first. SteamOS's own files, including `main.conf`, are left untouched, so system updates won't conflict.
The service runs after Bluetooth has started and never makes Bluetooth wait for it, because SteamOS's `set-bluetooth-mac-address.service`, which gives the Bluetooth chip its address, needs `bluetooth.service` to finish starting first. SteamOS's own files, including `main.conf`, are left untouched, so system updates won't conflict.
### Step 1: have a password for sudo
The install writes to `/etc`, so it needs `sudo` and the `steamos` user's password.
- **On the headset:** `sudo` asks for the password in the terminal. If you've never set one, run `passwd` first.
- **From a PC over SSH:** there's no terminal on the Frame to ask in, so put the password in a `.env` file at the repo root:
- On the headset, `sudo` asks for the password in the terminal. If you've never set one, run `passwd` first.
- From a PC over SSH, there's no terminal on the Frame to ask in, so put the password in a `.env` file at the repo root:
```
steamos_root_pwd="your-password"
@@ -58,13 +58,13 @@ It copies the files above into place (`/etc` survives SteamOS updates) and enabl
### Step 3: pair your mouse or keyboard
Pair it the normal way: in Steam, **Settings → Bluetooth**. Then apply the fixes to the new device, either way:
Pair it the normal way, in Steam under Settings → Bluetooth. Then apply the fixes to the new device:
```
setup/bluetooth/install.sh run
```
or, on the Frame, **Frametop Input Settings → Bluetooth → Apply Bluetooth fixes** (it asks for the password). Do this once per newly paired device. From then on, the service applies the fixes automatically at every boot.
or use Apply Bluetooth fixes on the Bluetooth page of Frametop Input Settings, which asks for the password. Do this once per newly paired device. From then on, the service applies the fixes automatically at every boot.
If a device won't pair at all, turn the fixes on first (`run`), then pair again. With privacy on, some devices, the Z3 included, fail to finish connecting.
@@ -84,9 +84,13 @@ The real test is to reboot the Frame, then move or click the device. It should r
### Troubleshooting
**The device paired but won't reconnect.** Run `setup/bluetooth/install.sh run` again, then wake the device. Check that its address appears in the service log. The script only flags devices that have an identity key: look for `[IdentityResolvingKey]` in `/var/lib/bluetooth/<controller>/<device>/info` (readable as root).
#### The device paired but won't reconnect
**No Bluetooth at all after a boot.** Check the controller:
Run `setup/bluetooth/install.sh run` again, then wake the device. Check that its address appears in the service log. The script only flags devices that have an identity key: look for `[IdentityResolvingKey]` in `/var/lib/bluetooth/<controller>/<device>/info` (readable as root).
#### No Bluetooth at all after a boot
Check the controller:
```
hciconfig hci0 | head -3 # healthy: "BD Address: 90:82:C3:..." and "UP RUNNING"
@@ -101,7 +105,9 @@ sudo systemctl restart steamframe-bt-fixups.service
This happened once, while an earlier version of the fix made Bluetooth wait on it. Never add an `ExecStartPost` or anything else that holds up `bluetooth.service`.
**The mouse connects but does nothing in VR.** That's not Bluetooth. SteamVR only reads input devices that existed when it started. Frametop's input relay (`README.md`) handles this with permanent virtual devices.
#### The mouse connects but does nothing in VR
That's not Bluetooth. SteamVR only reads input devices that existed when it started. Frametop's input relay handles this with permanent virtual devices (see `docs/reference.md`).
### Uninstall