8.2 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/hand/left/pose/raw while on (activeRole), /user/stylus/... while off → 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
Since 0.5.1 the role changes with laser mode. The device registers with role (default stylus, 5). On each toggle, RunFrame sets Prop_ControllerRoleHint_Int32 to activeRole (default left hand, 1) when switching on, or back to role when switching off, and logs role N. This is needed because dragging overlays only works with a hand (see below and the primer). SteamVR applies the change live: each switch is followed by event 108 (role changed), and the owner reports laser and dragging working with 0.5.1.
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.