# Phase 12: SteamVR IPD Slider UI - Research

**Researched:** 2026-03-28
**Domain:** SteamVR driver settings UI, OpenVR IVRSettings API, HID flash parsing
**Confidence:** HIGH

## Summary

Phase 12 adds a user-facing IPD slider in SteamVR and ensures Beyond 1 headsets are excluded from all IPD functionality. The phase has two tracks: Track B (native slider via IpdUIRange properties -- spike first) and Track A (settings tab slider via settingsschema.vrsettings -- proven fallback). Phase 10 already showed Track B properties are accepted (err=0) but the slider did not appear; Phase 12 must retry at runtime (not Init) per Phase 11.1 findings and with `DriverDisplaysIPDChanges_Bool=false` (previously tested as `true`).

The settings tab approach (Track A) is well-documented by reference implementations in the vrlink and Shiftall drivers. The driver creates `resources/settings/settingsschema.vrsettings` and `resources/localization/localization.json`, then polls `VRSettings()->GetFloat()` in RunFrame for changes. The HMD serial (tag 0x08) must be added to the TLV parser to distinguish Beyond 1 (BS1 prefix) from Beyond 2.

**Primary recommendation:** Spike Track B first (runtime IpdUIRange properties with DriverDisplaysIPDChanges=false). If it fails, implement Track A (settings tab slider) which is proven and well-understood from reference drivers. Both tracks require the same HMD serial parsing and auto-apply pipeline.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- D-01: Spike native slider first (Track B) -- try setting Prop_IpdUIRangeMinMeters_Float, Prop_IpdUIRangeMaxMeters_Float, and Prop_DriverDisplaysIPDChanges_Bool=false on HMD container at runtime (not Init)
- D-02: Also explore component-based approaches on HMD container (analogous to Phase 11.1 proximity handle probing)
- D-03: If Track B fails, fall back to SteamVR Settings Tab (Track A) using settingsschema.vrsettings with "control": "slider" -- proven by vrlink and Shiftall drivers
- D-04: If using settings tab, also need resources/localization/localization.json for UI labels
- D-05: Auto-apply -- when slider changes IPD, driver automatically calls SetDisplayEyeToHead (reuse existing Phase 11 pipeline)
- D-06: React to ALL VREvent_IpdChanged events, not just slider-originated ones
- D-07: Loop guard needed -- float comparison guard: if new IPD equals m_fCurrentIpd, skip re-application
- D-08: Named pipe ipd command stays fully operational alongside slider
- D-09: If HMD serial starts with BS1, skip ALL slider setup
- D-10: IPD pipe command also disabled for Beyond 1
- D-11: HMD serial is tag 0x08 in user flash (NOT tracking serial 0x09). Must add 0x08 to SigTag enum and CalibrationData struct
- D-12: Serial check at runtime (HMD container not available at Init)

### Claude's Discretion
- Exact spike test methodology for Track B (which properties/components to try, in what order)
- Settings tab slider formatting details (step size, decimal places, label text)
- How to structure the settings change polling (timer interval, event-based vs polling)

### Deferred Ideas (OUT OF SCOPE)
- IPD persistence across restarts (Phase 13)
- Explore /input/system/click handle probing on HMD
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SLIDER-01 | SteamVR IPD slider UI appears in dashboard for Beyond HMD | Track B (native properties) or Track A (settingsschema.vrsettings) -- both researched with reference implementations |
| SLIDER-02 | Driver detects IPD slider changes via VREvent_IpdChanged | VREvent polling already in RunFrame (line 159-171), needs auto-apply extension using HandleIpdSet pipeline |
| HARD-04 | SteamVR IPD slider only enabled when HMD serial does not start with BS1 | Tag 0x08 (HMD_Serial) must be added to TLV parser; serial check gates all IPD functionality |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| OpenVR SDK | 2.5.1 (in extern/) | IVRSettings, IVRProperties, VREvent_IpdChanged | Already integrated, sole API for SteamVR driver interaction |
| SteamVR settingsschema.vrsettings | N/A (JSON file format) | Settings tab UI definition (Track A) | Proven by vrlink and Shiftall drivers, no code dependency |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| localization.json | N/A (JSON file format) | UI label translations for settings tab | Required if Track A is used |

No new library dependencies. All work uses existing OpenVR SDK and SteamVR driver resource conventions.

## Architecture Patterns

### Recommended Project Structure (new files)
```
driver/BeyondProximity/
    driver.vrdrivermanifest    # existing
    resources/
        settings/
            settingsschema.vrsettings   # Track A: slider control definition
        localization/
            localization.json           # Track A: UI label strings
```

### Pattern 1: Settings Tab Slider (Track A)

**What:** Define a slider control in `settingsschema.vrsettings` that appears as a driver settings tab in SteamVR dashboard.

**When to use:** When Track B (native IpdUIRange properties) fails to show the native slider.

**Example (settingsschema.vrsettings):**
```json
[
    {
        "title": "#{BeyondProximity}Settings_Title",
        "values": [
            {
                "name": "/settings/driver_BeyondProximity/ipd_mm",
                "control": "slider",
                "label": "#{BeyondProximity}Setting_ipd_mm",
                "min": 48,
                "max": 75,
                "step": 0.5,
                "decimals": 1
            }
        ]
    }
]
```

Key observations from vrlink reference:
- `name` must use `/settings/driver_{drivername}/{key}` format
- `control`: "slider" renders a draggable slider
- `min`, `max`: numeric bounds
- `step`: increment granularity (0.5mm is reasonable for IPD)
- `decimals`: display precision (1 decimal place for mm)
- `title` and `label` use `#{drivername}key` localization references

**Example (localization.json):**
```json
[
    {
        "language_tag": "en_US",
        "Settings_Title": "Beyond Proximity",
        "Setting_ipd_mm": "IPD (mm)"
    }
]
```

Source: Examined `C:\Program Files (x86)\Steam\steamapps\common\SteamVR\drivers\vrlink\resources\settings\settingsschema.vrsettings` and `C:\Program Files (x86)\Steam\steamapps\common\Shiftall Controller Drivers\resources\localization\localization.json`

### Pattern 2: Settings Polling in RunFrame

**What:** Read the settings value each RunFrame iteration and detect changes.

**When to use:** Track A -- driver must detect when user moves the settings tab slider.

**Example:**
```cpp
// In RunFrame(), after VREvent polling:
{
    vr::EVRSettingsError settingsErr;
    float ipdMm = vr::VRSettings()->GetFloat(
        kSettingsSection, "ipd_mm", &settingsErr);
    if (settingsErr == vr::VRSettingsError_None)
    {
        float ipdMeters = ipdMm / 1000.0f;
        if (fabsf(ipdMeters - m_fCurrentIpd) > 0.0001f)
        {
            // IPD changed via settings slider
            ApplyIpd(ipdMm);
        }
    }
}
```

Source: Existing `VRSettings()->GetInt32()` pattern in `device_provider.cpp:33-49`

### Pattern 3: Native IpdUIRange Properties (Track B Spike)

**What:** Set IPD range properties on HMD container at runtime to trigger SteamVR's built-in IPD slider.

**When to use:** Track B spike -- try this first before falling back to Track A.

**Example:**
```cpp
// At runtime (NOT Init -- HMD container not ready at Init per Phase 11.1):
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, false);
```

Key difference from Phase 10 test: Phase 10 used `DriverDisplaysIPDChanges_Bool=true` (which may mean "driver handles IPD display" and thus SUPPRESSES the native slider). Phase 12 spike must test with `false`.

Source: `extern/openvr/headers/openvr_driver.h:536-544` (property enums), Phase 10 FINDINGS.md

### Pattern 4: HMD Serial Parsing (Tag 0x08)

**What:** Parse tag 0x08 (HMD_Serial) from user flash TLV data, following the same pattern as tag 0x09 (TrackingSerial).

**When to use:** Always -- needed for Beyond 1 vs Beyond 2 detection.

**Example (user_signature.h additions):**
```cpp
enum class SigTag : uint8_t
{
    // ... existing tags ...
    HmdSerial       = 0x08,    // NEW: HMD serial for BS1/BS2 detection
    TrackingSerial  = 0x09,
};

struct CalibrationData
{
    // ... existing fields ...
    char hmd_serial[32] = {};     // NEW: HMD serial from tag 0x08 (e.g. "BS2-XXXXXXXX")
};
```

**Example (user_signature.cpp addition, after tag 0x09 block):**
```cpp
// Tag 0x08: HMD serial (variable-length ASCII string)
if (tag == 0x08 && length < sizeof(cal.hmd_serial))
{
    memcpy(cal.hmd_serial, &sig[ptr + 2], length);
    cal.hmd_serial[length] = '\0';
}
```

Source: `code_samples/proximity_sensor_access/config_editor.py:28` (confirms tag 0x08 = HMD_Serial), existing tag 0x09 parsing in `user_signature.cpp:67-71`

### Pattern 5: Auto-Apply from VREvent_IpdChanged

**What:** Extend the existing VREvent_IpdChanged handler to automatically apply IPD changes using the HandleIpdSet pipeline.

**Example:**
```cpp
if (event.eventType == vr::VREvent_IpdChanged)
{
    float newIpdMeters = event.data.ipd.ipdMeters;
    // Loop guard: skip if already at this IPD
    if (fabsf(newIpdMeters - m_fCurrentIpd) > 0.0001f)
    {
        float mm = newIpdMeters * 1000.0f;
        char response[256];
        HandleIpdSet(mm, response, sizeof(response));
        DriverLog("IPD: Auto-applied from VREvent: %s\n", response);
    }
}
```

Source: Existing handler at `device_provider.cpp:159-171`, HandleIpdSet at `device_provider.cpp:704-764`

### Anti-Patterns to Avoid
- **Setting IpdUIRange properties at Init time:** HMD container is not available at Init (Phase 11.1 constraint). Must defer to runtime.
- **Using DriverDisplaysIPDChanges_Bool=true:** Phase 10 showed this did NOT trigger the slider. The name implies "driver handles IPD display" which likely suppresses the native UI. Try `false` first.
- **Re-applying IPD when value hasn't changed:** HandleIpdSet calls SetDisplayEyeToHead which fires VREvent_IpdChanged. Without a float-comparison guard, this creates an infinite loop.
- **Checking HMD serial at Init:** Serial comes from HID flash which may not be read yet at Init time. Check at runtime, cache the result.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Settings UI | Custom SteamVR overlay with IPD slider | settingsschema.vrsettings (Track A) | SteamVR settings tab is built-in, maintained by Valve, requires zero UI code |
| TLV parsing | New parser for tag 0x08 | Extend existing ParseCalibration in user_signature.cpp | Same pattern as tag 0x09, just add one more if-block |
| IPD application | New IPD pipeline | Refactor HandleIpdSet into reusable ApplyIpd helper | Avoids duplicating matrix math, validation, SetDisplayEyeToHead call |

**Key insight:** The settings tab approach (Track A) is zero-code for the UI -- the entire slider is defined in two JSON files. The only code needed is polling `VRSettings()->GetFloat()` in RunFrame.

## Common Pitfalls

### Pitfall 1: VREvent_IpdChanged Feedback Loop
**What goes wrong:** SetDisplayEyeToHead + SetFloatProperty fires VREvent_IpdChanged, which triggers auto-apply, which fires VREvent_IpdChanged again -- infinite loop.
**Why it happens:** The driver both produces and consumes IPD change events.
**How to avoid:** Float comparison guard: `if (fabsf(newIpd - m_fCurrentIpd) > 0.0001f)`. Update `m_fCurrentIpd` BEFORE calling SetDisplayEyeToHead.
**Warning signs:** Log spam showing repeated "Auto-applied" messages, high CPU usage in RunFrame.

### Pitfall 2: HMD Serial vs Tracking Serial Confusion
**What goes wrong:** Using tag 0x09 (TrackingSerial, e.g. "LHR-XXXXXXXX") instead of tag 0x08 (HMD_Serial, e.g. "BS1-..." or "BS2-...") for Beyond 1 detection.
**Why it happens:** The driver already parses tag 0x09 and stores it as `m_sTrackingSerial`. Tag 0x08 is not yet parsed.
**How to avoid:** Add tag 0x08 parsing to CalibrationData. The BS1/BS2 prefix is on the HMD serial (0x08), NOT the tracking serial (0x09).
**Warning signs:** Beyond 1 detection never triggers because tracking serial starts with "LHR-", not "BS1".

### Pitfall 3: Init-Time HMD Container Access
**What goes wrong:** Trying to set IpdUIRange properties or check HMD serial in Init() -- fails silently.
**Why it happens:** Phase 11.1 established that HMD container is not available during Init(). Property writes return err=0 but have no effect.
**How to avoid:** Use deferred pattern (like TryCreateProximityComponent) -- attempt in RunFrame, set a flag once done.
**Warning signs:** Properties set successfully (err=0) but no slider appears; works on second SteamVR restart.

### Pitfall 4: Settings Value Not Initialized
**What goes wrong:** First RunFrame poll reads default value (0.0) from settings, triggers IPD change to 0mm.
**Why it happens:** settingsschema.vrsettings defines the UI but not the initial value. VRSettings returns 0.0 if key doesn't exist.
**How to avoid:** Either set a default in a `default.vrsettings` file in `resources/settings/`, or guard against values outside valid range (48-75mm) in the polling code.
**Warning signs:** IPD resets to 0 or minimum on driver startup.

### Pitfall 5: Settings Tab Not Appearing
**What goes wrong:** settingsschema.vrsettings file exists but no settings tab appears in SteamVR.
**Why it happens:** File path wrong, JSON syntax error, or driver section name mismatch.
**How to avoid:** File must be at `driver/BeyondProximity/resources/settings/settingsschema.vrsettings`. Section name in the `name` field must match `kSettingsSection` = `"driver_BeyondProximity"`. Validate JSON syntax. Restart SteamVR after adding the file.
**Warning signs:** No error message -- settings tab simply doesn't appear.

## Code Examples

### Complete settingsschema.vrsettings (Track A)
```json
[
    {
        "title": "#{BeyondProximity}Settings_Title",
        "values": [
            {
                "name": "/settings/driver_BeyondProximity/ipd_mm",
                "control": "slider",
                "label": "#{BeyondProximity}Setting_ipd_mm",
                "min": 48,
                "max": 75,
                "step": 0.5,
                "decimals": 1
            }
        ]
    }
]
```
Source: Adapted from vrlink settingsschema.vrsettings slider pattern

### Complete localization.json (Track A)
```json
[
    {
        "language_tag": "en_US",
        "Settings_Title": "Beyond Proximity",
        "Setting_ipd_mm": "IPD (mm)"
    }
]
```
Source: Adapted from Shiftall localization.json pattern

### Beyond 1 Detection Guard
```cpp
// In DeviceProvider, add member:
bool m_bIsBeyond1 = false;
bool m_bHmdSerialChecked = false;

// In RunFrame or deferred init:
void DeviceProvider::CheckHmdSerial()
{
    if (m_bHmdSerialChecked) return;
    if (!m_pHidDevice) return;

    CalibrationData cal = m_pHidDevice->GetCalibration();
    if (cal.hmd_serial[0] == '\0') return;  // not yet read

    m_bHmdSerialChecked = true;
    m_bIsBeyond1 = (strncmp(cal.hmd_serial, "BS1", 3) == 0);

    DriverLog("HMD Serial: %s -> %s\n", cal.hmd_serial,
              m_bIsBeyond1 ? "Beyond 1 (IPD disabled)" : "Beyond 2 (IPD enabled)");
}
```

### Refactored ApplyIpd (shared by pipe command and auto-apply)
```cpp
// Extract core logic from HandleIpdSet into reusable method
bool DeviceProvider::ApplyIpd(float mm)
{
    if (mm < 48.0f || mm > 75.0f) return false;
    if (!m_bLhConfigLoaded && !LoadLighthouseConfig()) return false;

    float ipdMeters = mm / 1000.0f;

    // Build matrices (same as HandleIpdSet)
    vr::HmdMatrix34_t left = {}, right = {};
    for (int r = 0; r < 3; r++)
        for (int c = 0; c < 3; c++) {
            left.m[r][c] = m_cachedLeftRot[r][c];
            right.m[r][c] = m_cachedRightRot[r][c];
        }
    left.m[0][3]  = -ipdMeters / 2.0f;
    right.m[0][3] = +ipdMeters / 2.0f;

    vr::VRServerDriverHost()->SetDisplayEyeToHead(
        vr::k_unTrackedDeviceIndex_Hmd, left, right);

    vr::PropertyContainerHandle_t hmdProps =
        vr::VRProperties()->TrackedDeviceToPropertyContainer(
            vr::k_unTrackedDeviceIndex_Hmd);
    vr::VRProperties()->SetFloatProperty(hmdProps,
        vr::Prop_UserIpdMeters_Float, ipdMeters);

    m_fCurrentIpd = ipdMeters;
    return true;
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Prop_UserIpdMeters_Float only | SetDisplayEyeToHead + property | Phase 10/11 | Property alone does not change rendering when eye_to_head matrices are set |
| DriverDisplaysIPDChanges=true | DriverDisplaysIPDChanges=false | Phase 12 spike | Phase 10 tested true; false may enable native slider |
| Init-time HMD property writes | Runtime deferred writes | Phase 11.1 | HMD container not ready at Init |

## Open Questions

1. **Will Track B (native IpdUIRange + DriverDisplaysIPDChanges=false) work at runtime?**
   - What we know: Phase 10 tested with DriverDisplaysIPDChanges=true at runtime and slider did not appear. Properties were accepted (err=0).
   - What's unclear: Whether `false` makes the difference, or whether a sidecar fundamentally cannot trigger the native slider.
   - Recommendation: Quick spike in first plan. If it fails, immediately proceed to Track A.

2. **Does the settings tab slider fire VREvent_IpdChanged?**
   - What we know: The native IPD slider fires VREvent_IpdChanged. Settings tab changes are just VRSettings key updates.
   - What's unclear: Whether changing `driver_BeyondProximity/ipd_mm` in settings fires any VR event.
   - Recommendation: For Track A, poll VRSettings in RunFrame rather than relying on events. The polling approach works regardless.

3. **What is the actual HMD serial format for Beyond 1?**
   - What we know: Decision D-09 says "starts with BS1". Tag 0x08 is HMD_Serial.
   - What's unclear: Exact format (BS1-XXXXXXXX? BS1XXXXXXXX?). Only verifiable with a Beyond 1 unit.
   - Recommendation: Use `strncmp(serial, "BS1", 3) == 0` prefix check -- handles any suffix format.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Manual testing via beyond_prox_ctl.exe CLI + SteamVR visual verification |
| Config file | N/A (no automated test framework) |
| Quick run command | `beyond_prox_ctl.exe status` |
| Full suite command | `beyond_prox_ctl.exe ipd? && beyond_prox_ctl.exe status` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SLIDER-01 | IPD slider appears in SteamVR dashboard | manual | Visual check in SteamVR dashboard settings | N/A |
| SLIDER-02 | Driver detects IPD slider changes | manual + CLI | `beyond_prox_ctl.exe ipd?` after moving slider | Exists |
| HARD-04 | Slider disabled for Beyond 1 (BS1 serial) | manual | Check log output for "Beyond 1 (IPD disabled)" | N/A |

### Sampling Rate
- **Per task commit:** Build driver, deploy to SteamVR driver directory, restart SteamVR, verify with CLI
- **Per wave merge:** Full manual test: slider appears, slider changes IPD, pipe command still works, Beyond 1 exclusion (if Beyond 1 hardware available)
- **Phase gate:** All three requirements manually verified before `/gsd:verify-work`

### Wave 0 Gaps
- None -- no automated test infrastructure needed. This phase is inherently manual (SteamVR UI interaction).

## Sources

### Primary (HIGH confidence)
- `extern/openvr/headers/openvr_driver.h:536-544` -- IpdUIRange and DriverDisplaysIPDChanges property enums
- `extern/openvr/headers/openvr_driver.h:2337-2356` -- IVRSettings interface (GetFloat, SetFloat, etc.)
- `C:\Program Files (x86)\Steam\steamapps\common\SteamVR\drivers\vrlink\resources\settings\settingsschema.vrsettings` -- Reference slider control implementation
- `C:\Program Files (x86)\Steam\steamapps\common\Shiftall Controller Drivers\resources\settings\settingsschema.vrsettings` -- Reference toggle control with localization
- `C:\Program Files (x86)\Steam\steamapps\common\Shiftall Controller Drivers\resources\localization\localization.json` -- Reference localization format
- `code_samples/proximity_sensor_access/config_editor.py:19-29` -- SigTag enum confirming tag 0x08 = HMD_Serial
- `.planning/phases/10-feasibility-spike/10-FINDINGS.md` -- FEAS-03 slider test results

### Secondary (MEDIUM confidence)
- Phase 10 FEAS-03 analysis of why slider didn't appear (multiple hypotheses, not conclusively tested)
- DriverDisplaysIPDChanges_Bool semantics inferred from property name (not officially documented)

### Tertiary (LOW confidence)
- None

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- using only existing OpenVR SDK, no new dependencies
- Architecture: HIGH -- settingsschema.vrsettings format verified from two reference drivers on disk, TLV parsing pattern already established
- Pitfalls: HIGH -- feedback loop, Init-time failure, and serial confusion are all documented from prior phase findings

**Research date:** 2026-03-28
**Valid until:** 2026-04-28 (stable -- OpenVR driver API changes infrequently)

## Project Constraints (from CLAUDE.md)

- Windows environment only -- use Windows-compatible terminal commands
- cmake.exe path: `"C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe"`
- Build command: `"C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" --build build --config Release`
- Owl messaging: Start `/owl listen` session, assign IDs to subagents
