The first-time key authorisation piped the public key into ssh, so with stdin occupied Windows OpenSSH could not read the password. The key now goes in the command line, password auth is forced for that call, and a key already in authorized_keys is not appended again. README: describe the password prompt (nothing shows while typing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14 KiB
vrcft-steam-frame: notes for Claude Code sessions
Eye tracking for the Steam Frame in VRChat via VRCFaceTracking (VRCFT). Read README.md for the user-facing docs and
CLAUDE.local.md (git-ignored, if present) for machine-specific details (headset address, SSH key, paths).
Current state (2026-09-27)
- Latest release v0.2.2 (2026-09-28: review fixes, calibration voice fix, pinned frameeyeosc), prebuilt-DLL zip. Working end to end on the author's setup: per-eye gaze, blinks, both
winks, wink assist; frameeyeosc auto-starts on the headset and survived a real reboot;
doctor.ps1reports "All good". setup.ps1 -Headset(menu 2) was run for real on 2026-09-27 against an already-set-up Frame: SSH login, target kept, build no-op, unit refreshed, service active, doctor "All good"; a second run changed nothing. Still never exercised: the first-time path on a fresh headset (Rust install + first build). Treat that as a test when someone new tries it. The key authorisation by password was tested on 2026-09-28 with a throwaway key against the real headset: it used to pipe the public key intossh(stdin), which made Windows OpenSSH unable to read the password (reported by a user); now the key goes in the command line, password auth is forced and a duplicate key is not appended. Gotcha when testing: a key file under%TEMP%can have an orphaned-SID ACL, and Windows ssh then ignores it ("bad permissions"); useicacls <key> /inheritance:r /grant:r "$env:USERNAME:F". The!box in Claude Code is bash without a TTY: password prompts need a real PowerShell window.- Open ideas: verify the gaze scale (frameeyeosc +-1 == +-45 deg -> radians) against a reference; a first-run check in
Start Here.cmdthat VRCFT is running (users forget to start it from Steam each session; the doctor catches it).
Review fixes in 0.2.2 (2026-09-27/28, from an external review; all verified against the code first; released)
tune.py analyzerejects a calibration whose per-eye open-closed range is < 0.15 or reversed (was a ZeroDivisionError or bad values), keeps the previous config, and says so. It now also recommendswink.assist+assistMin(other eye's both-closed p75 + 0.08).tune.py recordchecks first (status file) that VRCFT runs and frameeyeosc data is fresh, and speaks what is missing.ModuleConfig.TryLoad+Validate: a broken or invalidsteamframe-config.jsonis not applied; the module keeps the last good config and reportsconfigError(status file, log, doctor). Previously it silently fell back to defaults.- Without fresh data from either source the module sets neutral eyes (open, centred) once, instead of replaying stale Steam Link values.
- Trace lines are written with
FormattableString.Invariant(decimal-comma locales broke the CSV);tune.pyskips malformed lines. setup.ps1sandbox mode (-VrcftData) no longer touches the real SteamVR settings (unless-SteamVrSettings <file>) or the firewall.- Firewall: a block rule is handled separately (disable it + add allow, elevated); an allow rule alone does not beat a block rule.
headset-setup.shpins frameeyeosc to a reviewed commit (REV, overrideFRAMEEYEOSC_REV) and moves existing checkouts to it;FRAMEEYEOSC_NO_BUILD=1tests only the checkout logic (works locally withHOME=<tmp>).Select-HeadsetTargetonly counts addresses of connected adapters (Windows lists a disconnected adapter's IP). Follow-up review off5ec145, also fixed in 0.2.2:Validatechecks relationships (maxFloor + minRange <= 1,minCeil > 0) and the adaptive clamp can no longer get a lower bound above 1 (fuzzed: 7269 valid configs x 200 samples throughLidCal.Map, no throw, output 0..1); a failed config read is retried every second (timestamp recorded only after success, each distinct error logged once; tested with a 3 s exclusive lock);tune.py analyzemaps with the configuredlid.deadbandandwink.assistClosed(STEAMFRAME_CONFIG=<file>overrides the config path for tests).settings_or_exitvalidates those values (deadband 0..0.45, assistClosed 0..1, numbers only) with the module's limits before any calculation, andrecord/calibraterun it before recording; broken files give a spoken error, nothing changes. Calibration voice:tune.pyused to start PowerShell + System.Speech for every prompt (~1.5 s idle, worse under VR load), so prompts lagged their beeps and piled up; the user could not hear them (2026-09-28).Speakerkeeps one engine for the whole recording (started in the lead-in, 7-10 ms per prompt, a new prompt cancels a late one). Not caused by 0.2.2: the prompt code was unchanged since v0.2.1. Not done yet: offline replay tests for the eyelid pipeline (the replays in this history were ad-hoc scripts).
Working with the user
The user is in VR most of the time and cannot read text in the headset. Prefer spoken output (Say in common.ps1, speak in tune.py),
one-click entry points (Start Here.cmd), and short answers. They test in VRChat and report what the avatar does.
Layout
module/C# (net10.0) VRCFT module, version inSteamFrameVRCFTModule.csproj(<Version>).SteamFrameVRCFTModule.cs(logic, status file, logging),OscReceiver.cs(UDP OSC parser),ModuleConfig.cs(hot-reloadedsteamframe-config.json). Built against the installed VRCFT DLLs; scripts pass-p:VrcftDir=<found install>(the csproj default is only a fallback).Start Here.cmduser menu (must stay CRLF): 1 setup, 2 headset, 3 status, 4 calibrate, 5 live view, 6 config folder, 7 uninstall.tools/tune.pycalibration (calibrate= record+analyze+apply with speech,record,analyze [--apply] [--speak],watch,show). Stdlib only, Windows. It turns the module'straceconfig flag on only while it needs samples (classTracing, restores the file exactly).scripts/(Windows PowerShell 5.1 compatible, sharecommon.ps1):setup.ps1idempotent installer (-DryRun,-Yes,-Uninstall,-Headset user@host,-HeadsetTarget; test flags-VrcftData <sandbox>-NoRestart),doctor.ps1spoken health check (reads%TEMP%\steamframe-status.json),deploy-module.ps1dev shortcut,package.ps1release zip (dist/, git-ignored; setup uses a DLL found next toStart Here.cmd). Headset side:headset-install.sh(--info, or<PC-IP> [port]: build + systemd user unit + linger),headset-setup.sh,headset-run.sh,frameeyeosc.service.
Data flow
Steam Frame eye service -> /dev/shm/eye-server.mmap -> frameeyeosc (on headset) -> OSC UDP :9020 -> module -> VRCFT -> VRChat :9000.
Fallback when frameeyeosc is silent for 500 ms: Steam Link driver OSC on 127.0.0.1:9015 (gaze only, heuristic blink).
Either port may fail to open; the module only refuses to start if both do (reason in startError).
frameeyeosc sends /avatar/parameters/FT/v2/{EyeLeftX,EyeLeftY,EyeRightX,EyeRightY,EyeLidLeft,EyeLidRight,EyeX,EyeY}
(+-1 == +-45 degrees, lids 0 = closed, 1 = open) and /avatar/parameters/FT/EyeTrackingActive.
Facts established by measurement (trust these, re-verify only if hardware/software changed)
- Steam Link OSC eyelid (
/tracking/eye/EyesClosedAmount) is always 0 and left == right (mirrored). Real per-eye lids exist only via frameeyeosc. - Steam Link driver settings (
driver_vrlinkinSteam\config\steamvr.vrsettings):OSCOutPort9000 = direct to VRChat, 9015 = alt (for the module);shareEyeTrackingDatamust be true. Edit only with SteamVR closed (it rewrites the file on exit).setup.ps1does this. - VRCFT outputs
EyeLid = openness * 0.75(0.75 = normal open). VRCFT only sends a parameter when it changes, and sends OSC inside bundles. - The module -> VRCFT chain keeps the eyes independent (synthetic sweep: L closed/R open ->
EyeLidLeft 0, EyeLidRight 0.75, and reverse). - Tracker levels differ per eye: closed about L 0.14 / R 0.32, open about L 0.65 / R 0.75 (calibrations 2026-09-26).
- Many blinks are reported lopsided: one lid ~0 while the other is pinned at raw ~1.0, up to ~350 ms. Real winks keep the open eye ~0.7-0.85.
- Closing one eye tightens the other: during a right wink the left lid reads ~0.23-0.45 (vs ~0.14 when both are closed).
- The Frame can reach the PC over two paths: its own Wi-Fi AP (PC "Wi-Fi 2", 10.35.78.x) or the home network. Which one Steam Link
uses can change between sessions; the headset's route to the AP subnet disappears when the PC's Wi-Fi 2 is disconnected, and packets to
the AP address then go to the home router and vanish. Target the PC's home-network address (the
$SSH_CLIENTaddress) when both exist. - After a headset reboot the network comes up after the service starts; frameeyeosc exits with "Network is unreachable" and systemd
Restart=alwaysretries until it works (observed 2026-09-27). This is expected. - Avatars can link the eyelids themselves: Haku_FT_Comfy has a Bool
FT/EyeSynctoggle; with it on, a wink closes both eyes. Check the avatar before debugging the module when "a wink closes both eyes".
Wink/blink pipeline (module UpdateFromFrame), in order
per-eye lid calibration (fixed levels from tune.py analyze --apply, else adaptive) -> blink glitch rule (blink.saturatedRaw: one lid closed
while the other is pinned at raw ~1.0 for glitchMinMs => both close) -> lopsided-closure timer (blink.coupleMs) -> wink assist
(wink.assist, off by default, on in the user's config: one eye at its floor and the other above its floor for assistPersistMs => other eye
shown open) -> wink sharpening -> peak hold (blink.holdMs).
A "latch on lopsided start" design was tried and abandoned: a right wink drops both lids almost together, so it never armed, and arming on the
first frame would turn blinks into winks. Validate new rules by replaying labelled sessions (sessions/*) offline before deploying.
Releasing
- Bump
<Version>inmodule/SteamFrameVRCFTModule.csprojand$script:ModuleVersioninscripts/common.ps1. - Test on the headset (doctor "All good", user checks blink/wink in VRChat), commit, push.
powershell -ExecutionPolicy Bypass -File scripts\package.ps1->dist\vrcft-steam-frame-<ver>.zip; unzip it and runscripts\setup.ps1 -DryRunfrom there to confirm it picks up the prebuilt DLL.gh release create v<ver> dist\vrcft-steam-frame-<ver>.zip --repo hakumaguro/vrcft-steam-frame --title v<ver> --notes-file <notes>, then download the asset back and compare hashes. Releases are public: ask the user first.
Testing without disturbing the user's install
- Hash
steamframe-config.json,steamvr.vrsettingsand VRCFT'sLocalSettings.jsonbefore, and check them after. - Setup/uninstall changes: sandbox = a copy of a VRCFT data folder, run
setup.ps1 -VrcftData <sandbox> -NoRestart -Yes(and-Uninstall). - Module source switching: send a synthetic frameeyeosc stream to UDP 9020 and read
%TEMP%\steamframe-status.json. - Doctor branches: point
$env:TEMP/$env:TMPat a folder with a hand-written status file. - Measuring VRCFT output: temporarily set
"OSCOutPort":"9001"in%APPDATA%\VRCFaceTracking\VRCFaceTracking\ApplicationData\LocalSettings.json(VRCFT closed), relaunch via Steam, stop frameeyeosc, send a synthetic stream to 9020 and parse OSC bundles on 9001. Always restore 9000 and restart frameeyeosc afterwards, otherwise VRChat gets no eye data.
Pitfalls
- Start VRCFT through Steam (
steam://rungameid/3329480). LaunchingVRCFaceTracking.exedirectly never spawns the module process. - VRCFT's Module Registry page crashes the whole app (WinUI
0xc000027b, stowed E_INVALIDARG) if any installed module'smodule.jsonlacksDownloadUrl/ModulePageUrl. Reproduced and bisected 2026-09-27 with%TEMP%\click_registry.ps1-style UI Automation (select the "Module Registry" list item, check the process survives 8 s).Write-ModuleJsonincommon.ps1writes every field; setup repairs old files. Bump$script:ModuleVersionincommon.ps1together with the csproj<Version>. - The module DLL is locked while VRCFT runs; setup/deploy close VRCFT first.
dotneton PATH may be runtime-only; the build needs a .NET 10 SDK (Find-DotnetSdk10incommon.ps1checks~\.dotnettoo).- Windows PowerShell 5.1: no ternary/
??;ConvertTo-Jsonneeds-Depth; stderr redirection of native tools underErrorActionPreference=Stopthrows (setup uses Continue and stops insideAct); remotesshcommands must not contain double quotes (put logic inheadset-install.sh);Start-Process -ArgumentListdoes not quote, so the elevated firewall command uses-EncodedCommand. - Line endings:
.cmd/.ps1CRLF,.sh/.service/.pyLF (.gitattributes). Python rewrites withnewline=''on text read in universal mode silently turn CRLF files into LF; fixStart Here.cmdafterwards. Setup also strips CR on the headset afterscp. - The bash tool mangles backslash escapes in inline heredocs; write patch scripts with the Write tool. Bash
/tmpis not visible to Windows Python. - frameeyeosc needs shared-memory ABI version 4 (
od -A d -t u4 -N 16 /dev/shm/eye-server.mmap-> 4, 1);headset-install.shwarns otherwise. - The headset's service target must be one of the PC's addresses;
setup.ps1 -Headsetkeeps an existing target only if it still is (the Frame-AP address only exists while the headset is on). - GitHub:
gh auth statusmay show a different keyring account thangh api user; the repo owner isgh api user --jq .login. tools/capture.ps1(untracked, local) captures only the VRChat window via PrintWindow. Never use a screen-region grab: it catches other windows.
Diagnostics
%TEMP%\steamframe-status.json (module state, 1 s; source = frameeyeosc / steamlink / none, port states, startError),
%TEMP%\steamframe-module.log (state changes only, rotated to .old at 1 MB) and %TEMP%\steamframe-trace.csv (only while config
trace is true; 30 ms samples time,FRAME,lx,ly,rx,ry,rawL,rawR,outL,outR,loL,hiL,loR,hiR, rotated at 20 MB).
tune.py record saves labelled sessions under sessions/ (git-ignored). Headset: journalctl --user -u frameeyeosc.
Conventions
- Keep tunables in
ModuleConfig(hot-reloaded) rather than constants, and teachtune.py analyzeto measure them. - Don't push to the public repo until a change has been tested on the headset. Never commit IPs, SSH keys or personal paths (scan tracked files before pushing).
- Commits as the repo owner:
git -c user.name=Hakumaguro -c user.email=257888523+hakumaguro@users.noreply.github.com commit .... - frameeyeosc is cloned and built on the headset, not vendored (credit: konsti219, MIT).