Files

7.7 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/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 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

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.