7.7 KiB
How the driver works
All code is in ../src/driver.cpp. Everything SteamVR loads lives in ../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/stylus/pose/raw (or /user/hand/{left,right} for hand roles) → 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 withEV_REL(REL_X, REL_Y) andEV_KEY(BTN_LEFT), optionally filtered bydeviceNameFilter. A keyboard's "Consumer Control" node doesn't match. poll()s with a 200 ms timeout. OnPOLLERR/POLLHUPorENODEV(mouse asleep or unplugged) it closes the device and rescans every 2 s.- Pressing the toggle key (
toggleButton, default BTN_EXTRA = 276) flipsactive:- 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.
- on:
- While active it accumulates
REL_X/REL_Y/REL_WHEELinto atomics and tracks BTN_LEFT, BTN_RIGHT and BTN_MIDDLE.RunFrameconsumes them withexchange(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 ifinvertY), wherek = 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, it depends on
role:- stylus (5, default since 0.5.0): the device stays connected with a valid pose, but its ray is parked pointing straight up (yaw 0, pitch 90°) and every button is released. This matters because a device that goes invalid or disconnects is dropped as the laser pointer until SteamVR sees a new "user interaction". That took about 10 s of quiet in earlier captures, so a quick off/on left the mouse grabbed with no laser.
- hand (1/2):
deviceIsConnected = falseandposeIsValid = false, so the real controller of that hand gets its slot back. A connected device with a hand role would compete with it.
Role and user path
SteamVR gives each controller a /user/... path based on its role. Hand roles share /user/hand/left|right with the real controllers. TrackedControllerRole_Stylus (5) maps to /user/stylus, which IsRoleAllowedAsHand() rules out of hand selection. Treadmill (4) maps to /user/treadmill. OptOut (3) has no path unless a tracker role is assigned in SteamVR. The compositor's laser accepts pose sources that aren't hands (the Frame HMD binds /user/head/pose/raw to lasermouse/in/pointer), so the bindings repeat every hand entry for /user/stylus.
Event diagnostics
The provider logs SteamVR events about our device, plus user-interaction, role-change and dashboard events (event N device M (active|off)). There is no driver-side signal for "the laser is up", and these events are what we're watching to look for one.
RunFrame (inputs)
| component | driven by | bound to (compositor) |
|---|---|---|
/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(+wheelSmoothImpulsefor each extra notch in the same event). OtherwisewheelSmoothImpulseis added to its current value. Either way it is capped atwheelDeflection. - For
wheelSmoothHoldMsafter a notch the value holds. After that it decays exponentially with time constantwheelSmoothDecayMs, 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
StickStepperdirectly.
step (0.2.0 behaviour):
- The stick is pushed to
±wheelDeflectionforwheelPressMs, then held at centre forwheelReleaseMs. - 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.sois 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 todefault.vrsettings, and document it in the README table.