# Phase 13: Slider Persistence and Hardening - Research

**Researched:** 2026-03-28
**Domain:** SteamVR lighthouse_console process spawning (C++17/Win32), JSON config manipulation, CMake compile flags
**Confidence:** HIGH

## Summary

Phase 13 persists IPD changes to the headset's lighthouse flash config so they survive SteamVR restarts. The proven mechanism is lighthouse_console.exe's interactive stdin: (1) `serial <serial>` to select the device, (2) `downloadconfig <file>` to get current config JSON from headset flash, (3) modify `ipd.default_mm` in the JSON, (4) `uploadconfig <file>` to write back. Two authoritative C++ and Python reference implementations exist in the project's `code_samples/`.

The Valve Index IPD persistence was investigated per D-05. The Index uses a physical mechanical slider with an analog sensor -- the lighthouse driver reads the physical lens position directly at startup. There is no SteamVR-native software persistence mechanism. The Beyond 2 has software-only IPD, so lighthouse_console `uploadconfig` is the correct and only approach for headset flash persistence. The tracking disruption from lighthouse_console (~20s) is irrelevant since persistence runs only at SteamVR shutdown (Cleanup).

The implementation hooks into `Cleanup()`, spawns lighthouse_console.exe via Win32 `CreateProcess` with stdin/stdout pipes, executes the download-modify-upload cycle, and is entirely wrapped in `#ifdef ENABLE_IPD_PERSIST`. The driver already has all prerequisite state: `m_sTrackingSerial` (device serial), `m_fCurrentIpd` (current value), and `ExtractVrPathValue()` (openvrpaths.vrpath parsing for runtime path discovery).

**Primary recommendation:** Implement as a single new method `PersistIpdToConfig()` called from `Cleanup()`, adapting the HMDUtility's proven `write_to_stdin()` CreateProcess+pipe pattern. Add `m_fStartupIpd` to track whether IPD changed during the session. All code behind `ENABLE_IPD_PERSIST` CMake option.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- D-01: Persist IPD on SteamVR shutdown only (VREvent_Quit / Cleanup), not on every slider change
- D-02: Persist from any IPD source (slider and pipe command), not just slider-originated changes
- D-03: Only persist if IPD actually changed during the session (compare against startup value)
- D-04: Use lighthouse_console.exe `uploadconfig` to write to headset flash -- config.json on disk is just a backup never actively read by anything
- D-05: Research alternative persistence methods that avoid tracking disruption -- the Valve Index persists IPD without tracking loss; investigate how SteamVR/lighthouse driver handles this (SetFloatProperty persistence? steamvr.vrsettings? direct flash protocol?) before committing to lighthouse_console
- D-06: lighthouse_console located via openvrpaths.vrpath -> runtime key -> `tools/lighthouse/bin/win64/lighthouse_console.exe` (same file already read for config dir in Phase 11)
- D-07: Blocking CreateProcess with short timeout (~10s) -- Cleanup() has a window before vrserver exits
- D-08: Log success/failure of persistence but never block SteamVR shutdown (per HARD-03)
- D-09: Use tracking/lighthouse serial (m_sTrackingSerial from Prop_SerialNumber_String) for lighthouse_console device identification -- same serial used for lhr-<serial> folder matching
- D-10: All persistence code wrapped in `#ifdef ENABLE_IPD_PERSIST` preprocessor guard
- D-11: CMake option `ENABLE_IPD_PERSIST` defaulting to ON, easily toggled OFF
- D-12: If the Beyond Utility app takes over persistence, this entire feature can be compiled out cleanly

### Claude's Discretion
- Exact lighthouse_console command-line arguments (research needed)
- How to update default_mm in the config payload for uploadconfig
- Error message wording for persistence failures
- Whether to also update config.json on disk as a secondary backup
- Internal code organization for the persistence path

### Deferred Ideas (OUT OF SCOPE)
- Beyond Utility app handling all IPD persistence externally -- may supersede this entire phase; build flag isolation covers this scenario
- Direct HID flash write protocol (bypassing lighthouse_console entirely) -- potential future optimization if tracking disruption is unacceptable
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SLIDER-03 | Slider-initiated IPD changes persist to headset lighthouse config (`default_mm` field) via lighthouse_console | Full download-modify-upload workflow documented from two reference implementations; `ipd.default_mm` JSON field confirmed in both HMDUtility C++ and VAP Python code |
| SLIDER-04 | Driver reads headset lighthouse serial from HID config data for lighthouse_console invocation | Already implemented: `m_sTrackingSerial` populated from HID user flash in Phase 11; reuse directly for `serial <serial>` command to lighthouse_console |
| HARD-03 | Lighthouse_console persistence failures are logged but do not block live IPD change | Persistence runs only at shutdown (Cleanup) after all live IPD changes already applied; any failure logged via DriverLog and function returns; timeout prevents hanging |
</phase_requirements>

## D-05 Resolution: Alternative Persistence Methods

**Conclusion: No viable alternative exists. lighthouse_console uploadconfig is the correct approach.**

| Method Investigated | Result | Confidence |
|---------------------|--------|------------|
| Valve Index approach | Index uses physical mechanical IPD slider with analog sensor -- lighthouse driver reads hardware position directly at startup. Not applicable to software-only IPD. | HIGH |
| SetFloatProperty persistence | Properties are runtime-only in-memory state. SteamVR does not persist `Prop_UserIpdMeters_Float` to any config on shutdown. | HIGH |
| steamvr.vrsettings ipdOffset | This is a user-facing offset applied on top of the hardware IPD. The lighthouse driver reads `ipd.default_mm` from headset flash, not from vrsettings. | HIGH |
| steamvr.vrsettings driver_lighthouse section | Does not contain IPD fields. Contains disambiguation, base station, and sensor settings only. | HIGH |
| Direct HID flash protocol | Beyond's ATSAMG55 has HID commands (0x57/0x56) for its own firmware config, but this is NOT the lighthouse calibration config stored in the Tundra SiP. Deferred per CONTEXT.md. | MEDIUM |
| Tundra SiP native persistence (SEED-001) | No public documentation found. SiP stores calibration data in its flash, which is what lighthouse_console reads/writes. No API for programmatic writes without lighthouse_console. | LOW |

**Tracking disruption note:** lighthouse_console takes over USB connection causing ~20s tracking loss. Since persistence runs only at SteamVR shutdown (Cleanup), tracking disruption is irrelevant -- tracking is already stopping.

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Win32 CreateProcess API | N/A (System) | Spawn lighthouse_console.exe with stdin/stdout pipes | Proven pattern from HMDUtility; no external dependencies |
| OpenVR Driver API | IVRServerDriverHost | Cleanup() hook for shutdown persistence | Only hook point for SteamVR shutdown |
| C++17 standard library | std::filesystem, std::ifstream/ofstream | File I/O, temp path, string manipulation | Already used throughout driver |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| CMake | 3.20+ | ENABLE_IPD_PERSIST option and compile definition | Build isolation per D-10/D-11 |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| CreateProcess interactive stdin | Command-line args to lighthouse_console | lighthouse_console is interactive-only (no CLI arg mode); stdin is the only interface |
| String-based JSON editing (find/replace default_mm) | Full JSON parser (nlohmann/json) | String replacement is simpler, proven by HMDUtility, avoids new dependency; safe because we only modify one numeric field |
| Temp file for config staging | In-memory pipe | Config must be written to disk for lighthouse_console to read from its `downloadconfig`/`uploadconfig` file path arguments |

## Architecture Patterns

### Recommended Code Structure

All new code goes in existing files:

```
src/driver/
  device_provider.h    -- Add: m_fStartupIpd, PersistIpdToConfig(), FindLighthouseConsole()
  device_provider.cpp  -- Add: PersistIpdToConfig() implementation, Cleanup() hook
CMakeLists.txt         -- Add: ENABLE_IPD_PERSIST option
```

### Pattern 1: lighthouse_console Interactive Stdin Spawning

**What:** Spawn lighthouse_console.exe, write commands to its stdin via pipe, read output from stdout pipe.
**When to use:** Any lighthouse_console operation (download/upload config).
**Source:** `code_samples/HMDUtility_source/src/utils.cpp` lines 231-350

The proven flow from HMDUtility:
1. Create stdin/stdout pipes with `CreatePipe` + `SECURITY_ATTRIBUTES` (inherit handles)
2. `SetHandleInformation` on parent-side handles to prevent inheritance
3. `CreateProcess` with `STARTF_USESTDHANDLES`, `CREATE_NO_WINDOW`
4. Write `"serial <serial>\r\n"` to stdin pipe
5. Write command (e.g., `"downloadconfig <file>\r\n"`) to stdin pipe
6. Read stdout to consume output / wait for completion
7. Close stdin pipe to signal EOF, wait for process exit with timeout

```cpp
// Source: Adapted from HMDUtility utils.cpp:231-300
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE hStdinRead, hStdinWrite, hStdoutRead, hStdoutWrite;
CreatePipe(&hStdinRead, &hStdinWrite, &sa, 0);
CreatePipe(&hStdoutRead, &hStdoutWrite, &sa, 0);
SetHandleInformation(hStdinWrite, HANDLE_FLAG_INHERIT, 0);  // Parent-side, don't inherit
SetHandleInformation(hStdoutRead, HANDLE_FLAG_INHERIT, 0);  // Parent-side, don't inherit

STARTUPINFOA si = {};
si.cb = sizeof(si);
si.hStdInput = hStdinRead;
si.hStdOutput = hStdoutWrite;
si.hStdError = hStdoutWrite;
si.dwFlags = STARTF_USESTDHANDLES;

PROCESS_INFORMATION pi = {};
if (!CreateProcessA(exePath.c_str(), NULL, NULL, NULL, TRUE,
    CREATE_NO_WINDOW, NULL, NULL, &si, &pi))
{
    DriverLog("IPD Persist: CreateProcess failed (err=%lu)\n", GetLastError());
    // cleanup handles...
    return;
}
// Close child-side handles in parent (child has its own copies)
CloseHandle(hStdinRead);
CloseHandle(hStdoutWrite);

// Write commands
DWORD written;
WriteFile(hStdinWrite, serialCmd.c_str(), (DWORD)serialCmd.size(), &written, NULL);
WriteFile(hStdinWrite, downloadCmd.c_str(), (DWORD)downloadCmd.size(), &written, NULL);

// Wait with timeout (D-07: ~10s)
DWORD waitResult = WaitForSingleObject(pi.hProcess, 10000);
if (waitResult == WAIT_TIMEOUT)
{
    DriverLog("IPD Persist: Timeout, terminating lighthouse_console\n");
    TerminateProcess(pi.hProcess, 1);
}
```

### Pattern 2: Download-Modify-Upload Config Cycle

**What:** Download current config from headset flash, modify `ipd.default_mm`, upload back.
**When to use:** IPD persistence at shutdown.

```
1. Locate lighthouse_console via ExtractVrPathValue(content, "runtime")
2. Create temp file path: GetTempPathA() + "beyond_ipd_config.json"
3. Spawn lighthouse_console
4. Send: "serial <serial>\r\n"
5. Send: "downloadconfig <absolute_tempfile_path>\r\n"
6. Wait for file to appear (poll ~500ms intervals, 5s max)
7. Read file content as string
8. Find "default_mm" : <value>, replace value with new IPD
9. Write modified content back to a second temp file
10. Send: "uploadconfig <absolute_tempfile2_path>\r\n"
11. Wait ~3s for upload completion
12. Close stdin (signals EOF to lighthouse_console)
13. WaitForSingleObject with remaining timeout
14. Close all handles, delete temp files
```

### Pattern 3: Startup IPD Tracking for Change Detection (D-03)

**What:** Record IPD at startup, compare at shutdown to decide whether persistence is needed.
**When to use:** Avoiding unnecessary flash writes every session.

```cpp
// In device_provider.h:
float m_fStartupIpd = 0.0f;  // IPD at session start, for D-03 change detection

// In Init(), after reading initial IPD (line ~84):
m_fStartupIpd = m_fCurrentIpd;  // Snapshot for comparison

// In Cleanup():
if (m_fCurrentIpd <= 0.0f || m_fStartupIpd <= 0.0f ||
    fabsf(m_fCurrentIpd - m_fStartupIpd) < 0.0001f)
{
    DriverLog("IPD Persist: No change during session, skipping\n");
    return;
}
```

### Pattern 4: Cleanup() Integration Order

**Critical ordering:** Persistence MUST happen before `CleanupDriverLog()` so failures can be logged, and before `hid_exit()` (though lighthouse_console uses its own USB connection, not HIDAPI).

```cpp
void DeviceProvider::Cleanup()
{
#ifdef ENABLE_IPD_PERSIST
    PersistIpdToConfig();  // D-01: persist on shutdown only, before log cleanup
#endif
    DestroyPipeServer();
    if (m_pHidDevice)
        m_pHidDevice->StopReading();
    m_pHidDevice.reset();
    hid_exit();
    CleanupDriverLog();
}
```

### Anti-Patterns to Avoid
- **Using lighthouse_console command-line arguments:** lighthouse_console is interactive-only; it has NO command-line argument mode. All commands must go through stdin.
- **Writing config.json on disk without uploadconfig:** The config.json in `<config_dir>/lighthouse/lhr-XXXX/` is a cached copy. The authoritative source is the headset's flash memory. Writing to disk only would NOT persist across reinstalls or when SteamVR re-downloads from the headset.
- **Persisting on every slider change:** Would cause tracking disruption during use and excessive flash wear. Shutdown-only per D-01.
- **Using a JSON parser library:** Project convention is manual string parsing (Phase 10.1 decision). The `default_mm` replacement is simple string find-replace.
- **Using relative paths for temp files:** lighthouse_console writes files relative to its CWD. In a driver context, CWD is unpredictable. Always use absolute paths from `GetTempPathA()`.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Process spawning with piped stdin | Raw CreateProcess from scratch | Adapt HMDUtility `write_to_stdin()` pattern exactly | Edge cases with handle inheritance and pipe buffering; proven reference handles them |
| JSON default_mm modification | Full JSON parser | String find-replace (HMDUtility pattern, utils.cpp:1246) | Project convention, avoids nlohmann/json dependency for one field |
| lighthouse_console.exe path discovery | Hardcoded paths | `ExtractVrPathValue(content, "runtime")` | Already implemented and tested for `"config"` key in Phase 11 |
| Temp file management | Manual path construction | `GetTempPathA()` Win32 API | Handles cross-user temp dir, avoids driver CWD issues |

**Key insight:** The HMDUtility C++ code (`code_samples/HMDUtility_source/src/utils.cpp`) is the authoritative reference for lighthouse_console invocation from C++. It handles all Win32 pipe setup edge cases. Follow its `write_to_stdin()` pattern closely.

## lighthouse_console Command Reference

**Confidence: HIGH** -- Verified from HMDUtility C++ and VAP Python references.

### Commands (sent via stdin, terminated with `\r\n`)

| Command | Purpose | Response |
|---------|---------|----------|
| `serial` | List attached devices | "Attached lighthouse receiver devices: N" + serial list |
| `serial <serial>` | Select device by serial | Silent if successful; "No connected lighthouse device found." on failure |
| `downloadconfig <filepath>` | Download config from device flash to file | File written to disk; poll for existence |
| `uploadconfig <filepath>` | Upload config from file to device flash | "Compressed config size is XXXX Wrote the contents of YY as the config on the device" |

### Config JSON Structure (IPD section)

The `ipd` object in the headset's lighthouse config:

```json
{
    "ipd": {
        "default_mm": 63.0,
        "enable": true,
        "high_mm": 65.00031,
        "low_mm": 65.00031,
        "minstep_mm": 2
    }
}
```

**Important note:** The sample configs in the repo (`lighthouse_config_new.json`, `lighthouse_config_backup.json`) show `high_mm`/`low_mm` but NOT `default_mm`. The HMDUtility code explicitly searches for `"default_mm"` (utils.cpp line 1246) and shows an error dialog if not found. The VAP tool writes `config['ipd']['default_mm'] = ipd` (steamvr.py line 74). This field exists in configs downloaded fresh from the headset but may have been stripped from the sample files. **The driver should handle the case where `default_mm` is missing** (log warning, skip persistence).

### Runtime Path Discovery

From `openvrpaths.vrpath` (verified on this machine):
```json
"runtime" : ["C:\\Program Files (x86)\\Steam\\steamapps\\common\\SteamVR"]
```

lighthouse_console path: `<runtime>\tools\lighthouse\bin\win64\lighthouse_console.exe`

Reuse existing `ExtractVrPathValue(content, "runtime")` -- same function already used for extracting the `"config"` key.

## Common Pitfalls

### Pitfall 1: Absolute Paths for Temp Files
**What goes wrong:** lighthouse_console writes downloaded config files relative to its CWD. In a driver context, CWD is the vrserver directory, which may not be writable or predictable.
**Why it happens:** `downloadconfig config.json` writes to CWD by default. HMDUtility runs from its own directory so relative paths work.
**How to avoid:** Always use absolute paths from `GetTempPathA()` for both download and upload config files.
**Warning signs:** Downloaded config file not appearing where expected.

### Pitfall 2: Missing default_mm Field
**What goes wrong:** The downloaded config may not contain the `"default_mm"` field.
**Why it happens:** Different firmware versions, or field was never set on this headset.
**How to avoid:** If `"default_mm"` not found via string search, log a warning and skip persistence. Do NOT try to insert the field -- modifying JSON structure with string manipulation is error-prone. HMDUtility shows an error dialog in this case.
**Warning signs:** `config_string.find("\"default_mm\"")` returns `std::string::npos`.

### Pitfall 3: Handle Inheritance Flags
**What goes wrong:** Pipe handles not inheritable causes child to get invalid handles. Or parent-side handles leaking into child prevents EOF detection.
**Why it happens:** CreatePipe creates both ends inheritable by default; must selectively remove inheritance on parent-side handles.
**How to avoid:** Call `SetHandleInformation(parentHandle, HANDLE_FLAG_INHERIT, 0)` on `hStdinWrite` and `hStdoutRead` (the parent-side handles). Leave `hStdinRead` and `hStdoutWrite` (child-side) inheritable.
**Warning signs:** Child process hangs indefinitely, or CreateProcess fails with invalid handle.

### Pitfall 4: Cleanup() Timing Window
**What goes wrong:** vrserver may terminate the driver process before lighthouse_console completes the upload.
**Why it happens:** Cleanup() has a limited time window. The download-modify-upload cycle takes 3-10 seconds.
**How to avoid:** 10s timeout per D-07 with `WaitForSingleObject`. If timeout, `TerminateProcess` the child and log failure. Per HARD-03, this is acceptable.
**Warning signs:** Process terminated mid-upload. Next session would show the old IPD value.

### Pitfall 5: lighthouse_console Needs USB Device Visible
**What goes wrong:** If the headset was disconnected before SteamVR shutdown, lighthouse_console cannot connect to it.
**Why it happens:** lighthouse_console opens USB HID handles directly to the headset hardware.
**How to avoid:** Check stdout for "No connected lighthouse device found." Log the failure and skip (HARD-03). The live IPD change already took effect for the session.
**Warning signs:** lighthouse_console sits at prompt without finding device.

### Pitfall 6: Float Formatting Precision
**What goes wrong:** `std::to_string(63.0f)` produces `"63.000000"` (six decimal places) which differs from the original format.
**Why it happens:** C++ default float-to-string formatting.
**How to avoid:** Use `snprintf(buf, sizeof(buf), "%.1f", ipdMm)` to match the one-decimal-place format seen in real configs (e.g., `65.0`).
**Warning signs:** Config file has unexpectedly long numeric values.

### Pitfall 7: Closing Child-Side Handles in Parent
**What goes wrong:** If parent keeps child-side pipe handles open, the child process may not detect EOF/close properly.
**Why it happens:** CreateProcess duplicates inheritable handles into the child. Parent must close its copies.
**How to avoid:** After `CreateProcess`, immediately close `hStdinRead` and `hStdoutWrite` in the parent. HMDUtility does NOT do this (it closes them later), but best practice is to close immediately.
**Warning signs:** Child process hangs waiting for more stdin input.

## Code Examples

### default_mm String Replacement (from HMDUtility)

```cpp
// Source: code_samples/HMDUtility_source/src/utils.cpp:1246-1263
// Proven pattern for modifying ipd.default_mm without a JSON parser
bool ReplaceDefaultMm(std::string& configStr, float newIpdMm)
{
    size_t pos = configStr.find("\"default_mm\"");
    if (pos == std::string::npos) return false;

    size_t colonPos = configStr.find(":", pos);
    if (colonPos == std::string::npos) return false;

    size_t valueStart = colonPos + 1;
    while (valueStart < configStr.size() && isspace(configStr[valueStart]))
        valueStart++;

    size_t valueEnd = valueStart;
    while (valueEnd < configStr.size() &&
           (isdigit(configStr[valueEnd]) || configStr[valueEnd] == '.' || configStr[valueEnd] == '-'))
        valueEnd++;

    char buf[32];
    snprintf(buf, sizeof(buf), "%.1f", newIpdMm);
    configStr.replace(valueStart, valueEnd - valueStart, buf);
    return true;
}
```

### lighthouse_console Python Reference

```python
# Source: code_samples/vertical_alignment_proximity/dist/main/_internal/steamvr/lighthouse_console.py
# Complete workflow:
lhc = LighthouseConsole.create()        # Popen([path], stdout=PIPE, stdin=PIPE)
lhc.select_device(serial)              # Writes "serial LHR-XXXXX\r\n" to stdin
config = lhc.download_config()         # downloadconfig to temp file, reads JSON
config['ipd']['default_mm'] = 65.0     # Modify in memory (steamvr.py:74)
lhc.upload_config(config)              # Saves to temp file, writes "uploadconfig temp.json\r\n"
lhc.close()                            # Closes stdin, kills process
```

### CMake Option

```cmake
# IPD persistence via lighthouse_console (can be disabled if Beyond Utility handles persistence)
option(ENABLE_IPD_PERSIST "Enable IPD persistence to lighthouse config on shutdown" ON)
if(ENABLE_IPD_PERSIST)
    target_compile_definitions(${DRIVER_NAME} PRIVATE ENABLE_IPD_PERSIST)
endif()
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| lighthouse_console for config reading | Direct file read from lhr-*/config.json | Phase 11 | Reading avoids tracking disruption; writing still needs lighthouse_console |
| Persisting on every IPD change | Persist only at shutdown | Phase 13 design | Avoids tracking disruption during use and flash wear |

**Key finding from D-05 research:** The Valve Index does NOT have a software-based IPD persistence mechanism. Its IPD is read from a physical potentiometer/sensor by the lighthouse driver at startup. There is no SteamVR API for "persist this property to headset config." lighthouse_console uploadconfig is the only path for software-only IPD persistence.

## Open Questions

1. **Cleanup() timing window reliability**
   - What we know: Cleanup() is called by vrserver during shutdown. lighthouse_console connects via USB HID independently of SteamVR.
   - What's unclear: Exact time budget available. Whether USB handles are still valid when Cleanup() runs.
   - Recommendation: 10s timeout per D-07. Log and skip on failure (HARD-03). Test empirically.

2. **default_mm presence across firmware versions**
   - What we know: HMDUtility searches for it and errors if missing. VAP tool writes it. Sample configs in repo don't have it.
   - What's unclear: Whether all Beyond 2 units have this field in their flash config.
   - Recommendation: Handle missing field gracefully -- log warning, skip persistence. Don't try to insert.

3. **Config validation before upload**
   - What we know: HMDUtility validates downloaded config contains `"device_class": "hmd"` and `"direct_mode_edid_vid": "BIG"` to ensure correct device.
   - What's unclear: Whether this validation is necessary when serial selection is correct.
   - Recommendation: Add basic validation (check for `"device_class"` or `"ipd"` section presence) before uploading modified config. Low cost, prevents edge case corruption.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| lighthouse_console.exe | Config upload to headset flash | Yes (verified) | At SteamVR runtime path | Skip persistence, log warning (HARD-03) |
| openvrpaths.vrpath | Runtime path discovery | Yes (verified) | At %LOCALAPPDATA%\openvr\ | -- |
| Win32 CreateProcess API | Process spawning | Yes | System API | -- |
| GetTempPathA | Temp file staging | Yes | System API | -- |
| CMake 3.20+ | Build option | Yes | Via VS2022 BuildTools | -- |

**Missing dependencies with no fallback:** None
**Missing dependencies with fallback:** lighthouse_console.exe at runtime (log and skip per HARD-03)

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Manual hardware testing + driver log inspection |
| Config file | N/A -- hardware-dependent validation |
| Quick run command | `beyond_prox_ctl.exe "ipd 60"` then restart SteamVR, check `beyond_prox_ctl.exe "ipd?"` |
| Full suite command | Set IPD via slider, restart SteamVR, verify IPD persisted; repeat with ENABLE_IPD_PERSIST=OFF |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SLIDER-03 | IPD change persists to headset config | manual | Set IPD via slider, restart SteamVR, verify `ipd?` returns same value | N/A |
| SLIDER-04 | Driver uses tracking serial for lighthouse_console | log-inspection | Check driver log for "serial <serial>" in persistence output | N/A |
| HARD-03 | Persistence failure is non-blocking | manual | Disconnect headset USB before shutdown, verify SteamVR exits cleanly with error in log | N/A |

### Sampling Rate
- **Per task commit:** Build succeeds with both `ENABLE_IPD_PERSIST=ON` and `ENABLE_IPD_PERSIST=OFF`
- **Per wave merge:** Full persistence cycle verified with headset hardware
- **Phase gate:** All three requirements verified with hardware

### Wave 0 Gaps
None -- this phase requires hardware testing only. No automated test infrastructure applicable.

## Sources

### Primary (HIGH confidence)
- `code_samples/HMDUtility_source/src/utils.cpp` lines 231-389 -- C++ `write_to_stdin()` CreateProcess + piped stdin pattern for lighthouse_console
- `code_samples/HMDUtility_source/src/utils.cpp` lines 1165-1312 -- C++ download-modify-upload IPD persistence workflow
- `code_samples/HMDUtility_source/src/utils.cpp` lines 1246-1263 -- `default_mm` string find-replace pattern
- `code_samples/vertical_alignment_proximity/dist/main/_internal/steamvr/lighthouse_console.py` -- Python reference: interactive stdin commands (serial, downloadconfig, uploadconfig)
- `code_samples/vertical_alignment_proximity/dist/main/_internal/steamvr/steamvr.py` line 73-74 -- `set_ipd_default_mm` writes `config['ipd']['default_mm']`
- `src/driver/device_provider.cpp` -- Existing `ExtractVrPathValue()`, `Cleanup()`, `m_sTrackingSerial`, `m_fCurrentIpd`, `LoadLighthouseConfig()`
- `%LOCALAPPDATA%\openvr\openvrpaths.vrpath` -- Verified runtime path on this machine

### Secondary (MEDIUM confidence)
- Valve Index IPD mechanism -- physical slider with analog sensor (Wikipedia, web search results)
- lighthouse_console `uploadconfig` output format: "Compressed config size is XXXX" (web search verified)

### Tertiary (LOW confidence)
- Cleanup() timing window relative to vrserver shutdown -- untested, needs empirical validation
- `default_mm` field presence across all Beyond 2 firmware versions -- only verified in code, not in sample config files

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- all Win32 system APIs, no new libraries
- Architecture: HIGH -- two reference implementations in project codebase provide exact patterns
- Pitfalls: HIGH -- derived from real HMDUtility error handling for each failure mode
- D-05 investigation: HIGH -- Valve Index uses hardware sensor; no software persistence API exists in SteamVR

**Research date:** 2026-03-28
**Valid until:** 2026-04-28 (stable domain -- lighthouse_console and OpenVR driver API change infrequently)
