Hand tracking from the headset’s own cameras. It’s deferred: it costs a lot of the headset’s CPU and needs more work, so install.sh doesn’t offer it and the README doesn’t list it. It still builds and runs, installed by hand (below), for working on it.
It serves two things in Frametop:
screens/handcut.cpp), so your hands show over the screens the way they do on a Vision Pro.POINTER_HANDS=1, the pointer helper takes them as clicks and drags. Look at something and pinch to click it, with the eye tracker doing the looking (gaze/), or close your hand to press and drag what the pointer is on. See “Pinches and grips in the pointer” below.Two programs, each a user service that stops when SteamVR does:
ft-camd (camd/, C) borrows XRService’s camera buffers and publishes the four IR tracking cameras’ frames to a shared-memory ring. It runs on the host.ft-hands (track/, C++) finds hands in those frames with MediaPipe’s palm and landmark models on ncnn, triangulates them, and publishes them. It runs in the dev container.They don’t start with SteamVR. hands/run.sh install builds them, gives ft-camd its capabilities, installs both services disabled, and links hands/ft-handsctl into ~/.local/bin. Then ft-handsctl on starts hand tracking and ft-handsctl off stops it. install.sh doesn’t install it.
For the cutouts alone, hands/ft-cutouts on starts the same two programs with ft-hands’ --no-gestures: your hands show through the screens, and no pinch or grip is detected, so nothing clicks or drags. It runs this checkout’s build as transient user units, so it needs hands/build.sh and ft-camd’s capabilities (hands/run.sh caps) but not hands/run.sh install. It and ft-handsctl on stop each other’s services, and it stops with SteamVR too.
ft-handsctl on | off # on the Frame: start or stop hand tracking (SteamVR must be running)
ft-handsctl status # the services, and ft-hands' last status lines
ft-handsctl log [lines]
ft-handsctl cutouts on|off|state # ft-screens' hand cutouts, without stopping tracking
ft-handsctl gestures # pinches and grips, live (tools/watch_gestures.py --distance)
hands/ft-cutouts on | off | status # the cutouts only: no pinches or grips
hands/run.sh install # build, give ft-camd its capabilities (sudo, once per build), install disabled
hands/run.sh start|stop # start or stop the services
hands/run.sh restart # after changing a setting
hands/run.sh status
hands/run.sh log [lines]
hands/run.sh caps # after rebuilding ft-camd (a rebuild clears its capabilities)
hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
hands/run.sh uninstall # the services, then uncaps
Settings in ~/.config/frametop.conf (FT_<name> in the environment overrides them), read when ft-camd and ft-hands start:
HANDS_SWAP_SIDES=auto (the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd’s names when they’re backwards (see “Which camera is which” below). 1 forces them exchanged and 0 forces ft-camd’s names; ft-hands still checks, and if the hands disagree it logs a warning and publishes the hands’ answer as the truth (sides.json), so recordings are labelled right. The example config said 0 until 2026-10-05; scripts/conf-migrate.sh (run by install.sh and hands/rec/install.sh) turns that untouched line into auto.HANDS_CPUS=5,6,7: the CPUs the model threads run on (below).HANDS_CAMERAS (auto), HANDS_BRIGHT (all), HANDS_BRIGHT_ON (40), HANDS_BRIGHT_OFF (25): which cameras ft-hands tracks with, as --cams, --bright, --bright-on and --bright-off (see ft-hands). HANDS_CAMERAS=mono also keeps ft-camd off the colour cameras.HANDS_MODELS (unset: hands/models/ncnn, the stock MediaPipe models): a folder holding palm.ncnn.* and hand.ncnn.*, as --models. Use it to run fine-tuned models, such as the ones trained on the hand dataset, without passing options to every launcher. Those folders usually lack the *-int8 files, so --int8 won’t load them.HANDS_COLOR_LEFT (color_video0), HANDS_COLOR_CROP (subtract): how the colour module’s calibration maps onto its images, as --color-left and --color-crop.The pointer helper’s POINTER_HANDS and POINTER_PINCH_*/POINTER_GRIP_* settings are in “Pinches and grips in the pointer” below.
Files, all in /run/user/UID/frametop-hands/ (private to the user; not /run/user/UID/frametop/, which the desktop session deletes whenever it starts):
| File | Written by | Layout | Read by |
|---|---|---|---|
cam-ring |
ft-camd | camd/fhring.h |
ft-hands, tools/ring.py |
hands |
ft-hands | include/fh_hands.h |
ft-screens (screens/handcut.cpp) |
gestures |
ft-hands | include/fh_gestures.h |
the pointer helper (pointer/helper/ft-pointer.cpp), tools/watch_gestures.py |
The source keeps the fh_ names and magic strings of frame-hands, the project it started as, so recordings made with it still work.
XRService owns the headset cameras. ft-camd borrows its DMA-BUFs read-only with pidfd_getfd, the same way FrameEyeCameraFeed does. It never touches XRService’s V4L2 descriptors. discovery in camd/xrcams.c is adapted from FrameEyeCameraFeed (MIT, see camd/LICENSE.FrameEyeCameraFeed).
Polling buffers for changes can catch a frame while the camera is still writing it. Instead, ft-camd listens to the v4l2:v4l2_dqbuf tracepoint, which fires when XRService takes a buffer. It gives the buffer index, the sequence number and the capture timestamp. ft-camd learns which DMA-BUF holds each V4L2 index by watching which buffer changes at each dequeue:
Privileges. Setting up needs three things. pidfd_getfd on XRService needs CAP_SYS_PTRACE, because the Frame has ptrace_scope=1. The system-wide tracepoint needs CAP_PERFMON, because perf_event_paranoid is 2. Its format files are root-only, which needs CAP_DAC_READ_SEARCH. hands/run.sh install gives the binary those capabilities with sudo setcap. File capabilities need a filesystem mounted without nosuid. The Frame’s /home (ext4) has no nosuid. ft-camd drops them all once it has set up, before it reads a frame, and then runs as you. XRService runs as you too. It also runs under sudo, for trying it by hand, and then drops to the user who ran sudo. It reads nothing from the ring’s readers.
The ring is mode 0600, in a folder only you can write. Frame handling:
Options:
--dark R: a frame dimmer than R times the camera’s recent brightest counts as near-black. Default 0.4.--with-dark: also publish the near-black frames, as extra ring cameras flagged FH_CAM_DARK. They show only light sources, so they’re no use for hands.--with-color: also publish the two Arcturus colour cameras, flagged FH_CAM_COLOR. The service leaves it off and runs the mono cameras only (see “Known issues”). To try the colour cameras, add it to ExecStart in hands/frametop-camd.service and run hands/run.sh install again; ft-hands then picks the cameras by the light. Each is the luma of the 10-bit frame’s valid 1972x2464 (the top 8 bits), at half size (--color-scale 2: 986x1232). They run at --color-idle (2 fps), enough for ft-hands to tell how bright it is, until a reader asks for more in /run/user/UID/frametop-hands/color-fps (ft-hands writes 30 while it tracks or records with them), up to --color-fps (30; the cameras run at 60). HANDS_CAMERAS=mono leaves them out. Frames that carry the module’s warped half-size copy are dropped. Their capture_ns is on the colour module’s clock (2.2 s off the mono cameras’ on 2026-09-29), so line them up with the mono cameras by dqbuf_ns. Each frame costs about 0.65 ms of cache sync and 1.1 ms of decoding, so both cameras at 30 fps take about 11% of a core.dark_mean): a short fixed exposure, so it follows the room’s IR light, sunlight above all.FT_CAMD_DEBUG=1 in the environment: at each stale colour frame, ft-camd logs to stderr the camera, the frame’s time, V4L2 index and sequence number, and, for every candidate buffer, how many of its sampled words changed, in all and in the last eighth of the samples.--sensor S: only the mono cameras whose sensor name contains S.--status S: a status line every S seconds (0: never).It exits when XRService exits, or when a camera’s buffers keep going stale, which means XRService has reallocated them. The service starts it again, and it attaches to the new buffers.
Which camera is which: video9 is slam_right (sensor og01a1b 4-0036), video13 is slam_left (4-0060), video6 is upper_left and video7 is upper_right; without the colour module the side pair is on video0 (slam_right) and video3. XRService says so in its log: Found camera 'slam_left': ... v4l_subdev=/dev/v4l-subdev30 names each sensor, and each TrackingCameraInit line gives the device and subdev it opened. ft-hands and camcheck.py name the devices that way. Until 2026-10-05 they named them by the TrackingCameraInit index instead, which is only the order XRService opens them in (index 0 was slam_right on every start logged), and ft-camd bound each buffer queue to a device by the order of XRService’s file descriptors, which changes when XRService restarts its cameras. The two mistakes made the side names come out swapped on most starts and right on some. ft-camd now asks each device which buffers it holds (VIDIOC_QUERYBUF names the descriptor XRService queued at each index) and only falls back to the order if that fails. With the names swapped, every hand is seen by one camera only, at the wrong depth, and the hand holes land beside the hands. ft-hands still checks by itself, in case (track/sides.h, HANDS_SWAP_SIDES=auto):
side cameras: SWAPPED, now exchanged after 3.2 s (votes ...)). So does /run/user/UID/frametop-hands/sides.json, which the hand recorder reads. Recordings get a DIR/sides.json (hands/rec/sides.py has the rules).--record-only can’t tell (it tracks nothing): it records ft-camd’s names unless --sides 0|1 says otherwise.tools/check_sides.py --ring is the independent check from the scene (ORB matches meeting under each naming): exit 0 as named, 3 swapped, 2 can’t tell. --pair upper checks the upper pair the same way: in every recording so far (3 XRService starts, both side namings) the upper pair was named right. The colour cameras are video3 (arcimx616 0-0010) and video0 (0-001a); which of them is passthrough_left in the module’s calibration is for tools/check_color.py to settle, on a recording with texture in view.
hands/build/ft-hands # status every 5 s; Ctrl+C to stop
hands/build/ft-hands --int8 # the 8-bit models (models/ncnn/*-int8.ncnn.*)
Run it in the dev container (distrobox enter dev -- ...). It reads the factory calibration from /persist (/run/host/persist in the container).
Options:
--threads N: model threads, pinned to the --cpus list. Default 3.--cpus LIST: CPUs for the model threads and the main loop. Default 5,6,7 (HANDS_CPUS). SteamOS starts user processes on CPUs 0-4, and XRService’s head tracking runs on 2-3. With the headset on, a step took 8.4 ms on 5-7 against 13.2 ms on 2-4, and SteamVR’s frame timing didn’t change (2026-09-29, three rounds of the same replayed frames).--contrast MODE or PALM/HAND: how crops are equalized before the models see them: clahe[:CLIP], none, or stretch (1st-99th percentile). Default clahe:2/none. In the dim recording, CLAHE let the palm search find about 10% more hands, but it made the landmarks jitter more (published median 6.9 mm, against 6.0 mm with plain landmark crops).--sides auto|0|1: which side camera is which (HANDS_SWAP_SIDES, see “Which camera is which”); --swap-sides is --sides 1.--seconds N: stop after N seconds.--status S: how often to print status, in seconds.--models DIR: where the models are.--nice N: niceness. Default 5, so the VR stack wins contested CPUs.--no-publish: don’t write the hands and gestures files.--no-gestures: hands for the cutouts only. No pinch or grip detection, so nothing reaches the pointer and a closing hand doesn’t raise the rate to 30 Hz. The gestures file is removed at start. ft-cutouts runs it this way.--record DIR, --record-for S, --record-hz N: save every frame set for S seconds (default 120) to DIR/sets.bin, or at most N sets a second with --record-hz (the hand recorder uses 10). Every set is about 80 MB/s. Sending the tracker SIGUSR1 (pkill -USR1 -x ft-hands) starts a recording in ~/.local/share/frametop/hands/rec-<time> without a restart. Recordings are images of your hands and room: they stay on the headset unless you move them.--record-only: record without tracking or publishing, so it can run beside the live tracker. Give it --record DIR, since SIGUSR1 would reach both trackers. With ft-camd --with-dark, recordings also hold each camera’s newest dark frame as <name>_dk, which doubles the rate. With --with-color, each colour camera’s newest frame is saved with every set, as color_video<N>, which adds about 70 MB/s. Run the recorder at normal I/O priority: idle I/O priority stalled a 165 MB/s recording.--keep-presence P: the landmark presence a tracked view needs to stay tracked. New views always need 0.5. Default 0.5. Lowering it to 0.2 barely helped in the bright recording, because lost hands drop to near-zero presence.--ring PATH: read frames from another ring, such as ft-ringplay’s.--cams auto|mono|color|all (HANDS_CAMERAS, default auto): which cameras to track with. The mono IR cameras light the hands themselves and track well in dim rooms, but in bright light they expose for the room and the hands come out dark. The colour pair is the other way round. auto goes by the colour frames’ mean brightness: at --bright-on (HANDS_BRIGHT_ON, 40) or over for 2 s it tracks with --bright (HANDS_BRIGHT: all, every camera, the default, or color), and under --bright-off (HANDS_BRIGHT_OFF, 25) for 2 s with the mono cameras again. A dim evening room read 9. The switch is logged (cameras: mono -> all (...)), and the status line gives the colour level, the mono cameras’ ambient IR, and how many steps had colour frames. Colour frames arrive on their own schedule, so a step holds the mono set, the colour pair, or both, and views wait in their camera for its next frame. With no colour cameras in the ring (ft-camd without --with-color, as the service runs it), ft-hands tracks with the mono cameras whatever this says.--color-left NODE (HANDS_COLOR_LEFT, color_video0) and --color-crop subtract|none (HANDS_COLOR_CROP, subtract): how the colour module’s calibration maps onto the images. Not settled yet: tools/check_color.py on a recording with a lit, textured view tells.--grip-begin R, --grip-end R: the grip detector (below). Defaults 1.2 and 1.45.--gesture-log: print what the pinch and grip detectors measure, 10 times a second: each hand’s thumb-to-index distance (world and triangulated), its palm-down reading and its finger curl.Gestures (/run/user/UID/frametop-hands/gestures, include/fh_gestures.h), for the pointer helper:
POINTER_GRIP_BELOW (0.35 m) below the eyes, which it can tell and ft-hands can’t.tools/watch_gestures.py --distance shows both live; ft-handreplay --timeline logs them and each hand’s finger curl.The status line also says how often a hand was on each side (by where the wrist is), and why views and hands came and went: views lost (the landmark model stopped seeing the hand), handoff misses (a crop projected from the hand’s 3D position found nothing), duplicates, splits (two views disagreed in 3D), and hands created, merged and forgotten.
How good the depth is, measured from recordings (2026-09-30, --depth below): the two lower cameras see the hands about 77% of the time, a lower and an upper camera 7-12%, and one camera 12-15%. Depth is the noisy direction. With the lower pair, it jitters 4-6 times as much as sideways position (published: 3-7 mm against 1-2 mm). The one-camera guess is a median 2-6 cm off. When a camera drops out, drifting 10% a frame toward that guess is worse than keeping the last distance (after 0.5 s a median 23-30 mm off, against 11-12 mm).
ft-hands detects a pinch per hand (track/pinch.h) and publishes it to the gestures file. The layout, and how to read it without missing quick taps, is in include/fh_gestures.h.
--pinch-begin (default 0.020 m). It ends when they open past --pinch-end (0.035 m) for 2 processed frames in a row, or when the hand stays lost for 0.25 s (flagged lost).--pinch-triangulated uses the triangulated tips instead. On two recordings without deliberate pinches, the world landmarks came under 2 cm in 0.2-1% of frames, against 3.3-4.5% for the triangulated tips. In the dim recording, typing still gave 2 pinches a minute (see the next point).--pinch-palm-down MAX holds back pinches begun with the palm facing down (MAX is the palm normal’s share of the head’s up axis). The default, 1, turns it off. A close held back that way has to open again before a pinch can begin. Typing curls the thumb onto the index: in the lit recording of 2026-09-30, typing on a keyboard in the lap began 23 pinches in about 2 minutes, all with the palm facing down (0.69-1.00), while the 26 deliberate ones read 0.00-0.50. But in the headset, deliberate pinches with the hand raised in front read 0.90-0.99 too, so the limit is off. Typing is caught by the pointer helper instead: the input relay tells it when you press a key, and no pinch begins within POINTER_PINCH_TYPING of one.tools/watch_gestures.py prints begins, ends and drag offsets live, and --distance prints each hand’s distance.With POINTER_HANDS=1, the pointer helper (pointer/helper/ft-pointer.cpp) reads the gestures file every frame. It’s off by default.
Settings in ~/.config/frametop.conf:
POINTER_HANDS (0): 1 turns pinches and grips on.POINTER_PINCH_GAIN (0.5): a held pinch moves the pointer this many times the hand’s angle, seen from the eye. Under 1 gives precision.POINTER_PINCH_DEADZONE (1.5): how many degrees the pinching hand moves before the pointer does, so a tap’s jitter and the pinch point shifting as the fingers close don’t move it.POINTER_GRIP_GAIN (1): a grip moves the pointer this many times the hand’s angle.POINTER_GRIP_BELOW (0.35): grips that begin more than this many metres below the eyes are ignored, because hands resting on a desk curl like a loose fist. Pinches have no such limit: deliberate ones sat 0.35-0.45 m below the eyes with the elbow resting.POINTER_PINCH_TYPING (1): no pinch begins within this many seconds of a key press, because typing touches thumb to index.hands/build.sh --tools also builds the offline tools.
ft-handreplay DIR runs a recording through the tracker with the live scheduling and reports how well it kept the hands: hands per set, left and right coverage, track lengths, pinches, jitter, and the same reasons as the status line.
hands/build/ft-handreplay ~/.local/share/frametop/hands/rec-20260929-120000 --cost --oracle 10 --timeline /tmp/tl.txt
--cost: instead of timing the steps, charge each round of model calls what it typically costs live (10 ms landmarks, 18 ms palms), so results repeat exactly.--oracle N: every N-th set, also search every tile of every camera, and report how often the tracker had the hands that full search could find.--slow F: live, the tracker skips sets that arrive while it’s busy. Replay counts each step’s time times F as busy (default 1; the headset is busier live).--timeline FILE: a line per processed set and hand, with pinch events and distances.--cams mono|color|all: which cameras to track with (default mono). color tracks with the Arcturus pair alone, for comparing it with the IR cameras on the same recording. It needs a recording made with ft-camd --with-color. --color-left NODE (color_video0 or color_video3) and --color-crop subtract|none say how the module’s calibration maps onto the images; tools/check_color.py finds out.--depth FILE: a line per hand per processed set for tools/depth_report.py, which measures the depth without ground truth: how the hands were seen, the noise along the line of sight against across it, each camera’s one-view distance against the triangulated one, and what a camera dropping out would do.ft-ringplay DIR --ring PATH [--from S] [--to S] [--loop] publishes a recording into a ring file in real time, as ft-camd would, so ft-hands --ring PATH --no-publish runs the same frames run after run. It needs no privileges, and it skips the dark frames.
Python, with NumPy and OpenCV. setup/dev-container.sh doesn’t install them, because Fedora’s python3-opencv pulls in over a gigabyte; in the dev container, run sudo dnf install python3-numpy python3-opencv once. Off the Frame, FRAME_JOB_DEVICE_ROOT can point at a folder with copies of the headset’s calibration files.
tools/check_sides.py --ring (or a recording, a sets file, --pair upper, --calib DIR, --json): are the side cameras named right, from the scene? ft-handreplay’s --sides file|0|1|auto replays with DIR/sides.json’s names (the default), as recorded, exchanged, or as auto decides, and reports what the side check found and when.tools/check_color.py REC: how the colour module’s calibration maps onto its images.tools/show_set.py REC: a recording’s frame sets as images.tools/watch_gestures.py [--distance]: pinches and grips, live.tools/depth_report.py DEPTH: the depth measures above.tools/cut_sets.py REC OUT [--sets N | --at I,J,...]: copies a few frame sets (by default 8, spread evenly) out of a recording into a small one, to look at or check elsewhere without moving gigabytes. Plain Python, so it also runs on the Frame’s host.tools/convert_models.py: how models/ncnn was made from the OpenCV Zoo ONNX ports of MediaPipe’s models (see models/NOTICE).To try the hand cutouts without restarting the desktop, screens/build/ft-handtest [--distance m] [--width m] [--seconds s] (built by screens/build.sh, run in the dev container, with hand tracking on) shows a test panel of its own, a light grid 1 m wide and 0.8 m ahead by default, and cuts your hands out of it the way ft-screens cuts them out of the screens.
Hand tracking needs all four mono cameras and the headset’s IR light. With the Arcturus colour module attached, SteamVR’s XRService loads an FPGA image (“VCINT”) onto the module every time it opens the cameras: when SteamVR starts and after every wake. When that load fails, XRService runs only the two side cameras, the frames come out darker and noisier, and ft-hands finds no hands at all. It happened on 2026-10-02 at 17:02, after the headset slept; the hand recorder then said “I can’t see your hands” for a whole session.
hands/camcheck.py (system Python, standard library) tells whether the four cameras run: ok, degraded: upper cameras and IR light off (VCINT FPGA failed to load) (or another degraded: reason), or unknown (SteamVR not running, the cameras closed while the headset sleeps). It prints the log lines and other evidence it used; --json is for programs; the exit status is 0, 1 or 2. It reads:
~/.local/share/Steam/logs/xrservice.txt): the last camera start (from the FPGA check to the next “Closing tracking camera interfaces”), its VCINT result, Upper cameras FPGA interleaving support: N, Created N tasks (T tracking, P passthrough) and the TrackingCameraInit lines. A wake that works prints no “Created N tasks”, so an older one doesn’t count;/dev/video* XRService has open (/proc/PID/fd; video9 and video13 are the side pair, video6 and video7 the upper pair). Only on the host: the dev container can’t read another process’s open files, so there it’s skipped;The hand recorder runs it before a session (DESIGN.md, “Camera check”). hands/tests/test_camcheck.py runs it on the 2026-10-02 log cut at several points, and on made-up logs.
hands/ft-camwatch follows the XRService log (a stat every 2 s, reading only what’s new). On a VCINT failure it posts a notification in the Frametop desktop, on the desktop’s own D-Bus, found through its plasmashell as decoration/apply.sh does. With CAMWATCH_AUTO_RESTART=1 in ~/.config/frametop.conf it also restarts SteamVR, but only:
vrcompositor runs and a /sys/class/backlight/*/brightness is over 0), and after it has been off for CAMWATCH_IDLE_S (60);SteamLaunch AppId=N in a process’s arguments; CAMWATCH_IGNORE_APPIDS lists ids that don’t count) and nobody on the remote desktop (an established connection to the VNC port, VNC_PORT, 5900);CAMWATCH_COOLDOWN_MIN (30) of the last automatic restart. It remembers both in ~/.local/state/frametop/camwatch.json, so its own restart doesn’t reset them.It logs every decision to the journal. ft-camwatch --once prints the state and what it would do, and does nothing; --dry-run keeps watching without acting. hands/frametop-camwatch.service is the unit (a template, @REPO@ as in the others; nothing installs or enables it yet). It isn’t PartOf=steamvr.service, so it outlives the restart it asks for. CAMWATCH_NOTIFY=0 turns the notification off. hands/tests/test_camwatch.py tests its decisions with made-up inputs.
Read from the code on the experimental branch, not tried live:
VREvent_Quit it ends its Wayland display (screens/vr.cpp, ft_vr_poll; screens/compositor.c, handle_vr_event). It never connects to SteamVR again: ft_vr_init runs once, at its start.frametop-desktop, is a transient systemd-run unit with Restart=no, so the desktop doesn’t come back by itself: start it again (Desktop in the library, or desktops.sh start).session/keep-apps.sh moves programs started in the desktop out of the unit first, but only desktops.sh stop runs it; here they stop too. Programs in the dev container (ft-screens, the hand recorder) are in the container’s cgroup and end when their Wayland connection goes.PartOf=steamvr.service restart with it: frametop-camd, frametop-hands, the pointer helper, gaze and power, and the hand recorder’s own transient ft-camd and ft-hands units.Verified, from the XRService logs of 2026-10-01 and 2026-10-02 and the running system:
Guesses, not tested:
room_led_en pin, but nothing shows the light’s state directly.SteamLaunch AppId=N: from Steam on other Linux systems; no VR game has run on the Frame to confirm it.hands/build.sh builds in the dev container on the Frame, into hands/build/, with hands/Makefile. The first build fetches ncnn at a pinned tag (NCNN_TAG in the Makefile) and builds it into hands/build/ncnn, which takes a few minutes; NCNN=DIR points at an ncnn install already built instead. ft-camd is linked statically, because it runs on the host, which has an older glibc than the container.
HANDS_SWAP_SIDES=auto, the default): until it has seen about 1-2 s of hands in both namings’ reach, the cutouts may sit beside the hands. ft-camd’s log says bound by VIDIOC_QUERYBUF for each camera when its buffers were matched exactly.passthrough_left (HANDS_COLOR_LEFT) and how the module’s crop applies (HANDS_COLOR_CROP) still need tools/check_color.py on a recording with a lit, textured view.kMonoDepthGain, 0.1, in track/tracker.cpp). In the 2026-09-30 replays that was worse than keeping the last distance (see “3D” above). A smaller gain, such as 0.02, is the next thing to try.ft-handsctl on, and POINTER_HANDS is 0 by default.camcheck.py tells, the hand recorder won’t start a session, and the fix is a SteamVR restart or a reboot.screens/vr.cpp, UpdateCutouts).