Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
8.5 KiB
FrameEyeOSC packet (FEO1), version 1
frameeyeosc (on the Steam Frame) sends one UDP datagram per eye sample to the FrameEyeOSC module for VRCFaceTracking (on the PC). The module turns it into VRCFT's eye data, and VRCFT drives each avatar's parameters itself (including the pupils, in whatever form the avatar takes them).
The same file is kept in both repositories (frameeyeosc docs/protocol.md and VRCFT-FrameEyeOSC
docs/protocol.md); change both together.
Transport
- UDP, one packet per datagram, nothing else in the datagram.
- Default port 11112 on the PC (the module listens on every interface). It sits next to the LiveLink module's
11111 and stays clear of VRChat's OSC (9000/9001), the ETVR module (8889) and Project Babble (8888). Both ends let
the port be changed (frameeyeosc's
portsetting, the module'sFrameEyeOSCConfig.json). - frameeyeosc sends every eye sample (about 90 a second, up to ~136 while streaming). The module reads every queued datagram each update and keeps the newest, so it never falls behind.
- While frameeyeosc has no eye data (the headset off, the eye server stopped) it sends a packet without the
TRACKINGflag every 500 ms, with relaxed open eyes looking ahead. It sends one such packet at once when the eye data stops, and when it stops sending there (paused, or the output or destination switched). A frameeyeosc that is killed sends nothing more; the module's 1 s timeout (below) covers that.
Layout
108 bytes with brows, else 76 (the packet stops after session); little-endian, no padding. f32 is IEEE 754
single precision. frameeyeosc sends 76-byte packets, and 108-byte packets with the brows block while
brow_from_widen is on (from 0.8.0, off by default). Those brows are raised from the widening (inner_up = outer_up,
lowerer = pinch = 0), since the eye cameras don't see brows.
| Offset | Size | Type | Field | Meaning |
|---|---|---|---|---|
| 0 | 4 | u8[4] |
magic | ASCII FEO1 (46 45 4F 31) |
| 4 | 2 | u16 |
version | 1 |
| 6 | 2 | u16 |
length | Bytes of the packet: 108 with brows, else 76 |
| 8 | 4 | u32 |
sequence | +1 per packet, wraps; starts at 0 when frameeyeosc starts |
| 12 | 4 | u32 |
flags | See below |
| 16 | 8 | u64 |
time_us | Sender's wall clock, microseconds since the Unix epoch (diagnostics only) |
| 24 | 24 | eye | left | The left eye |
| 48 | 24 | eye | right | The right eye |
| 72 | 4 | u32 |
session | Random and not 0, new each time frameeyeosc starts (see "Ordering") |
| 76 | 16 | brow | left_brow | The left eye's brow (only in a 108-byte packet) |
| 92 | 16 | brow | right_brow | The right eye's brow (only in a 108-byte packet) |
Each eye (24 bytes):
| Offset | Type | Field | Range | Meaning |
|---|---|---|---|---|
| +0 | f32 |
gaze_x | -1..1 | Sideways gaze, + right; 1 = 45° (frameeyeosc's v2/EyeLeftX) |
| +4 | f32 |
gaze_y | -1..1 | Up/down gaze, + up; 1 = 45° (frameeyeosc's v2/EyeLeftY) |
| +8 | f32 |
eyelid | 0..1 | VRCFT eyelid: 0 closed, 0.75 relaxed open, 1 fully widened (frameeyeosc's v2/EyeLidLeft) |
| +12 | f32 |
squint | 0..1 | Eye-camera squint; 0 unless the eye's CAMERA_LID flag is set |
| +16 | f32 |
pupil_mm | mm | Pupil diameter in millimetres; 0 unless the eye's PUPIL flag is set |
| +20 | f32 |
pupil_dilation | 0..1 | Pupil dilation, 0 smallest to 1 largest for this wearer; 0 unless PUPIL is set |
Each eye's gaze is its own with frameeyeosc's "move eyes separately", else both carry the combined gaze (as in frameeyeosc's other outputs).
Each brow (16 bytes):
| Offset | Type | Field | Range | Meaning |
|---|---|---|---|---|
| +0 | f32 |
brow_inner_up | 0..1 | Inner brow raised; 0 unless the eye's BROW flag is set |
| +4 | f32 |
brow_outer_up | 0..1 | Outer brow raised; 0 unless BROW is set |
| +8 | f32 |
brow_lowerer | 0..1 | Brow lowered; 0 unless BROW is set |
| +12 | f32 |
brow_pinch | 0..1 | Brows pinched together; 0 unless BROW is set |
Flags
| Bit | Name | Meaning |
|---|---|---|
| 0 | TRACKING |
The eye values are live. Clear: no eye data, the values are relaxed open eyes looking ahead |
| 1 | LEFT_CAMERA_LID |
The left eye's eyelid comes from the eye camera this sample, and its squint is valid |
| 2 | RIGHT_CAMERA_LID |
The same for the right eye |
| 3 | LEFT_PUPIL |
The left eye's pupil_mm and pupil_dilation are valid |
| 4 | RIGHT_PUPIL |
The same for the right eye |
| 5 | LEFT_BROW |
The left eye's brow fields are valid (only counts in a packet with the brows block) |
| 6 | RIGHT_BROW |
The same for the right eye |
| 7-31 | Reserved: sent as 0, ignored by receivers |
Receiving rules
- Drop a datagram that is shorter than 24 bytes, does not start with
FEO1, has anotherversion, haslengthbelow 72, or is shorter thanlength. Bytes after the first 108 (a later compatible version) are ignored. A packet withlength72 to 75 (the first draft, withoutsession) is read withsession0. - The brows block is read only when
lengthis at least 108. A packet withlength72 to 107 has no brows, whatever itsBROWflags say, and anything aftersessionin it is ignored. - Drop a packet with any non-finite float, the brow floats included when the brows block is read.
- Brow values are clamped to 0..1.
- Compatible additions append fields and raise
length; an incompatible change raisesversion. - Ordering: a packet whose
sessiondiffers from the newest one taken is taken, and the window below starts over from it: frameeyeosc started again, and itssequencewith it. From then on a packet from a session before (not 0; the module remembers the last 4) is a straggler from an old run and is dropped. Within one session, a packet whosesequenceis at most 1000 behind the newest one taken ((int32)(sequence - newest)in -1000..0) is a duplicate or late and is dropped; anything else is taken (a jump back further than that is also read as a restart, for senders without asession). After a timeout, any packet is taken.
How the module maps it to VRCFT
Per eye, from a TRACKING packet:
Gaze = (gaze_x, gaze_y)as is. VRCFT sends Gaze on asv2/EyeLeftX/Yand turns it into VRChat's eye angles asatan(x)/-atan(y), which is what frameeyeosc's LiveLink and VRChat outputs give it too.Openness = clamp(eyelid / 0.75, 0, 1)and theEyeWideshape= clamp((eyelid - 0.75) / 0.25, 0, 1), so VRCFT'sv2/EyeLid(Openness * 0.75 + EyeWide * 0.25) comes back toeyelid.- The
EyeSquintshape= squintwhileCAMERA_LIDis set, else 0. - Pupils, while at least one eye's
PUPILis set (an eye without it takes the other eye's values):PupilDiameter_MM = pupil_mm(VRCFT'sv2/PupilDiameterLeft/Right/PupilDiameterare those in cm, like frameeyeosc's VRChat output), and VRCFT's dilation range is set around their meanAso that itsv2/PupilDilation((A - min) / (max - min)) is the meanpupil_dilationD:min = A - D * S,max = min + S. The defaultSis 2.655 mm (eyecam's default dilation range). If floating-point rounding changesD, the module tries a power-of-two range withD * SbetweenAand2A(orS = 1whenA = 0). It uses that range only if its bounds are finite, avoid VRCFT's reserved "unset" value and recoverDexactly. This preserves boolean and bit-packed thresholds without changing either measured diameter. Negative mean diameters and extreme inputs that cannot use a finite exact range retain the default bounds. Without anyPUPILthe pupils are left as they were. - Brows, while the packet has the brows block and the eye's
BROWflag is set: theBrowInnerUp,BrowOuterUp,BrowLowererandBrowPinchshapes= brow_inner_up,brow_outer_up,brow_lowerer,brow_pinch. Otherwise that eye's 4 brow shapes are left alone (unset, so VRCFT leaves the brows to whatever else drives them), except that brows this module set before are set to 0 once, so a brow doesn't stick when the brows stop coming. - A packet without
TRACKING, or no packet for 1 s: relaxed open eyes looking ahead (Openness1,EyeWide0,EyeSquint0,Gaze0); the pupils stay; brows as withoutBROW(set to 0 once if this module set them).
Example
Sequence 7, flags TRACKING | LEFT_CAMERA_LID | LEFT_PUPIL | RIGHT_PUPIL (27), time_us 1700000000123456;
left eye 0.25, -0.5, 0.875, 0.5, 4.0, 0.25; right eye 0.125, -0.5, 0.375, 0.0, 3.5, 0.75; session 0x2a5f11c3; no
brows (76 bytes):
46454f3101004c00070000001b00000040222018240a0600
0000803e000000bf0000603f0000003f000080400000803e
0000003e000000bf0000c03e00000000000060400000403f
c3115f2a
Both repositories' tests check these bytes.