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. + +

+ Demo: the mouse aims the Steam Frame's laser pointer across the KDE desktop, the Steam UI and other VR overlays +

+ +> [!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."