Files

8.2 KiB
Raw Permalink Blame History

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 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, 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 = false and poseIsValid = 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 (+ 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.