Pointer: treadmill role by default, and per-hand last used wins

- The driver's default role is the treadmill (4), hinted from Activate;
  the helper's POINTER_ROLE defaults to treadmill. Needs the driver
  reinstalled and a SteamVR restart to take effect.
- The "no hand role" release skips a device whose hint is the treadmill.
- Hand gestures: none begins on a side whose controller is in use, or by a
  hand at a controller (by position); a held one ends, as lost, when its
  side turns controller.
- The moving-controller release no longer applies while a bare hand's ray
  drives the pointer (that ray goes only when its own side turns controller).
- update-check.py: the virtual controller's path, and the touch components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DeeJanuzandClaude Opus 5.5 committed 2026-10-08 21:32:14 -06:00
1 parent 742bd32c32
commit 564bb4bdc5
7 files changed
+60 -19

No files matched your search

+1 -1
View File
@@ -117,7 +117,7 @@ Driver poses are in SteamVR's raw tracking space, and client programs work in th
Frametop's SteamVR clients (ft-pointer, ft-screens, ft-gaze) connect as a background app first and switch to an overlay app only once that works. `VR_Init` as an overlay app starts vrserver itself when none is running, and one started that way from the dev container never finds the headset. At a boot where the gamescope session timed out, systemd dropped `steamvr.service`'s start job, the pointer service (ordered only `After=` it) started anyway, and its vrserver made every SteamVR launch fail with `HmdNotFound`. SteamOS's health check then kept resetting the Steam client and tried to fall back to the previous OS slot. The units also say `Requisite=steamvr.service`, so they don't start at all when SteamVR's start fails.
The driver starts disconnected, because holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. It connects when the mouse is used and claims the right hand. SteamVR keeps a hand role reserved for a disconnected device that still asks for it, so the driver switches its role hint between right hand (connected) and opt-out (not connected).
The driver starts disconnected, because holding the right-hand role while SteamVR starts leaves the Steam UI stuck on its loading icon. It connects when the mouse is used. With a hand role (`POINTER_ROLE=right`, the default before the treadmill role) it claims that hand: SteamVR keeps a hand role reserved for a disconnected device that still asks for it, so the driver switches its role hint between the hand (connected) and opt-out (not connected). The treadmill role (below), now the default, needs no switching.
#### Treadmill role
+4 -3
View File
@@ -115,9 +115,9 @@ A mouse drives SteamVR the way a controller's laser does, but shows up as a smal
- The input relay, in pointer mode (`POINTER=1`), sends mouse motion, clicks, and scrolling to the helper. A deliberate movement or a click wakes the pointer, and 30 seconds without mouse input releases it.
- The helper, `pointer/helper/ft-pointer`, runs in the `dev` container as `frametop-pointer.service` and starts with SteamVR. It keeps the cursor, tests it against every visible overlay, draws the dot, and sends the driver an exact pose.
- The driver, `pointer/driver/` (`ft_pointer`), is loaded by SteamVR. It's an invisible virtual right-hand controller whose laser follows the cursor.
- The driver, `pointer/driver/` (`ft_pointer`), is loaded by SteamVR. It's an invisible virtual controller whose laser follows the cursor. It takes the treadmill role, no hand, so the real controllers keep theirs and one can be used beside it (`POINTER_ROLE` and the driver's `role` setting; see docs/design.md, "Treadmill role").
Whichever device you used last wins. Picking up a controller hands the laser back at once, and moving the mouse takes it again. When the headset comes off, the pointer lets go, so the displays can sleep, and it stays off until you're wearing the headset again.
Whichever device you used last wins. Picking up a controller hands the laser back at once, and moving the mouse takes it again. With hand interaction (`POINTER_HANDS=1`), this goes per hand: a controller used recently wins its hand, and the other hand can stay bare. When the headset comes off, the pointer lets go, so the displays can sleep, and it stays off until you're wearing the headset again.
To move a floating panel, left-drag its grab bar. The scroll wheel pushes and pulls it while you drag. Hold the right button while dragging and move the mouse to tilt the panel around the grab point; the right press isn't sent as a click. The tilt stays for the rest of the drag, and releasing the left button drops the panel as it is. A mapped Toggle dashboard button wakes the pointer if needed and holds the virtual system button for 0.12 s, because SteamVR ignores a press and release in the same instant. Open Steam menu / close dashboard (`steam_menu`) needs no pointer: `steam/ft-steam menu` asks Steam's UI, over its debugging port (`steam/steamui.py`), to show its dashboard overlay and focus the Steam frame's menu, or to hide the dashboard if it's up.
@@ -125,7 +125,8 @@ To move a floating panel, left-drag its grab bar. The scroll wheel pushes and pu
pointer/driver/build.sh && pointer/driver/install.sh install # then restart SteamVR
pointer/helper/build.sh && pointer/helper/run.sh install
pointer/helper/run.sh status | log | restart
pointer/driver/install.sh probe # devices, hand roles, who owns the dashboard pointer
pointer/driver/install.sh probe # devices, hand roles, activity, charging, who owns the dashboard pointer
pointer/probe/build.sh # probes: vrprobe, lasertest (who has the laser), ctlnoise (controller noise)
```
The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `POINTER_IDLE`, `POINTER_WAKE_COUNTS`, `POINTER_CONTROLLER_PICKUP`, `POINTER_DISTANCE`, `POINTER_CURSOR_DEG`, `POINTER_ORIGIN_FRACTION`, `POINTER_ORIGIN_MARGIN`, `POINTER_SCENE_RADIUS`, `POINTER_EDGE_REACH`, `POINTER_LASER_WIDTH`, `POINTER_IGNORE`, the head follow settings `POINTER_FOLLOW`, `POINTER_LEASH_DEG`, `POINTER_LEASH_DELAY`, `POINTER_LEASH_RETURN`, and `POINTER_FOLLOW_REACH`, and the gaze mode settings `POINTER_GAZE`, `POINTER_GAZE_RETAKE`, `POINTER_GAZE_NUDGE_MAX`, `POINTER_GAZE_HOLD`, `POINTER_GAZE_DOT`, `POINTER_GAZE_SHOW`, `POINTER_GAZE_MOUSE`, `POINTER_GAZE_MOUSE_MOVE`, and the keyboard clicks' `POINTER_HEAD_DEADZONE` and `POINTER_KEY_TAP`, and the gaze service's `GAZE_TRACKER` (`auto`, the default: our own eye tracker when it's installed, else SteamVR's; or `own` or `steam`) and `GAZE_EYE` (the eye bias). The example config explains each. Frametop Input Settings changes them live; after editing the file by hand, restart the relay or the helper (the gaze service reads its two again when the file changes).
+4 -2
View File
@@ -31,8 +31,10 @@
// SteamVR keeps a hand role reserved for a disconnected device that still hints
// that hand, so the real controller would never get it back otherwise.
//
// Settings (steamvr.vrsettings section "driver_ft_pointer"): role (int, 2 = right hand, 4 = treadmill,
// 5 = stylus).
// Settings (steamvr.vrsettings section "driver_ft_pointer", defaults in
// ft_pointer/resources/settings/default.vrsettings): role (int, 2 = right hand, 4 = treadmill, the
// default, 5 = stylus). A treadmill is hinted from Activate on, shown or not: SteamVR gives it
// /user/treadmill only when the device is added. Only a hand role switches to OptOut while hidden.
#include <openvr_driver.h>
#include <atomic>
@@ -1,6 +1,6 @@
{
"driver_ft_pointer": {
"enable": true,
"role": 2
"role": 4
}
}
+35 -11
View File
@@ -229,10 +229,14 @@
// ft-gazed says it's up ("calpanel 1", renewed every second; it lapses 3 s after the last),
// the dot hides and a press answers the panel instead of clicking: a left click or gaze_left
// sends "calaccept" to @ft_gazed (take this dot now), a right click or gaze_right "calquit".
// POINTER_ROLE (right, left, or stylus): the hand role our device takes while connected. A
// Frame controller in your hand counts as used through its touch sensors and takes its hand's
// role back, and then no click lands (see "no hand role" in the main loop): with a controller
// held in the right hand, the pointer needs the left hand, or the stylus role.
// POINTER_ROLE (treadmill, the default, or right, left, stylus): the role our device takes while
// connected. Treadmill is no hand, so the real controllers keep both hand roles and a controller
// can be used beside the pointer (or a bare hand's ray). The driver must hint it from the start
// (its default.vrsettings role 4, or driver_ft_pointer/role; a SteamVR restart to change), so this
// setting can't make a device added as a hand into a treadmill. With a hand role, a Frame controller
// in your hand counts as used through its touch sensors and takes its hand's role back, and then
// no click lands (see "no hand role" in the main loop): with a controller held in the right hand,
// the pointer needs the left hand, or the stylus role. docs/design.md, "Treadmill role".
//
// Hands (POINTER_HANDS, off by default; needs hand tracking, hands/): ft-hands publishes
// pinches and grips (hands/include/fh_gestures.h), read here every frame.
@@ -747,7 +751,7 @@ int main() {
// holds back its press in gaze mode; direct: it clicks at once), POINTER_ROLE.
bool gazeMousePrecision = true;
bool gazeMouseHeld = true; // POINTER_GAZE_MOUSE_MOVE (see the top)
std::string role = "right";
std::string role = "treadmill";
double pickupScale = 1; // POINTER_CONTROLLER_PICKUP: scales the controller-moved limits
std::vector<std::string> ignore; // POINTER_IGNORE (see ParseIgnore)
auto loadConfig = [&] {
@@ -787,7 +791,9 @@ int main() {
const auto mm = conf.find("POINTER_GAZE_MOUSE_MOVE");
gazeMouseHeld = mm == conf.end() || mm->second != "free";
const auto ro = conf.find("POINTER_ROLE");
role = ro != conf.end() && (ro->second == "left" || ro->second == "stylus") ? ro->second : "right";
role = ro != conf.end() && (ro->second == "left" || ro->second == "right" || ro->second == "stylus")
? ro->second
: "treadmill";
pickupScale = std::clamp(ConfDouble(conf, "POINTER_CONTROLLER_PICKUP", 1), 0.5, 5.0);
const auto ig = conf.find("POINTER_IGNORE");
ignore = ParseIgnore(ig == conf.end() ? "" : ig->second);
@@ -992,6 +998,7 @@ int main() {
Clock::time_point lastTyping{}; // the relay's last "typing": a key on a keyboard
// Hand interaction (see "Handoff" at the top): the hands in the room, the arbiter, and what
// it last told ft-screens.
int raySide = -1; // the bare hand whose ray drives the pointer (see "Hand ray"), -1: none
hin::HandsInRoom handsRoom;
handoff::Handoff arbiter;
bool framePaused = false; // the relay's "paused 1|0": Frametop paused (for a VR game)
@@ -1679,9 +1686,12 @@ int main() {
if (debug) std::fflush(stdout);
}
// Didn't get the hand role (a held controller keeps it): release, back off.
// Didn't get the hand role (a held controller keeps it): release, back off. Not as a treadmill,
// which has no controller role at all (see POINTER_ROLE at the top): its hint says so.
if (active && tnow - wokeAt > std::chrono::seconds(1) && ours != vr::k_unTrackedDeviceIndexInvalid &&
sys->GetControllerRoleForTrackedDeviceIndex(ours) == vr::TrackedControllerRole_Invalid) {
sys->GetControllerRoleForTrackedDeviceIndex(ours) == vr::TrackedControllerRole_Invalid &&
sys->GetInt32TrackedDeviceProperty(ours, vr::Prop_ControllerRoleHint_Int32) !=
vr::TrackedControllerRole_Treadmill) {
active = false;
claimPending = claimHeld = false;
cursorDot.Show(false);
@@ -1694,8 +1704,10 @@ int main() {
}
// Last used wins: a real controller being moved releases the pointer (see the top),
// in gaze mode too.
if (active && hold.src == Src::None && tnow - lastMouse > std::chrono::milliseconds(500)) {
// in gaze mode too. Not while a bare hand's ray drives it: that ray goes only when its own
// side turns controller (see "Handoff" at the top), so a controller in the other hand
// doesn't take it.
if (active && hold.src == Src::None && raySide < 0 && tnow - lastMouse > std::chrono::milliseconds(500)) {
for (vr::TrackedDeviceIndex_t i = 1; i < vr::k_unMaxTrackedDeviceCount; ++i) {
if (i == ours || !all[i].bPoseIsValid || all[i].eTrackingResult != vr::TrackingResult_Running_OK ||
sys->GetTrackedDeviceClass(i) != vr::TrackedDeviceClass_Controller) {
@@ -2065,12 +2077,24 @@ int main() {
seenBegins[k][s] = g.begins, seenEnds[k][s] = g.ends;
auto holding = [&] { return hold.src == Src::Hand && hold.side == s && hold.grip == (k == 1); };
if (holding() && ended) endHold(lost); // the one held ended (another may have begun)
if (began && allowed) {
// Not on a side that has a controller in use, and not by a hand at a controller
// (reaching for it or holding it: by position, whatever ft-hands calls its side).
bool atController = arbiter.IsController(s);
vr::HmdMatrix34_t gh;
if (!atController && poses.At(g.begin_ns, gh)) {
const Vec3 at = hin::ToRoom(gh, g.begin_point);
atController = arbiter.NearController({at.x, at.y, at.z});
}
if (began && allowed && atController && debug)
std::printf("hand %s %s ignored: at a controller\n", s ? "right" : "left", k ? "grip" : "pinch");
if (began && allowed && !atController) {
beginHold(s, k == 1, g);
// begun and ended since the last read (a quick tap): its release too
if (!(g.flags & FH_PINCH_DOWN) && holding()) endHold(lost);
}
}
// A side that turns controller mid-gesture: the gesture ends, as lost.
if (hold.src == Src::Hand && arbiter.IsController(hold.side)) endHold(true);
// The held gesture's hand moves the pointer: past the dead zone (a tap's jitter,
// the pinch point shifting as the fingers close), then at the gain, from there.
if (hold.src == Src::Hand) {
+14
View File
@@ -420,8 +420,22 @@ def check_vr_socket():
except (OSError, ValueError) as e:
report("FAIL", label, f"/input/getstate.json didn't answer ({e}); the pause gesture can't read the controllers")
return
# The 3D mouse's virtual controller as a treadmill (the driver's default role 4): SteamVR gives it
# /user/treadmill, where its compositor binding has its laser entries (docs/design.md, "Treadmill role").
for d in devices:
if d.get("controller_type") == "ft_pointer":
path = d.get("root_path") or "?"
if path == "/user/treadmill":
report("ok", "pointer device role", "the virtual controller is at /user/treadmill")
else:
report("warn", "pointer device role", f"the virtual controller is at {path}, not /user/treadmill: "
"an older driver (role 2), or SteamVR moved the treadmill path. Retest the laser with "
"pointer/probe/build/lasertest (frame-hands/notes/hand-interaction.md, probe P1)")
found = [d for d in devices if d.get("controller_type") == "frame_controller" and d.get("root_path")
and d.get("side") in ("left", "right")]
if found and not all(any(str(c.get("path", "")).endswith("/touch") for c in d.get("components", [])) for d in found):
report("warn", "controller touch sensors", "a Frame controller lists no touch component; hand interaction's "
"handoff (pointer/helper/handoff.h) falls back to motion alone")
if not found:
report("skip", label, "no Frame controller is on, so it wasn't checked")
return
+1 -1
View File
@@ -15,7 +15,7 @@ POINTER_IDLE=30 # seconds without mouse use before the controllers get their
POINTER_WAKE_COUNTS=40 # mouse counts within 1 s needed to wake or re-claim the laser (ignores desk jitter)
POINTER_CONTROLLER_PICKUP=1 # how hard a controller must move (x 0.35 m/s or 2 rad/s, for 100 ms) to take the laser back; raise it if resting controllers do (0.5 to 5)
POINTER_DISTANCE=1.5 # metres to the cursor when it isn't on a panel
POINTER_ROLE=right # the hand role the pointer's controller takes: right | left | stylus (no hand); with a Frame controller held in that hand, clicks don't land, so pick the other
POINTER_ROLE=treadmill # the role the pointer's controller takes: treadmill (no hand: controllers keep theirs; needs the driver's default role 4 and a SteamVR restart) | right | left | stylus
POINTER_CURSOR_DEG=0.4 # size of the free-space dot, in degrees
POINTER_ORIGIN_FRACTION=0.95 # laser starts this far along eye->cursor: its beam is a few cm, SteamVR's hit dot tiny
POINTER_LASER_WIDTH=0.8 # controller beam width (dashboard.laserRayWidthScale), set once at helper start