# Phase 11: Core IPD Pipe Command - Research

**Researched:** 2026-03-25
**Domain:** OpenVR driver development (C++17), HID user flash parsing, SteamVR lighthouse config reading
**Confidence:** HIGH

## Summary

Phase 11 promotes the Phase 10.1 spike's `eyetohead_set` command into a production-quality `ipd` command, replacing the lighthouse_console.exe config reading with direct file reads from `lhr-<serial>/config.json`. The critical new work is extracting the lighthouse tracking serial from the HID user flash (TLV tag 0x09, `SigTag_Tracking_Serial`) to match the correct `lhr-*` config folder, and refactoring `ReadEyeToHeadRotation()` from spawning lighthouse_console.exe to reading the config file directly.

All core mechanisms are proven from Phase 10/10.1 spikes: SetDisplayEyeToHead from sidecar works (SPIKE-01), rotation matrix caching works (SPIKE-03), Euler decomposition is validated (SPIKE-02). The existing `ParseEyeToHead3x3()` and `ExtractVrPathValue()` helpers are reusable. The user flash TLV parser (`ParseCalibration`) provides a proven pattern for adding tracking serial extraction.

**Primary recommendation:** Refactor in layers: (1) extend HID user_signature to extract tracking serial (tag 0x09), (2) rewrite ReadEyeToHeadRotation to read from `<config_dir>/lighthouse/lhr-<serial>/config.json`, (3) add `ipd`/`ipd?`/`load_lh_config` commands while removing spike/legacy commands, (4) wire startup IPD reading + eager rotation cache into Init().

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- Remove ALL spike commands: `ipd_test`, `eyetohead_check`, `slider_test`, `eyetohead_set`
- Remove legacy `fallback1 on/off` commands
- Production command: `ipd <mm>` (set) and `ipd?` (query) -- internally uses the eyetohead_set approach (SetDisplayEyeToHead + SetFloatProperty)
- Keep CLI tool name as `beyond_prox_ctl.exe` -- established name, no rename
- Production pipe commands after cleanup: `proximity on/off/auto`, `status`, `ipd <mm>`, `ipd?`, `load_lh_config <path>`
- On Init: read Prop_UserIpdMeters_Float from HMD container, store in m_fCurrentIpd for status/query
- Do NOT call SetDisplayEyeToHead on startup -- lighthouse driver already sets correct transforms at boot
- Default m_fCurrentIpd = 0.0f (unknown) if property not set. `ipd?` returns `OK ipd=unknown` in that case
- Eager rotation cache on startup: read `lhr-<serial>/config.json` from SteamVR config directory
- Discover config dir via `openvrpaths.vrpath` -> `config` key
- Match `lhr-*` folder name to the HMD's lighthouse serial read from HID user flash (same data source as proximity calibration)
- Discover lighthouse serial extraction approach from `code_samples/` (testgui, beyond_firmware)
- NO fallback to lighthouse_console.exe -- if config.json not found, log error, IPD commands return ERR
- Fallback plan B: `load_lh_config <path>` pipe command -- external program can provide explicit config file path
- Rotation cache is static for the session (no invalidation) -- lens alignment doesn't change during a session
- `ipd 63.5` success: `OK ipd=63.5mm`
- `ipd 63.5` error: `ERR ipd out of range (48-75mm)` or `ERR lh_config not loaded`
- `ipd?` success: `OK ipd=63.5mm` (or `OK ipd=unknown` if not yet set)
- `status` command: append `ipd=63.5mm lh_config=loaded` (or `ipd=unknown lh_config=error`) to existing status key=value string
- `load_lh_config <path>` success: `OK lh_config=loaded` -- failure: `ERR failed to read config: <reason>`

### Claude's Discretion
- Exact lighthouse serial extraction from user flash (research code_samples for approach)
- How to structure the config.json parsing (reuse/refactor existing ParseEyeToHead3x3 or write new)
- Internal code organization (new methods, member variables)
- Error message wording details
- Log verbosity for rotation matrix diagnostics

### Deferred Ideas (OUT OF SCOPE)
- Tundra SiP IPD persistence investigation -- potentially relevant to Phase 13 (slider persistence) if the SiP driver handles config writes when IPD properties change on the HMD container
- SteamVR IPD slider UI -- Phase 12
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| IPD-01 | Driver accepts `ipd <mm>` command via named pipe and sets Prop_UserIpdMeters_Float on HMD container | Proven by `eyetohead_set` spike (device_provider.cpp:740-808). Refactor to `ipd` command with simplified response format |
| IPD-02 | Driver validates IPD value within Beyond physical range before applying | Existing validation at 48-75mm already in spike code (device_provider.cpp:314-316). Move to production command handler |
| IPD-03 | Driver responds with current IPD value via `ipd?` query command | New command. Uses `m_fCurrentIpd` member variable already maintained by spike code |
| IPD-04 | `beyond_prox_ctl.exe` CLI supports `ipd <mm>` and `ipd?` commands | Update CLI allowlist in src/ctl/main.cpp to replace spike commands with production commands |
| IPD-05 | Status command includes current IPD value in response | Append `ipd=<value> lh_config=<status>` to existing status snprintf |
| HARD-01 | IPD value validated and clamped to Beyond physical range on all input paths | Single validation point in ipd command handler (48-75mm range check). Also validate in VREvent_IpdChanged handler |
| HARD-02 | Driver reads initial IPD from HMD container on startup and tracks current value | Add to Init(): GetFloatProperty(Prop_UserIpdMeters_Float). Track via VREvent_IpdChanged in RunFrame() (already implemented) |
</phase_requirements>

## 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`

## Architecture Patterns

### Lighthouse Serial Extraction from HID User Flash

**Confidence: HIGH** -- verified from firmware source code and multiple code_samples

The Beyond 2 HMD stores configuration in a 512-byte user signature page on the MCU, accessible via HID feature reports. The data uses a TLV+CRC8 format (Tag-Length-Value-CRC). The lighthouse tracking serial is stored as:

| Tag | ID | Length | Format | Example |
|-----|----|--------|--------|---------|
| `SigTag_Tracking_Serial` | `0x09` | Variable | ASCII string | `"LHR-01CA9349"` |

**Source:** `code_samples/beyond_firmware/src/Drivers/signature.h` line 43:
```c
SigTag_Tracking_Serial = 0x09, // Variable length, ASCII characters. Serial number of the tracking flex in the HMD
```

**Source:** `code_samples/proximity_sensor_access/config_editor.py` lines 341-343:
```python
if(tag == SigTag.Tracking_Serial):
    self.sig_tracking_serial = tagval.decode('ascii')
```

**Source:** `code_samples/vertical_alignment_proximity/hmd_config.py` line 17:
```python
Tracking_Serial = 0x09
```

The existing driver already reads user flash via `HidDevice::ReadUserSignature()` (reads all 16 x 32-byte blocks), parses it with `ParseCalibration()` in `user_signature.cpp`. The parsing currently only handles tags 0x06, 0x0B, 0x0C, 0x0E (proximity-related). Extending it to also extract tag 0x09 is straightforward.

**Implementation approach:**
1. Add `SigTag_TrackingSerial = 0x09` to the `SigTag` enum in `user_signature.h`
2. Extend `CalibrationData` struct (or create a separate struct) to hold `char tracking_serial[32]` -- the serial is variable length ASCII, typically `"LHR-XXXXXXXX"` (12 chars)
3. In `ParseCalibration()`, add a case for tag 0x09 that copies the value bytes as ASCII
4. Expose the serial via `HidDevice::GetTrackingSerial()` (thread-safe getter)
5. The serial is read once during `ReadCalibration()` which happens on device open/reconnect -- no additional HID traffic needed

**Key detail:** The serial stored at tag 0x09 is the **tracking flex serial** (e.g., `"LHR-01CA9349"`). This matches the `lhr-*` folder name in SteamVR config directory. The folder name is lowercase (`lhr-01ca9349`) while the serial may be stored uppercase (`LHR-01CA9349`). **The folder lookup must be case-insensitive** or the serial must be lowercased before comparison.

### Config Directory Discovery

**Confidence: HIGH** -- verified on live system

The SteamVR config directory is discovered via `openvrpaths.vrpath`:

1. **File location:** `%LOCALAPPDATA%\openvr\openvrpaths.vrpath`
2. **Format:** JSON with `"config"` key containing an array of paths
3. **Verified content on this system:**
```json
{
    "config" : [
        "c:\\program files (x86)\\steam\\config"
    ]
}
```

The `ExtractVrPathValue()` function in `device_provider.cpp:495-529` already parses this file correctly. Currently it extracts the `"runtime"` key (for lighthouse_console.exe path). **Change it to extract the `"config"` key instead.**

### lhr-*/config.json Structure

**Confidence: HIGH** -- verified on live system

Config files live at: `<config_dir>/lighthouse/lhr-<serial>/config.json`

Example path: `C:\Program Files (x86)\Steam\config\lighthouse\lhr-01ca9349\config.json`

The `tracking_to_eye_transform` section contains two entries (eye 0 and eye 1), each with an `eye_to_head` 3x3 rotation matrix:

```json
"tracking_to_eye_transform": [
    {
        "eye_to_head": [
            [0.9993908, 0.0, 0.03489949],
            [-0.003073259, 0.9961151, 0.08800664],
            [-0.03476391, -0.08806028, 0.995508340]
        ],
        "distortion": { ... },
        "intrinsics": [ ... ]
    },
    {
        "eye_to_head": [
            [0.999390827, 0.0, -0.034899496],
            [0.003497741, 0.9949649, 0.10016220],
            [0.03472377, -0.10022325, 0.994358868]
        ],
        "distortion": { ... },
        "intrinsics": [ ... ]
    }
]
```

**The existing `ParseEyeToHead3x3()` function handles this format correctly.** It was already fixed in Phase 10.1 (bug #2, commit `bb6b623`) to handle the pretty-printed multi-line JSON format.

The config.json also contains:
- `"device_serial_number": "LHR-01CA9349"` -- matches the folder name (uppercase)
- `"ipd": { "default_mm": 65.0, ... }` -- current IPD setting

### ReadEyeToHeadRotation Refactoring

**Confidence: HIGH**

The current `ReadEyeToHeadRotation()` (device_provider.cpp:531-738) is 207 lines of code that:
1. Reads `openvrpaths.vrpath` to find SteamVR runtime path
2. Locates `lighthouse_console.exe`
3. Spawns it with piped stdin/stdout
4. Sends `downloadconfig` command
5. Waits up to 10 seconds for config file to appear
6. Terminates lighthouse_console
7. Reads the temp file
8. Parses `tracking_to_eye_transform` -> `eye_to_head`
9. Validates eye order by yaw sign

The refactored version replaces steps 1-7 (the lighthouse_console spawn) with:
1. Read `openvrpaths.vrpath` -> extract `"config"` key (reuse `ExtractVrPathValue`)
2. Get tracking serial from HidDevice
3. Open `<config_dir>/lighthouse/lhr-<serial>/config.json` as a file
4. Read contents into string
5. Parse with existing `ParseEyeToHead3x3()` + eye order validation

This eliminates the ~20 second tracking disruption and the process management complexity.

**New function signature suggestion:**
```cpp
bool ReadEyeToHeadFromConfigFile(const std::string& configPath, float leftRot[3][3], float rightRot[3][3]);
```

With a caller that handles path discovery:
```cpp
bool LoadLighthouseConfig();  // discovers path, calls ReadEyeToHeadFromConfigFile, sets m_bEyeToHeadCached
bool LoadLighthouseConfigFromPath(const std::string& path);  // for load_lh_config command
```

### Recommended Code Structure Changes

**device_provider.h additions:**
```cpp
// New member variables
bool m_bLhConfigLoaded = false;      // replaces m_bEyeToHeadCached semantically
std::string m_sTrackingSerial;        // from HID user flash, for lhr-* matching

// New/refactored methods
bool LoadLighthouseConfig();          // auto-discover + load
bool LoadLighthouseConfigFromPath(const std::string& path);  // explicit path
bool ReadEyeToHeadFromConfigFile(const std::string& configPath, float leftRot[3][3], float rightRot[3][3]);
void HandleIpdSet(float mm, char* response, size_t responseSize);
void HandleIpdQuery(char* response, size_t responseSize);
void HandleLoadLhConfig(const char* path, char* response, size_t responseSize);
```

**Methods to remove:**
- `HandleEyeToHeadCheck()` -- spike only
- `HandleSliderTest()` -- spike only
- `HandleEyeToHeadSet()` -- replaced by `HandleIpdSet()`
- `SetHmdIpd()` -- folded into `HandleIpdSet()`

### Init() Startup Sequence

```cpp
// In DeviceProvider::Init(), after HidDevice creation:

// 1. Read initial IPD from HMD container
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);
if (propErr == vr::TrackedProp_Success && ipd > 0.0f)
    m_fCurrentIpd = ipd;
// else m_fCurrentIpd remains 0.0f (unknown)

// 2. Eager rotation cache from config.json
// Note: tracking serial comes from HidDevice, which starts reading async
// The serial may not be available yet at Init() time since ReadCalibration()
// happens in the reader thread. Need to handle this timing.
```

**Timing concern:** The HID reader thread starts async in `StartReading()`. The tracking serial is read during `ReadCalibration()` which happens in `ReaderThreadFunc()` after device open. At `Init()` time, the serial may not be available yet.

**Options for handling this timing:**
1. **Blocking wait in Init()** -- wait for HID reader to complete first calibration read (up to ~2 seconds). Simple but blocks SteamVR startup.
2. **Lazy load on first ipd command** -- don't load config in Init(); load it when the first `ipd` command arrives. The serial should be available by then. If not, return ERR.
3. **Deferred init with notification** -- have the reader thread signal when calibration is done, then load config. More complex.

**Recommendation:** Option 2 (lazy load) is simplest and aligns with the existing caching pattern. The first `ipd` command triggers config load. If the HID device isn't connected yet, return `ERR lh_config not loaded` -- the user can retry or use `load_lh_config`.

However, the CONTEXT.md says "eager rotation cache on startup." To honor this:
- **Hybrid approach:** Attempt eager load in Init() with a short timeout (e.g., wait up to 3 seconds for serial). If serial isn't available, log a warning and fall back to lazy load on first ipd command. This preserves the "eager" intent while handling the async HID thread.

### Pipe Command Dispatch Changes

**Current commands to KEEP:**
- `proximity on` / `proximity off` / `proximity auto`
- `status` (with IPD additions)

**Commands to ADD:**
- `ipd <mm>` -- set IPD (replaces `eyetohead_set`)
- `ipd?` -- query current IPD
- `load_lh_config <path>` -- load config from explicit path

**Commands to REMOVE:**
- `fallback1 on` / `fallback1 off` -- legacy
- `ipd_test <mm>` -- spike
- `eyetohead_check` -- spike
- `slider_test` -- spike
- `eyetohead_set <mm>` -- spike (replaced by `ipd`)

### CLI Allowlist Update

The `beyond_prox_ctl.exe` needs its command validation updated:

```cpp
bool validCommand =
    strcmp(command, "proximity on") == 0 ||
    strcmp(command, "proximity off") == 0 ||
    strcmp(command, "proximity auto") == 0 ||
    strcmp(command, "status") == 0 ||
    strncmp(command, "ipd ", 4) == 0 ||
    strcmp(command, "ipd?") == 0 ||
    strncmp(command, "load_lh_config ", 15) == 0;
```

The usage text should also be updated to document the new commands and remove spike/legacy ones.

### user_signature.h/cpp Extension Pattern

**Current TLV parsing** (user_signature.cpp:21-68) iterates tags with a switch on tag value, only handling 2-byte uint16 values. For the tracking serial (tag 0x09), the value is a variable-length ASCII string.

**Extension approach:**
```cpp
// In user_signature.h:
struct CalibrationData
{
    uint16_t programmed_cal = 0;
    uint16_t proximity_threshold = 1500;
    uint16_t proximity_hysteresis = 100;
    int16_t  user_trim = 0;
    char tracking_serial[32] = {};  // NEW: null-terminated ASCII, e.g. "LHR-01CA9349"
};

// In user_signature.cpp, ParseCalibration():
// After the existing length==2 switch block:
if (tag == 0x09 && length < sizeof(cal.tracking_serial))
{
    memcpy(cal.tracking_serial, &sig[ptr + 2], length);
    cal.tracking_serial[length] = '\0';
}
```

This keeps the change minimal -- no new structs, no API changes to HidDevice. The tracking serial is simply available via `GetCalibration().tracking_serial`.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON parsing | Full JSON parser | Existing `ParseEyeToHead3x3()` + `ExtractVrPathValue()` | Project decision: manual strstr/sscanf parsing, no JSON library dependency. Already proven in spike |
| TLV parsing | New TLV parser | Extend existing `ParseCalibration()` | Same CRC8+TLV format already handled for proximity tags |
| Euler decomposition | Custom matrix math | Existing intrinsic XYZ decomposition in `HandleEyeToHeadSet()` | Already validated in Phase 10.1 spike |

**Key insight:** All the hard parts (matrix construction, SetDisplayEyeToHead calling, TLV parsing, config file parsing) are already implemented and tested. This phase is primarily refactoring and plumbing, not greenfield development.

## Common Pitfalls

### Pitfall 1: Case-sensitive lhr-* folder matching
**What goes wrong:** The tracking serial from HID flash is uppercase (`LHR-01CA9349`) but the filesystem folder is lowercase (`lhr-01ca9349`).
**Why it happens:** Windows NTFS is case-insensitive, so `FindFirstFileA` would find it. But `std::ifstream` path construction with the wrong case could fail on case-sensitive filesystems.
**How to avoid:** Lowercase the serial before constructing the path. The folder names are always lowercase in SteamVR's config directory.
**Warning signs:** Config load fails with "file not found" despite the folder existing.

### Pitfall 2: Async HID timing on Init()
**What goes wrong:** Trying to read tracking serial before the HID reader thread has completed `ReadCalibration()`.
**Why it happens:** `StartReading()` launches an async thread. `ReadCalibration()` runs after device open in the thread. Init() continues immediately.
**How to avoid:** Either wait for calibration with a timeout, or use lazy loading on first ipd command. Don't assume serial is available synchronously after `StartReading()`.
**Warning signs:** Empty tracking serial string, config load fails on first attempt.

### Pitfall 3: Config directory has "lighthouse" subdirectory
**What goes wrong:** Constructing path as `<config_dir>/lhr-<serial>/config.json` instead of `<config_dir>/lighthouse/lhr-<serial>/config.json`.
**Why it happens:** The CONTEXT.md says "lhr-<serial>/config.json from SteamVR config directory" which is slightly imprecise.
**How to avoid:** The actual path is `<config_dir>/lighthouse/lhr-<serial>/config.json`. Verified on live system.
**Warning signs:** File not found errors despite correct serial.

### Pitfall 4: Removing spike commands breaks existing tests
**What goes wrong:** Users who have been using spike commands (eyetohead_set, ipd_test) during Phase 10/10.1 testing will get "ERR unknown command" after Phase 11 update.
**Why it happens:** Clean break from spike to production commands.
**How to avoid:** This is intentional per CONTEXT.md decisions. Document the command changes in commit messages and any release notes.
**Warning signs:** N/A -- expected behavior.

### Pitfall 5: snprintf buffer overflow in status command
**What goes wrong:** Adding IPD and lh_config fields to the status response may exceed the 256-byte response buffer.
**Why it happens:** The current status response is already ~200 characters when fully populated. Adding `ipd=XX.Xmm lh_config=loaded` adds ~30 more characters.
**How to avoid:** Check that the total status string fits in the 256-byte response buffer. May need to increase buffer size or verify the math.
**Warning signs:** Truncated status response, missing fields at the end.

## Code Examples

### Tracking Serial Extraction (extension to ParseCalibration)
```cpp
// Source: code_samples/beyond_firmware/src/Drivers/signature.h (tag definition)
// Source: code_samples/proximity_sensor_access/config_editor.py (parsing example)

// In ParseCalibration, after the existing length==2 switch block:
if (tag == 0x09 && length < sizeof(cal.tracking_serial))
{
    memcpy(cal.tracking_serial, &sig[ptr + 2], length);
    cal.tracking_serial[length] = '\0';
    DriverLog("HID: tracking_serial=%s (from flash)\n", cal.tracking_serial);
}
```

### Config File Path Construction
```cpp
// Source: verified on live system -- openvrpaths.vrpath and filesystem inspection

std::string configDir = ExtractVrPathValue(vrpathContent, "config");
// configDir = "C:\Program Files (x86)\Steam\config"

// Lowercase the serial for folder matching
std::string serial = cal.tracking_serial;  // e.g., "LHR-01CA9349"
std::transform(serial.begin(), serial.end(), serial.begin(), ::tolower);
// serial = "lhr-01ca9349"

std::string configPath = configDir + "\\lighthouse\\" + serial + "\\config.json";
// configPath = "C:\Program Files (x86)\Steam\config\lighthouse\lhr-01ca9349\config.json"
```

### Production ipd Command Handler (based on spike eyetohead_set)
```cpp
// Source: device_provider.cpp:740-808 (HandleEyeToHeadSet, the spike implementation)

void DeviceProvider::HandleIpdSet(float mm, char* response, size_t responseSize)
{
    float ipdMeters = mm / 1000.0f;

    if (!m_bLhConfigLoaded)
    {
        // Attempt lazy load if not loaded yet
        if (!LoadLighthouseConfig())
        {
            snprintf(response, responseSize, "ERR lh_config not loaded");
            return;
        }
    }

    // Build HmdMatrix34_t with cached rotation + new IPD translation
    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;

    // Call SetDisplayEyeToHead
    vr::VRServerDriverHost()->SetDisplayEyeToHead(
        vr::k_unTrackedDeviceIndex_Hmd, left, right);

    // 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;

    // Log diagnostics (Euler angles for debug)
    // ... (decomposition code from HandleEyeToHeadSet)

    snprintf(response, responseSize, "OK ipd=%.1fmm", mm);
}
```

## Tundra SiP Research (Deferred per CONTEXT.md)

**Confidence: LOW** -- web documentation only, no IPD-specific features found

The Tundra TL448K6D SiP is a SteamVR tracking module containing dual ARM Cortex-M4 processors, FPGA, and IMU. It has GPIO pins that "can be used to connect buttons, IPD sensors or a peripheral Microcontroller." However, **no built-in IPD persistence, config reading/writing, or lighthouse config interaction features were found** in the product documentation.

The SiP runs Valve's SteamVR tracking firmware and handles sensor-to-pose computation. It does not appear to have HMD-specific driver features for IPD management. This is consistent with its role as a tracking subsystem, not an HMD configuration manager.

**Conclusion for Phase 11:** The Tundra SiP does not provide shortcuts for config reading. Continue with the file-based approach per CONTEXT.md decisions. Any IPD persistence investigation (Phase 13) would need to examine the SteamVR runtime's behavior when Prop_UserIpdMeters_Float changes, not the SiP hardware.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | None -- no automated test infrastructure in this C++ project |
| Config file | N/A |
| Quick run command | Build + manual SteamVR test |
| Full suite command | `"C:/Program Files (x86)/Microsoft Visual Studio/2022/BuildTools/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" --build build --config Release` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| IPD-01 | `ipd <mm>` sets IPD via SetDisplayEyeToHead | manual | `beyond_prox_ctl.exe "ipd 63.5"` | N/A |
| IPD-02 | Out-of-range values rejected | manual | `beyond_prox_ctl.exe "ipd 10"` -- expect ERR | N/A |
| IPD-03 | `ipd?` returns current IPD | manual | `beyond_prox_ctl.exe "ipd?"` | N/A |
| IPD-04 | CLI supports ipd commands | manual | Build CLI + test commands | N/A |
| IPD-05 | Status includes IPD | manual | `beyond_prox_ctl.exe "status"` -- check ipd= field | N/A |
| HARD-01 | Range validation on all paths | manual | Test boundary values: 48, 75, 47.9, 75.1 | N/A |
| HARD-02 | Startup IPD reading | manual | Restart SteamVR, check `ipd?` shows current value | N/A |

### Sampling Rate
- **Per task commit:** Build succeeds (`cmake --build build --config Release`)
- **Per wave merge:** Build + deploy to SteamVR + manual test of all commands
- **Phase gate:** All 7 requirements verified via manual testing with HMD connected

### Wave 0 Gaps
None -- no test framework to set up. All testing is manual verification via CLI + SteamVR with HMD hardware.

## Sources

### Primary (HIGH confidence)
- `code_samples/beyond_firmware/src/Drivers/signature.h` -- TLV tag definitions, SigTag_Tracking_Serial = 0x09
- `code_samples/beyond_firmware/src/Drivers/signature.c` -- TLV parsing implementation, CRC8 algorithm
- `code_samples/proximity_sensor_access/config_editor.py` -- Python TLV parser with tracking serial extraction
- `code_samples/vertical_alignment_proximity/hmd_config.py` -- SigTag enum with Tracking_Serial = 0x09
- `code_samples/vertical_alignment_proximity/backup_vap1_alignment.py` -- Confirms `config['device_serial_number']` matches LHR serial
- Live filesystem verification -- `%LOCALAPPDATA%\openvr\openvrpaths.vrpath` structure, `config/lighthouse/lhr-*/config.json` path and content
- `src/driver/device_provider.cpp` -- Existing spike code (HandleEyeToHeadSet, ParseEyeToHead3x3, ExtractVrPathValue, ReadEyeToHeadRotation)
- `src/hid/user_signature.h/cpp` -- Existing TLV parser for proximity calibration
- `src/hid/hid_device.h/cpp` -- HID reader thread, ReadUserSignature, ReadCalibration

### Secondary (MEDIUM confidence)
- `.planning/phases/10.1-setdisplayeyetohead-spike/10.1-FINDINGS.md` -- Spike results confirming SetDisplayEyeToHead works from sidecar

### Tertiary (LOW confidence)
- Tundra Labs TL448K6D product page -- No IPD-specific features found, deferred per CONTEXT.md

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new libraries, all existing C++17 + Win32 + OpenVR
- Architecture: HIGH -- all patterns verified from existing code and live filesystem
- Pitfalls: HIGH -- identified from direct code analysis and spike testing experience

**Research date:** 2026-03-25
**Valid until:** Indefinite (stable C++ codebase, no rapidly changing dependencies)
