Initial commit, with working version

This commit is contained in:
Juan Arteaga Carmona committed 2026-10-01 21:28:33 +02:00
1 parent 49589f3309
commit da48c37235
21 files changed
+1349 -1

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Build output (build locally, see README)
/build/
/driver/mouselaser/bin/
.cache
+31
View File
@@ -0,0 +1,31 @@
# CLAUDE.md
Context for AI coding sessions on this repo. Read it before changing anything.
## What this is
`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/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.
## Environment facts that bite
- The session usually runs **on the headset itself**, in a terminal inside the nested KDE desktop. That desktop sits inside gamescope, which is `PartOf=steamvr.service`. **Restarting SteamVR (or gamescope) kills the desktop and this session.** Ask the owner to reboot instead, and continue in a new session.
- Inside the nested desktop `XDG_RUNTIME_DIR=/run/user/1000/nested_plasma`. For `systemctl --user`, prefix the command with `XDG_RUNTIME_DIR=/run/user/1000`.
- `/usr` belongs to the read-only OS image. Don't modify it. Everything here is user-level: `vrpathreg` and `~/.config/openvr`.
- The driver `.so` and bindings are only loaded when SteamVR starts. Code changes need a rebuild **and** a reboot to test.
- Check the logs with `grep -a 'mouselaser:' ~/.local/share/Steam/logs/vrserver.txt | tail`.
## Ground rules from the owner
- **Never commit, stage or push.** Git is the owner's job.
- **Warn before doing anything that touches the system**: registering or unregistering the driver, editing `steamvr.vrsettings`, rebooting, or restarting services. Wait for approval. Editing and building inside this repo is fine.
- Keep the "vibecoded / experimental" warning in the README accurate. Don't oversell what has been tested.
- Record new findings and behaviour changes in `docs/`. Add an entry to `docs/development-log.md` for each meaningful change.
## Build
```sh
cmake -S . -B build -G Ninja && cmake --build build
```
Output: `driver/mouselaser/bin/linuxarm64/driver_mouselaser.so` (gitignored). The only warnings expected come from SteamVR's own `openvr_driver.h` (unused parameters). `build/compile_commands.json` is exported, so clangd can resolve the header.
Wheel logic can be checked without a headset: `cmake -S . -B build -DMOUSELASER_TESTS=ON && cmake --build build && ./build/wheel_sim`. Bump `k_version` in `src/driver.cpp` on every behaviour change, so the log (`mouselaser: version ...`) shows which build is running.
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: MIT
cmake_minimum_required(VERSION 3.20)
project(mouselaser CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # lets clangd find openvr_driver.h via build/compile_commands.json
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE RelWithDebInfo)
endif()
# Header-only OpenVR driver API, as shipped with SteamVR's hellovr sample.
set(OPENVR_HEADERS /opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers
CACHE PATH "Directory containing openvr_driver.h")
add_library(driver_mouselaser SHARED src/driver.cpp)
target_include_directories(driver_mouselaser PRIVATE ${OPENVR_HEADERS})
target_compile_options(driver_mouselaser PRIVATE -Wall -Wextra -fvisibility=hidden)
target_link_libraries(driver_mouselaser PRIVATE pthread)
set_target_properties(driver_mouselaser PROPERTIES
PREFIX ""
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/driver/mouselaser/bin/linuxarm64)
# Optional offline check of the wheel logic: cmake -DMOUSELASER_TESTS=ON ... && ./build/wheel_sim
option(MOUSELASER_TESTS "Build the offline wheel simulation" OFF)
if(MOUSELASER_TESTS)
add_executable(wheel_sim tests/wheel_sim.cpp)
target_include_directories(wheel_sim PRIVATE ${OPENVR_HEADERS})
target_link_libraries(wheel_sim PRIVATE pthread)
endif()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Andalu30 and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+142 -1
View File
@@ -1,2 +1,143 @@
# frame-unboundedMouse-vibed
An experimental steamvr driver to control the laser pointer of Steam Frame with a mouse
An experimental SteamVR driver that lets you control the **Steam Frame's laser pointer with a regular mouse**, across every VR overlay (Steam UI, the KDE desktop, the SteamVR dashboard, popups), instead of the mouse being trapped inside the desktop window.
<p align="center">
<img src="docs/media/demo.webp" alt="Demo: the mouse aims the Steam Frame's laser pointer across the KDE desktop, the Steam UI and other VR overlays" width="800">
</p>
> [!WARNING]
> ## ⚠️ This project is vibecoded. Read this first.
>
> This driver was written almost entirely by an AI coding assistant (Claude) during one exploratory session on a Steam Frame, with a human steering, testing and approving each step. It has **not** been reviewed the way hand-written, production code would be.
>
> - **Experimental.** It works on one headset, one SteamOS build and one mouse (see [Tested on](#tested-on)). It may not work on yours.
> - **It runs inside `vrserver`.** A bug in the driver can crash SteamVR. On the Frame, SteamVR is your whole session, so the headset can end up showing nothing until you remove the driver (see [Recovery](#recovery)).
> - **It grabs your mouse.** While the laser mode is on, the driver takes exclusive control of the mouse (`EVIOCGRAB`), and nothing else receives its input.
> - **It relies on undocumented behaviour.** It depends on SteamVR internals that Valve can change in any update: the compositor's `lasermouse` action set and Frame-specific bindings.
> - **No warranty.** Use it at your own risk. Read the code first; it's a single ~400-line file: [`src/driver.cpp`](src/driver.cpp).
## What it does
The Frame's laser pointer is drawn by SteamVR's compositor. The compositor aims it with whatever tracked device supplies a *pose*, normally a controller. A physical mouse never reaches that system: gamescope consumes it and clamps it to whichever window has focus.
This driver adds a **virtual controller** to SteamVR:
- **Position:** follows your head.
- **Direction:** aimed by mouse movement.
- **Buttons:** your mouse buttons.
The compositor then treats it like any other laser-pointing hand.
| Mouse | Laser mode OFF (default) | Laser mode ON |
|---|---|---|
| Forward side button (BTN_EXTRA) | **Turns laser mode on** | **Turns laser mode off** |
| Movement | Normal desktop cursor | Aims the laser (world-locked: turning your head doesn't move it) |
| Left / right / middle button | Normal | Laser click / right click / middle click |
| 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.
## Requirements
- A Steam Frame (aarch64, SteamOS VR variant) with SteamVR at `/opt/steamvr`.
- A USB or Bluetooth mouse. Your user must be able to read `/dev/input/event*`; the default `steamos` user is in the `input` group.
- `gcc` or `clang`, `cmake` and `ninja`. They are not guaranteed to be on a stock image; installing them may require disabling SteamOS read-only mode.
- The OpenVR driver header is **not vendored**. The build uses the copy SteamVR ships at `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers`. Override it with `-DOPENVR_HEADERS=<dir>`.
## Build and install
```sh
cmake -S . -B build -G Ninja && cmake --build build
./install.sh # registers driver/mouselaser with vrpathreg (backs up openvrpaths.vrpath first)
# then REBOOT the headset
```
`install.sh` refuses to run if another driver named `mouselaser` is already registered from a different folder.
Check that it loaded:
```sh
grep -a 'mouselaser:' ~/.local/share/Steam/logs/vrserver.txt | tail
# expect: "version 0.3.1-experimental", "activated as device N, role 1", "using /dev/input/eventX (<your mouse>)"
```
### Optional: offline wheel test
```sh
cmake -S . -B build -DMOUSELASER_TESTS=ON && cmake --build build && ./build/wheel_sim
```
This simulates the wheel → thumbstick logic at 120 Hz, without SteamVR, and exits non-zero if a single notch or a smooth spin misbehaves. It is handy when changing the `wheel*` defaults.
## Uninstall
```sh
./uninstall.sh # then reboot
```
## Settings
Add any of these to `~/.config/openvr/config/steamvr.vrsettings` under a `"driver_mouselaser"` section. They are read once, when SteamVR starts.
| key | default | meaning |
|---|---|---|
| `enable` | `true` | `false` loads the driver but adds no device. This is the kill switch. |
| `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). |
| `role` | `1` | 1 = left hand, 2 = right hand. |
| `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. |
| `wheelMode` | `"smooth"` | `"smooth"`: each notch bumps the virtual stick, which then eases back, and spinning holds it. `"step"`: each notch is one fixed flick (the 0.2.0 behaviour). |
| `wheelDeflection` | `1.0` | Maximum stick deflection (0–1), in both modes. |
| `wheelSmoothMin` | `0.7` | *smooth:* deflection after a single notch. Keep it above the UI's step threshold (about 0.5). |
| `wheelSmoothImpulse` | `0.3` | *smooth:* extra deflection added by each further notch, so faster spinning pushes harder. |
| `wheelSmoothHoldMs` | `80` | *smooth:* how long after a notch the stick holds before easing back. |
| `wheelSmoothDecayMs` | `150` | *smooth:* how fast it eases back (exponential time constant). Lower is snappier, higher glides longer. |
| `wheelPressMs` | `90` | *step:* how long each notch holds the stick pushed. |
| `wheelReleaseMs` | `60` | *step:* gap at centre between queued notches. |
| `wheelMaxQueued` | `10` | *step:* maximum notches buffered. |
**Tuning the wheel:**
- One notch skips two list items: lower `wheelSmoothHoldMs`.
- Scrolling stops abruptly: raise `wheelSmoothDecayMs`.
- Fast spins aren't fast enough: raise `wheelSmoothImpulse`.
## It shows up in SteamVR's "Manage Add-ons"
This is expected. SteamVR lists every driver that isn't resources-only under **Settings → Developer → Manage Add-ons** (exact menu names may differ). That entry is useful:
- **Its toggle is a kill switch.** It writes `"driver_mouselaser": {"enable": false}` to `steamvr.vrsettings`, which is the same `enable` setting the driver honours. Takes effect after a reboot.
- **Safe mode.** SteamVR can block add-ons in "safe mode", apparently when it crashes after they were added. The **Unblock** button on that page re-enables them, once you've fixed or updated the driver.
## Recovery
If SteamVR won't come up properly after installing, there are four options:
0. In VR, open **Manage Add-ons**, switch *mouselaser* off, then reboot.
1. **SSH or RDP in** (`xrdp` runs on the Frame), run `./uninstall.sh` or `vrpathreg removedriver <path>/driver/mouselaser`, and reboot.
2. Set `"driver_mouselaser": { "enable": false }` in `steamvr.vrsettings`, then reboot.
3. Restore the backup: `cp ~/.config/openvr/openvrpaths.vrpath.bak-mouselaser ~/.config/openvr/openvrpaths.vrpath`.
## Tested on
| | |
|---|---|
| Device | Steam Frame (Snapdragon SM8650) |
| SteamOS | `VERSION_ID=0.4.2`, `VARIANT_ID=vr`, build `20260928.6175029` |
| Kernel | `6.18.0-gfbdbca41fd45` |
| SteamVR | as installed at `/opt/steamvr` on 2026-10-01 |
| Mouse | MCHOSE G3 A (2.4 GHz, has BTN_SIDE/BTN_EXTRA) |
| Result | Loads, toggles, aims and clicks across overlays. Reconnects after the mouse sleeps or replugs. The wheel scrolls Steam UI lists like the thumbstick: 0.2.0 worked but felt a bit choppy. 0.3.0's smooth mode was confirmed working by the owner. |
## 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.
- **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.
- Two harmless log lines:
- `steam.client (mouselaser) has no configured binding`: only the compositor bindings are provided, and Steam's own binding for the Frame controllers is haptics-only anyway.
- `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/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.
- [CLAUDE.md](CLAUDE.md): context and ground rules for AI coding sessions on this repo.
## License
[MIT](LICENSE) © 2026 Andalu30 and contributors.
Third-party notes:
- **OpenVR SDK header** (`openvr_driver.h`, © Valve Corporation, BSD-3-Clause in the public [OpenVR SDK](https://github.com/ValveSoftware/openvr)). It is **not included** in this repo. The build uses the copy SteamVR ships on the headset.
- **SteamVR** is proprietary Valve software. This project doesn't contain or redistribute any of it. The input profile and bindings files follow SteamVR's documented JSON formats so the driver can interoperate.
- Not affiliated with or endorsed by Valve. "Steam", "SteamVR" and "Steam Frame" are trademarks of Valve Corporation.
+62
View File
@@ -0,0 +1,62 @@
# Development log
## Origin (2026-10-01)
This project came out of one exploratory session. The owner asked an AI assistant (Claude, running as Claude Code in a Konsole inside the Frame's nested KDE desktop) to analyse the Steam Frame's environment, document it, and possibly change some things. The session started **read-only**. Write access was granted later, step by step, and every system-touching action was announced first and approved by the owner.
The broader notes from that session (hardware, display pipeline, KDE scale, ultrawide options, input devices) live in the parent exploration folder `~/Projects/SteamFrameExploration/` (`README.md` plus numbered `0X-*.md` files) on the original headset. They aren't part of this repo. The parts relevant here are condensed in [steam-frame-background.md](steam-frame-background.md).
## Timeline
1. **Exploration.** We found that KDE runs nested in gamescope's VR overlay, and that the mouse belongs to gamescope while the laser belongs to vrcompositor.
2. **Quick mouse investigation.** It turned out to be only a grip-to-activate shortcut. Its binding showed that the laser takes any pose source, and the Frame HMD already binds a head-gaze laser.
3. **Driver written** (`mouselaser`, ~400 lines of C++). Built on-device with gcc 15.1 / cmake 3.29 / ninja against SteamVR's bundled `openvr_driver.h` (API 2.1.0).
4. **Installed** with `vrpathreg adddriver`, after a backup of `openvrpaths.vrpath`. Then the owner restarted SteamVR.
5. **Verified working**, from the owner's report and from `vrserver.txt`:
```
[Info] - mouselaser: mouselaser: ACTIVE (mouse drives the laser)
[Info] - mouselaser: mouselaser: inactive (mouse back to desktop)
[Info] - [Workshop] Successfully loaded binding file '.../vrcompositor_bindings_mouselaser.json' for app 'openvr.component.vrcompositor'.
[Info] - mouselaser: mouselaser: mouse disappeared
[Info] - mouselaser: mouselaser: using /dev/input/event6 (MCHOSE G3 A Mouse)
```
- The toggle works, and the compositor loaded our bindings.
- The mouse is re-found after it sleeps or reconnects. Its event node moved from event5 to event6 and was still found.
6. **Moved into this repo** as `0.1.0-experimental`. Changes made while copying:
- Removed the doubled log prefix: SteamVR already adds `mouselaser: `.
- Added a version line on startup.
- `install.sh` now refuses to register if a `mouselaser` driver is already registered from another folder.
7. **Repo build tested** (2026-10-01, 20:10). The old registration was removed, `./install.sh` was run from the repo, and the owner rebooted. The owner confirmed it works. The log shows a single `mouselaser:` prefix, which confirms the repo build is the one loading, and toggling works.
8. **0.2.0: wheel works as the thumbstick** (2026-10-01). The owner asked for the mouse wheel to act like the controller joystick, to scroll lists.
- Finding: Steam UI lists read the compositor's `/actions/dualanalog`; the Frame `steam.client` binding is haptics-only. Our bindings only had `scroll_discrete`.
- Change: added `dualanalog` (position + touch) and `scroll_smooth` bindings. Replaced the 6-frame pulse with a time-based `StickStepper` (one notch = one flick). The tilt wheel drives stick X. Four new `wheel*` settings.
- Verified offline with a 120 Hz simulation: one notch gives one push, fast notches give separate pushes, and the queue is capped.
- **On-headset test: works** (owner, after a reboot). Lists scroll with the wheel, but it feels "a bit choppy". Tuning or a smoother mode is a follow-up.
9. **Manage Add-ons.** The driver appears in SteamVR's developer *Manage Add-ons* list, which is expected. From the dashboard JS (`chunk~*.js`): the list shows non-`resourceOnly` drivers, its toggle calls `setEnabled`, which writes `driver_<name>.enable` (the same key our driver reads), and **Unblock** clears `blocked_by_safe_mode`. Documented in the README as an in-VR kill switch.
10. **0.3.0: smoother wheel and tidier texts** (2026-10-01). The owner found 0.2.0's scrolling "a bit choppy" and asked for some adjustment, plus a tidy-up of the text SteamVR shows.
- Added the `smooth` wheel mode as the default: each notch bumps the stick, which eases back, and spinning holds it (see how-it-works). The 0.2.0 behaviour is still available as `wheelMode: "step"`. New settings: `wheelMode`, `wheelSmoothMin`, `wheelSmoothImpulse`, `wheelSmoothHoldMs`, `wheelSmoothDecayMs`.
- First tuning (hold 50 ms, decay 120 ms, impulse 0.25) dipped to about 0.45 between notches when spinning at 10/s, which risks retriggering steps. Retuned to 80 / 150 / 0.3, which ramps to a steady hold. Verified offline only.
- Texts: added `resources/localization/localization.json` (en_US, es_ES) for SteamVR's binding screens. Model `Mouse Laser (virtual)`, manufacturer `frame-unboundedMouse-vibed`, and a clearer binding name and description.
- The display name in Manage Add-ons can't be changed from the driver: the dashboard looks it up in its own `driverPrettyNames` table and otherwise falls back to the manifest name `mouselaser`.
- **On-headset test: working** (owner, after a reboot).
11. **Pre-commit review, 0.3.1** (2026-10-01).
- Logs: clean load, activation and binding load. No crash. The only vrserver coredump predates the driver (2026-09-28). `no suitable devices` and `steam.client … no configured binding` are benign.
- A clean build from only the files that would be committed works. clang `-Wall -Wextra -Wshadow` reports nothing in our code. No personal data in the tree.
- **Fix:** `MouseReader` now stops its thread in its destructor. Previously, if SteamVR destroyed the device without `Deactivate()`, the joinable `std::thread` would `std::terminate` vrserver. This never happened in the logs; it is defensive.
- Added `tests/wheel_sim.cpp` (optional, `-DMOUSELASER_TESTS=ON`) and `CMAKE_EXPORT_COMPILE_COMMANDS`, so clangd finds `openvr_driver.h`.
- 0.3.1 is built but **not yet rebooted into**.
12. **License: MIT** (2026-10-01).
- The owner asked for an open-source license. The only Valve code involved is the OpenVR driver header. It is BSD-3-Clause in the public SDK and **not vendored**: we compile against SteamVR's local copy.
- MIT was chosen over GPL because the driver is a plugin loaded into proprietary `vrserver`, where GPL plugin licensing is a grey area. MIT is also common for SteamVR drivers.
- Copyright holder: `Andalu30` (the GitHub handle from the remote). The README notes third-party code and trademarks.
13. **Demo media** (2026-10-01). The owner recorded `~/Videos/mousedemo.mp4` (44.2 s, 1920×1080, 20 fps). It was trimmed (first 3 s and last 2 s removed, leaving 39.2 s) and converted to `docs/media/demo.webp`, which the README shows as the hero image.
## 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.
- Licensed MIT (see `LICENSE`). SPDX tags are on the sources.
+82
View File
@@ -0,0 +1,82 @@
# How the driver works
All code is in [`../src/driver.cpp`](../src/driver.cpp). Everything SteamVR loads lives in [`../driver/mouselaser/`](../driver/mouselaser/).
## Pieces
```
vrserver
└─ loads driver/mouselaser/bin/linuxarm64/driver_mouselaser.so (driver.vrdrivermanifest: alwaysActivate=true)
HmdDriverFactory() → MouseLaserProvider (IServerTrackedDeviceProvider)
└─ MouseLaserDevice (ITrackedDeviceServerDriver), serial "mouselaser-0", class Controller
├─ MouseReader thread ── reads /dev/input/eventX (evdev), EVIOCGRAB when active
└─ 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
```
### Why `alwaysActivate: true`
`steamvr.vrsettings` has `activateMultipleDrivers: false` by default. That setting only limits which **HMD** driver is picked. Drivers whose manifest sets `alwaysActivate` are loaded in addition. This is the standard route for add-on trackers.
### MouseReader (evdev thread)
- Scans `/dev/input/event*` for the first device with `EV_REL` (REL_X, REL_Y) and `EV_KEY` (BTN_LEFT), optionally filtered by `deviceNameFilter`. A keyboard's "Consumer Control" node doesn't match.
- `poll()`s with a 200 ms timeout. On `POLLERR`/`POLLHUP` or `ENODEV` (mouse asleep or unplugged) it closes the device and rescans every 2 s.
- Pressing the toggle key (`toggleButton`, default BTN_EXTRA = 276) flips `active`:
- **on:** `ioctl(EVIOCGRAB, 1)` gives exclusive access, so gamescope stops receiving the mouse.
- **off:** `ioctl(EVIOCGRAB, 0)` releases the mouse; button state and deltas are cleared.
- While active it accumulates `REL_X`/`REL_Y`/`REL_WHEEL` into atomics and tracks BTN_LEFT, BTN_RIGHT and BTN_MIDDLE. `RunFrame` consumes them with `exchange(0)`.
### RunFrame (pose)
- HMD pose: `VRServerDriverHost()->GetRawTrackedDevicePoses(0, &hmd, 1)`. Device 0 is the HMD.
- On each OFF→ON toggle, yaw and pitch are initialised from the HMD's forward vector (−Z column of `mDeviceToAbsoluteTracking`):
`yaw = atan2(-fx, -fz)`, `pitch = asin(fy)`.
- 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.
### RunFrame (inputs)
| component | driven by | bound to (compositor) |
|---|---|---|
| `/input/trigger/click`, `/value` | left button | `lasermouse/LeftClick`, `lasermouse_secondary/SwitchLaserHand` |
| `/input/a/click` | right button | `lasermouse/RightClick` |
| `/input/b/click` | middle button | `lasermouse/MiddleClick` |
| `/input/grip/click` | held **true** while active | `quickmouse/ActivateQuickMouse` (keeps the laser up, as squeezing a grip does on Frame controllers) |
| `/input/thumbstick/y`, `/x` (+ `/touch`) | wheel → Y, tilt wheel → X, via `StickStepper` (see below) | `dualanalog/LeftValue`/`RightValue` (+ touch, mode `joystick`), `scroll_discrete` and `scroll_smooth` (mode `scroll`), the same as the Frame controller's stick |
### Wheel → thumbstick (`StickStepper`)
Steam UI lists are navigated through the compositor's `/actions/dualanalog` stick values, not through scroll events. The Frame controllers' `steam.client` binding only carries haptics. A wheel only produces discrete notches, so `StickStepper` turns them into stick deflection. It is timed with `steady_clock`, independently of the frame rate, and has two modes, chosen with `wheelMode`.
**smooth** (default, 0.3.0):
- On a notch: if the stick is at rest, it jumps to `wheelSmoothMin` (+ `wheelSmoothImpulse` for each extra notch in the same event). Otherwise `wheelSmoothImpulse` is added to its current value. Either way it is capped at `wheelDeflection`.
- For `wheelSmoothHoldMs` after a notch the value holds. After that it decays exponentially with time constant `wheelSmoothDecayMs`, and snaps to 0 below 0.1.
- The result:
- A single notch is about 130 ms above 0.5, which is one list step, followed by a short glide.
- Spinning at 10 notches/s ramps up to a steady 0.8–1.0 hold, which scrolls continuously without dips.
- Slow notches (3/s) remain separate steps.
- These figures come from an offline 120 Hz simulation that drives `StickStepper` directly.
**step** (0.2.0 behaviour):
- The stick is pushed to `±wheelDeflection` for `wheelPressMs`, then held at centre for `wheelReleaseMs`.
- Further notches queue up, to at most `wheelMaxQueued`.
- Reversing direction drops the queue and switches immediately.
- When laser mode turns off, the queue is cleared.
### Render model
`{mouselaser}mouselaser_none` is a single 0.1 mm triangle with a transparent 1×1 texture. Without a model of its own, SteamVR would draw a generic controller at your face.
## Files
| file | purpose |
|---|---|
| `driver.vrdrivermanifest` | Driver name and `alwaysActivate`. |
| `resources/settings/default.vrsettings` | Defaults for the `driver_mouselaser` section. |
| `resources/input/mouselaser_profile.json` | Input profile. Controller type `mouselaser`, declares components and points to the default bindings. |
| `resources/input/vrcompositor_bindings_mouselaser.json` | Bindings for app key `openvr.component.vrcompositor`, the laser and dashboard. |
| `resources/rendermodels/mouselaser_none/` | The invisible model. |
| `resources/localization/localization.json` | Display names (en_US, es_ES) for the controller type and its inputs, as shown in SteamVR's binding screens: "Left Click", "Mouse Wheel" and so on. |
## Changing things safely
- After editing code: `cmake --build build`, then reboot. The `.so` is only loaded when SteamVR starts.
- After editing bindings or the profile: reboot. SteamVR may also cache bindings per controller type under `~/.local/share/Steam/config/` or `~/.config/openvr/`.
- To add a setting: add it to `Settings::Load()`, add the default to `default.vrsettings`, and document it in the README table.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

+61
View File
@@ -0,0 +1,61 @@
# Steam Frame background: why the mouse is trapped, and why a driver fixes it
These are findings from read-only exploration of a Steam Frame on 2026-10-01 (SteamOS 0.4.2 VR, build 20260928.6175029). Paths and versions may change with updates.
## The display chain
The KDE desktop doesn't run on the hardware. It is nested several layers deep:
```
DSI-1 panel (2× 2160x2160 @ 108/120/144 Hz)
└─ vrcompositor (SteamVR) owns the panel
└─ gamescope --backend openvr draws each app as a VR overlay (--virtual-connector-strategy PerAppId)
│ --output-width 1920 --output-height 1080, --vr-overlay-physical-width 2.67
└─ Xwayland :0
└─ kwin_wayland (X11 *windowed* backend), started by /usr/bin/steamos-nested-desktop with --width 1280 --height 800
└─ Plasma + apps
```
- Steam's "Desktop" entry is `/usr/share/applications/deckard-nested-desktop.desktop`. "Deckard" is the Frame's internal codename.
- Gamescope's arguments are hardcoded in `/usr/lib/steamos/gamescope-session` and run from the user unit `gamescope-session.service`. That unit is `PartOf=steamvr.service`, which is why restarting SteamVR kills the desktop.
- Gamescope resizes KWin's window to its own 1920×1080 output, whatever size KWin asks for.
## The input path
```
/dev/input/eventX (mouse) → gamescope (libinput) → focused overlay's Xwayland → KWin (as X11 events)
```
- KWin on the X11 windowed backend never uses libinput, so KDE's mouse and keyboard settings pages are empty. `/org/kde/KWin/InputDevice` doesn't exist.
- Gamescope delivers the mouse only to its **focused** overlay, clamped to that surface. **This is the "bounded" mouse the project is named after.**
## The laser pointer
- The laser belongs to **vrcompositor**. It casts a ray from a *pose* and sends the overlay it hits `VREvent_MouseMove/ButtonDown/ButtonUp/...`. Gamescope turns those into pointer input for its clients.
- The actions are in `/opt/steamvr/resources/config/vrcompositor_actions.json`. The important one is `/actions/lasermouse/in/Pointer` (type **pose**, mandatory), plus LeftClick, RightClick, MiddleClick, scroll and others.
- Frame controller bindings (`/opt/steamvr/drivers/frame_controller/resources/input/vrcompositor_bindings_frame_controller.json`):
- Pointer ← `/user/hand/*/pose/tip`
- LeftClick ← trigger
- "Quick mouse" (`/actions/quickmouse/in/ActivateQuickMouse`) ← squeeze the grip. This just brings the laser up while you're in gamepad mode.
- The Frame **HMD** also has a binding (`drivers/frame_hmd/.../vrcompositor_bindings_frame_hmd.json`): Pointer ← `/user/head/pose/raw`, which is a head-gaze laser.
- **Key insight:** the compositor accepts *any* pose source for the laser. A virtual device with a mouse-aimed pose is therefore enough, and no compositor hacking is needed. This driver is built on that.
## Alternatives that were considered
| idea | why not (yet) |
|---|---|
| Quick mouse | Only an "activate" shortcut. The ray still needs a pose. |
| `lasermouse` mailbox (`ws://127.0.0.1:27062`, messages `dump_laser_overlays`, `remote_laser_mouse_events`, `force_activate_laser_mouse`) | The remote-event payload is an undocumented protobuf (`VRLink_LaserMouseEvent`, from `vr_vrlink.proto`, built with protobuf-lite so there's no schema in the binary). It seems to be meant for VRLink PC streaming. |
| Gamescope flags (`--mouse-sensitivity`, `--force-grab-cursor`, overlay size) | They only change behaviour *inside* one overlay. |
| Running KWin on DRM/libinput | SteamVR owns the panel. |
### Mailbox wire protocol, for reference
This is what the dashboard JS does (`/opt/steamvr/resources/webinterface/dashboard/chunk~*.js`):
```
connect ws://127.0.0.1:27062[?secret=...]
send "mailbox_open <name>"
send "mailbox_send <target> <json>" json: {"type": "...", "returnAddress": "<name>", "message_id": N, ...}
recv JSON; replies mirror message_id
```
## Useful locations
| what | where |
|---|---|
| SteamVR runtime | `/opt/steamvr` (bin/linuxarm64, drivers/, resources/) |
| Driver registration | `~/.config/openvr/openvrpaths.vrpath` (`vrpathreg show/adddriver/removedriver`) |
| User SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` |
| Logs | `~/.local/share/Steam/logs/vrserver.txt`, `vrcompositor.txt`. They're also forwarded to the journal by `/usr/share/deckard/steamvr_logs_to_journald.py`. |
| OpenVR driver header | `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr_driver.h` (SteamVR 2.1 API: `IServerTrackedDeviceProvider_004`, `ITrackedDeviceServerDriver_005`) |
@@ -0,0 +1,7 @@
{
"alwaysActivate": true,
"name" : "mouselaser",
"directory" : "",
"resourceOnly" : false,
"hmd_presence" : []
}
@@ -0,0 +1,21 @@
{
"jsonid" : "input_profile",
"controller_type" : "mouselaser",
"device_class" : "TrackedDeviceClass_Controller",
"resource_root" : "mouselaser",
"driver_name" : "mouselaser",
"input_bindingui_mode" : "controller_handed",
"should_show_binding_errors" : true,
"input_source" : {
"/input/trigger" : { "type" : "trigger", "click" : true, "value" : true, "binding_image_point" : [ 50, 50 ], "order" : 1 },
"/input/a" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 2 },
"/input/b" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 3 },
"/input/grip" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 4 },
"/input/thumbstick" : { "type" : "joystick", "touch" : true, "binding_image_point" : [ 50, 50 ], "order" : 5 },
"/pose/raw" : { "type" : "pose", "binding_image_point" : [ 50, 50 ] },
"/pose/tip" : { "type" : "pose", "binding_image_point" : [ 50, 50 ] }
},
"default_bindings" : [
{ "app_key" : "openvr.component.vrcompositor", "binding_url" : "vrcompositor_bindings_mouselaser.json" }
]
}
@@ -0,0 +1,203 @@
{
"action_manifest_version": 0,
"alias_info": {},
"app_key": "openvr.component.vrcompositor",
"category": "steamvr_input",
"controller_type": "mouselaser",
"description": "Mouse aims the SteamVR laser. Buttons click, and the wheel acts as the thumbstick (lists and scrolling).",
"name": "Mouse Laser: dashboard and overlays",
"options": {},
"simulated_actions": [],
"bindings": {
"/actions/lasermouse": {
"poses": [
{
"output": "/actions/lasermouse/in/pointer",
"path": "/user/hand/left/pose/raw"
},
{
"output": "/actions/lasermouse/in/pointer",
"path": "/user/hand/right/pose/raw"
}
],
"sources": [
{
"path": "/user/hand/left/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/leftclick"
}
}
},
{
"path": "/user/hand/right/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/leftclick"
}
}
},
{
"path": "/user/hand/left/input/a",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/rightclick"
}
}
},
{
"path": "/user/hand/right/input/a",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/rightclick"
}
}
},
{
"path": "/user/hand/left/input/b",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/middleclick"
}
}
},
{
"path": "/user/hand/right/input/b",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse/in/middleclick"
}
}
}
]
},
"/actions/lasermouse_secondary": {
"sources": [
{
"path": "/user/hand/left/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
}
}
},
{
"path": "/user/hand/right/input/trigger",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
}
}
}
]
},
"/actions/quickmouse": {
"sources": [
{
"path": "/user/hand/left/input/grip",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/quickmouse/in/ActivateQuickMouse"
}
}
},
{
"path": "/user/hand/right/input/grip",
"mode": "button",
"inputs": {
"click": {
"output": "/actions/quickmouse/in/ActivateQuickMouse"
}
}
}
]
},
"/actions/scroll_discrete": {
"sources": [
{
"path": "/user/hand/left/input/thumbstick",
"mode": "scroll",
"inputs": {
"scroll": {
"output": "/actions/scroll_discrete/in/scroll"
}
}
},
{
"path": "/user/hand/right/input/thumbstick",
"mode": "scroll",
"inputs": {
"scroll": {
"output": "/actions/scroll_discrete/in/scroll"
}
}
}
]
},
"/actions/dualanalog": {
"sources": [
{
"path": "/user/hand/left/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/dualanalog/in/leftvalue"
},
"touch": {
"output": "/actions/dualanalog/in/lefttouch"
}
}
},
{
"path": "/user/hand/right/input/thumbstick",
"mode": "joystick",
"inputs": {
"position": {
"output": "/actions/dualanalog/in/rightvalue"
},
"touch": {
"output": "/actions/dualanalog/in/righttouch"
}
}
}
]
},
"/actions/scroll_smooth": {
"sources": [
{
"path": "/user/hand/left/input/thumbstick",
"mode": "scroll",
"parameters": {
"scroll_mode": "smooth"
},
"inputs": {
"scroll": {
"output": "/actions/scroll_smooth/in/scroll"
}
}
},
{
"path": "/user/hand/right/input/thumbstick",
"mode": "scroll",
"parameters": {
"scroll_mode": "smooth"
},
"inputs": {
"scroll": {
"output": "/actions/scroll_smooth/in/scroll"
}
}
}
]
}
}
}
@@ -0,0 +1,20 @@
[
{
"language_tag": "en_US",
"mouselaser": "Mouse Laser",
"/input/trigger": "Left Click",
"/input/a": "Right Click",
"/input/b": "Middle Click",
"/input/grip": "Laser Mode (held while active)",
"/input/thumbstick": "Mouse Wheel"
},
{
"language_tag": "es_ES",
"mouselaser": "Láser de ratón",
"/input/trigger": "Clic izquierdo",
"/input/a": "Clic derecho",
"/input/b": "Clic central",
"/input/grip": "Modo láser (activo mientras está encendido)",
"/input/thumbstick": "Rueda del ratón"
}
]
@@ -0,0 +1,3 @@
newmtl none
Kd 0 0 0
map_Kd mouselaser_none.png
@@ -0,0 +1,11 @@
# effectively invisible: one 0.1 mm triangle
mtllib mouselaser_none.mtl
v 0 0 0
v 0.0001 0 0
v 0 0.0001 0
vt 0 0
vt 1 0
vt 0 1
vn 0 0 1
usemtl none
f 1/1/1 2/2/1 3/3/1
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,20 @@
{
"driver_mouselaser": {
"enable": true,
"sensitivity": 0.05,
"toggleButton": 276,
"role": 1,
"deviceNameFilter": "",
"originOffsetY": -0.08,
"invertY": false,
"wheelMode": "smooth",
"wheelDeflection": 1.0,
"wheelSmoothMin": 0.7,
"wheelSmoothImpulse": 0.3,
"wheelSmoothHoldMs": 80,
"wheelSmoothDecayMs": 150,
"wheelPressMs": 90,
"wheelReleaseMs": 60,
"wheelMaxQueued": 10
}
}
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# Register the mouselaser driver with SteamVR.
# Only touches ~/.config/openvr/openvrpaths.vrpath (a backup is made first).
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)/driver/mouselaser"
VRPATHS="$HOME/.config/openvr/openvrpaths.vrpath"
VRPATHREG=/opt/steamvr/bin/linuxarm64/vrpathreg
[ -f "$DIR/bin/linuxarm64/driver_mouselaser.so" ] || {
echo "Driver not built. Run: cmake -S . -B build -G Ninja && cmake --build build"
exit 1
}
# Refuse if a mouselaser driver is already registered from another path:
# two drivers with the same name would conflict.
OTHER=$(python3 - "$VRPATHS" "$DIR" <<'PY'
import json, os, sys
try:
paths = json.load(open(sys.argv[1])).get("external_drivers") or []
except FileNotFoundError:
paths = []
for p in paths:
if os.path.realpath(p) == os.path.realpath(sys.argv[2]):
continue
try:
if json.load(open(os.path.join(p, "driver.vrdrivermanifest"))).get("name") == "mouselaser":
print(p)
except (OSError, ValueError):
if os.path.basename(p.rstrip("/")) == "mouselaser":
print(p)
PY
)
if [ -n "$OTHER" ]; then
echo "Another mouselaser driver is already registered:"
echo "$OTHER"
echo "Remove it first: $VRPATHREG removedriver <path>"
exit 1
fi
[ -f "$VRPATHS" ] && cp "$VRPATHS" "$VRPATHS.bak-mouselaser"
"$VRPATHREG" adddriver "$DIR"
"$VRPATHREG" show | grep -A5 -i "external drivers" || true
echo "Registered. Reboot to load it (on the Steam Frame, restarting SteamVR also restarts gamescope)."
+525
View File
@@ -0,0 +1,525 @@
// SPDX-License-Identifier: MIT
// mouselaser: a virtual SteamVR controller whose pose is aimed by a physical mouse,
// so the SteamVR laser pointer can be driven by the mouse across every overlay.
//
// 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.
//
// EXPERIMENTAL and AI-generated ("vibecoded"); see README.md before relying on it.
#include <openvr_driver.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <dirent.h>
#include <fcntl.h>
#include <linux/input.h>
#include <poll.h>
#include <sys/ioctl.h>
#include <unistd.h>
using namespace vr;
static const char *k_section = "driver_mouselaser";
static const char *k_version = "0.3.1-experimental";
static void Log(const char *fmt, ...)
{
char buf[1024];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
if (VRDriverLog())
VRDriverLog()->Log(buf);
}
static bool TestBit(const unsigned long *bits, int bit)
{
const int bpl = sizeof(unsigned long) * 8;
return (bits[bit / bpl] >> (bit % bpl)) & 1;
}
struct Settings
{
float sensitivityDeg = 0.05f; // degrees of ray rotation per mouse count
int toggleButton = BTN_EXTRA;
int role = TrackedControllerRole_LeftHand;
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;
// How wheel notches drive the virtual thumbstick:
// smooth: each notch bumps the stick, which eases back to centre; spinning holds it.
// step: each notch is one flick, pushed for wheelPressMs then centred for wheelReleaseMs.
bool wheelSmooth = true;
float wheelSmoothMin = 0.7f; // deflection right after a single notch
float wheelSmoothImpulse = 0.3f; // extra deflection per further notch
int wheelSmoothHoldMs = 80; // no decay this long after a notch
int wheelSmoothDecayMs = 150; // exponential decay time constant
int wheelPressMs = 90;
int wheelReleaseMs = 60;
float wheelDeflection = 1.0f; // maximum deflection (both modes)
int wheelMaxQueued = 10; // notches buffered while flicks are still playing out
void Load()
{
EVRSettingsError err;
IVRSettings *s = VRSettings();
float f = s->GetFloat(k_section, "sensitivity", &err);
if (err == VRSettingsError_None) sensitivityDeg = f;
int32_t i = s->GetInt32(k_section, "toggleButton", &err);
if (err == VRSettingsError_None) toggleButton = i;
i = s->GetInt32(k_section, "role", &err);
if (err == VRSettingsError_None) role = i;
char buf[256] = {};
s->GetString(k_section, "deviceNameFilter", buf, sizeof(buf), &err);
if (err == VRSettingsError_None) nameFilter = buf;
f = s->GetFloat(k_section, "originOffsetY", &err);
if (err == VRSettingsError_None) originOffsetY = f;
bool b = s->GetBool(k_section, "invertY", &err);
if (err == VRSettingsError_None) invertY = b;
i = s->GetInt32(k_section, "wheelPressMs", &err);
if (err == VRSettingsError_None) wheelPressMs = i;
i = s->GetInt32(k_section, "wheelReleaseMs", &err);
if (err == VRSettingsError_None) wheelReleaseMs = i;
f = s->GetFloat(k_section, "wheelDeflection", &err);
if (err == VRSettingsError_None) wheelDeflection = f;
i = s->GetInt32(k_section, "wheelMaxQueued", &err);
if (err == VRSettingsError_None) wheelMaxQueued = i;
s->GetString(k_section, "wheelMode", buf, sizeof(buf), &err);
if (err == VRSettingsError_None) wheelSmooth = strcmp(buf, "step") != 0;
f = s->GetFloat(k_section, "wheelSmoothMin", &err);
if (err == VRSettingsError_None) wheelSmoothMin = f;
f = s->GetFloat(k_section, "wheelSmoothImpulse", &err);
if (err == VRSettingsError_None) wheelSmoothImpulse = f;
i = s->GetInt32(k_section, "wheelSmoothHoldMs", &err);
if (err == VRSettingsError_None) wheelSmoothHoldMs = i;
i = s->GetInt32(k_section, "wheelSmoothDecayMs", &err);
if (err == VRSettingsError_None) wheelSmoothDecayMs = i;
}
};
// Turns wheel notches on one axis into thumbstick deflection, in "smooth" or "step" mode
// (see Settings). A change of direction drops whatever was still pending the other way.
class StickStepper
{
public:
float Update(int ticks, const Settings &s)
{
using clock = std::chrono::steady_clock;
clock::time_point now = clock::now();
return s.wheelSmooth ? UpdateSmooth(ticks, s, now) : UpdateStep(ticks, s, now);
}
void Reset() { m_queued = 0, m_pressed = false, m_mag = 0; }
private:
float UpdateSmooth(int ticks, const Settings &s, std::chrono::steady_clock::time_point now)
{
float dt = std::chrono::duration<float>(now - m_last).count();
m_last = now;
if (ticks != 0)
{
float d = ticks > 0 ? 1.f : -1.f;
if (d != m_dir) m_dir = d, m_mag = 0;
float bumped = m_mag > 0 ? m_mag + s.wheelSmoothImpulse * std::abs(ticks)
: s.wheelSmoothMin + s.wheelSmoothImpulse * (std::abs(ticks) - 1);
m_mag = std::min(s.wheelDeflection, std::max(bumped, s.wheelSmoothMin));
m_lastNotch = now;
}
else if (m_mag > 0 && now - m_lastNotch > std::chrono::milliseconds(s.wheelSmoothHoldMs))
{
m_mag *= expf(-dt * 1000.f / float(std::max(1, s.wheelSmoothDecayMs)));
if (m_mag < 0.1f) m_mag = 0;
}
return m_dir * m_mag;
}
float UpdateStep(int ticks, const Settings &s, std::chrono::steady_clock::time_point now)
{
if (ticks != 0)
{
float d = ticks > 0 ? 1.f : -1.f;
if (d != m_dir)
{
m_dir = d;
m_queued = 0;
m_pressed = false;
m_until = now;
}
m_queued = std::min(m_queued + std::abs(ticks), s.wheelMaxQueued);
}
if (now >= m_until)
{
if (m_pressed)
{
m_pressed = false;
m_until = now + std::chrono::milliseconds(s.wheelReleaseMs);
}
else if (m_queued > 0)
{
m_queued--;
m_pressed = true;
m_until = now + std::chrono::milliseconds(s.wheelPressMs);
}
}
return m_pressed ? m_dir * s.wheelDeflection : 0.f;
}
// step mode
int m_queued = 0;
bool m_pressed = false;
float m_dir = 0;
std::chrono::steady_clock::time_point m_until;
// smooth mode
float m_mag = 0;
std::chrono::steady_clock::time_point m_last, m_lastNotch;
};
// Reads the physical mouse on its own thread. All state shared with RunFrame is atomic.
class MouseReader
{
public:
std::atomic<bool> active{false};
std::atomic<bool> toggled{false}; // set on every toggle; RunFrame clears it
std::atomic<int> dx{0}, dy{0};
std::atomic<int> wheel{0}, hwheel{0};
std::atomic<bool> left{false}, right{false}, middle{false};
// Destroying a joinable std::thread calls std::terminate (taking vrserver down),
// so make sure the thread is stopped even if Deactivate() was never called.
~MouseReader() { Stop(); }
void Start(const Settings &s)
{
m_settings = s;
m_run = true;
m_thread = std::thread(&MouseReader::Loop, this);
}
void Stop()
{
m_run = false;
if (m_thread.joinable())
m_thread.join();
Close();
}
private:
Settings m_settings;
std::atomic<bool> m_run{false};
std::thread m_thread;
int m_fd = -1;
bool IsMouse(int fd, std::string &name)
{
unsigned long evbits[(EV_MAX + 1) / (8 * sizeof(long)) + 1] = {};
unsigned long relbits[(REL_MAX + 1) / (8 * sizeof(long)) + 1] = {};
unsigned long keybits[(KEY_MAX + 1) / (8 * sizeof(long)) + 1] = {};
char n[256] = {};
if (ioctl(fd, EVIOCGNAME(sizeof(n)), n) < 0) return false;
name = n;
if (ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits) < 0) return false;
if (!TestBit(evbits, EV_REL) || !TestBit(evbits, EV_KEY)) return false;
ioctl(fd, EVIOCGBIT(EV_REL, sizeof(relbits)), relbits);
ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits);
if (!TestBit(relbits, REL_X) || !TestBit(relbits, REL_Y) || !TestBit(keybits, BTN_LEFT))
return false;
return m_settings.nameFilter.empty() || name.find(m_settings.nameFilter) != std::string::npos;
}
bool Open()
{
DIR *dir = opendir("/dev/input");
if (!dir) return false;
while (dirent *e = readdir(dir))
{
if (strncmp(e->d_name, "event", 5) != 0) continue;
std::string path = std::string("/dev/input/") + e->d_name;
int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) continue;
std::string name;
if (IsMouse(fd, name))
{
m_fd = fd;
Log("using %s (%s)\n", path.c_str(), name.c_str());
closedir(dir);
return true;
}
close(fd);
}
closedir(dir);
return false;
}
void Close()
{
if (m_fd >= 0)
{
ioctl(m_fd, EVIOCGRAB, 0);
close(m_fd);
m_fd = -1;
}
SetActive(false);
}
void SetActive(bool on)
{
if (active == on) return;
if (m_fd >= 0 && ioctl(m_fd, EVIOCGRAB, on ? 1 : 0) < 0 && on)
{
Log("EVIOCGRAB failed (%s), staying inactive\n", strerror(errno));
return;
}
left = right = middle = false;
dx = dy = wheel = hwheel = 0;
active = on;
toggled = true;
Log("%s\n", on ? "ACTIVE (mouse drives the laser)" : "inactive (mouse back to desktop)");
}
void Loop()
{
while (m_run)
{
if (m_fd < 0 && !Open())
{
for (int i = 0; i < 20 && m_run; i++) usleep(100 * 1000);
continue;
}
pollfd pfd = {m_fd, POLLIN, 0};
int r = poll(&pfd, 1, 200);
if (r <= 0) continue;
if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))
{
Log("mouse disappeared\n");
Close();
continue;
}
input_event ev;
ssize_t n;
while ((n = read(m_fd, &ev, sizeof(ev))) == sizeof(ev))
Handle(ev);
if (n < 0 && errno == ENODEV)
{
Log("mouse disappeared\n");
Close();
}
}
}
void Handle(const input_event &ev)
{
if (ev.type == EV_KEY && ev.code == m_settings.toggleButton)
{
if (ev.value == 1) SetActive(!active);
return;
}
if (!active) return;
if (ev.type == EV_REL)
{
if (ev.code == REL_X) dx += ev.value;
else if (ev.code == REL_Y) dy += ev.value;
else if (ev.code == REL_WHEEL) wheel += ev.value;
else if (ev.code == REL_HWHEEL) hwheel += ev.value;
}
else if (ev.type == EV_KEY)
{
if (ev.code == BTN_LEFT) left = ev.value != 0;
else if (ev.code == BTN_RIGHT) right = ev.value != 0;
else if (ev.code == BTN_MIDDLE) middle = ev.value != 0;
}
}
};
class MouseLaserDevice final : public ITrackedDeviceServerDriver
{
public:
explicit MouseLaserDevice(const Settings &s) : m_settings(s) {}
EVRInitError Activate(uint32_t id) override
{
m_id = id;
PropertyContainerHandle_t c = VRProperties()->TrackedDeviceToPropertyContainer(id);
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "Mouse Laser (virtual)");
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "frame-unboundedMouse-vibed");
VRProperties()->SetStringProperty(c, Prop_ControllerType_String, "mouselaser");
VRProperties()->SetStringProperty(c, Prop_InputProfilePath_String, "{mouselaser}/input/mouselaser_profile.json");
VRProperties()->SetStringProperty(c, Prop_RenderModelName_String, "{mouselaser}mouselaser_none");
VRProperties()->SetInt32Property(c, Prop_ControllerRoleHint_Int32, m_settings.role);
VRProperties()->SetBoolProperty(c, Prop_DeviceProvidesBatteryStatus_Bool, false);
VRDriverInput()->CreateBooleanComponent(c, "/input/trigger/click", &m_trigger);
VRDriverInput()->CreateScalarComponent(c, "/input/trigger/value", &m_triggerValue,
VRScalarType_Absolute, VRScalarUnits_NormalizedOneSided);
VRDriverInput()->CreateBooleanComponent(c, "/input/a/click", &m_a);
VRDriverInput()->CreateBooleanComponent(c, "/input/b/click", &m_b);
VRDriverInput()->CreateBooleanComponent(c, "/input/grip/click", &m_grip);
VRDriverInput()->CreateBooleanComponent(c, "/input/thumbstick/touch", &m_stickTouch);
VRDriverInput()->CreateScalarComponent(c, "/input/thumbstick/x", &m_stickX,
VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
VRDriverInput()->CreateScalarComponent(c, "/input/thumbstick/y", &m_stickY,
VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
m_mouse.Start(m_settings);
Log("activated as device %u, role %d\n", id, m_settings.role);
return VRInitError_None;
}
void Deactivate() override
{
m_mouse.Stop();
m_id = k_unTrackedDeviceIndexInvalid;
}
void EnterStandby() override {}
void *GetComponent(const char *) override { return nullptr; }
void DebugRequest(const char *, char *resp, uint32_t size) override
{
if (size) resp[0] = 0;
}
DriverPose_t GetPose() override
{
std::lock_guard<std::mutex> lock(m_poseMutex);
return m_pose;
}
void RunFrame()
{
if (m_id == k_unTrackedDeviceIndexInvalid) return;
TrackedDevicePose_t hmd = {};
VRServerDriverHost()->GetRawTrackedDevicePoses(0, &hmd, 1);
const HmdMatrix34_t &m = hmd.mDeviceToAbsoluteTracking;
bool active = m_mouse.active;
if (m_mouse.toggled.exchange(false) && active && hmd.bPoseIsValid)
{
// Start the ray where the user is looking.
float fx = -m.m[0][2], fy = -m.m[1][2], fz = -m.m[2][2];
m_yaw = atan2f(-fx, -fz);
m_pitch = asinf(fmaxf(-1.f, fminf(1.f, fy)));
}
const float k = m_settings.sensitivityDeg * float(M_PI) / 180.f;
m_yaw -= m_mouse.dx.exchange(0) * k;
m_pitch -= m_mouse.dy.exchange(0) * k * (m_settings.invertY ? -1.f : 1.f);
const float limit = 89.f * float(M_PI) / 180.f;
m_pitch = fmaxf(-limit, fminf(limit, m_pitch));
DriverPose_t pose = {};
pose.qWorldFromDriverRotation.w = 1;
pose.qDriverFromHeadRotation.w = 1;
pose.deviceIsConnected = active;
pose.poseIsValid = active && 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);
pose.qRotation.w = cy * cp;
pose.qRotation.x = cy * sp;
pose.qRotation.y = sy * cp;
pose.qRotation.z = -sy * sp;
{
std::lock_guard<std::mutex> lock(m_poseMutex);
m_pose = pose;
}
VRServerDriverHost()->TrackedDevicePoseUpdated(m_id, pose, sizeof(DriverPose_t));
// Wheel notches become thumbstick flicks: vertical wheel -> Y, tilt wheel -> X.
if (!active) m_stepY.Reset(), m_stepX.Reset();
float stickY = m_stepY.Update(m_mouse.wheel.exchange(0), m_settings);
float stickX = m_stepX.Update(m_mouse.hwheel.exchange(0), m_settings);
bool l = active && m_mouse.left;
VRDriverInput()->UpdateBooleanComponent(m_trigger, l, 0);
VRDriverInput()->UpdateScalarComponent(m_triggerValue, l ? 1.f : 0.f, 0);
VRDriverInput()->UpdateBooleanComponent(m_a, active && m_mouse.right, 0);
VRDriverInput()->UpdateBooleanComponent(m_b, active && m_mouse.middle, 0);
VRDriverInput()->UpdateBooleanComponent(m_grip, active, 0); // holds "quick mouse" on
VRDriverInput()->UpdateBooleanComponent(m_stickTouch, stickX != 0.f || stickY != 0.f, 0);
VRDriverInput()->UpdateScalarComponent(m_stickX, stickX, 0);
VRDriverInput()->UpdateScalarComponent(m_stickY, stickY, 0);
}
private:
Settings m_settings;
MouseReader m_mouse;
uint32_t m_id = k_unTrackedDeviceIndexInvalid;
std::mutex m_poseMutex;
DriverPose_t m_pose = {};
float m_yaw = 0, m_pitch = 0;
StickStepper m_stepX, m_stepY;
VRInputComponentHandle_t m_trigger = 0, m_triggerValue = 0, m_a = 0, m_b = 0, m_grip = 0;
VRInputComponentHandle_t m_stickTouch = 0, m_stickX = 0, m_stickY = 0;
};
class MouseLaserProvider : public IServerTrackedDeviceProvider
{
public:
EVRInitError Init(IVRDriverContext *ctx) override
{
VR_INIT_SERVER_DRIVER_CONTEXT(ctx);
Log("version %s\n", k_version);
Settings s;
s.Load();
EVRSettingsError err;
bool enable = VRSettings()->GetBool(k_section, "enable", &err);
if (err == VRSettingsError_None && !enable)
{
Log("disabled by setting\n");
return VRInitError_None;
}
m_device = new MouseLaserDevice(s);
if (!VRServerDriverHost()->TrackedDeviceAdded("mouselaser-0", TrackedDeviceClass_Controller, m_device))
Log("TrackedDeviceAdded failed\n");
return VRInitError_None;
}
void Cleanup() override
{
delete m_device;
m_device = nullptr;
VR_CLEANUP_SERVER_DRIVER_CONTEXT();
}
const char *const *GetInterfaceVersions() override { return k_InterfaceVersions; }
void RunFrame() override
{
if (m_device) m_device->RunFrame();
VREvent_t ev;
while (VRServerDriverHost()->PollNextEvent(&ev, sizeof(ev))) {}
}
bool ShouldBlockStandbyMode() override { return false; }
void EnterStandby() override {}
void LeaveStandby() override {}
private:
MouseLaserDevice *m_device = nullptr;
};
static MouseLaserProvider g_provider;
extern "C" __attribute__((visibility("default"))) void *HmdDriverFactory(const char *iface, int *ret)
{
if (strcmp(iface, IServerTrackedDeviceProvider_Version) == 0)
return &g_provider;
if (ret) *ret = VRInitError_Init_InterfaceNotFound;
return nullptr;
}
+54
View File
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: MIT
// Offline simulation of the wheel -> thumbstick logic (StickStepper), no SteamVR needed.
// Build with -DMOUSELASER_TESTS=ON and run build/wheel_sim.
//
// Each line is a 120 Hz timeline: a digit is |deflection| in tenths, a letter (a-j) is a
// negative deflection, '.' is centred. Use it to sanity-check wheel* setting changes.
#include "../src/driver.cpp"
#include <cstdio>
#include <vector>
static int Run(const char *name, bool smooth, const std::vector<std::pair<int, int>> &notches, int frames)
{
Settings s;
s.wheelSmooth = smooth;
StickStepper st;
std::string line;
int above = 0;
for (int f = 0; f < frames; f++)
{
int n = 0;
for (auto &p : notches)
if (p.first == f) n += p.second;
float v = st.Update(n, s);
float a = fabsf(v);
if (a >= 0.5f) above++;
int d = std::min(9, int(a * 10));
line += a == 0 ? '.' : (v < 0 ? "abcdefghij"[d] : char('0' + d));
usleep(8333);
}
printf("%-6s %-24s frames>=0.5: %3d\n %s\n", smooth ? "smooth" : "step", name, above, line.c_str());
return above;
}
int main()
{
std::vector<std::pair<int, int>> spin, slow;
for (int f = 2; f < 110; f += 12) spin.push_back({f, 1}); // ~10 notches/s
for (int f = 2; f < 110; f += 40) slow.push_back({f, 1}); // ~3 notches/s
int failures = 0;
for (bool smooth : {true, false})
{
int one = Run("1 notch", smooth, {{2, 1}}, 40);
Run("slow 3 notches/s", smooth, slow, 130);
int fast = Run("spin 10 notches/s", smooth, spin, 130);
Run("reverse (+2 then -1)", smooth, {{2, 2}, {8, -1}}, 50);
// A single notch must register as a step; spinning must hold the stick most of the time.
if (one < 5) printf("FAIL: single notch too short\n"), failures++;
if (smooth && fast < 100) printf("FAIL: smooth spin does not hold the stick\n"), failures++;
}
return failures ? 1 : 0;
}
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# Unregister the mouselaser driver from SteamVR.
set -eu
DIR="$(cd "$(dirname "$0")" && pwd)/driver/mouselaser"
/opt/steamvr/bin/linuxarm64/vrpathreg removedriver "$DIR"
echo "Unregistered. Reboot to unload it."