Now a stylus device to not collide with left hand

This commit is contained in:
Juan Arteaga Carmona committed 2026-10-04 17:30:29 +02:00
1 parent 12416e005d
commit 7c9029e4ca
9 files changed
+615 -17

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ Context for AI coding sessions on this repo. Read it before changing anything.
`mouselaser` is an experimental, **AI-written ("vibecoded")** SteamVR driver for the Steam Frame. It adds a virtual controller whose pose is aimed by a physical mouse, so the mouse can drive SteamVR's laser pointer across all overlays. Start with:
- [README.md](README.md): usage, settings, recovery.
- [docs/development-log.md](docs/development-log.md): where this came from, what was verified, what's next.
- [docs/steamvr-primer.md](docs/steamvr-primer.md): SteamVR and Steam Frame concepts for newcomers.
- [docs/how-it-works.md](docs/how-it-works.md): internals.
- [docs/steam-frame-background.md](docs/steam-frame-background.md): the Frame's display and input stack, and why this approach was chosen.
+8 -4
View File
@@ -36,7 +36,9 @@ The compositor then treats it like any other laser-pointing hand.
| Back side button (BTN_SIDE) | Normal | SteamVR's laser **Back**, the same action as the right Frame controller's B button. Works in Steam UI menus; it does not reach the KDE desktop (see Known limitations). |
| Wheel (and tilt wheel) | Normal | Acts like the controller **thumbstick**. One notch moves one item in Steam UI lists, and spinning the wheel scrolls continuously. It also scrolls the overlay under the laser. |
When laser mode turns on, the ray starts where you're looking. When it's off, the virtual device reports itself as disconnected, so your real controllers keep the laser.
When laser mode turns on, the ray starts where you're looking. When it's off, the virtual device stays connected but parks its ray pointing at the sky with every button released, so your real controllers keep the laser.
Since 0.5.0 the virtual device registers as a **stylus** (`/user/stylus`), not as a hand, so it doesn't take your real left or right controller's slot. This is untested on the headset; see the development log.
## Requirements
- A Steam Frame (aarch64, SteamOS VR variant) with SteamVR at `/opt/steamvr`.
@@ -56,7 +58,7 @@ cmake -S . -B build -G Ninja && cmake --build build
Check that it loaded:
```sh
grep -a 'mouselaser:' ~/.local/share/Steam/logs/vrserver.txt | tail
# expect: "version 0.4.0-experimental", "activated as device N, role 1", "using /dev/input/eventX (<your mouse>)"
# expect: "version 0.5.0-experimental", "activated as device N, role 5", "using /dev/input/eventX (<your mouse>)"
```
### Optional: offline wheel test
@@ -79,7 +81,7 @@ Add any of these to `~/.config/openvr/config/steamvr.vrsettings` under a `"drive
| `sensitivity` | `0.05` | Degrees of ray rotation per mouse count. |
| `toggleButton` | `276` | evdev key code of the toggle: 276 = BTN_EXTRA (forward), 275 = BTN_SIDE (back). |
| `backButton` | `275` | evdev key code that sends the laser Back action while laser mode is on (275 = BTN_SIDE). `-1` disables it. |
| `role` | `1` | 1 = left hand, 2 = right hand. |
| `role` | `5` | 5 = stylus (its own `/user/stylus` path, stays connected, doesn't collide with real controllers). 1 = left hand, 2 = right hand: these share the slot with that real controller and disconnect while laser mode is off (pre-0.5.0 behaviour). |
| `deviceNameFilter` | `""` | Substring of the evdev mouse name. Empty means the first device with REL_X/REL_Y and BTN_LEFT. |
| `originOffsetY` | `-0.08` | Ray origin height relative to the HMD, in metres. |
| `invertY` | `false` | Invert vertical aim. |
@@ -117,7 +119,8 @@ If SteamVR won't come up properly after installing, there are four options:
## Known limitations
- The ray starts just below your head (`originOffsetY`) and points where you aim, so you see the beam almost end-on. Expect to rely mostly on the cursor dot on overlays.
- If a **real controller holds the same hand role** its own laser pointer seems to stop working.
- With `role` 1 or 2, a **real controller holding the same hand role** loses its laser. The default stylus role (5) is meant to avoid this, but that hasn't been tested on the headset yet.
- The driver logs some SteamVR events (`event N device M`) as diagnostics while the stylus role is being tested.
- **Wheel feel is approximate.** A wheel isn't a stick: it only sends notches. Smooth mode only *simulates* a held stick, so it still won't feel exactly like a real thumbstick. Tune it with the `wheel*` settings.
- Settings are only read when SteamVR starts.
- **Back doesn't reach the KDE desktop.** SteamVR's laser Back goes to SteamVR overlays. Steam's UI handles it, but gamescope doesn't pass it on to the windows it hosts (a real controller's B behaves the same). With laser mode off, the side buttons don't work in KDE either, because the nested KWin's X11 backend drops X buttons 8 and up. See [docs/steam-frame-background.md](docs/steam-frame-background.md).
@@ -126,6 +129,7 @@ If SteamVR won't come up properly after installing, there are four options:
- `Driver mouselaser has no suitable devices`: logged at load time, presumably because the driver provides no HMD. The device is added right after.
## Docs
- [docs/steamvr-primer.md](docs/steamvr-primer.md): **start here if SteamVR is new to you.** SteamVR internals (drivers, devices, roles, input bindings, the compositor's laser) and the Steam Frame specifics this project touches.
- [docs/how-it-works.md](docs/how-it-works.md): the driver internals, the pose math and the bindings.
- [docs/steam-frame-background.md](docs/steam-frame-background.md): what we learned about the Frame's display and input stack that led here.
- [docs/development-log.md](docs/development-log.md): where this came from, what was verified, and what is next.
+9
View File
@@ -58,6 +58,15 @@ The broader notes from that session (hardware, display pipeline, KDE scale, ultr
14. **Back button, 0.4.0** (2026-10-04). The mouse's back side button (BTN_SIDE, setting `backButton`) is now bound to the compositor's `/actions/lasermouse/in/back`. That is the same action as the right Frame controller's B; right A is `home`. The owner reports Back works in the Steam UI and not on the KDE overlay. Investigating KDE separately (see `steam-frame-background.md`, input path) showed two causes: gamescope doesn't forward SteamVR Back to its windows, and KWin's X11 nested backend drops X buttons 8 and up even with laser mode off. The driver can't fix either one, and nothing was added for it.
15. **Stylus role, 0.5.0** (2026-10-04). Untested on the headset.
- Problems: (a) pressing the toggle sometimes grabbed the mouse but the laser flashed briefly or never showed, leaving neither the KDE cursor nor the laser; (b) the real left controller's laser conflicted with the virtual device.
- An earlier session tried 0.4.1 (delay before pressing the grip), 0.4.2 (stay connected) and 0.4.3 (parked valid pose while off), all as the **left hand**. The owner reverted them from git. Its capture finding is kept: a device whose pose goes invalid or disconnects is dropped as the compositor's laser pointer until a new "user interaction started", so a quick off/on fails. Staying connected as the left hand caused conflict (b).
- Change: `role` now defaults to **Stylus (5)**, whose user path is `/user/stylus` and which is never a hand. The bindings repeat every hand entry for `/user/stylus` (the HMD's own binding shows the compositor laser accepts pose sources that aren't hands). With a role other than a hand, the device stays connected with a valid pose, parked straight up while off. Hand roles keep the old disconnect-while-off behaviour. The profile's binding UI mode is `single_device`.
- Added logging of SteamVR events about our device, plus interaction, role and dashboard events, to look for a "laser is up" signal. Grabbing the mouse only once the laser is up (or releasing the grab automatically) needs that signal; nothing is gated yet.
- Open question: whether vrcompositor's laser actually picks a `/user/stylus` pointer, and whether "quick mouse" via `/user/stylus/input/grip` brings the laser up.
16. **SteamVR primer** (2026-10-04). Added `docs/steamvr-primer.md`, an accessible overview for developers new to SteamVR. It gathers the parent exploration notes (01–09), this repo's docs and new checks on the headset. New facts recorded there: `frame_hmd` and `frame_controller` are resource-only, and the `cv` driver adds the headset and both controllers. Also the full list of compositor action sets, the controller roles and their user paths, and the `vrcmd` options.
## State at hand-off
- The driver is registered from this repo (`<repo>/driver/mouselaser`). The old prototype registration has been removed.
- Nothing is vendored. The build depends on SteamVR's bundled header.
+10 -2
View File
@@ -13,7 +13,7 @@ vrserver
└─ RunFrame() ── pose + input components, every vrserver frame
vrcompositor
└─ uses resources/input/vrcompositor_bindings_mouselaser.json (via the profile's default_bindings)
/actions/lasermouse/in/Pointer ← /user/hand/{left,right}/pose/raw → the laser
/actions/lasermouse/in/Pointer ← /user/stylus/pose/raw (or /user/hand/{left,right} for hand roles) → the laser
```
### Why `alwaysActivate: true`
@@ -34,7 +34,15 @@ vrcompositor
- Mouse deltas: `yaw -= dx·k`, `pitch -= dy·k` (sign flipped if `invertY`), where `k = sensitivity·π/180`. Pitch is clamped to ±89°.
- Orientation is **world-locked**: `q = yaw(Y) · pitch(X)` = `(cy·cp, cy·sp, sy·cp, −sy·sp)` with half-angle sines and cosines. OpenVR controllers point along −Z, so this aims the ray.
- Position = HMD position + `(0, originOffsetY, 0)` in world space.
- When inactive: `deviceIsConnected = false` and `poseIsValid = false`, so the compositor falls back to the real controllers.
- When inactive, it depends on `role`:
- **stylus (5, default since 0.5.0):** the device stays connected with a valid pose, but its ray is parked pointing straight up (yaw 0, pitch 90°) and every button is released. This matters because a device that goes invalid or disconnects is dropped as the laser pointer until SteamVR sees a new "user interaction". That took about 10 s of quiet in earlier captures, so a quick off/on left the mouse grabbed with no laser.
- **hand (1/2):** `deviceIsConnected = false` and `poseIsValid = false`, so the real controller of that hand gets its slot back. A connected device with a hand role would compete with it.
### Role and user path
SteamVR gives each controller a `/user/...` path based on its role. Hand roles share `/user/hand/left|right` with the real controllers. `TrackedControllerRole_Stylus` (5) maps to `/user/stylus`, which `IsRoleAllowedAsHand()` rules out of hand selection. Treadmill (4) maps to `/user/treadmill`. OptOut (3) has no path unless a tracker role is assigned in SteamVR. The compositor's laser accepts pose sources that aren't hands (the Frame HMD binds `/user/head/pose/raw` to `lasermouse/in/pointer`), so the bindings repeat every hand entry for `/user/stylus`.
### Event diagnostics
The provider logs SteamVR events about our device, plus user-interaction, role-change and dashboard events (`event N device M (active|off)`). There is no driver-side signal for "the laser is up", and these events are what we're watching to look for one.
### RunFrame (inputs)
| component | driven by | bound to (compositor) |
+459
View File
@@ -0,0 +1,459 @@
# SteamVR and the Steam Frame: a primer
This primer is for a developer who is comfortable with Linux and C++ but has never looked inside SteamVR. It explains the parts of SteamVR this project touches, and what is specific to the Steam Frame, in the order you need them. It brings together everything learned while building `mouselaser`. The deeper notes it draws on are listed at the end.
> **How sure are we?** SteamVR is closed source. Facts here come from three places: Valve's public OpenVR header (`openvr_driver.h`), the JSON and settings files SteamVR ships in `/opt/steamvr`, and what we observed in logs and experiments on one headset. Where something is a guess, it says so. Everything was observed on SteamOS 0.4.2 (VR), build 20260928.6175029, in October 2026. Valve can change any of it in an update.
## Contents
1. [The 30-second picture](#1-the-30-second-picture)
2. [The Steam Frame as a Linux computer](#2-the-steam-frame-as-a-linux-computer)
3. [How a session starts, and why restarting SteamVR kills your terminal](#3-how-a-session-starts-and-why-restarting-steamvr-kills-your-terminal)
4. [SteamVR's processes](#4-steamvrs-processes)
5. [Drivers](#5-drivers)
6. [Tracked devices, poses and roles](#6-tracked-devices-poses-and-roles)
7. [SteamVR Input: from button to action](#7-steamvr-input-from-button-to-action)
8. [The compositor, overlays and the laser pointer](#8-the-compositor-overlays-and-the-laser-pointer)
9. [The display chain: how the KDE desktop gets into the headset](#9-the-display-chain-how-the-kde-desktop-gets-into-the-headset)
10. [The input chain: where a mouse event really goes](#10-the-input-chain-where-a-mouse-event-really-goes)
11. [How mouselaser fits in](#11-how-mouselaser-fits-in)
12. [Lessons learned the hard way](#12-lessons-learned-the-hard-way)
13. [Cheat sheet: files, logs and tools](#13-cheat-sheet-files-logs-and-tools)
14. [Glossary](#14-glossary)
15. [Open questions](#15-open-questions)
---
## 1. The 30-second picture
On a PC, SteamVR is an app you start on top of your desktop. **On the Steam Frame it's the other way round: SteamVR *is* the session.** It owns the display, and everything else, including Steam's UI and the KDE desktop, is a flat panel ("overlay") that SteamVR draws floating in 3D.
```
┌──────────────────────── SteamVR ─────────────────────────┐
tracking ───► │ vrserver loads drivers; knows every device and its pose │
(cameras) │ ├─ cv driver → headset + Frame controllers │
│ └─ mouselaser → our virtual "mouse laser" device │
│ │
│ vrcompositor draws the 3D scene + all overlays on the │
│ panel; owns the laser pointer │
└────────────▲────────────────────────────▲─────────────────┘
│ overlays │ overlays
Steam UI gamescope (one overlay per app)
└─ KDE desktop (nested)
```
Two facts explain almost everything in this project:
- **The laser pointer belongs to SteamVR's compositor.** It can point at any overlay.
- **The physical mouse belongs to gamescope.** It can only move inside the one overlay gamescope has focused.
`mouselaser` bridges the two. It pretends to be a SteamVR controller whose aim comes from the mouse.
---
## 2. The Steam Frame as a Linux computer
| | |
|---|---|
| SoC | Qualcomm Snapdragon 8 Gen 3 (SM8650), 8 ARM cores, **aarch64**. Build everything for `linuxarm64`. |
| GPU | Adreno 750. Vulkan via Mesa Turnip. KWin's OpenGL goes through **zink** (GL on Vulkan). |
| RAM | 15 GiB plus zram swap. |
| Display | One DSI panel, `2×2160×2160` (one square per eye), 108/120/144 Hz. |
| OS | SteamOS `VARIANT_ID=vr`, Arch-based, internal codename **"Deckard"** (you'll see it in file names like `deckard-nested-desktop.desktop`). |
| User | `steamos`, in the `input` group, so it can read `/dev/input/event*` directly. |
| Remote access | `xrdp` runs, and SSH works over Wi-Fi or USB networking (`usb0`). Keep one of them ready when testing drivers. |
**The OS image is read-only.** `/usr` comes from an A/B system image. Anything you change there is lost on the next update, and changing it needs root plus disabling read-only mode. That's why this project lives entirely in user space: a driver registered from your home folder, and settings in `~/.config/openvr`.
---
## 3. How a session starts, and why restarting SteamVR kills your terminal
```
SDDM (autologin)
└─ gamescope-session.service (systemd --user; PartOf=steamvr.service)
└─ gamescope --backend openvr ... ← talks to SteamVR as an overlay app
└─ Steam -deckard -gamepadui -vrgamepadui ...
└─ "Desktop" entry → steamos-nested-desktop
└─ kwin_wayland (nested) → Plasma → Konsole → this Claude session
```
- `gamescope-session.service` is declared `PartOf=steamvr.service`. **If SteamVR restarts, systemd restarts gamescope too, and with it Steam and the whole nested desktop.** Any terminal running inside the desktop dies. That's why we always reboot instead of "just restarting SteamVR".
- The nested desktop gets its own runtime directory: `XDG_RUNTIME_DIR=/run/user/1000/nested_plasma`. Commands that need the real user session, like `systemctl --user`, need `XDG_RUNTIME_DIR=/run/user/1000` in front of them.
- The desktop's size (1280×800) is hardcoded in `/usr/bin/steamos-nested-desktop`. Gamescope then stretches its window to 1920×1080 anyway (see [section 9](#9-the-display-chain-how-the-kde-desktop-gets-into-the-headset)).
---
## 4. SteamVR's processes
SteamVR lives in `/opt/steamvr`. Binaries are in `bin/linuxarm64/`.
| Process | What it does | Why you care |
|---|---|---|
| `vrserver` | The core. Loads **drivers**, keeps the list of tracked devices and their poses, runs the input system, stores settings. | **Our driver's `.so` is loaded into this process.** If our code crashes, vrserver crashes, and SteamVR goes with it. |
| `vrcompositor` | Renders the final image for each eye: the 3D scene, every overlay, and the laser pointer. Owns the panel. | It decides where the laser points and which overlay gets clicks. |
| `vrdashboard`, `vrwebhelper` | The SteamVR dashboard UI. It's a web app (Chromium-based helper) whose JS is in `resources/webinterface/dashboard/`. | Reading that JS is how we found how "Manage Add-ons" and the mailbox work. |
| `vrmonitor`, `vrstartup` | Status window and launcher. | Rarely matters on the Frame. |
| `vrcmd` | A command-line client. `vrcmd --help` lists options such as `--events`, `--overlays`, `--pollposes` and `--info`. | Handy for watching what SteamVR does. Some options *change* state (`--set-settings-*`, `--send-vrevent`); stick to the read-only ones unless you mean it. |
| `XRService`, `dsp_service`, `eyetracking` | Frame-specific: computer-vision tracking and eye tracking, partly on the Hexagon DSP. | They feed the `cv` driver. |
| `v4l2cam`, `proxmicmute` | Passthrough cameras; mic mute from the proximity sensor. | Not relevant here. |
Each process has a log in `~/.local/share/Steam/logs/`: `vrserver.txt`, `vrcompositor.txt` and so on. The previous run is kept as `*.previous.txt`.
---
## 5. Drivers
### What a driver is
A SteamVR **driver** is a shared library (`.so`) that `vrserver` loads. Its job is to **add tracked devices** (headsets, controllers, trackers) and to keep telling SteamVR where they are and what their buttons are doing. Drivers don't draw anything and don't talk to apps. Apps only ever see devices, poses and actions.
A driver folder looks like this (ours):
```
driver/mouselaser/
├── driver.vrdrivermanifest ← "who am I" (JSON)
├── bin/linuxarm64/driver_mouselaser.so ← the code; folder name = platform
└── resources/
├── settings/default.vrsettings ← default values of our settings section
├── input/ ← input profile + default bindings (section 7)
├── localization/ ← names shown in SteamVR's binding UI
└── rendermodels/ ← 3D model drawn for the device
```
### The manifest fields that matter
| Field | Meaning |
|---|---|
| `name` | The driver's name. It decides the settings section (`driver_<name>`) and the `{name}` prefix used in resource paths like `{mouselaser}/input/...`. |
| `alwaysActivate` | Normally SteamVR loads only **one** "HMD driver" (the one that finds a headset). Drivers with `alwaysActivate: true` load **in addition**, which is the normal route for add-on devices like ours. |
| `resourceOnly` | `true` means "no code, just resources". It supplies profiles, bindings and models for devices that another driver creates. |
| `hmd_presence` | USB IDs that mean "my headset is plugged in". |
### The Frame's own drivers (`/opt/steamvr/drivers/`)
- **`cv`** is the only one that matters at runtime. Its `driver_cv.so` adds the **headset and both Frame controllers** (the log shows `Driver 'cv' started activation of tracked device ...` three times). It gets tracking from `XRService`. `hmd_presence: 28DE.2300` (28DE is Valve's USB vendor ID).
- **`frame_hmd`** and **`frame_controller`** are **resource-only**. They supply the input profiles, bindings and models for the devices `cv` creates. **This is where to look to see how Valve binds the Frame hardware.**
- `prism` (display redirect, disabled in settings), and `htc`, `indexcontroller`, `indexhmd` and `oculus`, which are resources for PC headsets that SteamVR ships everywhere.
### How SteamVR finds a driver
- Built-in drivers live in `/opt/steamvr/drivers/`.
- Extra drivers are listed in `~/.config/openvr/openvrpaths.vrpath` under `external_drivers`. You edit that list with `vrpathreg adddriver <folder>` / `removedriver <folder>` (`/opt/steamvr/bin/linuxarm64/vrpathreg`). Our `install.sh` does this after making a backup.
- The SteamVR dashboard's developer **Manage Add-ons** page lists every driver that isn't resource-only. Its toggle writes `driver_<name>.enable` to `steamvr.vrsettings`, which our driver honours as a kill switch.
### A driver's life cycle (OpenVR driver API)
```
vrserver starts
└─ dlopen(driver_mouselaser.so)
└─ HmdDriverFactory("IServerTrackedDeviceProvider_004") → returns our provider
└─ provider.Init(context) read settings, then:
└─ VRServerDriverHost()->TrackedDeviceAdded("mouselaser-0", Controller, device)
└─ device.Activate(index) set properties, create input components
every frame (~ display rate):
provider.RunFrame() push pose + button states, drain events
shutdown:
device.Deactivate(), provider.Cleanup()
```
- **Two interfaces:** the *provider* (`IServerTrackedDeviceProvider`) is the driver as a whole; each *device* is an `ITrackedDeviceServerDriver`.
- **Version strings** like `IServerTrackedDeviceProvider_004` are how SteamVR checks the driver was built against a compatible API. The Frame's SteamVR is API 2.1. We compile against the header SteamVR itself ships: `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr_driver.h`.
- **Everything is read once at startup.** Settings, bindings and the `.so` itself. A code or binding change needs a **rebuild and a reboot** on the Frame (see section 3).
- `RunFrame` runs on vrserver's main thread. Blocking there stalls SteamVR, so we read the mouse on our own thread and hand values over through atomics.
### Settings
- Every driver gets a section named `driver_<name>` in SteamVR's settings.
- Defaults come from the driver's `resources/settings/default.vrsettings`. User overrides go in `~/.config/openvr/config/steamvr.vrsettings`. SteamVR's own defaults are in `/opt/steamvr/resources/settings/default.vrsettings` (for example the laser's `laserLength`, `cursorScale` and `laserMouseDebugging`).
- A driver reads them with `VRSettings()->GetInt32/GetFloat/GetBool/GetString(section, key, &err)`.
---
## 6. Tracked devices, poses and roles
### Devices and indices
Every device gets an **index** from 0 to 63. **Index 0 is always the HMD.** Ours usually lands on index 1 because it's added before the controllers; the log says `activated as device 1`. Index `4294967295` (`0xFFFFFFFF`, `k_unTrackedDeviceIndexInvalid`) in an event means "no particular device".
Each device has a **class**:
| Class | Example |
|---|---|
| `HMD` (1) | The Frame headset |
| `Controller` (2) | Frame controllers, **and our virtual device** |
| `GenericTracker` (3) | Vive trackers on feet or waist |
| `TrackingReference` (4) | Lighthouse base stations (not used by the Frame, which tracks with cameras) |
| `DisplayRedirect` (5) | Devices that show video elsewhere |
### Properties
A device describes itself with **properties**: key/value pairs set in `Activate()`. The ones we set:
- `Prop_ControllerType_String = "mouselaser"`: which input profile and bindings apply (section 7).
- `Prop_InputProfilePath_String`: where the input profile JSON is.
- `Prop_RenderModelName_String`: the 3D model to draw. Ours is an invisible 0.1 mm triangle; without it SteamVR draws a generic controller at your face.
- `Prop_ControllerRoleHint_Int32`: the device's **role** (below).
- Model number and manufacturer, shown in SteamVR's UI.
### Poses
A **pose** is "where the device is and which way it points". The driver sends it with `TrackedDevicePoseUpdated()` as a `DriverPose_t`:
| Field | Meaning |
|---|---|
| `vecPosition` | Position in metres. |
| `qRotation` | Orientation as a quaternion `(w, x, y, z)`. |
| `qWorldFromDriverRotation`, `vecWorldFromDriverTranslation` | Transform from the driver's own space into SteamVR's world. We use identity because we compute in world space directly. |
| `deviceIsConnected` | Whether the device is there at all. |
| `poseIsValid` | Whether the position can be trusted right now. |
| `result` | Tracking state, for example `TrackingResult_Running_OK` or `Running_OutOfRange`. |
**Coordinates:** right-handed, metres, **+Y is up**, and **a controller points along its −Z axis**. So to aim a ray, you rotate −Z towards the target. We build the rotation from a yaw (around Y) and a pitch (around X), driven by mouse movement.
You can read other devices' poses from a driver with `VRServerDriverHost()->GetRawTrackedDevicePoses()`. We read the HMD's (index 0) so the ray starts at your head.
### Roles: which "hand" a device is
A controller declares a **role**, and the role decides its **user path**. User paths are how the input system (section 7) refers to "the left hand" without caring which physical device that is.
| Role (`ETrackedControllerRole`) | Value | User path | Notes |
|---|---|---|---|
| `LeftHand` | 1 | `/user/hand/left` | Only one device holds this slot at a time. |
| `RightHand` | 2 | `/user/hand/right` | Same. |
| `OptOut` | 3 | none, unless you assign a tracker role (e.g. `/user/waist`) in SteamVR | For generic trackers. |
| `Treadmill` | 4 | `/user/treadmill` | Locomotion devices. Never treated as a hand. |
| `Stylus` | 5 | `/user/stylus` | Pen-like pointers (Logitech's VR Ink stylus). Never treated as a hand. |
`IsRoleAllowedAsHand()` in the header returns true only for Invalid, LeftHand and RightHand. Other user paths SteamVR knows about include `/user/head`, `/user/hand/secondary`, `/user/keyboard`, `/user/waist`, `/user/foot/...`, `/user/knee/...` and the Vive tracker role paths.
**Why this mattered for us:** up to 0.4.x, `mouselaser` registered as the **left hand**. Whenever it was connected it competed with your real left controller for `/user/hand/left`, and one of them lost its laser. 0.5.0 registers as a **stylus**, which has its own slot.
### Activity events
SteamVR tracks whether each device is in use and sends events about it. Ones we've seen and care about:
| Event | Id | Meaning |
|---|---|---|
| `TrackedDeviceActivated` / `Deactivated` | 100 / 101 | A device came up or went away. |
| `TrackedDeviceUserInteractionStarted` / `Ended` | 103 / 104 | The user started or stopped using a device (moving it, pressing buttons). "Ended" fires after a timeout with no activity. |
| `TrackedDeviceRoleChanged` | 108 | Hand assignments changed. |
| `PropertyChanged` | 111 | A device property changed. Many at startup. |
These matter because **the compositor uses them to decide which device drives the laser** (section 8). A driver can see them with `VRServerDriverHost()->PollNextEvent()`. 0.5.0 logs them as `mouselaser: event N device M`.
---
## 7. SteamVR Input: from button to action
This is the part that takes longest to understand, and the part that matters most to us. **Apps never ask "is the trigger pressed?". They ask "is the *Click* action on?"**, and a **binding** decides which button means Click on each kind of controller.
### The chain
```
driver input profile binding (per app, per controller type) app
────── ───────────── ───────────────────────────────────── ───
component handle ──► /input/trigger ──► /user/hand/left/input/trigger ──► /actions/lasermouse/in/LeftClick
(UpdateBooleanComponent) "type": trigger mode: button, input: click (boolean action)
pose (TrackedDevicePoseUpdated) ──► /user/hand/left/pose/raw ──► /actions/lasermouse/in/Pointer
(pose action)
```
1. **Components.** In `Activate()` the driver creates named inputs, such as `CreateBooleanComponent(c, "/input/trigger/click", &handle)`, then updates them every frame with `UpdateBooleanComponent(handle, value, 0)`. Scalars (`/input/thumbstick/x`) work the same way with floats.
2. **Input profile** (`resources/input/mouselaser_profile.json`). This declares, for the controller type, which sources exist (`/input/trigger`, `/input/a`, `/input/thumbstick`, `/pose/raw`, ...) and what kind each one is (button, trigger, joystick, pose). It also lists **default bindings**, one file per app. `input_bindingui_mode` tells the binding UI whether to show left/right hands (`controller_handed`), a single device (`single_device`) or an HMD (`hmd`).
3. **User path.** The device's role puts its sources under a user path, so `/input/trigger` on a left-hand device becomes `/user/hand/left/input/trigger`. On a stylus it's `/user/stylus/input/trigger`.
4. **Bindings** connect user-path sources to an app's **actions**. A binding has a **mode** that interprets the raw input: `button` (click), `trigger`, `joystick` (position + touch), `scroll` (turn a stick into scroll ticks), and so on.
5. **Actions** are what the app defines in its **action manifest**: named, typed inputs (`boolean`, `vector1/2`, `pose`, `vibration`) grouped in **action sets** (`/actions/<set>/in/<name>`). Each action has a `requirement` (`mandatory`, `suggested`, `optional`).
### Who is "the app" here?
Not just games. **The compositor itself is an input app**, with app key `openvr.component.vrcompositor`. Its actions are in `/opt/steamvr/resources/config/vrcompositor_actions.json`. That's why our driver ships `vrcompositor_bindings_mouselaser.json`: it tells the compositor how our device maps onto the compositor's laser actions.
Other apps you'll see in the logs:
- `steam.client` is Steam's UI. The Frame controllers' binding for it is haptics only, which is why the log line `steam.client (mouselaser) has no configured binding` is harmless.
- `system.generated.*` are apps without their own manifests, which get a generic "legacy" binding.
### The compositor's action sets (the ones we bind)
| Action set | Key actions | What it's for |
|---|---|---|
| `/actions/lasermouse` | `Pointer` (**pose, mandatory**), `LeftClick` (mandatory), `RightClick`, `MiddleClick`, `Back`, `Home`, `LockMousePosition`, `Move*`, `Enter` | The laser pointer itself. |
| `/actions/lasermouse_secondary` | `SwitchLaserHand` | Pulling the trigger on the *other* hand moves the laser to it. |
| `/actions/quickmouse` | `ActivateQuickMouse` | "Squeeze the grip to bring up the laser." |
| `/actions/scroll_discrete`, `/actions/scroll_smooth` | `Scroll` (vector2) | Scrolling the overlay under the laser. |
| `/actions/dualanalog` | `LeftValue`, `RightValue`, touches, clicks | Stick input to overlays. **Steam's UI lists navigate with this**, which is why the wheel drives the virtual thumbstick. |
| `/actions/system` | `ToggleDashboard`, volume, screenshot... | System buttons. |
| `/actions/locomotion`, `/actions/roomsetup`, `/actions/quickrecenter` | | Not used by us. |
### Where bindings come from, and caching
- **Default bindings** ship with the driver and are referenced from the input profile's `default_bindings`.
- If you edit bindings in SteamVR's **Controller Bindings** UI, SteamVR saves your own copy under `~/.local/share/Steam/config/` or `~/.config/openvr/`, and **that copy wins over the default**. If you change our JSON and nothing happens, check for a saved copy. (There was none for `mouselaser` when 0.5.0 was installed.)
- The log confirms loading: `[Workshop] Successfully loaded binding file '.../vrcompositor_bindings_mouselaser.json' for app 'openvr.component.vrcompositor'`.
### Where to learn by example
`/opt/steamvr/drivers/frame_controller/resources/input/` has the real Frame controller profile (`frame_controller_profile.json`) and its compositor bindings. `/opt/steamvr/drivers/frame_hmd/resources/input/` has the headset's. Reading them side by side with ours is the fastest way to understand the format.
---
## 8. The compositor, overlays and the laser pointer
### Overlays
An **overlay** is a flat 2D image that the compositor places in 3D space. Steam's UI, the SteamVR dashboard, notifications and every gamescope window are overlays. Overlays can receive **mouse events** from the compositor: `VREvent_MouseMove`, `MouseButtonDown/Up`, `ScrollDiscrete/Smooth`, focus enter and leave, all in the overlay's own 2D coordinates.
### How the laser works
Every frame, while the laser is up:
1. The compositor reads the **`Pointer` pose** action, from whichever device currently drives the laser.
2. It casts a ray from that pose along −Z and finds the overlay it hits.
3. It draws the beam and a cursor dot, and sends that overlay mouse events at the hit point. `LeftClick`, `RightClick` and the others become button events.
4. For a gamescope overlay, **gamescope turns those events into pointer input** for the X/Wayland windows inside it.
**The compositor doesn't care what the pose comes from.** The Frame headset's own binding feeds `Pointer` from `/user/head/pose/raw` (a head-gaze laser, clicking with the headset's system button). That was the key insight behind this project: no compositor hacking is needed, only a device with a pose the mouse controls.
### When the laser is up, and which device drives it
This is the least documented part. It's all inferred from behaviour, logs and strings in `vrcompositor`:
- **On the Frame, controllers are in "gamepad" mode most of the time**, and the laser is hidden. It comes up when the dashboard or an overlay that wants it is open, or while **quick mouse** is held (squeezing a grip). Our driver holds its virtual grip down while laser mode is on, to keep quick mouse active.
- **The compositor keeps one "pointer device".** It picks it from user activity (the interaction-started events in section 6) and from `SwitchLaserHand`.
- **Observed (0.4.x captures):** a device that **disconnects or reports an invalid pose is dropped as the pointer device**, and isn't picked up again until SteamVR sees a new "user interaction started". That took about 10 s of quiet. A quick off/on toggle therefore left the mouse grabbed with no laser. That's why 0.5.0 keeps the device connected with a valid pose (parked pointing at the sky) while laser mode is off.
- The compositor also has settings like `modalGamepadAndLaser` and `laserMouseDebugging` in SteamVR's default settings. We haven't experimented with them.
### Other ways into the laser (considered, not used)
- **The `lasermouse` mailbox.** SteamVR has an internal message bus served by vrserver as a WebSocket on `ws://127.0.0.1:27062` (local only). The compositor listens on a mailbox called `lasermouse` with messages such as `dump_laser_overlays`, `force_activate_laser_mouse` and `remote_laser_mouse_events`. The last one is how **VRLink** (PC↔headset streaming) sends laser input. Its payload is an undocumented protobuf, so it's a dead end without reverse engineering. Protocol details are in [steam-frame-background.md](steam-frame-background.md).
- **Gamescope flags** (`--mouse-sensitivity`, `--force-grab-cursor`). These only change behaviour *inside* one overlay.
---
## 9. The display chain: how the KDE desktop gets into the headset
```
DSI-1 panel (2× 2160×2160)
└─ vrcompositor (SteamVR) owns the panel
└─ gamescope --backend openvr each Steam app = one VR overlay (PerAppId), 2.67 m wide
│ output 1920×1080
└─ Xwayland :0 gamescope's X server
└─ kwin_wayland "X11 windowed" backend: KWin is just an X11 window on :0
└─ Plasma, Konsole, Firefox ... (Wayland clients of KWin; KWin also runs Xwayland :2)
```
What follows from this:
- **KDE never touches the hardware.** Its "Display" settings show a single fake output, `X11-0`, at 1920×1080@60. Resolution and refresh choices there do nothing. **Scale** does work (it's set to 0.8, giving a 2400×1350 logical desktop).
- **The aspect ratio and pixel size are gamescope's** (`--output-width/height` in `/usr/lib/steamos/gamescope-session`), not KDE's. An ultrawide desktop would mean overriding that script with a systemd user drop-in (see the parent folder's `07-aspect-ratio-ultrawide.md`).
- `KWIN_FORCE_SW_CURSOR=1` is set, presumably because a hardware cursor plane can't pass through a VR overlay.
---
## 10. The input chain: where a mouse event really goes
```
mouse ─► /dev/input/event5 (evdev)
├─► gamescope (libinput) ─► focused overlay's Xwayland ─► KWin (as X11 events) ─► KDE apps
└─► mouselaser (reads evdev directly; EVIOCGRAB while laser mode is on)
```
- **evdev** is the kernel's raw input interface: `/dev/input/eventN` streams `input_event` structs (`EV_REL` for motion and wheel, `EV_KEY` for buttons such as `BTN_LEFT`, `BTN_SIDE` (275, "back") and `BTN_EXTRA` (276, "forward")). Any process with read access can listen.
- **`EVIOCGRAB`** is an `ioctl` that gives one reader **exclusive** access. While we hold it, gamescope receives nothing from the mouse, so the KDE cursor freezes. That's intended in laser mode.
- **Gamescope clamps the mouse to the focused overlay.** This is the "bounded" mouse the project's name refers to.
- **KDE's mouse and keyboard settings pages are empty.** KWin on the X11 windowed backend never opens input devices itself (no libinput), so its device D-Bus API doesn't exist. Per-device options like acceleration, natural scroll and left-handed mode are therefore gamescope's or libinput's business, not KDE's. The keyboard layout, cursor theme and shortcuts still work.
- **Back/forward side buttons never reach KDE apps.** Gamescope delivers them (as X buttons 8 and 9), but KWin 6.2.5's X11 windowed backend throws away every X button above 7. Upstream deleted that backend in 2025 rather than fixing it. KWin's Wayland nested backend passes them through; a "Desktop (Wayland)" launcher to try that exists but is untested (parent folder notes `04` and `09`).
- **SteamVR's laser `Back` action** reaches SteamVR overlays such as Steam's UI. Gamescope only forwards laser motion, buttons 1 to 3 and scrolling to its windows, so Back never reaches KDE either.
- **Bluetooth mice sleep.** The MCHOSE mouse disconnects after about 25 s idle and may come back as a different `eventN`. The driver closes the device on `ENODEV`/`POLLHUP` and rescans every 2 s.
---
## 11. How mouselaser fits in
With all of the above, the driver is short. Details are in [how-it-works.md](how-it-works.md).
1. **Provider** (`alwaysActivate` driver) adds one device: class `Controller`, controller type `mouselaser`, role **Stylus** (since 0.5.0).
2. **Mouse thread** finds the first evdev device with relative X/Y and a left button. The forward side button toggles laser mode and `EVIOCGRAB`.
3. **Every frame** (`RunFrame`):
- Position = HMD position, a little lower (`originOffsetY`).
- Orientation = yaw and pitch built up from mouse movement. When laser mode turns on, it starts from where you're looking. While off, it's parked pointing straight up.
- Components: left button → trigger, right → A, middle → B, back side button → back, wheel → virtual thumbstick (with a smoothing model, because a wheel only sends notches). Grip is held while laser mode is on (quick mouse).
4. **Bindings** (`vrcompositor_bindings_mouselaser.json`) map those onto the compositor's `lasermouse`, `quickmouse`, `scroll_*` and `dualanalog` actions, for `/user/stylus` and both hands.
---
## 12. Lessons learned the hard way
| Lesson | Why |
|---|---|
| **Never restart SteamVR from inside the desktop.** Reboot instead. | gamescope is `PartOf=steamvr.service`; the desktop and your terminal die with it. |
| **Rebuild *and* reboot to test anything.** | The `.so`, settings and bindings are only read at startup. |
| **Bump the version string on every change.** | It's the only quick way to know which build is running. We once lost track: git was reverted but the old `.so` was still in place, and the log showed `0.4.3` while the source said `0.4.0`. The `.so` is gitignored, so reverting git doesn't touch it. |
| **A crash in the driver takes SteamVR down.** | The driver runs inside `vrserver`. Keep SSH/RDP ready, know the [recovery steps](../README.md#recovery), and never let a joinable `std::thread` be destroyed (that calls `std::terminate`). |
| **Don't disconnect or invalidate the pointer device to "turn it off".** | The compositor drops it as the laser pointer, and only takes it back after a new user interaction (about 10 s of quiet). |
| **Don't register as a hand unless you want to replace that hand.** | Hand slots are exclusive; the real controller loses its laser. |
| **Saved user bindings beat the driver's defaults.** | If a binding change "does nothing", look for a saved copy. |
| **Benign log noise:** `Driver mouselaser has no suitable devices`, and `steam.client (mouselaser) has no configured binding`. | The first is logged because our driver provides no HMD; the device is added right after. The second, because only compositor bindings are shipped (Steam's own Frame binding is haptics only anyway). |
| **SteamVR adds the driver name to log lines itself.** | Logging `"mouselaser: ..."` yourself gives `mouselaser: mouselaser: ...`. |
| **The display name in Manage Add-ons can't be changed.** | The dashboard looks names up in its own table and falls back to the manifest `name`. |
---
## 13. Cheat sheet: files, logs and tools
### Files
| What | Where |
|---|---|
| SteamVR runtime | `/opt/steamvr/` (`bin/linuxarm64/`, `drivers/`, `resources/`) |
| OpenVR driver header | `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr_driver.h` |
| Compositor actions | `/opt/steamvr/resources/config/vrcompositor_actions.json` |
| Frame controller profile + bindings | `/opt/steamvr/drivers/frame_controller/resources/input/` |
| Frame HMD profile + bindings | `/opt/steamvr/drivers/frame_hmd/resources/input/` |
| SteamVR default settings | `/opt/steamvr/resources/settings/default.vrsettings` |
| Your settings | `~/.config/openvr/config/steamvr.vrsettings` |
| Registered external drivers | `~/.config/openvr/openvrpaths.vrpath` |
| Logs | `~/.local/share/Steam/logs/vrserver.txt`, `vrcompositor.txt` (also forwarded to the journal) |
| Dashboard web UI (JS) | `/opt/steamvr/resources/webinterface/dashboard/` |
| Session scripts | `/usr/lib/steamos/gamescope-session`, `/usr/bin/steamos-nested-desktop` |
### Commands
```sh
# Is our driver loaded, which version, what is it doing?
grep -a 'mouselaser:' ~/.local/share/Steam/logs/vrserver.txt | tail -40
# Everything SteamVR said about our driver (loading, bindings)
grep -a -i mouselaser ~/.local/share/Steam/logs/vrserver.txt | grep -v legacy_bindings | tail
# Which devices were added, and by which driver
grep -a "started activation of tracked device" ~/.local/share/Steam/logs/vrserver.txt | tail
# Registered drivers
/opt/steamvr/bin/linuxarm64/vrpathreg show
# Which input devices exist (find your mouse's eventN)
cat /proc/bus/input/devices
# vrcmd: list options; --events / --overlays / --pollposes are useful for watching
/opt/steamvr/bin/linuxarm64/vrcmd --help
# systemd user units from inside the nested desktop
XDG_RUNTIME_DIR=/run/user/1000 systemctl --user status gamescope-session
```
---
## 14. Glossary
| Term | Meaning |
|---|---|
| **OpenVR** | Valve's API for VR. Apps use `openvr.h`; drivers use `openvr_driver.h`. SteamVR is the runtime that implements it. |
| **OpenXR** | The cross-vendor standard VR API. SteamVR also implements it (`libopenxr_loader.so`, `helloxr`). Not used here. |
| **vrserver / vrcompositor** | SteamVR's core process and its renderer (section 4). |
| **Driver** | A `.so` loaded by vrserver that adds devices (section 5). |
| **Provider** | The driver-wide object (`IServerTrackedDeviceProvider`). |
| **Tracked device** | Anything SteamVR knows the pose of: HMD, controller, tracker. |
| **Pose** | Position + orientation of a device. |
| **Role** | Left hand, right hand, stylus...; decides the user path (section 6). |
| **User path** | `/user/hand/left`, `/user/head`, `/user/stylus`...: role-based names the input system binds to. |
| **Component** | A named input a driver creates, e.g. `/input/trigger/click`. |
| **Input profile** | JSON describing a controller type's inputs and default bindings. |
| **Action / action set** | What an app wants (`LeftClick`), grouped by purpose (`/actions/lasermouse`). |
| **Binding** | The mapping from user-path inputs to an app's actions. |
| **App key** | An input app's identity; the compositor's is `openvr.component.vrcompositor`. |
| **Overlay** | A 2D panel the compositor places in 3D. |
| **Dashboard** | SteamVR's system menu overlay. |
| **Laser mouse** | The compositor's laser pointer that turns overlays into mouse targets. |
| **Quick mouse** | Squeeze-grip shortcut that brings up the laser. |
| **Mailbox** | SteamVR's internal WebSocket message bus on port 27062. |
| **VRLink** | Valve's PC↔headset streaming. |
| **gamescope** | Valve's compositor; here it runs as an OpenVR overlay app and hosts Steam and the desktop. |
| **Nested desktop** | KDE Plasma running as a window inside gamescope. |
| **evdev / EVIOCGRAB** | The kernel's raw input interface, and the call that grabs a device exclusively. |
| **Deckard** | Internal codename of the Steam Frame. |
| **cv driver** | The Frame's built-in SteamVR driver for headset and controllers, fed by XRService. |
---
## 15. Open questions
- **Does the compositor accept a `/user/stylus` pointer?** 0.5.0's first run shows it toggling and receiving interaction events, but the on-headset verdict is pending. See [development-log.md](development-log.md).
- **Exactly how the compositor picks the pointer device.** Is it interaction events, `SwitchLaserHand`, or something else? The 0.5.0 event logging is meant to answer this.
- **Is there a driver-side signal for "the laser is up"?** It would let the driver grab the mouse only when the laser actually works.
- **The mailbox.** Does it need a `?secret=`, and what does `dump_laser_overlays` return?
- **KWin on the Wayland backend.** Would it fix back/forward buttons, and does gamescope show it properly?
## Sources
- In this repo: [steam-frame-background.md](steam-frame-background.md), [how-it-works.md](how-it-works.md), [development-log.md](development-log.md).
- Parent folder `~/Projects/SteamFrameExploration/` on the original headset (not in this repo): `01-system-overview.md`, `02-display-and-resolution.md`, `03-input-devices.md`, `04-proposed-changes.md`, `05-open-questions.md`, `06-vr-laser-pointer-and-mouse.md`, `07-aspect-ratio-ultrawide.md`, `08-quickmouse-and-laser-mailbox.md`, `09-mouse-back-button-kde.md`.
- Valve's public OpenVR SDK and its wiki (github.com/ValveSoftware/openvr) for the general driver and input concepts.
@@ -4,7 +4,7 @@
"device_class" : "TrackedDeviceClass_Controller",
"resource_root" : "mouselaser",
"driver_name" : "mouselaser",
"input_bindingui_mode" : "controller_handed",
"input_bindingui_mode" : "single_device",
"should_show_binding_errors" : true,
"input_source" : {
"/input/trigger" : { "type" : "trigger", "click" : true, "value" : true, "binding_image_point" : [ 50, 50 ], "order" : 1 },
@@ -18,6 +18,10 @@
{
"output": "/actions/lasermouse/in/pointer",
"path": "/user/hand/right/pose/raw"
},
{
"output": "/actions/lasermouse/in/pointer",
"path": "/user/stylus/pose/raw"
}
],
"sources": [
@@ -39,6 +43,15 @@
}
}
},
{
"path": "/user/stylus/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/leftclick"
}
}
},
{
"path": "/user/hand/left/input/a",
"mode": "button",
@@ -57,6 +70,15 @@
}
}
},
{
"path": "/user/stylus/input/a",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/rightclick"
}
}
},
{
"path": "/user/hand/left/input/b",
"mode": "button",
@@ -75,6 +97,15 @@
}
}
},
{
"path": "/user/stylus/input/b",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/middleclick"
}
}
},
{
"path": "/user/hand/left/input/back",
"mode": "button",
@@ -92,6 +123,15 @@
"output": "/actions/lasermouse/in/back"
}
}
},
{
"path": "/user/stylus/input/back",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/back"
}
}
}
]
},
@@ -114,6 +154,15 @@
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
}
}
},
{
"path": "/user/stylus/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
}
}
}
]
},
@@ -136,6 +185,15 @@
"output": "/actions/quickmouse/in/ActivateQuickMouse"
}
}
},
{
"path": "/user/stylus/input/grip",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/quickmouse/in/ActivateQuickMouse"
}
}
}
]
},
@@ -158,6 +216,15 @@
"output": "/actions/scroll_discrete/in/scroll"
}
}
},
{
"path": "/user/stylus/input/thumbstick",
"mode": "scroll",
"inputs": {
"scroll": {
"output": "/actions/scroll_discrete/in/scroll"
}
}
}
]
},
@@ -186,6 +253,18 @@
"output": "/actions/dualanalog/in/righttouch"
}
}
},
{
"path": "/user/stylus/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/dualanalog/in/rightvalue"
},
"touch": {
"output": "/actions/dualanalog/in/righttouch"
}
}
}
]
},
@@ -214,6 +293,18 @@
"output": "/actions/scroll_smooth/in/scroll"
}
}
},
{
"path": "/user/stylus/input/thumbstick",
"mode": "scroll",
"parameters": {
"scroll_mode": "smooth"
},
"inputs": {
"scroll": {
"output": "/actions/scroll_smooth/in/scroll"
}
}
}
]
}
@@ -3,7 +3,7 @@
"enable": true,
"sensitivity": 0.05,
"toggleButton": 276,
"role": 1,
"role": 5,
"deviceNameFilter": "",
"originOffsetY": -0.08,
"invertY": false,
+35 -9
View File
@@ -4,7 +4,9 @@
//
// Toggle with the mouse's forward side button (BTN_EXTRA by default). While active the
// mouse is grabbed (EVIOCGRAB) so gamescope stops seeing it; while inactive the device
// reports disconnected and the real controllers keep the laser.
// stays connected (so the compositor keeps it as a pointer source), but parks its ray
// pointing at the sky and releases every button.
// It registers as a stylus, not a hand, so it never takes a real controller's hand slot.
//
// EXPERIMENTAL and AI-generated ("vibecoded"); see README.md before relying on it.
@@ -31,7 +33,7 @@
using namespace vr;
static const char *k_section = "driver_mouselaser";
static const char *k_version = "0.4.0-experimental";
static const char *k_version = "0.5.0-experimental";
static void Log(const char *fmt, ...)
{
@@ -55,7 +57,9 @@ struct Settings
float sensitivityDeg = 0.05f; // degrees of ray rotation per mouse count
int toggleButton = BTN_EXTRA;
int backButton = BTN_SIDE; // sends the compositor's laser "back"; -1 = none
int role = TrackedControllerRole_LeftHand;
// Stylus (5) gets its own /user/stylus path. A hand role (1/2) shares the slot with the
// real controller of that hand, so the device disconnects while off (pre-0.5.0 behaviour).
int role = TrackedControllerRole_Stylus;
std::string nameFilter; // substring of the evdev name; empty = first mouse found
float originOffsetY = -0.08f; // metres, world space, relative to the HMD
bool invertY = false;
@@ -400,6 +404,9 @@ public:
return m_pose;
}
uint32_t Id() const { return m_id; }
bool Active() const { return m_mouse.active; }
void RunFrame()
{
if (m_id == k_unTrackedDeviceIndexInvalid) return;
@@ -409,6 +416,11 @@ public:
const HmdMatrix34_t &m = hmd.mDeviceToAbsoluteTracking;
bool active = m_mouse.active;
// A hand role would steal the real controller's slot while connected, so it only
// connects while active. Other roles stay connected: a device that disappears or goes
// invalid is dropped as the laser pointer until a new "user interaction" (about 10 s of
// quiet), so a quick off/on would grab the mouse with no laser.
bool present = active || IsRoleAllowedAsHand(ETrackedControllerRole(m_settings.role));
if (m_mouse.toggled.exchange(false) && active && hmd.bPoseIsValid)
{
// Start the ray where the user is looking.
@@ -426,15 +438,16 @@ public:
DriverPose_t pose = {};
pose.qWorldFromDriverRotation.w = 1;
pose.qDriverFromHeadRotation.w = 1;
pose.deviceIsConnected = active;
pose.poseIsValid = active && hmd.bPoseIsValid;
pose.deviceIsConnected = present;
pose.poseIsValid = present && hmd.bPoseIsValid;
pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange;
pose.vecPosition[0] = m.m[0][3];
pose.vecPosition[1] = m.m[1][3] + m_settings.originOffsetY;
pose.vecPosition[2] = m.m[2][3];
// q = yaw(Y) * pitch(X)
float cy = cosf(m_yaw / 2), sy = sinf(m_yaw / 2);
float cp = cosf(m_pitch / 2), sp = sinf(m_pitch / 2);
// q = yaw(Y) * pitch(X). While off the ray is parked straight up, where it hits nothing.
float yaw = active ? m_yaw : 0.f, pitch = active ? m_pitch : float(M_PI) / 2;
float cy = cosf(yaw / 2), sy = sinf(yaw / 2);
float cp = cosf(pitch / 2), sp = sinf(pitch / 2);
pose.qRotation.w = cy * cp;
pose.qRotation.x = cy * sp;
pose.qRotation.y = sy * cp;
@@ -508,8 +521,21 @@ public:
void RunFrame() override
{
if (m_device) m_device->RunFrame();
// Diagnostics: events about our device, and the global ones that decide which device
// the compositor treats as the laser. There is no direct "laser is up" signal.
VREvent_t ev;
while (VRServerDriverHost()->PollNextEvent(&ev, sizeof(ev))) {}
while (VRServerDriverHost()->PollNextEvent(&ev, sizeof(ev)))
{
bool ours = m_device && ev.trackedDeviceIndex == m_device->Id();
bool global = ev.eventType == VREvent_TrackedDeviceUserInteractionStarted ||
ev.eventType == VREvent_TrackedDeviceUserInteractionEnded ||
ev.eventType == VREvent_TrackedDeviceRoleChanged ||
ev.eventType == VREvent_DashboardActivated ||
ev.eventType == VREvent_DashboardDeactivated;
if (ours || global)
Log("event %u device %u (%s)\n", ev.eventType, ev.trackedDeviceIndex,
m_device && m_device->Active() ? "active" : "off");
}
}
bool ShouldBlockStandbyMode() override { return false; }