Compare commits

...
Author SHA1 Message Date
4d50739393 Gaze: leave SteamVR's gaze action alone during VR games
From curiousjtuber's PR #13: with the gaze service running, SteamVR
restarted its eye tracker every 10 to 13 s of Beat Saber, as if the
headset came off, and each restart took input focus from the game.
The PR stopped every read in a game. Only the action path reaches
SteamVR (UpdateActionState on the gaze set at overlay-global priority,
then GetEyeTrackingDataRelativeToNow); the mmap and our tracker are
read-only files. So only the action is skipped while a scene app runs,
and gaze keeps moving the pointer over the dashboard in a game. The
action source is only used with --source action.

Co-Authored-By: CuriousJ <curious.j.tuber@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:15:57 -06:00
DeeJanuzandClaude Opus 5.5 f60da63702 Merge PR #12 and #14 (relay udev retry, catcher crash) into experimental
From curiousjtuber's PRs: the relay leaves a new input node for the next
scan until udev gives it to the input group, rather than marking it seen
after a failed open; and ft-screens' catcher takes a screen's overlays
from one copy of All() instead of begin() and end() of two temporaries,
which crashed libc++ builds on the first click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:15:04 -06:00
CuriousJ 82d2107e1d Screens: take a screen's overlays from one copy in the catcher
While a button pressed on a screen is held, UpdateCatcher checks every tick
whether the laser is still on one of the screen's overlays. It built that list
from s.All().begin() and s.All().end(), but All() returns a std::array by
value: iterators into two different temporaries, which is undefined behaviour.
A clang build of ft-screens got a garbage length, threw std::length_error, and
aborted on the first click, taking KWin and the desktop with it.
2026-10-02 09:11:46 -06:00
CuriousJ 12f2e844d5 Input relay: retry a new device until udev gives it to the input group
A new /dev/input node is root:root 0600 until udev applies GROUP=input. The
scan probed each new node once and marked it seen even when the open failed, so
a node caught in that gap was never opened. Behind a KVM, a switch brings back a
hub of devices at once: on the Frame, four nodes failed with EACCES in one switch,
the keyboard was never grabbed, and its keys went to gamescope instead of the
desktop screens. A node that isn't readable yet now waits for the next scan.
2026-10-02 09:11:46 -06:00
DeeJanuzandClaude Opus 5.5 ee2ce1a8d2 Merge PR #11 (controller click stability) into experimental
From jlneal's PR: a trigger press on a desktop screen stays put until the
laser moves more than 8 logical pixels, so controller jitter doesn't turn
a click into a drag. On top of it, only a hand controller's press starts
the filter: the 3D mouse's laser reaches the screens the same way, and the
PR as sent turned the mouse's short drags into clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:20:06 -06:00
3 changed files with 42 additions and 19 deletions

No files matched your search

+35 -18
View File
@@ -479,9 +479,24 @@ int main(int argc, char **argv) {
double lastEmit = 0; double lastEmit = 0;
int actionErrors = 0; int actionErrors = 0;
vr::EVRInputError lastActionError = vr::VRInputError_None; vr::EVRInputError lastActionError = vr::VRInputError_None;
// During a VR game, SteamVR's gaze action is left alone. With ft-gaze reading the eyes, SteamVR
// restarted its eye tracker every 10 s or so in a game, as if the headset came off, and each
// restart took input focus from the game: Beat Saber paused (PR #13). Of what ft-gaze reads,
// only the action reaches SteamVR (the mmap and our tracker are files), so gaze still works
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
bool inGame = false;
double nextGameCheck = 0;
while (true) { while (true) {
const double now = NowRaw(); const double now = NowRaw();
if (now >= nextGameCheck) {
nextGameCheck = now + 0.5;
const bool game = vr::VRApplications()->GetCurrentSceneProcessId() != 0;
if (game != inGame)
std::fprintf(stderr, "ft-gaze: %s\n",
game ? "a VR game is running: SteamVR's gaze action left alone" : "the VR game ended");
inGame = game;
}
vr::TrackedDevicePose_t hp; vr::TrackedDevicePose_t hp;
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &hp, 1); sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &hp, 1);
if (hp.bPoseIsValid) history.Add(now, hp.mDeviceToAbsoluteTracking); if (hp.bPoseIsValid) history.Add(now, hp.mDeviceToAbsoluteTracking);
@@ -502,24 +517,26 @@ int main(int argc, char **argv) {
// SteamVR's action: a room-space origin and fixation point, turned into the head // SteamVR's action: a room-space origin and fixation point, turned into the head
// frame so every source reports the same kind of angles. // frame so every source reports the same kind of angles.
std::string action = "{\"ok\":0}"; std::string action = "{\"ok\":0}";
vr::VRActiveActionSet_t active{}; if (!inGame) {
active.ulActionSet = set; vr::VRActiveActionSet_t active{};
active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin; active.ulActionSet = set;
input->UpdateActionState(&active, sizeof active, 1); active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
vr::VREyeTrackingData_t e{}; input->UpdateActionState(&active, sizeof active, 1);
const vr::EVRInputError ae = vr::VREyeTrackingData_t e{};
input->GetEyeTrackingDataRelativeToNow(gaze, vr::TrackingUniverseStanding, 0, &e, sizeof e); const vr::EVRInputError ae =
if (ae == vr::VRInputError_None && e.bActive && e.bValid) { input->GetEyeTrackingDataRelativeToNow(gaze, vr::TrackingUniverseStanding, 0, &e, sizeof e);
const Vec3 o{e.vGazeOrigin.v[0], e.vGazeOrigin.v[1], e.vGazeOrigin.v[2]}; if (ae == vr::VRInputError_None && e.bActive && e.bValid) {
const Vec3 t{e.vGazeTarget.v[0], e.vGazeTarget.v[1], e.vGazeTarget.v[2]}; const Vec3 o{e.vGazeOrigin.v[0], e.vGazeOrigin.v[1], e.vGazeOrigin.v[2]};
const Vec3 dHead = RotateInverse(headNow, Normalize(t - o)); const Vec3 t{e.vGazeTarget.v[0], e.vGazeTarget.v[1], e.vGazeTarget.v[2]};
char extra[96]; const Vec3 dHead = RotateInverse(headNow, Normalize(t - o));
std::snprintf(extra, sizeof extra, "\"tracked\":%d,\"dist\":%.3f,", int(e.bTracked), Length(t - o)); char extra[96];
action = SrcJson(list, headNow, dHead, extra); std::snprintf(extra, sizeof extra, "\"tracked\":%d,\"dist\":%.3f,", int(e.bTracked), Length(t - o));
} else if (ae != lastActionError || (verbose && ++actionErrors % 90 == 1)) { action = SrcJson(list, headNow, dHead, extra);
std::fprintf(stderr, "ft-gaze: action: error %d active %d valid %d\n", int(ae), int(e.bActive), } else if (ae != lastActionError || (verbose && ++actionErrors % 90 == 1)) {
int(e.bValid)); std::fprintf(stderr, "ft-gaze: action: error %d active %d valid %d\n", int(ae), int(e.bActive),
lastActionError = ae; int(e.bValid));
lastActionError = ae;
}
} }
std::string m1 = "{\"ok\":0}", m2 = m1, left = m1, right = m1, eye = "null"; std::string m1 = "{\"ok\":0}", m2 = m1, left = m1, right = m1, eye = "null";
+5
View File
@@ -1139,6 +1139,11 @@ def main():
# New here: a new device, or one that came back in the same place. # New here: a new device, or one that came back in the same place.
for old in [n for n in nodes.values() if n.path == path]: for old in [n for n in nodes.values() if n.path == path]:
drop(old, "replaced by a new device node") drop(old, "replaced by a new device node")
# A new node is root's alone until udev gives it to the input group, a moment
# after it appears. Opened in that gap, it would fail and never be tried
# again: leave it for the next scan instead.
if not os.access(path, os.R_OK):
continue
seen[path] = ino seen[path] = ino
node = probe(path) node = probe(path)
if node and node.volume_keys and not take_volume(node): if node and node.volume_keys and not take_volume(node):
+2 -1
View File
@@ -1330,7 +1330,8 @@ void UpdateCatcher() {
for (int k = 0; k < 3; ++k) params.vSource.v[k] = l.m[k][3], params.vDirection.v[k] = -l.m[k][2]; for (int k = 0; k < 3; ++k) params.vSource.v[k] = l.m[k][3], params.vDirection.v[k] = -l.m[k][2];
for (auto &[i, s] : g_screens) { for (auto &[i, s] : g_screens) {
if (!s.visible) continue; if (!s.visible) continue;
std::vector<vr::VROverlayHandle_t> parts(s.All().begin(), s.All().end()); const auto all = s.All(); // one copy: two calls give two temporaries, not one range
std::vector<vr::VROverlayHandle_t> parts(all.begin(), all.end());
for (const auto &[k, sub] : s.subs) parts.push_back(sub.overlay); for (const auto &[k, sub] : s.subs) parts.push_back(sub.overlay);
for (auto o : parts) { for (auto o : parts) {
vr::VROverlayIntersectionResults_t hit; vr::VROverlayIntersectionResults_t hit;