diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42f6598
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+# Build output (build locally, see README)
+/build/
+/driver/mouselaser/bin/
+
+.cache
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..02c49b0
--- /dev/null
+++ b/CLAUDE.md
@@ -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.
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..d022ff6
--- /dev/null
+++ b/CMakeLists.txt
@@ -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()
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8d3634f
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index ff2e121..0b810ce 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
+
+
+
+> [!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=`.
+
+## 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 ()"
+```
+
+### 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 /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.
diff --git a/docs/development-log.md b/docs/development-log.md
new file mode 100644
index 0000000..53b0622
--- /dev/null
+++ b/docs/development-log.md
@@ -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_.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 (`/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.
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
new file mode 100644
index 0000000..f0b70c3
--- /dev/null
+++ b/docs/how-it-works.md
@@ -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.
diff --git a/docs/media/demo.webp b/docs/media/demo.webp
new file mode 100644
index 0000000..0f7e1ed
Binary files /dev/null and b/docs/media/demo.webp differ
diff --git a/docs/steam-frame-background.md b/docs/steam-frame-background.md
new file mode 100644
index 0000000..1f00536
--- /dev/null
+++ b/docs/steam-frame-background.md
@@ -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 "
+send "mailbox_send " json: {"type": "...", "returnAddress": "", "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`) |
diff --git a/driver/mouselaser/driver.vrdrivermanifest b/driver/mouselaser/driver.vrdrivermanifest
new file mode 100644
index 0000000..e944a79
--- /dev/null
+++ b/driver/mouselaser/driver.vrdrivermanifest
@@ -0,0 +1,7 @@
+{
+ "alwaysActivate": true,
+ "name" : "mouselaser",
+ "directory" : "",
+ "resourceOnly" : false,
+ "hmd_presence" : []
+}
diff --git a/driver/mouselaser/resources/input/mouselaser_profile.json b/driver/mouselaser/resources/input/mouselaser_profile.json
new file mode 100644
index 0000000..f3f4b72
--- /dev/null
+++ b/driver/mouselaser/resources/input/mouselaser_profile.json
@@ -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" }
+ ]
+}
diff --git a/driver/mouselaser/resources/input/vrcompositor_bindings_mouselaser.json b/driver/mouselaser/resources/input/vrcompositor_bindings_mouselaser.json
new file mode 100644
index 0000000..d1ad392
--- /dev/null
+++ b/driver/mouselaser/resources/input/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"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/driver/mouselaser/resources/localization/localization.json b/driver/mouselaser/resources/localization/localization.json
new file mode 100644
index 0000000..332bb31
--- /dev/null
+++ b/driver/mouselaser/resources/localization/localization.json
@@ -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"
+ }
+]
diff --git a/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.mtl b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.mtl
new file mode 100644
index 0000000..894a206
--- /dev/null
+++ b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.mtl
@@ -0,0 +1,3 @@
+newmtl none
+Kd 0 0 0
+map_Kd mouselaser_none.png
diff --git a/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.obj b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.obj
new file mode 100644
index 0000000..8162db8
--- /dev/null
+++ b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.obj
@@ -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
diff --git a/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.png b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.png
new file mode 100644
index 0000000..35a340b
Binary files /dev/null and b/driver/mouselaser/resources/rendermodels/mouselaser_none/mouselaser_none.png differ
diff --git a/driver/mouselaser/resources/settings/default.vrsettings b/driver/mouselaser/resources/settings/default.vrsettings
new file mode 100644
index 0000000..ee293db
--- /dev/null
+++ b/driver/mouselaser/resources/settings/default.vrsettings
@@ -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
+ }
+}
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..ce409bc
--- /dev/null
+++ b/install.sh
@@ -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 "
+ 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)."
diff --git a/src/driver.cpp b/src/driver.cpp
new file mode 100644
index 0000000..dc1661d
--- /dev/null
+++ b/src/driver.cpp
@@ -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
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+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(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 active{false};
+ std::atomic toggled{false}; // set on every toggle; RunFrame clears it
+ std::atomic dx{0}, dy{0};
+ std::atomic wheel{0}, hwheel{0};
+ std::atomic 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 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 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 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;
+}
diff --git a/tests/wheel_sim.cpp b/tests/wheel_sim.cpp
new file mode 100644
index 0000000..c753ea4
--- /dev/null
+++ b/tests/wheel_sim.cpp
@@ -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
+#include
+
+static int Run(const char *name, bool smooth, const std::vector> ¬ches, 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> 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;
+}
diff --git a/uninstall.sh b/uninstall.sh
new file mode 100755
index 0000000..d608c35
--- /dev/null
+++ b/uninstall.sh
@@ -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."