# Phase 10.1: SetDisplayEyeToHead Spike - Research

**Researched:** 2026-03-24
**Domain:** OpenVR IVRServerDriverHost, rotation matrix mathematics, C++ driver development
**Confidence:** MEDIUM (core API usage is documented; cross-driver call permission is the unknown this spike tests)

## Summary

This spike tests whether `VRServerDriverHost()->SetDisplayEyeToHead(0, leftMatrix, rightMatrix)` can be called from a sidecar driver (BeyondProximity) to change the EyeToHead transforms on the HMD (device 0) owned by the lighthouse driver. Phase 10 established that property-based IPD does not affect rendering because the lighthouse driver has already called SetDisplayEyeToHead with rotation matrices containing lens cant geometry. The sidecar must call SetDisplayEyeToHead itself to override those matrices with updated IPD translations while preserving the rotation.

The implementation requires: (1) reading the current EyeToHead rotation from lighthouse config JSON, (2) decomposing the 3x3 rotation matrix to extract Euler angles as a validation step, (3) rebuilding HmdMatrix34_t with the existing rotation plus new IPD-based translation, and (4) calling SetDisplayEyeToHead from the sidecar. The Euler angle decomposition must use the intrinsic XYZ convention matching the VAP alignment code.

**Primary recommendation:** Implement `eyetohead_set <mm>` pipe command that reads rotation from lighthouse config, validates via Euler decomposition (expecting +/-6.17 deg yaw), rebuilds matrices with new IPD translation, and calls SetDisplayEyeToHead(0, ...). Test across 48/55/63/75mm visually.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Matrix computation: Read current EyeToHead transforms at runtime (try property readback first, fall back to lighthouse config JSON). Deconstruct existing 3x3 rotation to extract Euler angles (XYZ convention). Expect +/-6.17 deg yaw as validation. Rebuild full HmdMatrix34_t with rotation + new IPD translation.
- Test verification: Visual inspection in HMD is primary. Step through 48/55/63/75mm. Log Euler angles before/after. Test repeated calls for live-change capability.
- Pipe command: `eyetohead_set <mm>` -- reads rotation, deconstructs, logs, rebuilds, calls SetDisplayEyeToHead. Also calls SetFloatProperty for property sync. Response includes diagnostics. IPD range 48-75mm.
- Failure handling: SetDisplayEyeToHead returns void. Detection is visual-only. If fails, document BeyondEyetracking IVRServerDriverHost_006 hook approach as fallback.
- Go/no-go: PASS = visual eye separation changes. FAIL = no visual change.
- Research MUST include proper study of rotation matrix decomposition -- mathematical foundations.

### Claude's Discretion
- Exact approach for reading current EyeToHead transforms (which properties to query, which lighthouse config paths)
- Euler angle decomposition implementation details (library choice, edge case handling)
- Order of IPD test values during visual verification
- Log format and verbosity beyond the required Euler angle output

### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope.
</user_constraints>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| OpenVR Driver API | IVRServerDriverHost_006 | SetDisplayEyeToHead call | Only API for runtime EyeToHead updates |
| C++ standard math | `<cmath>` | atan2, sin, cos, sqrt for Euler decomposition | No external dependency needed for 3x3 matrix math |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| nlohmann/json or manual parse | N/A | Parse lighthouse config JSON | Reading eye_to_head rotation matrices from lhr-*/config.json |
| Windows API | N/A | File I/O for config reading | Reading lighthouse config from SteamVR runtime directory |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Hand-coded Euler decomposition | Eigen library | Eigen is overkill for a single 3x3 decomposition; adds build dependency |
| Lighthouse config JSON parsing | OpenVR property readback | Properties may not expose the full 3x3 rotation -- need to verify, fall back to JSON |

**No new dependencies needed.** The spike uses only OpenVR API calls already available to the driver, standard C math, and basic JSON parsing (or even manual float extraction from the config file).

## Architecture Patterns

### Recommended Implementation Structure

The spike adds to the existing `DeviceProvider` class:

```
src/driver/
  device_provider.h    # Add: HandleEyeToHeadSet(), helper methods
  device_provider.cpp  # Add: eyetohead_set command handler, matrix math
```

### Pattern 1: Pipe Command Handler (Established)

**What:** New pipe command `eyetohead_set <mm>` follows the established HandlePipeCommand dispatch pattern.
**When to use:** All new driver commands.
**Example:**
```cpp
// Follows existing pattern from device_provider.cpp:275-296
else if (strncmp(cmd, "eyetohead_set ", 14) == 0)
{
    float mm = 0.0f;
    if (sscanf(cmd + 14, "%f", &mm) == 1)
    {
        if (mm >= 48.0f && mm <= 75.0f)
        {
            HandleEyeToHeadSet(mm, response, sizeof(response));
        }
        else
        {
            snprintf(response, sizeof(response), "ERR ipd out of range (48-75mm)");
        }
    }
}
```

### Pattern 2: HmdMatrix34_t Construction

**What:** Building a 3x4 matrix from a 3x3 rotation and a 3x1 translation.
**When to use:** Every SetDisplayEyeToHead call.
**Example:**
```cpp
// Source: openvr_driver.h line 39-42
// HmdMatrix34_t.m[row][col] -- row-major, 3 rows x 4 columns
// Columns 0-2: rotation, Column 3: translation
vr::HmdMatrix34_t MakeEyeToHead(const float rot[3][3], float tx, float ty, float tz)
{
    vr::HmdMatrix34_t m = {};
    for (int r = 0; r < 3; r++)
        for (int c = 0; c < 3; c++)
            m.m[r][c] = rot[r][c];
    m.m[0][3] = tx;
    m.m[1][3] = ty;
    m.m[2][3] = tz;
    return m;
}
```

### Pattern 3: SetDisplayEyeToHead Call

**What:** Calling SetDisplayEyeToHead on device 0 from the sidecar.
**When to use:** The core spike test.
**Example:**
```cpp
// Source: openvr_driver.h line 3806, docs line 1684-1689
// "must be TrackedDeviceClass_HMD, should be device index 0"
float ipdMeters = mm / 1000.0f;
vr::HmdMatrix34_t left  = MakeEyeToHead(leftRot,  -ipdMeters / 2.0f, 0.0f, 0.0f);
vr::HmdMatrix34_t right = MakeEyeToHead(rightRot, +ipdMeters / 2.0f, 0.0f, 0.0f);
vr::VRServerDriverHost()->SetDisplayEyeToHead(
    vr::k_unTrackedDeviceIndex_Hmd, left, right);
```

### Anti-Patterns to Avoid
- **Using identity rotation:** Do NOT pass identity matrices -- the Beyond 2 has real lens cant (~6.17 deg horizontal, ~-5 deg downward). Losing the rotation would break rendering alignment.
- **Hardcoding rotation values:** Do NOT hardcode the cant angles. Read them from the lighthouse config so the driver works with any Beyond 2 calibration.
- **Ignoring the property sync:** Always call SetFloatProperty(Prop_UserIpdMeters_Float) alongside SetDisplayEyeToHead to keep the property system consistent for GUI and event consumers.

## Rotation Matrix Decomposition -- Mathematical Foundations

### Convention: Intrinsic XYZ (matching VAP alignment code)

The VAP alignment code (alignment.py:546) uses:
```python
rot = Rotation.from_euler("XYZ", [pitch, yaw, 0], degrees=True)
```

In scipy, uppercase "XYZ" means **intrinsic** rotations: rotate first about X (pitch), then about the new Y (yaw), then about the new Z (roll=0).

**Source:** [scipy.spatial.transform.Rotation.from_euler](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.from_euler.html) -- uppercase = intrinsic.

Intrinsic XYZ is equivalent to **extrinsic ZYX** (reversed order). The combined rotation matrix is:

R = Rx(alpha) * Ry(beta) * Rz(gamma)

Where alpha = pitch (X-axis), beta = yaw (Y-axis), gamma = roll (Z-axis).

### The Rotation Matrix (Intrinsic XYZ)

For R = Rx(a) * Ry(b) * Rz(g):

```
R[0][0] = cos(b)*cos(g)
R[0][1] = cos(b)*sin(g)
R[0][2] = -sin(b)

R[1][0] = sin(a)*sin(b)*cos(g) - cos(a)*sin(g)
R[1][1] = sin(a)*sin(b)*sin(g) + cos(a)*cos(g)
R[1][2] = sin(a)*cos(b)

R[2][0] = cos(a)*sin(b)*cos(g) + sin(a)*sin(g)
R[2][1] = cos(a)*sin(b)*sin(g) - sin(a)*cos(g)
R[2][2] = cos(a)*cos(b)
```

### Decomposition Formulas

Given a 3x3 rotation matrix R, extract the three angles:

```cpp
// beta (yaw, Y-axis rotation) -- from R[0][2] = -sin(b)
float beta  = atan2f(-R[0][2], sqrtf(R[0][0]*R[0][0] + R[0][1]*R[0][1]));

// alpha (pitch, X-axis rotation) -- from R[1][2]/R[2][2] = sin(a)*cos(b) / cos(a)*cos(b)
float alpha = atan2f(R[1][2], R[2][2]);

// gamma (roll, Z-axis rotation) -- from R[0][1]/R[0][0] = cos(b)*sin(g) / cos(b)*cos(g)
float gamma = atan2f(R[0][1], R[0][0]);
```

**Angle ranges:** alpha in (-pi, pi], beta in (-pi/2, pi/2], gamma in (-pi, pi].

**Source:** [Nghia Ho -- Decomposing and composing a 3x3 rotation matrix](https://nghiaho.com/?page_id=846), adapted from ZYX extrinsic (equivalent to XYZ intrinsic).

### Gimbal Lock

Gimbal lock occurs when beta = +/-90 degrees (cos(beta) = 0). In this case alpha and gamma are coupled and cannot be uniquely determined. **For the Beyond 2 use case, yaw is ~6.17 degrees -- nowhere near gimbal lock.** No special handling needed for the spike.

### Expected Values for Beyond 2

From VAP alignment code (alignment.py:72-73):
- DOWNWARD_CANT = -5 degrees (pitch)
- HORIZONTAL_CANT = 6.17 degrees (yaw)
- Roll = 0 degrees

**Left eye:** pitch = (user-specific adjustment + DOWNWARD_CANT), yaw = +6.17 deg, roll = 0
**Right eye:** pitch = (user-specific adjustment + DOWNWARD_CANT), yaw = -6.17 deg, roll = 0

**Validation:** After decomposition, if |yaw| is approximately 6.17 degrees, the math is correct.

### Recomposition (Building the Matrix)

```cpp
// Given angles alpha (pitch), beta (yaw), gamma (roll)
void EulerXYZToMatrix(float a, float b, float g, float out[3][3])
{
    float ca = cosf(a), sa = sinf(a);
    float cb = cosf(b), sb = sinf(b);
    float cg = cosf(g), sg = sinf(g);

    out[0][0] = cb*cg;       out[0][1] = cb*sg;       out[0][2] = -sb;
    out[1][0] = sa*sb*cg-ca*sg; out[1][1] = sa*sb*sg+ca*cg; out[1][2] = sa*cb;
    out[2][0] = ca*sb*cg+sa*sg; out[2][1] = ca*sb*sg-sa*cg; out[2][2] = ca*cb;
}
```

**For the spike:** Decomposition is used for logging/validation only. The actual rotation matrix is preserved from the lighthouse config and passed through to SetDisplayEyeToHead unchanged -- only the translation column changes with the new IPD.

## Reading Current EyeToHead Rotation

### Approach 1: OpenVR Property Readback (Try First)

There are no documented OpenVR properties that expose the full 3x3 EyeToHead rotation matrix. The property system only has `Prop_UserIpdMeters_Float` (a scalar). **This approach will likely not work** for reading the rotation.

**Confidence: LOW** -- no evidence that EyeToHead matrix is readable via properties.

### Approach 2: Lighthouse Config JSON (Recommended Fallback)

The Beyond 2 lighthouse config (`lhr-<serial>/config.json`) contains the rotation matrices:

```json
{
  "tracking_to_eye_transform": [
    {
      "eye_to_head": [[r00, r01, r02], [r10, r11, r12], [r20, r21, r22]],
      "distortion": { ... }
    },
    {
      "eye_to_head": [[r00, r01, r02], [r10, r11, r12], [r20, r21, r22]],
      "distortion": { ... }
    }
  ]
}
```

**Config location:** `<SteamVR_runtime>/config/lighthouse/lhr-<serial>/config.json`

Typical SteamVR config path: `C:\Program Files (x86)\Steam\config\lighthouse\lhr-<serial>\config.json`

Or via environment: `%LOCALAPPDATA%\openvr\openvrpaths.vrpath` contains the config directory.

**Confidence: HIGH** -- Phase 10 findings confirm this structure exists and contains the rotation data (10-FINDINGS.md: "Beyond 2 lighthouse config contains non-trivial eye_to_head rotation matrices").

### Config Discovery Strategy

1. Find SteamVR config dir from `openvrpaths.vrpath`
2. List `lighthouse/lhr-*` directories
3. Read `config.json` from the HMD entry (not base stations -- filter by device type or serial prefix)
4. Parse `tracking_to_eye_transform[0].eye_to_head` (left) and `[1].eye_to_head` (right)

**Alternative:** Hardcode a path for the spike, then make it robust in Phase 11.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON parsing | Custom string parser | nlohmann/json or even a simple sscanf-based extractor | JSON has edge cases (escaping, nested structures) |
| Rotation composition | Quaternion library | Direct matrix math (multiply 3x3 * 3x3) | The decompose-recompose is only for validation logging; actual rotation passes through unchanged |

**Key insight:** The spike should NOT recompose the rotation from extracted Euler angles. Instead, preserve the original 3x3 rotation matrix from the config and only change the translation column [3]. Euler decomposition is for logging/validation, not for building the output matrix.

## Common Pitfalls

### Pitfall 1: Row-Major vs Column-Major Matrix Layout
**What goes wrong:** HmdMatrix34_t uses `m[row][col]` (row-major). If you confuse this with column-major, the rotation will be transposed and the translation will be in the wrong elements.
**Why it happens:** Different APIs use different conventions (OpenGL = column-major, DirectX = row-major).
**How to avoid:** OpenVR uses `m[3][4]` -- row-major. Row index first, column index second. Translation is in column 3: `m[0][3]`, `m[1][3]`, `m[2][3]`.
**Warning signs:** If the extracted yaw is not near +/-6.17 deg, check if the matrix is transposed.

### Pitfall 2: IPD Sign Convention
**What goes wrong:** Left and right eye translations have opposite signs. Getting them backwards means IPD changes go the wrong direction.
**Why it happens:** Unclear which eye is positive X.
**How to avoid:** OpenVR coordinate system: +X is to the right. Left eye = negative X offset, right eye = positive X offset. `left.m[0][3] = -ipd/2`, `right.m[0][3] = +ipd/2`.
**Warning signs:** Eyes converge/diverge unexpectedly when IPD changes.

### Pitfall 3: Degrees vs Radians
**What goes wrong:** VAP code uses degrees (HORIZONTAL_CANT = 6.17 degrees). C++ `sinf`/`cosf` use radians. Mixing them gives wrong matrices.
**Why it happens:** Convention mismatch between reference code and implementation language.
**How to avoid:** Convert: `radians = degrees * M_PI / 180.0f`. Log angles in degrees for human readability but compute in radians.

### Pitfall 4: Lighthouse Config Eye Order
**What goes wrong:** Assuming `tracking_to_eye_transform[0]` is always left eye.
**Why it happens:** Not verified against actual config.
**How to avoid:** Check actual config values -- left eye should have positive yaw (~+6.17 deg), right eye should have negative yaw (~-6.17 deg). If reversed, swap.
**Warning signs:** Yaw signs don't match expectations after decomposition.

### Pitfall 5: SetDisplayEyeToHead Returns Void
**What goes wrong:** No way to programmatically detect failure. The function returns void.
**Why it happens:** OpenVR API design -- this is a notification to the server, not a request.
**How to avoid:** Visual verification is the only option. Test with extreme IPD values (48mm vs 75mm) to make the difference unmistakable.

### Pitfall 6: Stale Config vs Runtime State
**What goes wrong:** Lighthouse config may not match runtime state if the BeyondEyetracking shim has modified the matrices after init.
**Why it happens:** The ET driver hooks IVRServerDriverHost_006 and may apply dynamic adjustments.
**How to avoid:** For the spike, lighthouse config is sufficient. If decomposed angles differ from expected (e.g., include per-user vertical alignment adjustments from the VAP tool), that's fine -- the rotation matrix still contains the correct current calibration. Log the actual values for diagnostics.

## Code Examples

### Complete EyeToHead Set Flow
```cpp
// Source: Synthesized from openvr_driver.h:3806, alignment.py:546-549, VAP conventions

void DeviceProvider::HandleEyeToHeadSet(float mm, char* response, size_t responseSize)
{
    float ipdMeters = mm / 1000.0f;

    // 1. Read rotation matrices from lighthouse config
    //    (leftRot[3][3] and rightRot[3][3] populated from JSON)
    float leftRot[3][3], rightRot[3][3];
    if (!ReadEyeToHeadRotation(leftRot, rightRot))
    {
        snprintf(response, responseSize, "ERR failed to read eye_to_head config");
        return;
    }

    // 2. Decompose for validation logging (intrinsic XYZ convention)
    float lPitch = atan2f(leftRot[1][2], leftRot[2][2]) * 180.0f / 3.14159265f;
    float lYaw   = atan2f(-leftRot[0][2], sqrtf(leftRot[0][0]*leftRot[0][0] + leftRot[0][1]*leftRot[0][1])) * 180.0f / 3.14159265f;
    float lRoll  = atan2f(leftRot[0][1], leftRot[0][0]) * 180.0f / 3.14159265f;

    float rPitch = atan2f(rightRot[1][2], rightRot[2][2]) * 180.0f / 3.14159265f;
    float rYaw   = atan2f(-rightRot[0][2], sqrtf(rightRot[0][0]*rightRot[0][0] + rightRot[0][1]*rightRot[0][1])) * 180.0f / 3.14159265f;
    float rRoll  = atan2f(rightRot[0][1], rightRot[0][0]) * 180.0f / 3.14159265f;

    DriverLog("EyeToHead: L pitch=%.2f yaw=%.2f roll=%.2f  R pitch=%.2f yaw=%.2f roll=%.2f\n",
              lPitch, lYaw, lRoll, rPitch, rYaw, rRoll);

    // 3. Build HmdMatrix34_t with preserved rotation + new IPD translation
    vr::HmdMatrix34_t left = {};
    vr::HmdMatrix34_t right = {};
    for (int r = 0; r < 3; r++)
        for (int c = 0; c < 3; c++) {
            left.m[r][c] = leftRot[r][c];
            right.m[r][c] = rightRot[r][c];
        }
    left.m[0][3]  = -ipdMeters / 2.0f;  // Left eye: negative X
    right.m[0][3] = +ipdMeters / 2.0f;  // Right eye: positive X
    // Y and Z translation = 0

    // 4. Call SetDisplayEyeToHead
    vr::VRServerDriverHost()->SetDisplayEyeToHead(
        vr::k_unTrackedDeviceIndex_Hmd, left, right);

    // 5. Also set property for metadata consistency
    vr::PropertyContainerHandle_t hmdProps =
        vr::VRProperties()->TrackedDeviceToPropertyContainer(
            vr::k_unTrackedDeviceIndex_Hmd);
    vr::VRProperties()->SetFloatProperty(hmdProps,
        vr::Prop_UserIpdMeters_Float, ipdMeters);

    m_fCurrentIpd = ipdMeters;

    // 6. Response with diagnostics
    snprintf(response, responseSize,
        "OK ipd=%.1fmm left_yaw=%.2f right_yaw=%.2f left_pitch=%.2f right_pitch=%.2f",
        mm, lYaw, rYaw, lPitch, rPitch);
}
```

### JSON Config Reading (Pseudocode)
```cpp
bool DeviceProvider::ReadEyeToHeadRotation(float leftRot[3][3], float rightRot[3][3])
{
    // 1. Find lighthouse config directory
    //    Read %LOCALAPPDATA%\openvr\openvrpaths.vrpath -> config[0]
    //    List <config_dir>/lighthouse/lhr-*/config.json
    //    (For spike: can hardcode path if needed)

    // 2. Open and parse JSON
    //    Look for tracking_to_eye_transform[0].eye_to_head (left)
    //    and tracking_to_eye_transform[1].eye_to_head (right)

    // 3. Extract 3x3 float arrays
    //    eye_to_head is a 3x3 nested array: [[r00,r01,r02],[r10,r11,r12],[r20,r21,r22]]

    return true; // false on any parse failure
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Property-based IPD (Prop_UserIpdMeters_Float only) | SetDisplayEyeToHead with full matrix | Phase 10 finding | Must call SetDisplayEyeToHead to affect rendering |
| BeyondEyetracking shim hook (IVRServerDriverHost_006) | Direct sidecar SetDisplayEyeToHead call (if it works) | Phase 10.1 spike | Simpler if direct call succeeds; hook is fallback |

**Key discovery from Phase 10:** Once any driver calls SetDisplayEyeToHead, property-based IPD auto-computation is permanently disabled for that session. The lighthouse driver (or BeyondEyetracking shim) does this at init, so the sidecar MUST call SetDisplayEyeToHead to change rendering IPD.

## Critical Unknown: Cross-Driver SetDisplayEyeToHead Permission

The OpenVR documentation says:
- `unWhichDevice` "**must** be a `TrackedDeviceClass_HMD`, and **should** be device index 0" (Driver_API_Documentation.md:1686-1687)
- It does NOT say the caller must be the owning driver
- The header comment says "only permitted on devices of the HMD class" (openvr_driver.h:3804-3805) -- referring to device class, not driver ownership

**Evidence for success:**
- VRServerDriverHost() returns the same shared interface pointer to all drivers
- The sidecar already successfully calls other methods on HMD properties (SetBoolProperty, SetFloatProperty) for device 0
- The device index parameter is explicit -- the API is designed for any driver to specify which device

**Evidence for failure:**
- OpenVR may have internal ownership checks not documented
- The comment "only permitted on devices of the HMD class" could imply additional restrictions
- No documented examples of cross-driver SetDisplayEyeToHead calls

**Confidence: MEDIUM** -- the API design suggests it should work (explicit device index, shared interface), but no definitive documentation confirms cross-driver calls are permitted.

**This is exactly what the spike tests.**

## Open Questions

1. **Does SetDisplayEyeToHead work from a sidecar for device 0 it doesn't own?**
   - What we know: The API takes an explicit device index; the sidecar can write properties to device 0; the function returns void (no error code)
   - What's unclear: Whether the runtime enforces ownership checks internally
   - Recommendation: This is the spike's primary question -- test it empirically

2. **Does the lighthouse config always use index 0 = left eye?**
   - What we know: VAP code treats `tracking_to_eye_transform[0]` as left (positive yaw) and `[1]` as right (negative yaw)
   - What's unclear: Whether this is universal across all Beyond 2 units
   - Recommendation: Validate via Euler decomposition -- check yaw sign to confirm eye assignment

3. **Does the BeyondEyetracking shim re-apply transforms after our SetDisplayEyeToHead call?**
   - What we know: The ET shim hooks IVRServerDriverHost_006 and wraps the HMD
   - What's unclear: Whether it periodically re-applies EyeToHead or only at init
   - Recommendation: Test repeated SetDisplayEyeToHead calls to see if our values "stick"

4. **How to find the correct lighthouse config file?**
   - What we know: Config is under `<steam>/config/lighthouse/lhr-<serial>/config.json`; serial can be read from HMD properties
   - What's unclear: Exact path resolution in all SteamVR installations
   - Recommendation: For spike, try common paths or hardcode. Make robust in Phase 11

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Manual visual testing in HMD + driver log inspection |
| Config file | N/A -- spike uses visual verification |
| Quick run command | `echo eyetohead_set 65 > \\.\pipe\beyond_proximity_ctl` |
| Full suite command | Sequential: `eyetohead_set 48`, `eyetohead_set 55`, `eyetohead_set 63`, `eyetohead_set 75` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SPIKE-01 | SetDisplayEyeToHead changes rendering from sidecar | manual | `beyond_prox_ctl.exe eyetohead_set 48` then `eyetohead_set 75` | N/A |
| SPIKE-02 | Euler angle decomposition extracts ~6.17 deg yaw | log-inspection | Check driver log for yaw values after eyetohead_set | N/A |
| SPIKE-03 | Repeated calls work (live-change, not one-shot) | manual | Multiple eyetohead_set calls in sequence | N/A |

### Sampling Rate
- **Per task commit:** Build + deploy + manual visual test
- **Per wave merge:** Full IPD sweep (48/55/63/75mm) with visual verification
- **Phase gate:** Go/no-go decision based on visual rendering change

### Wave 0 Gaps
None -- this is a manual verification spike. No automated test infrastructure needed.

## Sources

### Primary (HIGH confidence)
- `extern/openvr/headers/openvr_driver.h` line 3806 -- SetDisplayEyeToHead signature, void return, device index parameter
- `extern/openvr/docs/Driver_API_Documentation.md` lines 1684-1689 -- SetDisplayEyeToHead documentation, device class restriction
- `code_samples/vertical_alignment_proximity/alignment.py` lines 72-73, 545-549 -- HORIZONTAL_CANT=6.17, DOWNWARD_CANT=-5, intrinsic XYZ Euler convention
- `.planning/phases/10-feasibility-spike/10-FINDINGS.md` -- Phase 10 results establishing need for SetDisplayEyeToHead

### Secondary (MEDIUM confidence)
- [scipy Rotation.from_euler documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.from_euler.html) -- uppercase XYZ = intrinsic convention
- [Nghia Ho: Decomposing a 3x3 rotation matrix](https://nghiaho.com/?page_id=846) -- atan2-based decomposition formulas
- [Wikipedia: Euler angles](https://en.wikipedia.org/wiki/Euler_angles) -- mathematical foundations, gimbal lock conditions

### Tertiary (LOW confidence)
- Cross-driver SetDisplayEyeToHead permission -- no definitive source; inferred from API design

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- OpenVR API is well-documented, math is textbook
- Architecture: HIGH -- follows established pipe command pattern in existing codebase
- Rotation math: HIGH -- well-established mathematics with clear reference implementation in VAP code
- Cross-driver permission: LOW -- this is the unknown the spike resolves
- Pitfalls: MEDIUM -- based on practical experience with OpenVR coordinate conventions

**Research date:** 2026-03-24
**Valid until:** 2026-04-24 (stable domain -- OpenVR driver API changes infrequently)
