# Phase 10: Feasibility Spike - Research

**Researched:** 2026-03-24
**Domain:** OpenVR driver API -- IPD property mechanism, EyeToHead transforms, SteamVR slider UI
**Confidence:** MEDIUM-HIGH

## Summary

This phase is a feasibility spike to confirm that property-based IPD (Prop_UserIpdMeters_Float) works from a sidecar driver. The research reveals a critical architectural detail: OpenVR has two mechanisms for IPD/eye separation, and they are mutually exclusive. Method 1 (property-based, using Prop_UserIpdMeters_Float) lets SteamVR auto-compute EyeToHead transforms. Method 2 (SetDisplayEyeToHead) gives the driver full control but permanently disables Method 1. The spike's primary risk is that the lighthouse driver may call SetDisplayEyeToHead based on its JSON extrinsics config, which would block the sidecar's property writes from taking effect.

The existing codebase has all the infrastructure needed: the named pipe command handler, HMD container access pattern, and SetBoolProperty pattern that directly extends to SetFloatProperty. The spike adds three pipe commands and an event listener, following established patterns. Code is not throwaway -- it evolves into production.

**Primary recommendation:** Implement the three spike commands (ipd_test, eyetohead_check, slider_test) as pipe commands in HandlePipeCommand, verify empirically by reading back the IPD property and observing EyeToHead transform changes, and check SteamVR logs for SetDisplayEyeToHead calls from the lighthouse driver.

<user_constraints>

## User Constraints (from CONTEXT.md)

### Locked Decisions
- All three FEAS tests implemented as pipe commands via the existing named pipe infrastructure
- `ipd_test <mm>` -- sets Prop_UserIpdMeters_Float on HMD container (FEAS-01)
- `eyetohead_check` -- reads current EyeToHead transforms to verify IPD change took effect (FEAS-02)
- `slider_test` -- sets IpdUIRange properties on HMD container (FEAS-03)
- Verification is programmatic: set IPD via pipe command, read back EyeToHead transforms to confirm change
- Test values use the Beyond 2 official range: 48mm-75mm (matching Beyond Utility)
- Spike code evolves into production -- `ipd_test` becomes basis for Phase 11's `ipd` command, `eyetohead_check` stays as debug diagnostic
- Lighthouse driver interaction: empirical test + SteamVR log analysis for SetDisplayEyeToHead calls
- If lighthouse driver blocks property-based IPD: document finding, defer workaround to Phase 11
- Slider test: Set IpdUIRangeMin=0.048, IpdUIRangeMax=0.075, test Prop_DriverDisplaysIPDChanges_Bool with both true/false
- Add VREvent_IpdChanged detection to RunFrame during spike
- Spike is fully automatable (no manual user testing required)
- Go/no-go criteria defined: FEAS-01 pass + slider = full go; FEAS-01 pass + slider fail = go for Phase 11 only; FEAS-01 fail = no-go

### Claude's Discretion
- Whether to read EyeToHead transforms from driver side (VRServerDriverHost) or client side (beyond_prox_ctl via IVRSystem) -- pick whichever is more practical given API access
- Exact spike command syntax and response format
- Order of tests during execution
- SteamVR log parsing approach for SetDisplayEyeToHead detection

### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope

</user_constraints>

<phase_requirements>

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| FEAS-01 | Confirm SetFloatProperty(Prop_UserIpdMeters_Float) works from sidecar on HMD container | SetFloatProperty API confirmed in openvr_driver.h (line 3271). Pattern identical to existing SetBoolProperty. simplehmd sample shows exact usage (line 83). Risk: lighthouse driver may call SetDisplayEyeToHead which permanently blocks property-based IPD updates. |
| FEAS-02 | Determine if lighthouse driver calls SetDisplayEyeToHead (which would block property-based IPD) | SetDisplayEyeToHead on IVRServerDriverHost (line 3806) permanently takes over EyeToHead computation. Lighthouse JSON defines extrinsics (tracking_to_eye_transform) -- unknown whether lighthouse driver applies these via SetDisplayEyeToHead at runtime. Verification: read back IPD property + check EyeToHead transform change + grep SteamVR logs. |
| FEAS-03 | Test whether setting IpdUIRange properties from sidecar triggers SteamVR IPD slider UI | Properties confirmed: Prop_IpdUIRangeMinMeters_Float=2100, Prop_IpdUIRangeMaxMeters_Float=2101, Prop_DriverDisplaysIPDChanges_Bool=2108 (all in openvr_driver.h). VREvent_IpdChanged=105 with VREvent_Ipd_t struct containing ipdMeters field. No official docs on exact trigger conditions -- empirical test required. |

</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| OpenVR SDK | 2.5.1 (vendored) | Driver API for property access, event polling | Already vendored in extern/openvr, provides all needed APIs |

### Supporting
No additional libraries needed. This spike extends the existing driver using only the OpenVR driver API already in use.

**Build command:**
```bash
"C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" --build build --config Release
```

## Architecture Patterns

### Recommended Changes Structure
```
src/
  driver/
    device_provider.h    # Add new member variables (m_fCurrentIpd, etc.)
    device_provider.cpp  # Add new pipe commands + VREvent polling in RunFrame
  ctl/
    main.cpp             # Add new commands to CLI tool validation
```

### Pattern 1: SetFloatProperty on HMD Container (FEAS-01)
**What:** Set Prop_UserIpdMeters_Float using the same pattern as SetHmdProximity
**When to use:** For the `ipd_test` command
**Example:**
```cpp
// Source: existing SetHmdProximity pattern (device_provider.cpp:291-308)
// + simplehmd sample (hmd_device_driver.cpp:83)
void DeviceProvider::SetHmdIpd(float ipdMeters)
{
    vr::PropertyContainerHandle_t hmdProps =
        vr::VRProperties()->TrackedDeviceToPropertyContainer(
            vr::k_unTrackedDeviceIndex_Hmd);

    vr::ETrackedPropertyError propErr =
        vr::VRProperties()->SetFloatProperty(hmdProps,
            vr::Prop_UserIpdMeters_Float, ipdMeters);

    DriverLog("IPD: SetFloatProperty(HMD, UserIpdMeters, %.4f) = %d\n",
        ipdMeters, propErr);
}
```

### Pattern 2: Read-Back Verification via GetFloatProperty (FEAS-01/02)
**What:** Read the IPD property back from the HMD container to confirm the write took effect
**When to use:** For the `eyetohead_check` command (driver-side verification)
**Example:**
```cpp
// Source: openvr_driver.h CVRPropertyHelpers::GetFloatProperty (line 3520)
vr::PropertyContainerHandle_t hmdProps =
    vr::VRProperties()->TrackedDeviceToPropertyContainer(
        vr::k_unTrackedDeviceIndex_Hmd);
vr::ETrackedPropertyError propErr;
float currentIpd = vr::VRProperties()->GetFloatProperty(hmdProps,
    vr::Prop_UserIpdMeters_Float, &propErr);
```

### Pattern 3: VREvent Polling in RunFrame (FEAS-03)
**What:** Poll for VREvent_IpdChanged in the RunFrame loop
**When to use:** To detect when SteamVR processes an IPD change (from slider or property write)
**Example:**
```cpp
// Source: openvr_driver.h IVRServerDriverHost::PollNextEvent (line 3789)
// VREvent_IpdChanged = 105, VREvent_Ipd_t has ipdMeters field (line 1183-1186)
void DeviceProvider::RunFrame()
{
    PollPipe();

    // Poll for IPD events
    vr::VREvent_t event;
    while (vr::VRServerDriverHost()->PollNextEvent(&event, sizeof(event)))
    {
        if (event.eventType == vr::VREvent_IpdChanged)
        {
            DriverLog("IPD Event: VREvent_IpdChanged ipd=%.4f\n",
                event.data.ipd.ipdMeters);
        }
    }

    // ... existing proximity logic
}
```

### Pattern 4: IpdUIRange Property Setting (FEAS-03)
**What:** Set the IPD slider range and display properties
**When to use:** For the `slider_test` command
**Example:**
```cpp
// Source: openvr_driver.h property IDs (lines 536-537, 544)
vr::PropertyContainerHandle_t hmdProps =
    vr::VRProperties()->TrackedDeviceToPropertyContainer(
        vr::k_unTrackedDeviceIndex_Hmd);

vr::VRProperties()->SetFloatProperty(hmdProps,
    vr::Prop_IpdUIRangeMinMeters_Float, 0.048f);
vr::VRProperties()->SetFloatProperty(hmdProps,
    vr::Prop_IpdUIRangeMaxMeters_Float, 0.075f);
vr::VRProperties()->SetBoolProperty(hmdProps,
    vr::Prop_DriverDisplaysIPDChanges_Bool, true);
```

### Pattern 5: Pipe Command with Float Argument
**What:** Parse a float argument from a pipe command string
**When to use:** For `ipd_test <mm>` command
**Example:**
```cpp
// Extend HandlePipeCommand pattern (device_provider.cpp:180)
if (strncmp(cmd, "ipd_test ", 9) == 0)
{
    float mm = 0.0f;
    if (sscanf(cmd + 9, "%f", &mm) == 1)
    {
        float meters = mm / 1000.0f;
        if (meters >= 0.048f && meters <= 0.075f)
        {
            SetHmdIpd(meters);
            snprintf(response, sizeof(response),
                "OK ipd_set=%.1fmm (%.4fm)", mm, meters);
        }
        else
        {
            snprintf(response, sizeof(response),
                "ERR ipd out of range (48-75mm)");
        }
    }
}
```

### Anti-Patterns to Avoid
- **Calling SetDisplayEyeToHead from sidecar:** This would permanently take over EyeToHead transform management from the lighthouse driver. The sidecar should only use the property-based approach (Method 1).
- **Reading EyeToHead from driver-side without VRServerDriverHost:** GetEyeToHeadTransform is a client-side API (IVRSystem in openvr.h), not available in the driver context. Driver-side verification should use GetFloatProperty to read back Prop_UserIpdMeters_Float instead.
- **Blocking pipe commands:** All pipe commands must be non-blocking since HandlePipeCommand runs inside RunFrame.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| IPD property write | Custom vrserver IPC | VRProperties()->SetFloatProperty | Proven mechanism, same pattern as existing SetBoolProperty |
| Event detection | Custom polling/timer | VRServerDriverHost()->PollNextEvent | Standard OpenVR event loop, receives VREvent_IpdChanged |
| EyeToHead verification | Custom matrix computation | GetFloatProperty readback + SteamVR log grep | Property readback confirms write; logs confirm whether lighthouse driver overrides |
| Float parsing | Custom parser | sscanf | Standard C, no edge cases for this use |

## Common Pitfalls

### Pitfall 1: SetDisplayEyeToHead Permanently Blocks Property-Based IPD
**What goes wrong:** If the lighthouse driver calls SetDisplayEyeToHead during initialization (to apply its JSON extrinsics), then ALL subsequent SetFloatProperty(Prop_UserIpdMeters_Float) calls from the sidecar will be silently ignored -- the EyeToHead transforms will not update.
**Why it happens:** OpenVR documentation states: "Once you call SetDisplayEyeToHead, SteamVR will no longer automatically update the HeadFromEyePose based on changes to Prop_UserIpdMeters_Float. There is no mechanism to re-enable automatic IPD handling."
**How to avoid:** This is exactly what FEAS-02 tests. The spike must verify empirically whether the lighthouse driver uses SetDisplayEyeToHead.
**Warning signs:** SetFloatProperty returns success (TrackedProp_Success) but EyeToHead transforms don't change. SteamVR logs show SetDisplayEyeToHead calls from the lighthouse driver.

### Pitfall 2: IPD Units Mismatch (mm vs meters)
**What goes wrong:** Prop_UserIpdMeters_Float is in meters (e.g., 0.063), but user-facing commands use millimeters (e.g., 63mm). Off-by-1000x error.
**Why it happens:** Easy to forget the conversion at the boundary.
**How to avoid:** Pipe command accepts mm, converts to meters immediately (mm / 1000.0f), all internal handling in meters.
**Warning signs:** IPD values like 63.0 (should be 0.063) or 0.000063 (double-converted).

### Pitfall 3: VREvent Polling Consuming Events Meant for Other Components
**What goes wrong:** PollNextEvent in the sidecar driver consumes events that other components in the driver process might need.
**Why it happens:** PollNextEvent removes events from the queue.
**How to avoid:** This sidecar driver is the only component -- there are no other consumers. But log all events during the spike for debugging, not just IpdChanged.
**Warning signs:** Missing events in other drivers loaded in the same process (not applicable here since sidecar is standalone).

### Pitfall 4: Property Write Succeeds But No Observable Effect
**What goes wrong:** SetFloatProperty returns success, but nothing visually changes.
**Why it happens:** Could be: (a) lighthouse driver called SetDisplayEyeToHead (pitfall 1), (b) SteamVR compositor hasn't picked up the change yet, or (c) the property write affects metadata but not rendering.
**How to avoid:** Multi-layered verification: (1) read property back, (2) check for VREvent_IpdChanged, (3) read EyeToHead transforms from client side, (4) check SteamVR logs.
**Warning signs:** Property readback shows new value but VREvent_IpdChanged never fires.

### Pitfall 5: Sidecar Cannot Set Properties on HMD Owned by Another Driver
**What goes wrong:** The sidecar driver (which does not register its own HMD device) tries to set properties on the HMD container owned by the lighthouse driver, and the property system rejects it.
**Why it happens:** Some property writes may be restricted to the driver that owns the device.
**How to avoid:** This has already been proven to work in v1.0 -- SetBoolProperty(Prop_ContainsProximitySensor_Bool) successfully writes to the HMD container from the sidecar. SetFloatProperty should follow the same path.
**Warning signs:** SetFloatProperty returns an error code other than TrackedProp_Success.

## Code Examples

### EyeToHead Verification: Driver-Side Approach (Recommended)
```cpp
// Source: openvr_driver.h GetFloatProperty (line 3243)
// Driver-side: read back IPD property to confirm write
void DeviceProvider::HandleEyeToHeadCheck(char* response, size_t responseSize)
{
    vr::PropertyContainerHandle_t hmdProps =
        vr::VRProperties()->TrackedDeviceToPropertyContainer(
            vr::k_unTrackedDeviceIndex_Hmd);

    vr::ETrackedPropertyError propErr;
    float ipd = vr::VRProperties()->GetFloatProperty(hmdProps,
        vr::Prop_UserIpdMeters_Float, &propErr);

    snprintf(response, responseSize,
        "OK ipd=%.4fm (%.1fmm) err=%d",
        ipd, ipd * 1000.0f, propErr);
}
```

### EyeToHead Verification: Client-Side Approach (Alternative)
```cpp
// Source: openvr.h IVRSystem::GetEyeToHeadTransform (line 2242)
// Client-side (in beyond_prox_ctl or separate tool):
// Requires VR_Init(VRApplication_Utility) or VR_Init(VRApplication_Background)
vr::HmdMatrix34_t leftEye = vr::VRSystem()->GetEyeToHeadTransform(vr::Eye_Left);
vr::HmdMatrix34_t rightEye = vr::VRSystem()->GetEyeToHeadTransform(vr::Eye_Right);
// leftEye.m[0][3] should be -ipd/2, rightEye.m[0][3] should be +ipd/2
```

### SteamVR Log Grep for SetDisplayEyeToHead
```
# SteamVR logs location (Windows):
# %LOCALAPPDATA%\openvr\logs\vrserver.txt
# Grep for evidence of lighthouse driver calling SetDisplayEyeToHead
# Look for: "SetDisplayEyeToHead" or "EyeToHead" in lighthouse driver context
```

### CLI Tool Command Addition
```cpp
// Source: src/ctl/main.cpp -- extend validation list
// Add to the strcmp chain:
// "ipd_test <mm>" -- e.g., "ipd_test 63"
// "eyetohead_check" -- no args
// "slider_test" -- no args
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| GetIPD() function | Prop_UserIpdMeters_Float property | Deprecated in OpenVR | Property is the standard mechanism |
| Driver computes EyeToHead | Two methods: auto (property) or manual (SetDisplayEyeToHead) | IVRServerDriverHost_006 | Method choice is permanent per-session |

## Open Questions

1. **Does the lighthouse driver call SetDisplayEyeToHead?**
   - What we know: Lighthouse JSON defines extrinsics (tracking_to_eye_transform) for eye transforms. SetDisplayEyeToHead on IVRServerDriverHost permanently disables property-based IPD.
   - What's unclear: Whether the lighthouse driver applies JSON extrinsics via SetDisplayEyeToHead at runtime or uses a different internal mechanism. No source code available for the lighthouse driver.
   - Recommendation: FEAS-02 resolves this empirically -- set IPD property, read back EyeToHead transforms, check if they changed. Also grep vrserver.txt logs.

2. **Do IpdUIRange properties trigger the SteamVR slider for a sidecar driver?**
   - What we know: Properties exist (2100, 2101, 2108) and are defined in openvr_driver.h. No official documentation on exact trigger conditions.
   - What's unclear: Whether SteamVR shows the slider based on HMD container properties regardless of which driver set them, or only for the owning driver.
   - Recommendation: FEAS-03 resolves this empirically.

3. **Does VREvent_IpdChanged fire when property is set from sidecar?**
   - What we know: VREvent_IpdChanged=105 with VREvent_Ipd_t.ipdMeters field. Event should fire when IPD changes.
   - What's unclear: Whether the event fires only for slider-initiated changes, or also for property writes.
   - Recommendation: Spike's RunFrame event polling will answer this.

4. **EyeToHead readback approach -- driver vs client side?**
   - What we know: GetEyeToHeadTransform is client-side only (IVRSystem in openvr.h). Driver-side can only read back the property via GetFloatProperty, not the actual EyeToHead matrix.
   - What's unclear: Whether property readback alone is sufficient verification, or if client-side EyeToHead transform check is needed to confirm the compositor actually uses the new IPD.
   - Recommendation: Use driver-side GetFloatProperty for quick verification (sufficient for go/no-go). Client-side EyeToHead check is optional bonus verification -- would require extending beyond_prox_ctl to init as VRApplication_Background and call IVRSystem. For the spike, driver-side readback + VREvent_IpdChanged + log analysis is likely sufficient.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Manual empirical testing (hardware driver) |
| Config file | none -- no automated test framework |
| Quick run command | `beyond_prox_ctl.exe "ipd_test 63"` |
| Full suite command | `beyond_prox_ctl.exe "ipd_test 63" && beyond_prox_ctl.exe "eyetohead_check" && beyond_prox_ctl.exe "slider_test"` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| FEAS-01 | SetFloatProperty(Prop_UserIpdMeters_Float) changes IPD | smoke (empirical) | `beyond_prox_ctl.exe "ipd_test 63"` then `beyond_prox_ctl.exe "eyetohead_check"` | N/A -- spike IS the test |
| FEAS-02 | Lighthouse SetDisplayEyeToHead detection | smoke (log analysis) | Grep vrserver.txt for SetDisplayEyeToHead after ipd_test | N/A -- spike IS the test |
| FEAS-03 | IpdUIRange triggers slider UI | smoke (visual + event) | `beyond_prox_ctl.exe "slider_test"` + check for VREvent_IpdChanged in driver log | N/A -- spike IS the test |

### Sampling Rate
- **Per task commit:** Build succeeds (`cmake --build build --config Release`)
- **Per wave merge:** Full spike sequence (ipd_test -> eyetohead_check -> slider_test) with SteamVR running
- **Phase gate:** All three FEAS requirements documented in FINDINGS.md with go/no-go decision

### Wave 0 Gaps
None -- this phase IS the test infrastructure. The spike commands themselves are the tests. Build verification is the only automated gate.

## Sources

### Primary (HIGH confidence)
- `extern/openvr/headers/openvr_driver.h` -- Property IDs (Prop_UserIpdMeters_Float=2003, IpdUIRange 2100/2101, DriverDisplaysIPDChanges=2108), SetFloatProperty API, SetDisplayEyeToHead, VREvent_IpdChanged=105, VREvent_Ipd_t struct, PollNextEvent, GetFloatProperty
- `extern/openvr/samples/drivers/drivers/simplehmd/src/hmd_device_driver.cpp` -- Reference SetFloatProperty(Prop_UserIpdMeters_Float) usage (line 83)
- `src/driver/device_provider.cpp` -- Existing SetHmdProximity pattern, HandlePipeCommand dispatcher, pipe server infrastructure
- [OpenVR Wiki: ITrackedDeviceServerDriver Overview](https://github.com/ValveSoftware/openvr/wiki/vr::ITrackedDeviceServerDriver-Overview) -- SetDisplayEyeToHead permanently disables property-based IPD, no re-enable mechanism

### Secondary (MEDIUM confidence)
- [OpenVR Wiki: Lighthouse JSON File](https://github.com/ValveSoftware/openvr/wiki/The-JSON-File-(Lighthouse-Devices)) -- Lighthouse extrinsics define eye transforms via tracking_to_eye_transform
- [ALVR OpenVR driver implementation](https://github.com/alvr-org/ALVR) -- Confirms SetFloatProperty(Prop_UserIpdMeters_Float) is used by third-party drivers successfully

### Tertiary (LOW confidence)
- Whether lighthouse driver actually calls SetDisplayEyeToHead at runtime -- no source available, must test empirically
- Whether IpdUIRange properties from sidecar trigger slider UI -- no documentation found, must test empirically

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- using existing vendored OpenVR SDK, no new dependencies
- Architecture: HIGH -- extending proven pipe command pattern with identical API (SetFloatProperty vs SetBoolProperty)
- Pitfalls: MEDIUM-HIGH -- SetDisplayEyeToHead blocking behavior is documented by Valve; whether lighthouse driver triggers it is the core unknown
- Feasibility outcome: MEDIUM -- this is explicitly what the spike resolves; research identified the risks but only empirical testing confirms

**Research date:** 2026-03-24
**Valid until:** 2026-04-24 (stable -- OpenVR driver API changes rarely)
