mirror of
https://github.com/Andalu30/frame-unboundedMouse-vibed.git
synced 2026-10-04 22:00:03 +02:00
Initial commit, with working version
This commit is contained in:
1 parent
49589f3309
commit
da48c37235
21 files changed
+1349
-1
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Build output (build locally, see README)
|
||||
/build/
|
||||
/driver/mouselaser/bin/
|
||||
|
||||
.cache
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -1,2 +1,143 @@
|
||||
# frame-unboundedMouse-vibed
|
||||
An experimental steamvr driver to control the laser pointer of Steam Frame with a mouse
|
||||
|
||||
An experimental SteamVR driver that lets you control the **Steam Frame's laser pointer with a regular mouse**, across every VR overlay (Steam UI, the KDE desktop, the SteamVR dashboard, popups), instead of the mouse being trapped inside the desktop window.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/media/demo.webp" alt="Demo: the mouse aims the Steam Frame's laser pointer across the KDE desktop, the Steam UI and other VR overlays" width="800">
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> ## ⚠️ This project is vibecoded. Read this first.
|
||||
>
|
||||
> This driver was written almost entirely by an AI coding assistant (Claude) during one exploratory session on a Steam Frame, with a human steering, testing and approving each step. It has **not** been reviewed the way hand-written, production code would be.
|
||||
>
|
||||
> - **Experimental.** It works on one headset, one SteamOS build and one mouse (see [Tested on](#tested-on)). It may not work on yours.
|
||||
> - **It runs inside `vrserver`.** A bug in the driver can crash SteamVR. On the Frame, SteamVR is your whole session, so the headset can end up showing nothing until you remove the driver (see [Recovery](#recovery)).
|
||||
> - **It grabs your mouse.** While the laser mode is on, the driver takes exclusive control of the mouse (`EVIOCGRAB`), and nothing else receives its input.
|
||||
> - **It relies on undocumented behaviour.** It depends on SteamVR internals that Valve can change in any update: the compositor's `lasermouse` action set and Frame-specific bindings.
|
||||
> - **No warranty.** Use it at your own risk. Read the code first; it's a single ~400-line file: [`src/driver.cpp`](src/driver.cpp).
|
||||
|
||||
## What it does
|
||||
|
||||
The Frame's laser pointer is drawn by SteamVR's compositor. The compositor aims it with whatever tracked device supplies a *pose*, normally a controller. A physical mouse never reaches that system: gamescope consumes it and clamps it to whichever window has focus.
|
||||
|
||||
This driver adds a **virtual controller** to SteamVR:
|
||||
- **Position:** follows your head.
|
||||
- **Direction:** aimed by mouse movement.
|
||||
- **Buttons:** your mouse buttons.
|
||||
|
||||
The compositor then treats it like any other laser-pointing hand.
|
||||
|
||||
| Mouse | Laser mode OFF (default) | Laser mode ON |
|
||||
|---|---|---|
|
||||
| Forward side button (BTN_EXTRA) | **Turns laser mode on** | **Turns laser mode off** |
|
||||
| Movement | Normal desktop cursor | Aims the laser (world-locked: turning your head doesn't move it) |
|
||||
| Left / right / middle button | Normal | Laser click / right click / middle click |
|
||||
| Wheel (and tilt wheel) | Normal | Acts like the controller **thumbstick**. One notch moves one item in Steam UI lists, and spinning the wheel scrolls continuously. It also scrolls the overlay under the laser. |
|
||||
|
||||
When laser mode turns on, the ray starts where you're looking. When it's off, the virtual device reports itself as disconnected, so your real controllers keep the laser.
|
||||
|
||||
## Requirements
|
||||
- A Steam Frame (aarch64, SteamOS VR variant) with SteamVR at `/opt/steamvr`.
|
||||
- A USB or Bluetooth mouse. Your user must be able to read `/dev/input/event*`; the default `steamos` user is in the `input` group.
|
||||
- `gcc` or `clang`, `cmake` and `ninja`. They are not guaranteed to be on a stock image; installing them may require disabling SteamOS read-only mode.
|
||||
- The OpenVR driver header is **not vendored**. The build uses the copy SteamVR ships at `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers`. Override it with `-DOPENVR_HEADERS=<dir>`.
|
||||
|
||||
## Build and install
|
||||
```sh
|
||||
cmake -S . -B build -G Ninja && cmake --build build
|
||||
./install.sh # registers driver/mouselaser with vrpathreg (backs up openvrpaths.vrpath first)
|
||||
# then REBOOT the headset
|
||||
```
|
||||
|
||||
`install.sh` refuses to run if another driver named `mouselaser` is already registered from a different folder.
|
||||
|
||||
Check that it loaded:
|
||||
```sh
|
||||
grep -a 'mouselaser:' ~/.local/share/Steam/logs/vrserver.txt | tail
|
||||
# expect: "version 0.3.1-experimental", "activated as device N, role 1", "using /dev/input/eventX (<your mouse>)"
|
||||
```
|
||||
|
||||
### Optional: offline wheel test
|
||||
```sh
|
||||
cmake -S . -B build -DMOUSELASER_TESTS=ON && cmake --build build && ./build/wheel_sim
|
||||
```
|
||||
This simulates the wheel → thumbstick logic at 120 Hz, without SteamVR, and exits non-zero if a single notch or a smooth spin misbehaves. It is handy when changing the `wheel*` defaults.
|
||||
|
||||
## Uninstall
|
||||
```sh
|
||||
./uninstall.sh # then reboot
|
||||
```
|
||||
|
||||
## Settings
|
||||
Add any of these to `~/.config/openvr/config/steamvr.vrsettings` under a `"driver_mouselaser"` section. They are read once, when SteamVR starts.
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `enable` | `true` | `false` loads the driver but adds no device. This is the kill switch. |
|
||||
| `sensitivity` | `0.05` | Degrees of ray rotation per mouse count. |
|
||||
| `toggleButton` | `276` | evdev key code of the toggle: 276 = BTN_EXTRA (forward), 275 = BTN_SIDE (back). |
|
||||
| `role` | `1` | 1 = left hand, 2 = right hand. |
|
||||
| `deviceNameFilter` | `""` | Substring of the evdev mouse name. Empty means the first device with REL_X/REL_Y and BTN_LEFT. |
|
||||
| `originOffsetY` | `-0.08` | Ray origin height relative to the HMD, in metres. |
|
||||
| `invertY` | `false` | Invert vertical aim. |
|
||||
| `wheelMode` | `"smooth"` | `"smooth"`: each notch bumps the virtual stick, which then eases back, and spinning holds it. `"step"`: each notch is one fixed flick (the 0.2.0 behaviour). |
|
||||
| `wheelDeflection` | `1.0` | Maximum stick deflection (0–1), in both modes. |
|
||||
| `wheelSmoothMin` | `0.7` | *smooth:* deflection after a single notch. Keep it above the UI's step threshold (about 0.5). |
|
||||
| `wheelSmoothImpulse` | `0.3` | *smooth:* extra deflection added by each further notch, so faster spinning pushes harder. |
|
||||
| `wheelSmoothHoldMs` | `80` | *smooth:* how long after a notch the stick holds before easing back. |
|
||||
| `wheelSmoothDecayMs` | `150` | *smooth:* how fast it eases back (exponential time constant). Lower is snappier, higher glides longer. |
|
||||
| `wheelPressMs` | `90` | *step:* how long each notch holds the stick pushed. |
|
||||
| `wheelReleaseMs` | `60` | *step:* gap at centre between queued notches. |
|
||||
| `wheelMaxQueued` | `10` | *step:* maximum notches buffered. |
|
||||
|
||||
**Tuning the wheel:**
|
||||
- One notch skips two list items: lower `wheelSmoothHoldMs`.
|
||||
- Scrolling stops abruptly: raise `wheelSmoothDecayMs`.
|
||||
- Fast spins aren't fast enough: raise `wheelSmoothImpulse`.
|
||||
|
||||
## It shows up in SteamVR's "Manage Add-ons"
|
||||
This is expected. SteamVR lists every driver that isn't resources-only under **Settings → Developer → Manage Add-ons** (exact menu names may differ). That entry is useful:
|
||||
- **Its toggle is a kill switch.** It writes `"driver_mouselaser": {"enable": false}` to `steamvr.vrsettings`, which is the same `enable` setting the driver honours. Takes effect after a reboot.
|
||||
- **Safe mode.** SteamVR can block add-ons in "safe mode", apparently when it crashes after they were added. The **Unblock** button on that page re-enables them, once you've fixed or updated the driver.
|
||||
|
||||
## Recovery
|
||||
If SteamVR won't come up properly after installing, there are four options:
|
||||
0. In VR, open **Manage Add-ons**, switch *mouselaser* off, then reboot.
|
||||
1. **SSH or RDP in** (`xrdp` runs on the Frame), run `./uninstall.sh` or `vrpathreg removedriver <path>/driver/mouselaser`, and reboot.
|
||||
2. Set `"driver_mouselaser": { "enable": false }` in `steamvr.vrsettings`, then reboot.
|
||||
3. Restore the backup: `cp ~/.config/openvr/openvrpaths.vrpath.bak-mouselaser ~/.config/openvr/openvrpaths.vrpath`.
|
||||
|
||||
## Tested on
|
||||
| | |
|
||||
|---|---|
|
||||
| Device | Steam Frame (Snapdragon SM8650) |
|
||||
| SteamOS | `VERSION_ID=0.4.2`, `VARIANT_ID=vr`, build `20260928.6175029` |
|
||||
| Kernel | `6.18.0-gfbdbca41fd45` |
|
||||
| SteamVR | as installed at `/opt/steamvr` on 2026-10-01 |
|
||||
| Mouse | MCHOSE G3 A (2.4 GHz, has BTN_SIDE/BTN_EXTRA) |
|
||||
| Result | Loads, toggles, aims and clicks across overlays. Reconnects after the mouse sleeps or replugs. The wheel scrolls Steam UI lists like the thumbstick: 0.2.0 worked but felt a bit choppy. 0.3.0's smooth mode was confirmed working by the owner. |
|
||||
|
||||
## Known limitations
|
||||
- The ray starts just below your head (`originOffsetY`) and points where you aim, so you see the beam almost end-on. Expect to rely mostly on the cursor dot on overlays.
|
||||
- If a **real controller holds the same hand role** its own laser pointer seems to stop working.
|
||||
- **Wheel feel is approximate.** A wheel isn't a stick: it only sends notches. Smooth mode only *simulates* a held stick, so it still won't feel exactly like a real thumbstick. Tune it with the `wheel*` settings.
|
||||
- Settings are only read when SteamVR starts.
|
||||
- Two harmless log lines:
|
||||
- `steam.client (mouselaser) has no configured binding`: only the compositor bindings are provided, and Steam's own binding for the Frame controllers is haptics-only anyway.
|
||||
- `Driver mouselaser has no suitable devices`: logged at load time, presumably because the driver provides no HMD. The device is added right after.
|
||||
|
||||
## Docs
|
||||
- [docs/how-it-works.md](docs/how-it-works.md): the driver internals, the pose math and the bindings.
|
||||
- [docs/steam-frame-background.md](docs/steam-frame-background.md): what we learned about the Frame's display and input stack that led here.
|
||||
- [docs/development-log.md](docs/development-log.md): where this came from, what was verified, and what is next.
|
||||
- [CLAUDE.md](CLAUDE.md): context and ground rules for AI coding sessions on this repo.
|
||||
|
||||
|
||||
## License
|
||||
[MIT](LICENSE) © 2026 Andalu30 and contributors.
|
||||
|
||||
Third-party notes:
|
||||
- **OpenVR SDK header** (`openvr_driver.h`, © Valve Corporation, BSD-3-Clause in the public [OpenVR SDK](https://github.com/ValveSoftware/openvr)). It is **not included** in this repo. The build uses the copy SteamVR ships on the headset.
|
||||
- **SteamVR** is proprietary Valve software. This project doesn't contain or redistribute any of it. The input profile and bindings files follow SteamVR's documented JSON formats so the driver can interoperate.
|
||||
- Not affiliated with or endorsed by Valve. "Steam", "SteamVR" and "Steam Frame" are trademarks of Valve Corporation.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Development log
|
||||
|
||||
## Origin (2026-10-01)
|
||||
This project came out of one exploratory session. The owner asked an AI assistant (Claude, running as Claude Code in a Konsole inside the Frame's nested KDE desktop) to analyse the Steam Frame's environment, document it, and possibly change some things. The session started **read-only**. Write access was granted later, step by step, and every system-touching action was announced first and approved by the owner.
|
||||
|
||||
The broader notes from that session (hardware, display pipeline, KDE scale, ultrawide options, input devices) live in the parent exploration folder `~/Projects/SteamFrameExploration/` (`README.md` plus numbered `0X-*.md` files) on the original headset. They aren't part of this repo. The parts relevant here are condensed in [steam-frame-background.md](steam-frame-background.md).
|
||||
|
||||
## Timeline
|
||||
1. **Exploration.** We found that KDE runs nested in gamescope's VR overlay, and that the mouse belongs to gamescope while the laser belongs to vrcompositor.
|
||||
2. **Quick mouse investigation.** It turned out to be only a grip-to-activate shortcut. Its binding showed that the laser takes any pose source, and the Frame HMD already binds a head-gaze laser.
|
||||
3. **Driver written** (`mouselaser`, ~400 lines of C++). Built on-device with gcc 15.1 / cmake 3.29 / ninja against SteamVR's bundled `openvr_driver.h` (API 2.1.0).
|
||||
4. **Installed** with `vrpathreg adddriver`, after a backup of `openvrpaths.vrpath`. Then the owner restarted SteamVR.
|
||||
5. **Verified working**, from the owner's report and from `vrserver.txt`:
|
||||
```
|
||||
[Info] - mouselaser: mouselaser: ACTIVE (mouse drives the laser)
|
||||
[Info] - mouselaser: mouselaser: inactive (mouse back to desktop)
|
||||
[Info] - [Workshop] Successfully loaded binding file '.../vrcompositor_bindings_mouselaser.json' for app 'openvr.component.vrcompositor'.
|
||||
[Info] - mouselaser: mouselaser: mouse disappeared
|
||||
[Info] - mouselaser: mouselaser: using /dev/input/event6 (MCHOSE G3 A Mouse)
|
||||
```
|
||||
- The toggle works, and the compositor loaded our bindings.
|
||||
- The mouse is re-found after it sleeps or reconnects. Its event node moved from event5 to event6 and was still found.
|
||||
6. **Moved into this repo** as `0.1.0-experimental`. Changes made while copying:
|
||||
- Removed the doubled log prefix: SteamVR already adds `mouselaser: `.
|
||||
- Added a version line on startup.
|
||||
- `install.sh` now refuses to register if a `mouselaser` driver is already registered from another folder.
|
||||
|
||||
7. **Repo build tested** (2026-10-01, 20:10). The old registration was removed, `./install.sh` was run from the repo, and the owner rebooted. The owner confirmed it works. The log shows a single `mouselaser:` prefix, which confirms the repo build is the one loading, and toggling works.
|
||||
|
||||
8. **0.2.0: wheel works as the thumbstick** (2026-10-01). The owner asked for the mouse wheel to act like the controller joystick, to scroll lists.
|
||||
- Finding: Steam UI lists read the compositor's `/actions/dualanalog`; the Frame `steam.client` binding is haptics-only. Our bindings only had `scroll_discrete`.
|
||||
- Change: added `dualanalog` (position + touch) and `scroll_smooth` bindings. Replaced the 6-frame pulse with a time-based `StickStepper` (one notch = one flick). The tilt wheel drives stick X. Four new `wheel*` settings.
|
||||
- Verified offline with a 120 Hz simulation: one notch gives one push, fast notches give separate pushes, and the queue is capped.
|
||||
- **On-headset test: works** (owner, after a reboot). Lists scroll with the wheel, but it feels "a bit choppy". Tuning or a smoother mode is a follow-up.
|
||||
|
||||
9. **Manage Add-ons.** The driver appears in SteamVR's developer *Manage Add-ons* list, which is expected. From the dashboard JS (`chunk~*.js`): the list shows non-`resourceOnly` drivers, its toggle calls `setEnabled`, which writes `driver_<name>.enable` (the same key our driver reads), and **Unblock** clears `blocked_by_safe_mode`. Documented in the README as an in-VR kill switch.
|
||||
|
||||
10. **0.3.0: smoother wheel and tidier texts** (2026-10-01). The owner found 0.2.0's scrolling "a bit choppy" and asked for some adjustment, plus a tidy-up of the text SteamVR shows.
|
||||
- Added the `smooth` wheel mode as the default: each notch bumps the stick, which eases back, and spinning holds it (see how-it-works). The 0.2.0 behaviour is still available as `wheelMode: "step"`. New settings: `wheelMode`, `wheelSmoothMin`, `wheelSmoothImpulse`, `wheelSmoothHoldMs`, `wheelSmoothDecayMs`.
|
||||
- First tuning (hold 50 ms, decay 120 ms, impulse 0.25) dipped to about 0.45 between notches when spinning at 10/s, which risks retriggering steps. Retuned to 80 / 150 / 0.3, which ramps to a steady hold. Verified offline only.
|
||||
- Texts: added `resources/localization/localization.json` (en_US, es_ES) for SteamVR's binding screens. Model `Mouse Laser (virtual)`, manufacturer `frame-unboundedMouse-vibed`, and a clearer binding name and description.
|
||||
- The display name in Manage Add-ons can't be changed from the driver: the dashboard looks it up in its own `driverPrettyNames` table and otherwise falls back to the manifest name `mouselaser`.
|
||||
- **On-headset test: working** (owner, after a reboot).
|
||||
|
||||
11. **Pre-commit review, 0.3.1** (2026-10-01).
|
||||
- Logs: clean load, activation and binding load. No crash. The only vrserver coredump predates the driver (2026-09-28). `no suitable devices` and `steam.client … no configured binding` are benign.
|
||||
- A clean build from only the files that would be committed works. clang `-Wall -Wextra -Wshadow` reports nothing in our code. No personal data in the tree.
|
||||
- **Fix:** `MouseReader` now stops its thread in its destructor. Previously, if SteamVR destroyed the device without `Deactivate()`, the joinable `std::thread` would `std::terminate` vrserver. This never happened in the logs; it is defensive.
|
||||
- Added `tests/wheel_sim.cpp` (optional, `-DMOUSELASER_TESTS=ON`) and `CMAKE_EXPORT_COMPILE_COMMANDS`, so clangd finds `openvr_driver.h`.
|
||||
- 0.3.1 is built but **not yet rebooted into**.
|
||||
|
||||
12. **License: MIT** (2026-10-01).
|
||||
- The owner asked for an open-source license. The only Valve code involved is the OpenVR driver header. It is BSD-3-Clause in the public SDK and **not vendored**: we compile against SteamVR's local copy.
|
||||
- MIT was chosen over GPL because the driver is a plugin loaded into proprietary `vrserver`, where GPL plugin licensing is a grey area. MIT is also common for SteamVR drivers.
|
||||
- Copyright holder: `Andalu30` (the GitHub handle from the remote). The README notes third-party code and trademarks.
|
||||
|
||||
13. **Demo media** (2026-10-01). The owner recorded `~/Videos/mousedemo.mp4` (44.2 s, 1920×1080, 20 fps). It was trimmed (first 3 s and last 2 s removed, leaving 39.2 s) and converted to `docs/media/demo.webp`, which the README shows as the hero image.
|
||||
|
||||
## State at hand-off
|
||||
- The driver is registered from this repo (`<repo>/driver/mouselaser`). The old prototype registration has been removed.
|
||||
- Nothing is vendored. The build depends on SteamVR's bundled header.
|
||||
- Licensed MIT (see `LICENSE`). SPDX tags are on the sources.
|
||||
@@ -0,0 +1,82 @@
|
||||
# How the driver works
|
||||
|
||||
All code is in [`../src/driver.cpp`](../src/driver.cpp). Everything SteamVR loads lives in [`../driver/mouselaser/`](../driver/mouselaser/).
|
||||
|
||||
## Pieces
|
||||
|
||||
```
|
||||
vrserver
|
||||
└─ loads driver/mouselaser/bin/linuxarm64/driver_mouselaser.so (driver.vrdrivermanifest: alwaysActivate=true)
|
||||
HmdDriverFactory() → MouseLaserProvider (IServerTrackedDeviceProvider)
|
||||
└─ MouseLaserDevice (ITrackedDeviceServerDriver), serial "mouselaser-0", class Controller
|
||||
├─ MouseReader thread ── reads /dev/input/eventX (evdev), EVIOCGRAB when active
|
||||
└─ RunFrame() ── pose + input components, every vrserver frame
|
||||
vrcompositor
|
||||
└─ uses resources/input/vrcompositor_bindings_mouselaser.json (via the profile's default_bindings)
|
||||
/actions/lasermouse/in/Pointer ← /user/hand/{left,right}/pose/raw → the laser
|
||||
```
|
||||
|
||||
### Why `alwaysActivate: true`
|
||||
`steamvr.vrsettings` has `activateMultipleDrivers: false` by default. That setting only limits which **HMD** driver is picked. Drivers whose manifest sets `alwaysActivate` are loaded in addition. This is the standard route for add-on trackers.
|
||||
|
||||
### MouseReader (evdev thread)
|
||||
- Scans `/dev/input/event*` for the first device with `EV_REL` (REL_X, REL_Y) and `EV_KEY` (BTN_LEFT), optionally filtered by `deviceNameFilter`. A keyboard's "Consumer Control" node doesn't match.
|
||||
- `poll()`s with a 200 ms timeout. On `POLLERR`/`POLLHUP` or `ENODEV` (mouse asleep or unplugged) it closes the device and rescans every 2 s.
|
||||
- Pressing the toggle key (`toggleButton`, default BTN_EXTRA = 276) flips `active`:
|
||||
- **on:** `ioctl(EVIOCGRAB, 1)` gives exclusive access, so gamescope stops receiving the mouse.
|
||||
- **off:** `ioctl(EVIOCGRAB, 0)` releases the mouse; button state and deltas are cleared.
|
||||
- While active it accumulates `REL_X`/`REL_Y`/`REL_WHEEL` into atomics and tracks BTN_LEFT, BTN_RIGHT and BTN_MIDDLE. `RunFrame` consumes them with `exchange(0)`.
|
||||
|
||||
### RunFrame (pose)
|
||||
- HMD pose: `VRServerDriverHost()->GetRawTrackedDevicePoses(0, &hmd, 1)`. Device 0 is the HMD.
|
||||
- On each OFF→ON toggle, yaw and pitch are initialised from the HMD's forward vector (−Z column of `mDeviceToAbsoluteTracking`):
|
||||
`yaw = atan2(-fx, -fz)`, `pitch = asin(fy)`.
|
||||
- Mouse deltas: `yaw -= dx·k`, `pitch -= dy·k` (sign flipped if `invertY`), where `k = sensitivity·π/180`. Pitch is clamped to ±89°.
|
||||
- Orientation is **world-locked**: `q = yaw(Y) · pitch(X)` = `(cy·cp, cy·sp, sy·cp, −sy·sp)` with half-angle sines and cosines. OpenVR controllers point along −Z, so this aims the ray.
|
||||
- Position = HMD position + `(0, originOffsetY, 0)` in world space.
|
||||
- When inactive: `deviceIsConnected = false` and `poseIsValid = false`, so the compositor falls back to the real controllers.
|
||||
|
||||
### RunFrame (inputs)
|
||||
| component | driven by | bound to (compositor) |
|
||||
|---|---|---|
|
||||
| `/input/trigger/click`, `/value` | left button | `lasermouse/LeftClick`, `lasermouse_secondary/SwitchLaserHand` |
|
||||
| `/input/a/click` | right button | `lasermouse/RightClick` |
|
||||
| `/input/b/click` | middle button | `lasermouse/MiddleClick` |
|
||||
| `/input/grip/click` | held **true** while active | `quickmouse/ActivateQuickMouse` (keeps the laser up, as squeezing a grip does on Frame controllers) |
|
||||
| `/input/thumbstick/y`, `/x` (+ `/touch`) | wheel → Y, tilt wheel → X, via `StickStepper` (see below) | `dualanalog/LeftValue`/`RightValue` (+ touch, mode `joystick`), `scroll_discrete` and `scroll_smooth` (mode `scroll`), the same as the Frame controller's stick |
|
||||
|
||||
### Wheel → thumbstick (`StickStepper`)
|
||||
Steam UI lists are navigated through the compositor's `/actions/dualanalog` stick values, not through scroll events. The Frame controllers' `steam.client` binding only carries haptics. A wheel only produces discrete notches, so `StickStepper` turns them into stick deflection. It is timed with `steady_clock`, independently of the frame rate, and has two modes, chosen with `wheelMode`.
|
||||
|
||||
**smooth** (default, 0.3.0):
|
||||
- On a notch: if the stick is at rest, it jumps to `wheelSmoothMin` (+ `wheelSmoothImpulse` for each extra notch in the same event). Otherwise `wheelSmoothImpulse` is added to its current value. Either way it is capped at `wheelDeflection`.
|
||||
- For `wheelSmoothHoldMs` after a notch the value holds. After that it decays exponentially with time constant `wheelSmoothDecayMs`, and snaps to 0 below 0.1.
|
||||
- The result:
|
||||
- A single notch is about 130 ms above 0.5, which is one list step, followed by a short glide.
|
||||
- Spinning at 10 notches/s ramps up to a steady 0.8–1.0 hold, which scrolls continuously without dips.
|
||||
- Slow notches (3/s) remain separate steps.
|
||||
- These figures come from an offline 120 Hz simulation that drives `StickStepper` directly.
|
||||
|
||||
**step** (0.2.0 behaviour):
|
||||
- The stick is pushed to `±wheelDeflection` for `wheelPressMs`, then held at centre for `wheelReleaseMs`.
|
||||
- Further notches queue up, to at most `wheelMaxQueued`.
|
||||
- Reversing direction drops the queue and switches immediately.
|
||||
- When laser mode turns off, the queue is cleared.
|
||||
|
||||
### Render model
|
||||
`{mouselaser}mouselaser_none` is a single 0.1 mm triangle with a transparent 1×1 texture. Without a model of its own, SteamVR would draw a generic controller at your face.
|
||||
|
||||
## Files
|
||||
| file | purpose |
|
||||
|---|---|
|
||||
| `driver.vrdrivermanifest` | Driver name and `alwaysActivate`. |
|
||||
| `resources/settings/default.vrsettings` | Defaults for the `driver_mouselaser` section. |
|
||||
| `resources/input/mouselaser_profile.json` | Input profile. Controller type `mouselaser`, declares components and points to the default bindings. |
|
||||
| `resources/input/vrcompositor_bindings_mouselaser.json` | Bindings for app key `openvr.component.vrcompositor`, the laser and dashboard. |
|
||||
| `resources/rendermodels/mouselaser_none/` | The invisible model. |
|
||||
| `resources/localization/localization.json` | Display names (en_US, es_ES) for the controller type and its inputs, as shown in SteamVR's binding screens: "Left Click", "Mouse Wheel" and so on. |
|
||||
|
||||
## Changing things safely
|
||||
- After editing code: `cmake --build build`, then reboot. The `.so` is only loaded when SteamVR starts.
|
||||
- After editing bindings or the profile: reboot. SteamVR may also cache bindings per controller type under `~/.local/share/Steam/config/` or `~/.config/openvr/`.
|
||||
- To add a setting: add it to `Settings::Load()`, add the default to `default.vrsettings`, and document it in the README table.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 MiB |
@@ -0,0 +1,61 @@
|
||||
# Steam Frame background: why the mouse is trapped, and why a driver fixes it
|
||||
|
||||
These are findings from read-only exploration of a Steam Frame on 2026-10-01 (SteamOS 0.4.2 VR, build 20260928.6175029). Paths and versions may change with updates.
|
||||
|
||||
## The display chain
|
||||
The KDE desktop doesn't run on the hardware. It is nested several layers deep:
|
||||
```
|
||||
DSI-1 panel (2× 2160x2160 @ 108/120/144 Hz)
|
||||
└─ vrcompositor (SteamVR) owns the panel
|
||||
└─ gamescope --backend openvr draws each app as a VR overlay (--virtual-connector-strategy PerAppId)
|
||||
│ --output-width 1920 --output-height 1080, --vr-overlay-physical-width 2.67
|
||||
└─ Xwayland :0
|
||||
└─ kwin_wayland (X11 *windowed* backend), started by /usr/bin/steamos-nested-desktop with --width 1280 --height 800
|
||||
└─ Plasma + apps
|
||||
```
|
||||
- Steam's "Desktop" entry is `/usr/share/applications/deckard-nested-desktop.desktop`. "Deckard" is the Frame's internal codename.
|
||||
- Gamescope's arguments are hardcoded in `/usr/lib/steamos/gamescope-session` and run from the user unit `gamescope-session.service`. That unit is `PartOf=steamvr.service`, which is why restarting SteamVR kills the desktop.
|
||||
- Gamescope resizes KWin's window to its own 1920×1080 output, whatever size KWin asks for.
|
||||
|
||||
## The input path
|
||||
```
|
||||
/dev/input/eventX (mouse) → gamescope (libinput) → focused overlay's Xwayland → KWin (as X11 events)
|
||||
```
|
||||
- KWin on the X11 windowed backend never uses libinput, so KDE's mouse and keyboard settings pages are empty. `/org/kde/KWin/InputDevice` doesn't exist.
|
||||
- Gamescope delivers the mouse only to its **focused** overlay, clamped to that surface. **This is the "bounded" mouse the project is named after.**
|
||||
|
||||
## The laser pointer
|
||||
- The laser belongs to **vrcompositor**. It casts a ray from a *pose* and sends the overlay it hits `VREvent_MouseMove/ButtonDown/ButtonUp/...`. Gamescope turns those into pointer input for its clients.
|
||||
- The actions are in `/opt/steamvr/resources/config/vrcompositor_actions.json`. The important one is `/actions/lasermouse/in/Pointer` (type **pose**, mandatory), plus LeftClick, RightClick, MiddleClick, scroll and others.
|
||||
- Frame controller bindings (`/opt/steamvr/drivers/frame_controller/resources/input/vrcompositor_bindings_frame_controller.json`):
|
||||
- Pointer ← `/user/hand/*/pose/tip`
|
||||
- LeftClick ← trigger
|
||||
- "Quick mouse" (`/actions/quickmouse/in/ActivateQuickMouse`) ← squeeze the grip. This just brings the laser up while you're in gamepad mode.
|
||||
- The Frame **HMD** also has a binding (`drivers/frame_hmd/.../vrcompositor_bindings_frame_hmd.json`): Pointer ← `/user/head/pose/raw`, which is a head-gaze laser.
|
||||
- **Key insight:** the compositor accepts *any* pose source for the laser. A virtual device with a mouse-aimed pose is therefore enough, and no compositor hacking is needed. This driver is built on that.
|
||||
|
||||
## Alternatives that were considered
|
||||
| idea | why not (yet) |
|
||||
|---|---|
|
||||
| Quick mouse | Only an "activate" shortcut. The ray still needs a pose. |
|
||||
| `lasermouse` mailbox (`ws://127.0.0.1:27062`, messages `dump_laser_overlays`, `remote_laser_mouse_events`, `force_activate_laser_mouse`) | The remote-event payload is an undocumented protobuf (`VRLink_LaserMouseEvent`, from `vr_vrlink.proto`, built with protobuf-lite so there's no schema in the binary). It seems to be meant for VRLink PC streaming. |
|
||||
| Gamescope flags (`--mouse-sensitivity`, `--force-grab-cursor`, overlay size) | They only change behaviour *inside* one overlay. |
|
||||
| Running KWin on DRM/libinput | SteamVR owns the panel. |
|
||||
|
||||
### Mailbox wire protocol, for reference
|
||||
This is what the dashboard JS does (`/opt/steamvr/resources/webinterface/dashboard/chunk~*.js`):
|
||||
```
|
||||
connect ws://127.0.0.1:27062[?secret=...]
|
||||
send "mailbox_open <name>"
|
||||
send "mailbox_send <target> <json>" json: {"type": "...", "returnAddress": "<name>", "message_id": N, ...}
|
||||
recv JSON; replies mirror message_id
|
||||
```
|
||||
|
||||
## Useful locations
|
||||
| what | where |
|
||||
|---|---|
|
||||
| SteamVR runtime | `/opt/steamvr` (bin/linuxarm64, drivers/, resources/) |
|
||||
| Driver registration | `~/.config/openvr/openvrpaths.vrpath` (`vrpathreg show/adddriver/removedriver`) |
|
||||
| User SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` |
|
||||
| Logs | `~/.local/share/Steam/logs/vrserver.txt`, `vrcompositor.txt`. They're also forwarded to the journal by `/usr/share/deckard/steamvr_logs_to_journald.py`. |
|
||||
| OpenVR driver header | `/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers/openvr_driver.h` (SteamVR 2.1 API: `IServerTrackedDeviceProvider_004`, `ITrackedDeviceServerDriver_005`) |
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"alwaysActivate": true,
|
||||
"name" : "mouselaser",
|
||||
"directory" : "",
|
||||
"resourceOnly" : false,
|
||||
"hmd_presence" : []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"jsonid" : "input_profile",
|
||||
"controller_type" : "mouselaser",
|
||||
"device_class" : "TrackedDeviceClass_Controller",
|
||||
"resource_root" : "mouselaser",
|
||||
"driver_name" : "mouselaser",
|
||||
"input_bindingui_mode" : "controller_handed",
|
||||
"should_show_binding_errors" : true,
|
||||
"input_source" : {
|
||||
"/input/trigger" : { "type" : "trigger", "click" : true, "value" : true, "binding_image_point" : [ 50, 50 ], "order" : 1 },
|
||||
"/input/a" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 2 },
|
||||
"/input/b" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 3 },
|
||||
"/input/grip" : { "type" : "button", "click" : true, "binding_image_point" : [ 50, 50 ], "order" : 4 },
|
||||
"/input/thumbstick" : { "type" : "joystick", "touch" : true, "binding_image_point" : [ 50, 50 ], "order" : 5 },
|
||||
"/pose/raw" : { "type" : "pose", "binding_image_point" : [ 50, 50 ] },
|
||||
"/pose/tip" : { "type" : "pose", "binding_image_point" : [ 50, 50 ] }
|
||||
},
|
||||
"default_bindings" : [
|
||||
{ "app_key" : "openvr.component.vrcompositor", "binding_url" : "vrcompositor_bindings_mouselaser.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"action_manifest_version": 0,
|
||||
"alias_info": {},
|
||||
"app_key": "openvr.component.vrcompositor",
|
||||
"category": "steamvr_input",
|
||||
"controller_type": "mouselaser",
|
||||
"description": "Mouse aims the SteamVR laser. Buttons click, and the wheel acts as the thumbstick (lists and scrolling).",
|
||||
"name": "Mouse Laser: dashboard and overlays",
|
||||
"options": {},
|
||||
"simulated_actions": [],
|
||||
"bindings": {
|
||||
"/actions/lasermouse": {
|
||||
"poses": [
|
||||
{
|
||||
"output": "/actions/lasermouse/in/pointer",
|
||||
"path": "/user/hand/left/pose/raw"
|
||||
},
|
||||
{
|
||||
"output": "/actions/lasermouse/in/pointer",
|
||||
"path": "/user/hand/right/pose/raw"
|
||||
}
|
||||
],
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/trigger",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/leftclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/trigger",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/leftclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/a",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/rightclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/rightclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/left/input/b",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/middleclick"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/b",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse/in/middleclick"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/lasermouse_secondary": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/trigger",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/trigger",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/lasermouse_secondary/in/switchlaserhand"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/quickmouse": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/quickmouse/in/ActivateQuickMouse"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/quickmouse/in/ActivateQuickMouse"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/scroll_discrete": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "scroll",
|
||||
"inputs": {
|
||||
"scroll": {
|
||||
"output": "/actions/scroll_discrete/in/scroll"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "scroll",
|
||||
"inputs": {
|
||||
"scroll": {
|
||||
"output": "/actions/scroll_discrete/in/scroll"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/dualanalog": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/dualanalog/in/leftvalue"
|
||||
},
|
||||
"touch": {
|
||||
"output": "/actions/dualanalog/in/lefttouch"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/dualanalog/in/rightvalue"
|
||||
},
|
||||
"touch": {
|
||||
"output": "/actions/dualanalog/in/righttouch"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/scroll_smooth": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "scroll",
|
||||
"parameters": {
|
||||
"scroll_mode": "smooth"
|
||||
},
|
||||
"inputs": {
|
||||
"scroll": {
|
||||
"output": "/actions/scroll_smooth/in/scroll"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "scroll",
|
||||
"parameters": {
|
||||
"scroll_mode": "smooth"
|
||||
},
|
||||
"inputs": {
|
||||
"scroll": {
|
||||
"output": "/actions/scroll_smooth/in/scroll"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"language_tag": "en_US",
|
||||
"mouselaser": "Mouse Laser",
|
||||
"/input/trigger": "Left Click",
|
||||
"/input/a": "Right Click",
|
||||
"/input/b": "Middle Click",
|
||||
"/input/grip": "Laser Mode (held while active)",
|
||||
"/input/thumbstick": "Mouse Wheel"
|
||||
},
|
||||
{
|
||||
"language_tag": "es_ES",
|
||||
"mouselaser": "Láser de ratón",
|
||||
"/input/trigger": "Clic izquierdo",
|
||||
"/input/a": "Clic derecho",
|
||||
"/input/b": "Clic central",
|
||||
"/input/grip": "Modo láser (activo mientras está encendido)",
|
||||
"/input/thumbstick": "Rueda del ratón"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
newmtl none
|
||||
Kd 0 0 0
|
||||
map_Kd mouselaser_none.png
|
||||
@@ -0,0 +1,11 @@
|
||||
# effectively invisible: one 0.1 mm triangle
|
||||
mtllib mouselaser_none.mtl
|
||||
v 0 0 0
|
||||
v 0.0001 0 0
|
||||
v 0 0.0001 0
|
||||
vt 0 0
|
||||
vt 1 0
|
||||
vt 0 1
|
||||
vn 0 0 1
|
||||
usemtl none
|
||||
f 1/1/1 2/2/1 3/3/1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"driver_mouselaser": {
|
||||
"enable": true,
|
||||
"sensitivity": 0.05,
|
||||
"toggleButton": 276,
|
||||
"role": 1,
|
||||
"deviceNameFilter": "",
|
||||
"originOffsetY": -0.08,
|
||||
"invertY": false,
|
||||
"wheelMode": "smooth",
|
||||
"wheelDeflection": 1.0,
|
||||
"wheelSmoothMin": 0.7,
|
||||
"wheelSmoothImpulse": 0.3,
|
||||
"wheelSmoothHoldMs": 80,
|
||||
"wheelSmoothDecayMs": 150,
|
||||
"wheelPressMs": 90,
|
||||
"wheelReleaseMs": 60,
|
||||
"wheelMaxQueued": 10
|
||||
}
|
||||
}
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: MIT
|
||||
# Register the mouselaser driver with SteamVR.
|
||||
# Only touches ~/.config/openvr/openvrpaths.vrpath (a backup is made first).
|
||||
set -eu
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)/driver/mouselaser"
|
||||
VRPATHS="$HOME/.config/openvr/openvrpaths.vrpath"
|
||||
VRPATHREG=/opt/steamvr/bin/linuxarm64/vrpathreg
|
||||
|
||||
[ -f "$DIR/bin/linuxarm64/driver_mouselaser.so" ] || {
|
||||
echo "Driver not built. Run: cmake -S . -B build -G Ninja && cmake --build build"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Refuse if a mouselaser driver is already registered from another path:
|
||||
# two drivers with the same name would conflict.
|
||||
OTHER=$(python3 - "$VRPATHS" "$DIR" <<'PY'
|
||||
import json, os, sys
|
||||
try:
|
||||
paths = json.load(open(sys.argv[1])).get("external_drivers") or []
|
||||
except FileNotFoundError:
|
||||
paths = []
|
||||
for p in paths:
|
||||
if os.path.realpath(p) == os.path.realpath(sys.argv[2]):
|
||||
continue
|
||||
try:
|
||||
if json.load(open(os.path.join(p, "driver.vrdrivermanifest"))).get("name") == "mouselaser":
|
||||
print(p)
|
||||
except (OSError, ValueError):
|
||||
if os.path.basename(p.rstrip("/")) == "mouselaser":
|
||||
print(p)
|
||||
PY
|
||||
)
|
||||
if [ -n "$OTHER" ]; then
|
||||
echo "Another mouselaser driver is already registered:"
|
||||
echo "$OTHER"
|
||||
echo "Remove it first: $VRPATHREG removedriver <path>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -f "$VRPATHS" ] && cp "$VRPATHS" "$VRPATHS.bak-mouselaser"
|
||||
"$VRPATHREG" adddriver "$DIR"
|
||||
"$VRPATHREG" show | grep -A5 -i "external drivers" || true
|
||||
echo "Registered. Reboot to load it (on the Steam Frame, restarting SteamVR also restarts gamescope)."
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// mouselaser: a virtual SteamVR controller whose pose is aimed by a physical mouse,
|
||||
// so the SteamVR laser pointer can be driven by the mouse across every overlay.
|
||||
//
|
||||
// Toggle with the mouse's forward side button (BTN_EXTRA by default). While active the
|
||||
// mouse is grabbed (EVIOCGRAB) so gamescope stops seeing it; while inactive the device
|
||||
// reports disconnected and the real controllers keep the laser.
|
||||
//
|
||||
// EXPERIMENTAL and AI-generated ("vibecoded"); see README.md before relying on it.
|
||||
|
||||
#include <openvr_driver.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <dirent.h>
|
||||
#include <fcntl.h>
|
||||
#include <linux/input.h>
|
||||
#include <poll.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace vr;
|
||||
|
||||
static const char *k_section = "driver_mouselaser";
|
||||
static const char *k_version = "0.3.1-experimental";
|
||||
|
||||
static void Log(const char *fmt, ...)
|
||||
{
|
||||
char buf[1024];
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
vsnprintf(buf, sizeof(buf), fmt, args);
|
||||
va_end(args);
|
||||
if (VRDriverLog())
|
||||
VRDriverLog()->Log(buf);
|
||||
}
|
||||
|
||||
static bool TestBit(const unsigned long *bits, int bit)
|
||||
{
|
||||
const int bpl = sizeof(unsigned long) * 8;
|
||||
return (bits[bit / bpl] >> (bit % bpl)) & 1;
|
||||
}
|
||||
|
||||
struct Settings
|
||||
{
|
||||
float sensitivityDeg = 0.05f; // degrees of ray rotation per mouse count
|
||||
int toggleButton = BTN_EXTRA;
|
||||
int role = TrackedControllerRole_LeftHand;
|
||||
std::string nameFilter; // substring of the evdev name; empty = first mouse found
|
||||
float originOffsetY = -0.08f; // metres, world space, relative to the HMD
|
||||
bool invertY = false;
|
||||
// How wheel notches drive the virtual thumbstick:
|
||||
// smooth: each notch bumps the stick, which eases back to centre; spinning holds it.
|
||||
// step: each notch is one flick, pushed for wheelPressMs then centred for wheelReleaseMs.
|
||||
bool wheelSmooth = true;
|
||||
float wheelSmoothMin = 0.7f; // deflection right after a single notch
|
||||
float wheelSmoothImpulse = 0.3f; // extra deflection per further notch
|
||||
int wheelSmoothHoldMs = 80; // no decay this long after a notch
|
||||
int wheelSmoothDecayMs = 150; // exponential decay time constant
|
||||
int wheelPressMs = 90;
|
||||
int wheelReleaseMs = 60;
|
||||
float wheelDeflection = 1.0f; // maximum deflection (both modes)
|
||||
int wheelMaxQueued = 10; // notches buffered while flicks are still playing out
|
||||
|
||||
void Load()
|
||||
{
|
||||
EVRSettingsError err;
|
||||
IVRSettings *s = VRSettings();
|
||||
float f = s->GetFloat(k_section, "sensitivity", &err);
|
||||
if (err == VRSettingsError_None) sensitivityDeg = f;
|
||||
int32_t i = s->GetInt32(k_section, "toggleButton", &err);
|
||||
if (err == VRSettingsError_None) toggleButton = i;
|
||||
i = s->GetInt32(k_section, "role", &err);
|
||||
if (err == VRSettingsError_None) role = i;
|
||||
char buf[256] = {};
|
||||
s->GetString(k_section, "deviceNameFilter", buf, sizeof(buf), &err);
|
||||
if (err == VRSettingsError_None) nameFilter = buf;
|
||||
f = s->GetFloat(k_section, "originOffsetY", &err);
|
||||
if (err == VRSettingsError_None) originOffsetY = f;
|
||||
bool b = s->GetBool(k_section, "invertY", &err);
|
||||
if (err == VRSettingsError_None) invertY = b;
|
||||
i = s->GetInt32(k_section, "wheelPressMs", &err);
|
||||
if (err == VRSettingsError_None) wheelPressMs = i;
|
||||
i = s->GetInt32(k_section, "wheelReleaseMs", &err);
|
||||
if (err == VRSettingsError_None) wheelReleaseMs = i;
|
||||
f = s->GetFloat(k_section, "wheelDeflection", &err);
|
||||
if (err == VRSettingsError_None) wheelDeflection = f;
|
||||
i = s->GetInt32(k_section, "wheelMaxQueued", &err);
|
||||
if (err == VRSettingsError_None) wheelMaxQueued = i;
|
||||
s->GetString(k_section, "wheelMode", buf, sizeof(buf), &err);
|
||||
if (err == VRSettingsError_None) wheelSmooth = strcmp(buf, "step") != 0;
|
||||
f = s->GetFloat(k_section, "wheelSmoothMin", &err);
|
||||
if (err == VRSettingsError_None) wheelSmoothMin = f;
|
||||
f = s->GetFloat(k_section, "wheelSmoothImpulse", &err);
|
||||
if (err == VRSettingsError_None) wheelSmoothImpulse = f;
|
||||
i = s->GetInt32(k_section, "wheelSmoothHoldMs", &err);
|
||||
if (err == VRSettingsError_None) wheelSmoothHoldMs = i;
|
||||
i = s->GetInt32(k_section, "wheelSmoothDecayMs", &err);
|
||||
if (err == VRSettingsError_None) wheelSmoothDecayMs = i;
|
||||
}
|
||||
};
|
||||
|
||||
// Turns wheel notches on one axis into thumbstick deflection, in "smooth" or "step" mode
|
||||
// (see Settings). A change of direction drops whatever was still pending the other way.
|
||||
class StickStepper
|
||||
{
|
||||
public:
|
||||
float Update(int ticks, const Settings &s)
|
||||
{
|
||||
using clock = std::chrono::steady_clock;
|
||||
clock::time_point now = clock::now();
|
||||
return s.wheelSmooth ? UpdateSmooth(ticks, s, now) : UpdateStep(ticks, s, now);
|
||||
}
|
||||
|
||||
void Reset() { m_queued = 0, m_pressed = false, m_mag = 0; }
|
||||
|
||||
private:
|
||||
float UpdateSmooth(int ticks, const Settings &s, std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
float dt = std::chrono::duration<float>(now - m_last).count();
|
||||
m_last = now;
|
||||
if (ticks != 0)
|
||||
{
|
||||
float d = ticks > 0 ? 1.f : -1.f;
|
||||
if (d != m_dir) m_dir = d, m_mag = 0;
|
||||
float bumped = m_mag > 0 ? m_mag + s.wheelSmoothImpulse * std::abs(ticks)
|
||||
: s.wheelSmoothMin + s.wheelSmoothImpulse * (std::abs(ticks) - 1);
|
||||
m_mag = std::min(s.wheelDeflection, std::max(bumped, s.wheelSmoothMin));
|
||||
m_lastNotch = now;
|
||||
}
|
||||
else if (m_mag > 0 && now - m_lastNotch > std::chrono::milliseconds(s.wheelSmoothHoldMs))
|
||||
{
|
||||
m_mag *= expf(-dt * 1000.f / float(std::max(1, s.wheelSmoothDecayMs)));
|
||||
if (m_mag < 0.1f) m_mag = 0;
|
||||
}
|
||||
return m_dir * m_mag;
|
||||
}
|
||||
|
||||
float UpdateStep(int ticks, const Settings &s, std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
if (ticks != 0)
|
||||
{
|
||||
float d = ticks > 0 ? 1.f : -1.f;
|
||||
if (d != m_dir)
|
||||
{
|
||||
m_dir = d;
|
||||
m_queued = 0;
|
||||
m_pressed = false;
|
||||
m_until = now;
|
||||
}
|
||||
m_queued = std::min(m_queued + std::abs(ticks), s.wheelMaxQueued);
|
||||
}
|
||||
if (now >= m_until)
|
||||
{
|
||||
if (m_pressed)
|
||||
{
|
||||
m_pressed = false;
|
||||
m_until = now + std::chrono::milliseconds(s.wheelReleaseMs);
|
||||
}
|
||||
else if (m_queued > 0)
|
||||
{
|
||||
m_queued--;
|
||||
m_pressed = true;
|
||||
m_until = now + std::chrono::milliseconds(s.wheelPressMs);
|
||||
}
|
||||
}
|
||||
return m_pressed ? m_dir * s.wheelDeflection : 0.f;
|
||||
}
|
||||
|
||||
// step mode
|
||||
int m_queued = 0;
|
||||
bool m_pressed = false;
|
||||
float m_dir = 0;
|
||||
std::chrono::steady_clock::time_point m_until;
|
||||
// smooth mode
|
||||
float m_mag = 0;
|
||||
std::chrono::steady_clock::time_point m_last, m_lastNotch;
|
||||
};
|
||||
|
||||
// Reads the physical mouse on its own thread. All state shared with RunFrame is atomic.
|
||||
class MouseReader
|
||||
{
|
||||
public:
|
||||
std::atomic<bool> active{false};
|
||||
std::atomic<bool> toggled{false}; // set on every toggle; RunFrame clears it
|
||||
std::atomic<int> dx{0}, dy{0};
|
||||
std::atomic<int> wheel{0}, hwheel{0};
|
||||
std::atomic<bool> left{false}, right{false}, middle{false};
|
||||
|
||||
// Destroying a joinable std::thread calls std::terminate (taking vrserver down),
|
||||
// so make sure the thread is stopped even if Deactivate() was never called.
|
||||
~MouseReader() { Stop(); }
|
||||
|
||||
void Start(const Settings &s)
|
||||
{
|
||||
m_settings = s;
|
||||
m_run = true;
|
||||
m_thread = std::thread(&MouseReader::Loop, this);
|
||||
}
|
||||
|
||||
void Stop()
|
||||
{
|
||||
m_run = false;
|
||||
if (m_thread.joinable())
|
||||
m_thread.join();
|
||||
Close();
|
||||
}
|
||||
|
||||
private:
|
||||
Settings m_settings;
|
||||
std::atomic<bool> m_run{false};
|
||||
std::thread m_thread;
|
||||
int m_fd = -1;
|
||||
|
||||
bool IsMouse(int fd, std::string &name)
|
||||
{
|
||||
unsigned long evbits[(EV_MAX + 1) / (8 * sizeof(long)) + 1] = {};
|
||||
unsigned long relbits[(REL_MAX + 1) / (8 * sizeof(long)) + 1] = {};
|
||||
unsigned long keybits[(KEY_MAX + 1) / (8 * sizeof(long)) + 1] = {};
|
||||
char n[256] = {};
|
||||
if (ioctl(fd, EVIOCGNAME(sizeof(n)), n) < 0) return false;
|
||||
name = n;
|
||||
if (ioctl(fd, EVIOCGBIT(0, sizeof(evbits)), evbits) < 0) return false;
|
||||
if (!TestBit(evbits, EV_REL) || !TestBit(evbits, EV_KEY)) return false;
|
||||
ioctl(fd, EVIOCGBIT(EV_REL, sizeof(relbits)), relbits);
|
||||
ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(keybits)), keybits);
|
||||
if (!TestBit(relbits, REL_X) || !TestBit(relbits, REL_Y) || !TestBit(keybits, BTN_LEFT))
|
||||
return false;
|
||||
return m_settings.nameFilter.empty() || name.find(m_settings.nameFilter) != std::string::npos;
|
||||
}
|
||||
|
||||
bool Open()
|
||||
{
|
||||
DIR *dir = opendir("/dev/input");
|
||||
if (!dir) return false;
|
||||
while (dirent *e = readdir(dir))
|
||||
{
|
||||
if (strncmp(e->d_name, "event", 5) != 0) continue;
|
||||
std::string path = std::string("/dev/input/") + e->d_name;
|
||||
int fd = open(path.c_str(), O_RDONLY | O_NONBLOCK | O_CLOEXEC);
|
||||
if (fd < 0) continue;
|
||||
std::string name;
|
||||
if (IsMouse(fd, name))
|
||||
{
|
||||
m_fd = fd;
|
||||
Log("using %s (%s)\n", path.c_str(), name.c_str());
|
||||
closedir(dir);
|
||||
return true;
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
closedir(dir);
|
||||
return false;
|
||||
}
|
||||
|
||||
void Close()
|
||||
{
|
||||
if (m_fd >= 0)
|
||||
{
|
||||
ioctl(m_fd, EVIOCGRAB, 0);
|
||||
close(m_fd);
|
||||
m_fd = -1;
|
||||
}
|
||||
SetActive(false);
|
||||
}
|
||||
|
||||
void SetActive(bool on)
|
||||
{
|
||||
if (active == on) return;
|
||||
if (m_fd >= 0 && ioctl(m_fd, EVIOCGRAB, on ? 1 : 0) < 0 && on)
|
||||
{
|
||||
Log("EVIOCGRAB failed (%s), staying inactive\n", strerror(errno));
|
||||
return;
|
||||
}
|
||||
left = right = middle = false;
|
||||
dx = dy = wheel = hwheel = 0;
|
||||
active = on;
|
||||
toggled = true;
|
||||
Log("%s\n", on ? "ACTIVE (mouse drives the laser)" : "inactive (mouse back to desktop)");
|
||||
}
|
||||
|
||||
void Loop()
|
||||
{
|
||||
while (m_run)
|
||||
{
|
||||
if (m_fd < 0 && !Open())
|
||||
{
|
||||
for (int i = 0; i < 20 && m_run; i++) usleep(100 * 1000);
|
||||
continue;
|
||||
}
|
||||
pollfd pfd = {m_fd, POLLIN, 0};
|
||||
int r = poll(&pfd, 1, 200);
|
||||
if (r <= 0) continue;
|
||||
if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL))
|
||||
{
|
||||
Log("mouse disappeared\n");
|
||||
Close();
|
||||
continue;
|
||||
}
|
||||
input_event ev;
|
||||
ssize_t n;
|
||||
while ((n = read(m_fd, &ev, sizeof(ev))) == sizeof(ev))
|
||||
Handle(ev);
|
||||
if (n < 0 && errno == ENODEV)
|
||||
{
|
||||
Log("mouse disappeared\n");
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Handle(const input_event &ev)
|
||||
{
|
||||
if (ev.type == EV_KEY && ev.code == m_settings.toggleButton)
|
||||
{
|
||||
if (ev.value == 1) SetActive(!active);
|
||||
return;
|
||||
}
|
||||
if (!active) return;
|
||||
if (ev.type == EV_REL)
|
||||
{
|
||||
if (ev.code == REL_X) dx += ev.value;
|
||||
else if (ev.code == REL_Y) dy += ev.value;
|
||||
else if (ev.code == REL_WHEEL) wheel += ev.value;
|
||||
else if (ev.code == REL_HWHEEL) hwheel += ev.value;
|
||||
}
|
||||
else if (ev.type == EV_KEY)
|
||||
{
|
||||
if (ev.code == BTN_LEFT) left = ev.value != 0;
|
||||
else if (ev.code == BTN_RIGHT) right = ev.value != 0;
|
||||
else if (ev.code == BTN_MIDDLE) middle = ev.value != 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class MouseLaserDevice final : public ITrackedDeviceServerDriver
|
||||
{
|
||||
public:
|
||||
explicit MouseLaserDevice(const Settings &s) : m_settings(s) {}
|
||||
|
||||
EVRInitError Activate(uint32_t id) override
|
||||
{
|
||||
m_id = id;
|
||||
PropertyContainerHandle_t c = VRProperties()->TrackedDeviceToPropertyContainer(id);
|
||||
VRProperties()->SetStringProperty(c, Prop_ModelNumber_String, "Mouse Laser (virtual)");
|
||||
VRProperties()->SetStringProperty(c, Prop_ManufacturerName_String, "frame-unboundedMouse-vibed");
|
||||
VRProperties()->SetStringProperty(c, Prop_ControllerType_String, "mouselaser");
|
||||
VRProperties()->SetStringProperty(c, Prop_InputProfilePath_String, "{mouselaser}/input/mouselaser_profile.json");
|
||||
VRProperties()->SetStringProperty(c, Prop_RenderModelName_String, "{mouselaser}mouselaser_none");
|
||||
VRProperties()->SetInt32Property(c, Prop_ControllerRoleHint_Int32, m_settings.role);
|
||||
VRProperties()->SetBoolProperty(c, Prop_DeviceProvidesBatteryStatus_Bool, false);
|
||||
|
||||
VRDriverInput()->CreateBooleanComponent(c, "/input/trigger/click", &m_trigger);
|
||||
VRDriverInput()->CreateScalarComponent(c, "/input/trigger/value", &m_triggerValue,
|
||||
VRScalarType_Absolute, VRScalarUnits_NormalizedOneSided);
|
||||
VRDriverInput()->CreateBooleanComponent(c, "/input/a/click", &m_a);
|
||||
VRDriverInput()->CreateBooleanComponent(c, "/input/b/click", &m_b);
|
||||
VRDriverInput()->CreateBooleanComponent(c, "/input/grip/click", &m_grip);
|
||||
VRDriverInput()->CreateBooleanComponent(c, "/input/thumbstick/touch", &m_stickTouch);
|
||||
VRDriverInput()->CreateScalarComponent(c, "/input/thumbstick/x", &m_stickX,
|
||||
VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
|
||||
VRDriverInput()->CreateScalarComponent(c, "/input/thumbstick/y", &m_stickY,
|
||||
VRScalarType_Absolute, VRScalarUnits_NormalizedTwoSided);
|
||||
|
||||
m_mouse.Start(m_settings);
|
||||
Log("activated as device %u, role %d\n", id, m_settings.role);
|
||||
return VRInitError_None;
|
||||
}
|
||||
|
||||
void Deactivate() override
|
||||
{
|
||||
m_mouse.Stop();
|
||||
m_id = k_unTrackedDeviceIndexInvalid;
|
||||
}
|
||||
|
||||
void EnterStandby() override {}
|
||||
void *GetComponent(const char *) override { return nullptr; }
|
||||
void DebugRequest(const char *, char *resp, uint32_t size) override
|
||||
{
|
||||
if (size) resp[0] = 0;
|
||||
}
|
||||
|
||||
DriverPose_t GetPose() override
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_poseMutex);
|
||||
return m_pose;
|
||||
}
|
||||
|
||||
void RunFrame()
|
||||
{
|
||||
if (m_id == k_unTrackedDeviceIndexInvalid) return;
|
||||
|
||||
TrackedDevicePose_t hmd = {};
|
||||
VRServerDriverHost()->GetRawTrackedDevicePoses(0, &hmd, 1);
|
||||
const HmdMatrix34_t &m = hmd.mDeviceToAbsoluteTracking;
|
||||
|
||||
bool active = m_mouse.active;
|
||||
if (m_mouse.toggled.exchange(false) && active && hmd.bPoseIsValid)
|
||||
{
|
||||
// Start the ray where the user is looking.
|
||||
float fx = -m.m[0][2], fy = -m.m[1][2], fz = -m.m[2][2];
|
||||
m_yaw = atan2f(-fx, -fz);
|
||||
m_pitch = asinf(fmaxf(-1.f, fminf(1.f, fy)));
|
||||
}
|
||||
|
||||
const float k = m_settings.sensitivityDeg * float(M_PI) / 180.f;
|
||||
m_yaw -= m_mouse.dx.exchange(0) * k;
|
||||
m_pitch -= m_mouse.dy.exchange(0) * k * (m_settings.invertY ? -1.f : 1.f);
|
||||
const float limit = 89.f * float(M_PI) / 180.f;
|
||||
m_pitch = fmaxf(-limit, fminf(limit, m_pitch));
|
||||
|
||||
DriverPose_t pose = {};
|
||||
pose.qWorldFromDriverRotation.w = 1;
|
||||
pose.qDriverFromHeadRotation.w = 1;
|
||||
pose.deviceIsConnected = active;
|
||||
pose.poseIsValid = active && hmd.bPoseIsValid;
|
||||
pose.result = pose.poseIsValid ? TrackingResult_Running_OK : TrackingResult_Running_OutOfRange;
|
||||
pose.vecPosition[0] = m.m[0][3];
|
||||
pose.vecPosition[1] = m.m[1][3] + m_settings.originOffsetY;
|
||||
pose.vecPosition[2] = m.m[2][3];
|
||||
// q = yaw(Y) * pitch(X)
|
||||
float cy = cosf(m_yaw / 2), sy = sinf(m_yaw / 2);
|
||||
float cp = cosf(m_pitch / 2), sp = sinf(m_pitch / 2);
|
||||
pose.qRotation.w = cy * cp;
|
||||
pose.qRotation.x = cy * sp;
|
||||
pose.qRotation.y = sy * cp;
|
||||
pose.qRotation.z = -sy * sp;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_poseMutex);
|
||||
m_pose = pose;
|
||||
}
|
||||
VRServerDriverHost()->TrackedDevicePoseUpdated(m_id, pose, sizeof(DriverPose_t));
|
||||
|
||||
// Wheel notches become thumbstick flicks: vertical wheel -> Y, tilt wheel -> X.
|
||||
if (!active) m_stepY.Reset(), m_stepX.Reset();
|
||||
float stickY = m_stepY.Update(m_mouse.wheel.exchange(0), m_settings);
|
||||
float stickX = m_stepX.Update(m_mouse.hwheel.exchange(0), m_settings);
|
||||
|
||||
bool l = active && m_mouse.left;
|
||||
VRDriverInput()->UpdateBooleanComponent(m_trigger, l, 0);
|
||||
VRDriverInput()->UpdateScalarComponent(m_triggerValue, l ? 1.f : 0.f, 0);
|
||||
VRDriverInput()->UpdateBooleanComponent(m_a, active && m_mouse.right, 0);
|
||||
VRDriverInput()->UpdateBooleanComponent(m_b, active && m_mouse.middle, 0);
|
||||
VRDriverInput()->UpdateBooleanComponent(m_grip, active, 0); // holds "quick mouse" on
|
||||
VRDriverInput()->UpdateBooleanComponent(m_stickTouch, stickX != 0.f || stickY != 0.f, 0);
|
||||
VRDriverInput()->UpdateScalarComponent(m_stickX, stickX, 0);
|
||||
VRDriverInput()->UpdateScalarComponent(m_stickY, stickY, 0);
|
||||
}
|
||||
|
||||
private:
|
||||
Settings m_settings;
|
||||
MouseReader m_mouse;
|
||||
uint32_t m_id = k_unTrackedDeviceIndexInvalid;
|
||||
std::mutex m_poseMutex;
|
||||
DriverPose_t m_pose = {};
|
||||
float m_yaw = 0, m_pitch = 0;
|
||||
StickStepper m_stepX, m_stepY;
|
||||
VRInputComponentHandle_t m_trigger = 0, m_triggerValue = 0, m_a = 0, m_b = 0, m_grip = 0;
|
||||
VRInputComponentHandle_t m_stickTouch = 0, m_stickX = 0, m_stickY = 0;
|
||||
};
|
||||
|
||||
class MouseLaserProvider : public IServerTrackedDeviceProvider
|
||||
{
|
||||
public:
|
||||
EVRInitError Init(IVRDriverContext *ctx) override
|
||||
{
|
||||
VR_INIT_SERVER_DRIVER_CONTEXT(ctx);
|
||||
Log("version %s\n", k_version);
|
||||
Settings s;
|
||||
s.Load();
|
||||
EVRSettingsError err;
|
||||
bool enable = VRSettings()->GetBool(k_section, "enable", &err);
|
||||
if (err == VRSettingsError_None && !enable)
|
||||
{
|
||||
Log("disabled by setting\n");
|
||||
return VRInitError_None;
|
||||
}
|
||||
m_device = new MouseLaserDevice(s);
|
||||
if (!VRServerDriverHost()->TrackedDeviceAdded("mouselaser-0", TrackedDeviceClass_Controller, m_device))
|
||||
Log("TrackedDeviceAdded failed\n");
|
||||
return VRInitError_None;
|
||||
}
|
||||
|
||||
void Cleanup() override
|
||||
{
|
||||
delete m_device;
|
||||
m_device = nullptr;
|
||||
VR_CLEANUP_SERVER_DRIVER_CONTEXT();
|
||||
}
|
||||
|
||||
const char *const *GetInterfaceVersions() override { return k_InterfaceVersions; }
|
||||
|
||||
void RunFrame() override
|
||||
{
|
||||
if (m_device) m_device->RunFrame();
|
||||
VREvent_t ev;
|
||||
while (VRServerDriverHost()->PollNextEvent(&ev, sizeof(ev))) {}
|
||||
}
|
||||
|
||||
bool ShouldBlockStandbyMode() override { return false; }
|
||||
void EnterStandby() override {}
|
||||
void LeaveStandby() override {}
|
||||
|
||||
private:
|
||||
MouseLaserDevice *m_device = nullptr;
|
||||
};
|
||||
|
||||
static MouseLaserProvider g_provider;
|
||||
|
||||
extern "C" __attribute__((visibility("default"))) void *HmdDriverFactory(const char *iface, int *ret)
|
||||
{
|
||||
if (strcmp(iface, IServerTrackedDeviceProvider_Version) == 0)
|
||||
return &g_provider;
|
||||
if (ret) *ret = VRInitError_Init_InterfaceNotFound;
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Offline simulation of the wheel -> thumbstick logic (StickStepper), no SteamVR needed.
|
||||
// Build with -DMOUSELASER_TESTS=ON and run build/wheel_sim.
|
||||
//
|
||||
// Each line is a 120 Hz timeline: a digit is |deflection| in tenths, a letter (a-j) is a
|
||||
// negative deflection, '.' is centred. Use it to sanity-check wheel* setting changes.
|
||||
|
||||
#include "../src/driver.cpp"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
static int Run(const char *name, bool smooth, const std::vector<std::pair<int, int>> ¬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<std::pair<int, int>> spin, slow;
|
||||
for (int f = 2; f < 110; f += 12) spin.push_back({f, 1}); // ~10 notches/s
|
||||
for (int f = 2; f < 110; f += 40) slow.push_back({f, 1}); // ~3 notches/s
|
||||
|
||||
int failures = 0;
|
||||
for (bool smooth : {true, false})
|
||||
{
|
||||
int one = Run("1 notch", smooth, {{2, 1}}, 40);
|
||||
Run("slow 3 notches/s", smooth, slow, 130);
|
||||
int fast = Run("spin 10 notches/s", smooth, spin, 130);
|
||||
Run("reverse (+2 then -1)", smooth, {{2, 2}, {8, -1}}, 50);
|
||||
// A single notch must register as a step; spinning must hold the stick most of the time.
|
||||
if (one < 5) printf("FAIL: single notch too short\n"), failures++;
|
||||
if (smooth && fast < 100) printf("FAIL: smooth spin does not hold the stick\n"), failures++;
|
||||
}
|
||||
return failures ? 1 : 0;
|
||||
}
|
||||
Executable
+7
@@ -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."
|
||||
Reference in new issue
Block a user