Interface control document
1. Identification
| Item | Value |
|---|---|
| Document | ICD-CMVARJO-001 rev 2 |
| Product | Varjo gaze module (Varjo SDK in the module registry, package HEAT.VarjoGazeModule) |
| Product versions covered | Managed module b2618c7, native bridge dbbef2a |
| Managed repository | https://github.com/VRAIie/cm-varjo-gaze.git |
| Bridge repository | https://github.com/VRAIie/CaptureApp-VarjoGazeBridge.git |
| Peer | VarjoGazeBridge.dll, and through it the Varjo Base runtime and compositor |
| Host | HEAT Capture, HEAT.Capture.ModuleSDK 2.0.5 |
| Data model | HEAT ProtoLib SimulationFrame, serialized by the bridge and reparsed by the module |
| Target architecture | Windows x64 |
| Date | 2026-09-02 |
| Owner | VRAI Simulation |
This document is the contract required by audit findings VARJO-01 and VBRIDGE-01: before it, neither the managed module nor the bridge had an ICD specifying exports, buffer ownership, lifecycle, framing or error behaviour. The bridge has no ICD of its own by design; it is documented here, through the module that owns and ships it.
2. Scope
The product spans two repositories and one process. The managed module is loaded by HEAT Capture,
P/Invokes into a native bridge DLL that is vendored inside the module package, and the bridge
holds the Varjo session, drives the compositor frame loop, reads gaze, encodes per-eye video and
serializes complete SimulationFrame messages. The managed module reparses those bytes and hands
them to the Module SDK.
This document defines the managed-to-native ABI, the framing and ownership rules of the frame
handoff, the lifecycle across both sides, the published data, timing, and error behaviour on both
sides. It does not define the Varjo SDK itself; the authority for Varjo semantics is Varjo’s own
documentation, and the SDK headers vendored in the bridge repository under varjo-sdk/include/.
3. Interface inventory
| ID | Name | Direction | Transport | Peer |
|---|---|---|---|---|
| IF-CMVARJO-01 | Bridge control ABI | Module to bridge | Exported C functions in VarjoGazeBridge.dll, cdecl | Native bridge |
| IF-CMVARJO-02 | Serialized frame handoff | Bridge to module | Caller-supplied byte buffer drained from a bounded in-process FIFO | Native bridge |
| IF-CMVARJO-03 | Varjo runtime session | Bridge to runtime | Varjo SDK session, compositor layers and mirror swap chain | Varjo Base |
| IF-CMVARJO-04 | Frame publication | Module to host | Module SDK publication path | HEAT Capture |
| IF-CMVARJO-05 | Bridge diagnostic log | Bridge to filesystem | Appended text file | Workstation |
4. IF-CMVARJO-01 Bridge control ABI
4.1 Exports
The bridge exports exactly five functions, declared extern "C" with __declspec(dllexport) and
no explicit calling-convention specifier (VarjoGazeBridge.h:6-12). There is one integer calling
convention on Windows x64, and the managed side declares CallingConvention.Cdecl
(VarjoInterop.cs:17-30).
| Export | Native signature | Managed declaration |
|---|---|---|
Initialize | int Initialize(bool enableVideoCapture, int outputWidth, int outputHeight) | int Initialize(bool, int, int) |
Shutdown | void Shutdown() | void Shutdown() |
GetFrame | int GetFrame(uint8_t* buffer, int bufferSize) | int GetFrame(IntPtr buffer, int bufferSize) |
GetGazeTrackingStatus | int GetGazeTrackingStatus() | int GetGazeTrackingStatus() |
RequestGazeCalibration | void RequestGazeCalibration(GazeTracking::CalibrationType) | void RequestGazeCalibration(CalibrationType) |
The DLL is loaded by plain module name, VarjoGazeBridge.dll (VarjoInterop.cs:15), so the
default Windows loader search applies. There is no export that reports a bridge version, build
identity or ABI revision, and no handshake of any kind. See section 12.
outputWidth and outputHeight are accepted and ignored. The bridge derives its encode
resolution from the Varjo view description instead (VarjoGazeBridge.cpp:696-697,
:110-114, :184-193), and the managed side passes 0, 0 deliberately
(VarjoGazeDataModule.cs:38-45).
4.2 Return values
| Export | Return |
|---|---|
Initialize | 1 on success, 0 on failure. Note the inversion relative to a C exit code: the managed side treats 0 as failure (VarjoGazeDataModule.cs:47-52) |
GetFrame | Byte count written to the caller’s buffer, or 0 for “nothing delivered”. Never negative |
GetGazeTrackingStatus | -1 bridge not initialised, 1 gaze not allowed, 2 no HMD connected, 3 calibrating, 4 calibrated, 5 not calibrated (VarjoGazeBridge.cpp:833-842) |
The GetGazeTrackingStatus codes are the bridge’s own numbering, not Varjo’s; they are derived
from four Varjo session properties in that fixed precedence order. The managed module logs the
same mapping verbatim at session start (VarjoGazeDataModule.cs:54-55).
4.3 Buffer ownership
No bridge-allocated memory ever crosses the boundary, and there is no free or release function to call. Ownership is one-directional and total.
| Item | Rule |
|---|---|
GetFrame buffer | Allocated and owned by the managed caller. The bridge copies into it and does not retain the pointer beyond the call (FrameQueue.cpp:21-34) |
| Buffer allocation | One 4 MiB byte[] per capture session, allocated in StartCapture and released in EndCapture (VarjoGazeDataModule.cs:24-28, :63, :132-133) |
| Pinning | The array is pinned only for the duration of each call, with fixed inside unsafe (VarjoGazeDataModule.cs:85-90) |
| Callbacks | None. The bridge never calls back into managed code, so no delegate lifetime has to be kept alive |
| Strings | None cross the boundary |
| Bridge-side frame storage | Owned by the bridge’s FIFO, freed when an entry is popped or when Shutdown clears the queue (VarjoGazeBridge.cpp:824-825) |
4.4 Thread affinity
| Export | Affinity |
|---|---|
Initialize | Any thread. It creates the Varjo session, the D3D11 device and the transparent swap chain on the caller’s thread, then starts the bridge’s own capture thread and returns (VarjoGazeBridge.cpp:698-786) |
GetFrame | Any thread. The FIFO is mutex-guarded (FrameQueue.cpp:21-22) |
Shutdown | Any thread, but it blocks: it clears the run flag and joins the capture thread (VarjoGazeBridge.cpp:790-792) |
GetGazeTrackingStatus, RequestGazeCalibration | Called on the caller’s thread and touch the Varjo session directly (:833-848) while the capture thread is also using that session |
Video setup, encoder activation, submit, drain and teardown all happen on the bridge’s capture
thread, which establishes an MTA apartment for itself because hardware H.264 encoder MFTs fail to
activate from an STA thread (VarjoGazeBridge.cpp:250-259, :96-101, :235-248). That is why
Initialize defers video setup rather than doing it inline: its caller is a GUI thread.
The two session-touching queries in the last row are issued from the Module SDK’s threads while the capture thread is inside the Varjo frame loop. The Varjo SDK headers vendored in this revision state no thread-safety contract for a session handle, so this document cannot assert that the concurrent use is safe. It is recorded here as a property of the audited revision.
4.5 Enumeration mapping
CalibrationType is passed by value as a 32-bit enum. The two sides do not declare the same set.
| Managed value | Numeric | Native equivalent |
|---|---|---|
Fast | 0 | GazeTracking::CalibrationType::FAST |
OneDot | 1 | GazeTracking::CalibrationType::ONE_DOT |
None | 2 | No native equivalent exists (GazeTracking.h:22-25) |
None is a managed-only sentinel: the module skips the call entirely rather than passing 2 across
the boundary (VarjoGazeDataModule.cs:56-61). A caller that did pass 2 would land on the native
default: branch and get FAST (GazeTracking.cpp:36-42), silently. Treat None as
“do not call this export”, not as a value the ABI accepts.
4.6 Authentication
None, on either interface. The ABI is an in-process function call. Any code in the HEAT Capture process can call the same exports, and the bridge accepts whichever Varjo session the local runtime hands it. Video capture requires a Varjo Pro licence, but neither side checks for one in code; enforcement, if any, is inside Varjo’s runtime.
5. IF-CMVARJO-02 Serialized frame handoff
5.1 Framing
There is no envelope, length prefix, sequence number or delimiter. A single GetFrame call
delivers exactly one complete, self-delimiting serialized SimulationFrame message, and its
return value is that message’s length. The unit of transfer is one message, so the managed side
never has to reassemble or scan for a boundary.
The bytes are produced natively by the HEAT ProtoLib C API through
HEAT::Proto::Builders::SimulationFrameBuilder::serialize (VarjoGazeBridge.cpp:326-331,
:661-667) and reparsed by SimulationFrame.Parser.ParseFrom on the managed side
(VarjoGazeDataModule.cs:95). Both sides must therefore be built against compatible ProtoLib
schemas; see section 12.
5.2 Queue semantics
| Item | Value |
|---|---|
| Structure | Bounded FIFO of complete serialized frames, mutex-guarded (FrameQueue.h:10-32) |
| Capacity | 90 entries, chosen as roughly one second at the ~90 Hz headset cadence (VarjoGazeBridge.cpp:70-71) |
| Producers | The capture thread, for both gaze frames and per-eye video frames |
| Overflow | The oldest entry is dropped, and both encoders are forced to produce a fresh IDR keyframe (FrameQueue.cpp:11-18, VarjoGazeBridge.cpp:667-672) |
| Drain | GetFrame pops the single oldest entry per call and returns its size (FrameQueue.cpp:21-34) |
| Cleared | On Shutdown (VarjoGazeBridge.cpp:824-825) |
Overflow is a data-loss event with a visible consequence: the discarded entry may be a gaze frame or a video access unit, and the forced keyframe means the affected eye’s video shows a discontinuity rather than a delay.
5.3 Undersized buffer
If the oldest queued frame is larger than the buffer the caller supplied, popInto returns 0
and leaves that frame at the head of the queue (FrameQueue.cpp:26-28). The return value is
therefore ambiguous by construction: 0 means either “queue empty” or “head entry does not fit”,
and the caller cannot tell which.
The managed drain loop treats any non-positive return as end-of-queue and stops
(VarjoGazeDataModule.cs:92). An oversized head entry consequently blocks the queue permanently
for the rest of the session: nothing behind it can ever be delivered, no diagnostic is emitted on
either side, and the module’s own parse-error counter does not move. The 4 MiB managed buffer is
sized against an IDR access unit at the bridge’s ~8 Mbit/s per-eye CBR budget
(VarjoGazeDataModule.cs:24-28, VarjoGazeBridge.cpp:51-53), so the condition is not expected in
normal operation, but nothing in either side enforces the bound or detects it being exceeded.
5.4 Drain contract
The managed module drains the whole queue on every capture cycle rather than taking one frame per
cycle (VarjoGazeDataModule.cs:78-118). Of the frames drained in one cycle, all but the last are
published directly through the SDK’s Publish, and the last is returned from HandleCapture so
the base class publishes it on the normal path (:112-115, :127). Every successfully parsed
frame is published exactly once; nothing is coalesced or discarded on the managed side.
Draining a full queue in one cycle is required, not an optimisation. A single GetFrame per cycle
cannot keep pace with the bridge’s producer rate, and the queue would sit at its overflow
threshold permanently.
6. IF-CMVARJO-03 Varjo runtime session
6.1 Session setup
Initialize performs, in order: varjo_SessionInit; a session priority of 1 so the compositor
layers the bridge above a target application at default priority 0; gaze tracking initialisation
with the standard output filter and the maximum supported output frequency; adapter selection by
matching the Varjo-reported LUID; D3D11 device creation with video and BGRA support; D3D11
multithread protection; discovery of the left and right context view indices; and creation of a
4x4 transparent swap chain (VarjoGazeBridge.cpp:698-773).
The transparent swap chain is submitted as a render layer on every tick for one reason: it keeps
the compositor servicing the bridge’s session (:765, :637-679). It carries no visible content.
When video capture is enabled, the capture thread additionally starts Media Foundation, reads the
native view size, creates a double-width mirror swap chain, registers both eyes’ viewports with
varjo_SetMirrorConfig, creates two per-eye encode textures, and initialises two encoders
(:104-232).
6.2 What the video actually is
The video is the compositor mirror: the fully composited scene as presented to each eye, captured
through varjo_SetMirrorConfig (VarjoGazeBridge.cpp:128-149). It is not imagery of the user’s
eyes. The bridge does not include Varjo_datastream.h and opens no Varjo data stream, so no
eye-camera, colour-camera or environment-cubemap stream is read at this revision. The stream names
eye.left and eye.right denote which eye’s view was rendered, not which camera was read.
6.3 Encoder configuration
| Item | Value |
|---|---|
| Codec | H.264, one independent stream per eye |
| Backend | NVENC on NVIDIA adapters, otherwise a Media Foundation H.264 MFT; NVENC init failure falls back to the MFT (VarjoGazeBridge.cpp:194-210) |
| Bitrate | 8,000,000 bit/s per eye, constant bitrate (:51, MftEyeEncoder.cpp:97-99, NvencEyeEncoder.cpp:117-118) |
| GOP | 90 frames (:52, MftEyeEncoder.cpp:100, NvencEyeEncoder.cpp:114) |
| B-frames | None (MftEyeEncoder.cpp:103-104, NvencEyeEncoder.cpp:115) |
| Declared frame rate | 90 (VarjoGazeBridge.cpp:53) |
| Encode resolution | Native view size scaled so the larger dimension is at most 1088, then floored to a multiple of 16, minimum 16 (:184-193) |
| Colour path | RGBA mirror texture colour-converted to NV12 by the D3D11 video processor before encoding |
| MFT profile | H.264 Baseline (MftEyeEncoder.cpp:72) |
| NVENC packaging | In-band SPS/PPS repeated on every IDR (NvencEyeEncoder.cpp:126-127) |
The MFT backend sets no explicit NAL-packaging attribute, so its output packaging is whatever the selected Media Foundation H.264 encoder produces by default. A consumer must not assume the two backends are byte-identical in their parameter-set placement.
7. IF-CMVARJO-04 Frame publication and data model reference
7.1 Entity
Every frame the bridge produces carries exactly one entity, and the managed module republishes it untouched. It does not rebuild the frame, does not assign an entity id, and does not reinterpret any field.
| Item | Value |
|---|---|
| Entity id | 0x56415246, a fixed compile-time constant (VarjoGazeBridge.cpp:73) |
| Entity name | Varjo_Data (:316, :622) |
7.2 Frame kinds
The bridge emits two independent kinds of frame into the same queue. They are distinguished only by which components the entity carries.
| Kind | Emitted | Components |
|---|---|---|
| Gaze frame | Once per capture tick, when the tick has valid gaze (:433, :264-266) | SpatialComponent, GazeComponent, PupillometryDataComponent |
| Video frame | Once per eye per completed H.264 access unit (:583-679) | SpatialComponent, StreamComponent, plus GazeComponent and PupillometryDataComponent when that tick’s snapshot had valid gaze |
Gaze and video are fully decoupled producers. Gaze is published whether or not video capture is running, and is not gated on an encoder or on a metadata match.
A video frame’s gaze copy is the same sample already delivered on that tick’s gaze frame, stashed
per tick so the encoder-delayed video carries temporally correct gaze (:479-497, :625-656). It
is not a second, independent reading. Do not average or double-count the two.
7.3 Published fields
| Component | Field | Source |
|---|---|---|
SpatialComponent | pose.position | Translation column of varjo_FrameGetPose(varjo_PoseType_Center) (:346-350) |
SpatialComponent | pose.orientation | Quaternion derived from the same matrix, with the Y term negated at emission (:352-380, :433) |
GazeComponent | ipd | varjo_EyeMeasurements.interPupillaryDistanceInMM (:393) |
GazeComponent | gaze_origin, left_gaze_origin, right_gaze_origin | varjo_Gaze.gaze/leftEye/rightEye.origin (:394-403) |
GazeComponent | gaze_direction, left_gaze_direction, right_gaze_direction | The corresponding forward rays with Z negated (:404-411) |
GazeComponent | left_eye_occluded, right_eye_occluded | leftStatus/rightStatus equal to varjo_GazeEyeStatus_Invalid (:412-413) |
GazeComponent | blink | left_eye_occluded OR right_eye_occluded (:414) |
PupillometryDataComponent | left_eyelid_openness, right_eyelid_openness | leftEyeOpenness, rightEyeOpenness (:415-416) |
PupillometryDataComponent | left_eye_pupil_diameter, right_eye_pupil_diameter | leftPupilDiameterInMM, rightPupilDiameterInMM (:417-418) |
StreamComponent | One VideoSource per frame: name (eye.left or eye.right), codec (H264), width, height, frame_rate, video_data | Encode dimensions and the drained access unit (:604-609) |
SimulationFrame | capture_time.timestamp_ms | Host system_clock milliseconds taken on the capture tick (:426-428, :325-328, :658-660) |
No other field of these components is populated. Notably absent: every *_quality field,
has_glasses, gaze_point, gaze_screen_point, the combined eyelid_openness and
pupil_diameter fields, and other_pupil_data. A consumer must not read an absent field as a
measured zero.
left_eye_occluded and right_eye_occluded come from Varjo’s per-eye gaze validity status, not
from an eyelid-openness threshold, and blink is derived from them. That is the reverse of the
derivation order used by some other eye-tracking modules in this catalogue.
7.4 Publication envelope
Each published frame is wrapped by the Module SDK as a DataPacket whose TimestampUnixMs is
UTC now at publication and whose Payload is the packed SimulationFrame, with NodeId when
OverrideNodeId is set and -1 otherwise. See the SDK contract. A frame
therefore carries two timestamps with different meanings: the bridge’s capture tick inside
capture_time, and the host’s publication instant on the packet. They are not interchangeable,
and the gap between them includes the queue dwell time, the encoder delay for video frames, and
one poll interval.
8. Timing and cadence
| Item | Value |
|---|---|
| Bridge tick pacing | varjo_WaitSync, so the compositor’s frame cadence (VarjoGazeBridge.cpp:337) |
| Assumed tick rate | ~90 Hz, hard-coded into the queue capacity, GOP, declared frame rate and log intervals (:52-53, :70) |
| Gaze sampling requested | Standard output filter, maximum supported output frequency (:706, GazeTracking.cpp:15-31) |
| Gaze samples used per tick | One. getGazeDataWithEyeMeasurements returns every sample buffered since the previous read, and the bridge keeps only the newest (VarjoGazeBridge.cpp:388-391) |
| Video access-unit latency | NVENC drains with a fixed output delay of 3 frames (NvencEyeEncoder.cpp:212, :280-293) |
| Forced keyframe | Wall-clock, whenever 1000 ms have elapsed since the last one, in addition to the frame-count GOP (VarjoGazeBridge.cpp:466-472) |
| Managed poll interval | Interval, default 8 ms for this module against the SDK default of 1000 ms (Config/VarjoGazeSettings.cs:12) |
| Managed diagnostics | A [VarjoDiag] line every 120 capture cycles (VarjoGazeDataModule.cs:120-126) |
| Bridge heartbeat | A log line every 90 ticks (VarjoGazeBridge.cpp:341-343) |
Because only the newest gaze sample per tick survives, the effective published gaze rate is the compositor tick rate, not the requested maximum sampling rate. When the runtime samples faster than the compositor ticks, the intermediate samples are discarded silently and no counter records the loss. Anyone using this module for saccade-scale analysis needs to know that the published series is decimated, not raw.
9. Connection lifecycle
| Step | Managed side | Bridge side |
|---|---|---|
| Initialise | InitialiseModule logs and returns true; it touches no native code (VarjoGazeDataModule.cs:31-35) | Nothing |
| Start | StartCapture reads EnableVideoCapture, calls Initialize, logs the returned status, logs the gaze tracking status, optionally requests calibration, allocates the 4 MiB buffer, resets counters, sets the capturing flag (:37-70) | Initialize builds the session, device and transparent swap chain, then starts the capture thread; video setup runs on that thread (VarjoGazeBridge.cpp:694-786) |
| Run | HandleCapture drains the queue every Interval (:72-127) | The capture thread runs sync, pose, gaze, begin frame, mirror copy and submit, end frame, and drain, once per tick (:334-685) |
| Stop | EndCapture clears the capturing flag, releases the buffer, calls Shutdown, logs Varjo session ended (:129-137) | Shutdown clears the run flag, joins the capture thread, resets gaze tracking, resets mirror config, frees both swap chains, shuts down the session, releases the D3D objects, clears both queues (:788-826) |
Both InitialiseModule and StartCapture are bounded by the SDK’s StartupTimeout, 5 seconds by
default, which this module does not override. HandleCapture has no timeout.
There is no reconnect logic on either side. Nothing retries varjo_SessionInit, and nothing
detects a session that dies mid-capture: the capture thread keeps calling into a session the
runtime may have abandoned. Recovery is a capture restart, which is what makes the ordering below
matter.
9.1 Failed start does not tear down
Initialize has four failure returns, and three of them occur after the Varjo session already
exists: no matching DXGI adapter (VarjoGazeBridge.cpp:720), D3D11 device creation failure
(:736-737), and transparent swap-chain creation failure (:773). None of them undoes the
session, the gaze tracking object or any D3D object created up to that point.
On the managed side a 0 return logs Varjo Initialize failed and returns false without calling
Shutdown (VarjoGazeDataModule.cs:47-52), and the SDK does not call EndCapture when
StartCapture returns false (BaseDataModule.cs:123-128). The native session therefore stays
alive with no owner. A subsequent start calls Initialize again, which overwrites the session
handle with a fresh varjo_SessionInit (VarjoGazeBridge.cpp:698), so the previous session and
its D3D objects are leaked for the lifetime of the process. Restart HEAT Capture rather than only
the capture session after a failed Varjo start.
9.2 Video failure is silent to the managed side
Video setup runs on the capture thread after Initialize has already returned 1. Every failure
in that path (Media Foundation startup, mirror swap chain, encode textures, DXGI device manager,
either encoder) leaves g_mirrorEnabled false and is reported only to the bridge’s own log file
(VarjoGazeBridge.cpp:104-232). Gaze continues normally and no video frame is ever produced. The
managed module is never told, its videoEnabled debug indicator reflects the setting rather than
the outcome (VarjoGazeDataModule.cs:174), and the only evidence is IF-CMVARJO-05.
10. Error behaviour
| Condition | Bridge behaviour | Managed behaviour |
|---|---|---|
varjo_SessionInit fails | Initialize returns 0 immediately | Logs Varjo Initialize failed, returns false, module does not start |
Adapter, D3D or swap-chain failure during Initialize | Returns 0 leaving the session and gaze tracking alive | Same as above, and Shutdown is never called: see 9.1 |
| Any video-path setup failure | Logged to the bridge log, video stays off, gaze unaffected | Not notified: see 9.2 |
| No valid gaze on a tick | No gaze frame is pushed for that tick | Nothing published for that tick from that source |
| Encoder submit returns false for an eye | That eye’s frame is dropped for the tick; pending metadata is pushed only if at least one eye submitted | Not notified |
| Access unit drained with no matching tick metadata | Dropped rather than emitted with a wrong timestamp, and logged (VarjoGazeBridge.cpp:589-595) | Not notified |
| Frame queue at capacity on push | Oldest entry dropped, both encoders forced to a fresh IDR | Not notified; sees a gap and a keyframe |
| Head queue entry larger than the supplied buffer | Returns 0 and leaves the entry queued | Treats 0 as end of queue; the queue stalls permanently: see 5.3 |
| Malformed or truncated frame bytes | Not detected | Increments the parse-error counter, logs Failed to parse Varjo SimulationFrame ({Written} bytes); skipping, and continues draining the rest of the cycle (VarjoGazeDataModule.cs:98-105) |
Exception escaping HandleCapture | Not applicable | SDK logs Error in module and the capture loop continues |
Shutdown while the capture thread is inside varjo_WaitSync | The join waits for the current wait to return, so Shutdown blocks for up to one frame period | EndCapture blocks for the same period |
GetFrame or GetGazeTrackingStatus after Shutdown | Returns 0 and -1 respectively, without crashing | Reports Stopped in the debug view |
A single malformed frame is deliberately not fatal to the drain cycle. Aborting the drain would discard every other frame queued that cycle, gaze included.
11. Units and conventions
| Concept | Convention |
|---|---|
| Gaze origins | Metres, in the Varjo runtime’s own coordinate system, narrowed from double to float without transformation |
| Gaze directions | Unit rays from the same source with the Z component negated, a deliberate axis conversion applied identically to the combined and both per-eye vectors |
| Head pose position | Metres, taken from the pose matrix translation without sign change |
| Head pose orientation | Quaternion, with the Y term negated at emission |
| IPD | Millimetres, as the Varjo runtime reports it. The ProtoLib ipd field is specified in metres |
| Pupil diameter | Millimetres, as the Varjo runtime reports it |
| Eyelid openness | Varjo’s ratio, 1 fully open and 0 fully closed |
| Occlusion and blink | Booleans derived from Varjo per-eye validity status, not from a measured threshold |
| Video dimensions | Pixels, the encode resolution, not the native view resolution |
| Frame rate field | The bridge’s declared constant 90, not a measured rate |
| Time base | Host system_clock Unix milliseconds, sampled on the capture tick. Varjo’s own nanosecond captureTime on each gaze sample is read but not carried |
Two consequences follow from that table and both belong to the audited revision rather than to the
Varjo protocol. ipd is published in millimetres into a field the ProtoLib schema documents as
metres, so a consumer reading it as metres is off by a factor of 1000. And because direction
vectors are Z-negated while origins and the head-pose position are not, an origin and its
direction are not expressed in a single consistent handedness; a consumer that builds a world-space
ray from the pair must decide which convention to trust rather than assume they agree.
Beyond the Z negation and the narrowing to float, no value is scaled, filtered, validated or
range-checked. The authority for their meaning is Varjo’s own documentation.
12. Configuration affecting the interfaces
| Setting | Default | Interface effect |
|---|---|---|
CalibrationType | Fast | Whether RequestGazeCalibration is called at all, and which native calibration parameter is requested. None suppresses the call |
EnableVideoCapture | true | Passed as the enableVideoCapture argument. When false the bridge starts no Media Foundation, creates no mirror swap chain and no encoders, and IF-CMVARJO-02 carries gaze frames only |
Interval | 8 ms | How often the queue is drained. This module overrides the SDK default of 1000 ms because at 1000 ms the queue sits at its overflow threshold every cycle |
Enabled | true | Whether the module, and therefore the bridge, runs at all |
Calibration is requested at every capture start unless CalibrationType is None; the bridge
always passes the automatic headset-alignment guidance mode and does not expose a choice
(VarjoGazeBridge.cpp:844-848). See
Manage capture modules for the generic settings.
13. Compatibility and versioning
The bridge and the managed module must be deployed as a matched pair. This is a hard requirement, and nothing enforces it.
There is no version export, no ABI revision constant and no handshake. A mismatched pair does not
fail at load: it fails as a wrong-shaped call or as frames the managed parser rejects, and the only
symptom is a sustained parse-error rate or absent data. The bridge repository’s README.md
documents an export set that does not exist in this revision (InitializeGazeTracking,
ShutdownGazeTracking, GetGazeData, GetPupillometryData); it is stale and must not be used as
the ABI authority. This document is the authority.
Pairing is established by vendoring, not by resolution. The module repository commits three
prebuilt native binaries under bridge/ (VarjoGazeBridge.dll, VarjoLib.dll,
heat_protolib_capi.dll) and marks each CopyToOutputDirectory Always
(HEAT.VarjoGazeModule.csproj:18-30), so a given module commit ships whichever bridge build was
committed alongside it. The bridge is not separately installable and must not be replaced in a
deployed module directory.
Schema compatibility is a third axis. The frame is built by ProtoLib’s native C API on one side and
parsed by HEAT.Proto.Simulation from HEAT.Capture.ModuleSDK 2.0.5 on the other. A ProtoLib
schema change that renumbers or retypes a field surfaces as parse failures or as silently missing
components, not as a load error.
Release validation must therefore pin and verify a specific bridge and managed pair together, as
required by audit finding VBRIDGE-01. Audit finding VARJO-02 makes that harder than it should be:
the module’s release workflow selects the full prerelease SemVer for the package version
(module-release.yml:75-106) but publishes the release and the manifest download location at the
stable base tag v{majorMinorPatch} (:126-134, :150-161). A prerelease and its eventual stable
release can therefore occupy the same tag, so a tag alone does not identify a bridge and managed
pair. Pin by commit until VARJO-02 is fixed.
14. Security considerations
Both interfaces are local and in-process. There is no network transport, no authentication and no encryption, and no privilege boundary between the module and the bridge: any code in the HEAT Capture process can call the same exports and drain the same queue.
Gaze, pupillometry and eyelid data are biometric personal data. The per-eye video is the composited scene as presented to the user (see 6.2), so it can contain whatever the target application displays, including material that has nothing to do with eye tracking. Treat a captured session as containing both biometric measurements and a screen recording, and restrict access accordingly.
15. IF-CMVARJO-05 Bridge diagnostic log
The bridge appends diagnostics to VarjoBridge.log in the directory named by the TEMP
environment variable, falling back to the process working directory when TEMP is unset
(VarjoGazeBridge.cpp:75-96). Each line is prefixed with the epoch time in milliseconds.
The file is opened, appended and closed per line under a mutex. It is never rotated, truncated or deleted, so it grows for as long as the workstation keeps running capture sessions. Its content is adapter identity, encoder selection and activation results, per-tick heartbeats, mirror acquisition errors, drain sizes and queue depth. No gaze value, pose value or video payload is written to it.
The code comment marks this log as temporary and to be removed once video capture is confirmed working; it is still present and still unconditional in the audited revision. Treat it as the only diagnostic channel for video setup failures (see 9.2), and as an unbounded file that deployment housekeeping has to account for.
16. Conformance
- Start the Varjo Base runtime with a headset connected, then start a capture session. Confirm
the log records
Initialize returned 1and aGazeTrackingStatusline. - Confirm the reported status is
4(calibrated). Any other value is a documented condition, not a fault, and produces no gaze frames. - Confirm one entity, id
0x56415246and nameVarjo_Data, appears in the captured data. - Confirm
GazeComponentandPupillometryDataComponentvalues change across successive frames while the headset is worn, and that removing the headset stops frames rather than producing frames with zeroed fields. - Check the published
ipdvalue. Until the unit mismatch in section 11 is resolved, confirm it is in the tens (millimetres) and not a fraction of one (metres), and that downstream consumers apply the conversion. - With
EnableVideoCaptureon, confirm twoVideoSourcestreams namedeye.leftandeye.rightappear, each with codecH264, and confirm from the bridge log thatg_mirrorEnabled=1 (video ACTIVE). A gaze-only session with the setting on means video setup failed silently; the log is the only evidence. - Confirm the encode resolution reported on the
VideoSourcematches the bridge log’sencode sizeline, and that its larger dimension does not exceed 1088. - Decode one eye’s access units in receive order and confirm a keyframe arrives at least once per second of wall clock.
- Raise
Intervalwell above its default, confirm the bridge log shows a risingqueueDepthand then a video discontinuity, and restore the default. This exercises the documented overflow policy, not a fault. - Stop the capture session. Confirm
Varjo session endedis logged and that the bridge heartbeat lines stop. - Validate the release pair, not the sides separately: record a session with the exact bridge and managed build under test, confirm a clean start and stop trace on both sides, and confirm the module’s parse-error counter stays at zero. A non-zero counter on a healthy headset indicates a mismatched pair.
- Pin the pair by commit rather than by tag while VARJO-02 is open.
17. Revision history
| Rev | Date | Product version | Change |
|---|---|---|---|
| 1 | 2026-09-02 | b2618c7 | Initial issue. |
| 2 | 2026-09-02 | b2618c7, bridge dbbef2a | Replaced the placeholder with the source-derived contract: exports, buffer ownership, thread affinity, framing, queue semantics, lifecycle across both sides, published fields, timing, error behaviour, units and version pairing. Resolves VARJO-01 and VBRIDGE-01. |