# Phase 11.1: Proximity Sensor App Compatibility Spike - Research

**Researched:** 2026-03-26
**Domain:** OpenVR client API, companion app for proximity state monitoring
**Confidence:** HIGH

## Summary

This research covers the remaining work for Phase 11.1: building a test companion app that connects to SteamVR as a client application and monitors proximity state changes while the driver-side probes (already implemented in Plan 01) fire `UpdateBooleanComponent` on candidate handles. The companion app is necessary because `IVRDriverInput` is write-only -- there is no `GetBooleanComponent` on the driver side.

The OpenVR client API provides two independent signals for proximity detection: `IVRSystem::GetTrackedDeviceActivityLevel(0)` which returns `k_EDeviceActivityLevel_UserInteraction` when proximity is active, and `VRControllerState_t::ulButtonPressed` bit 31 (`k_EButton_ProximitySensor`). Additionally, `PollNextEvent` can catch `VREvent_TrackedDeviceUserInteractionStarted` (103) and `VREvent_TrackedDeviceUserInteractionEnded` (104) events. All three should be monitored.

**Primary recommendation:** Build a minimal C++ console app (`beyond_spike_monitor`) as a new CMake target in the existing project. It links against the same `openvr_api.lib`, initializes with `VRApplication_Background`, and polls all three proximity indicators in a tight loop. The `openvr_api.dll` from `extern/openvr/bin/win64/` must be copied alongside the executable at build time.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
- D-01: Beyond 2 does NOT communicate proximity sensor status to the onboard Tundra SiP
- D-02: Lighthouse driver DOES create `/user/head/proximity` input component on HMD but never updates it
- D-03: Sidecar reads proximity from HID and needs to update lighthouse driver's existing `/proximity` component
- D-04: CreateBooleanComponent on HMD container from sidecar returns VRInputError_InvalidParam (err=4)
- D-05: TrackedDeviceButtonPressed/Unpressed does not exist in IVRServerDriverHost_006
- D-06: VendorSpecificEvent only allows events in range 10000-19999
- D-07: Prop_ContainsProximitySensor_Bool toggling works for SteamVR standby/wake but causes issues in other apps
- D-08: Handle probing -- probe range 1-20, driver's probe_proximity command already implemented
- D-09: Test companion app -- monitors GetTrackedDeviceActivityLevel(0) and VRControllerState_t::ulButtonPressed bit 31
- D-10: Coordinated spike flow -- driver probes, companion app detects which handle triggers proximity
- D-12/D-13/D-14: Verification targets for SteamVR standby/wake, VRChat AFK, Beyond ET enrollment

### Claude's Discretion
- Companion app implementation details (polling interval, output format, logging)
- Spike code structure for coordinated testing
- How to build the companion app (same CMake project or standalone)

### Deferred Ideas (OUT OF SCOPE)
- If spike fails: virtual device approach
- Production implementation of proximity fix -- separate phase after spike
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| SPIKE-01 | Probe commands implemented | COMPLETED in Plan 01 -- probe_proximity and test_handle pipe commands |
| SPIKE-02 | Probe results documented | COMPLETED in Plan 01 -- commands report error codes per handle |
| SPIKE-03 | Discovered handle changes proximity state observed by SteamVR | Companion app monitors 3 signals: ActivityLevel, ButtonPressed bit 31, UserInteraction events |
| SPIKE-04 | Go/no-go decision recorded | Companion app output + test_handle confirmation provides evidence for decision |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| openvr_api.lib | 2.5.1 (vendored) | OpenVR client API linkage | Already in project, same lib for driver and client |
| openvr_api.dll | 2.5.1 (vendored) | OpenVR runtime binding | Client apps need DLL at runtime (driver loads it differently) |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| Windows API (Win32) | N/A | Named pipe client, console output | Pipe communication with driver |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| New companion app | Extend beyond_prox_ctl.exe | ctl.exe has no OpenVR dependency; adding one changes its nature. Separate binary is cleaner for spike. |
| Same CMake project | Standalone CMakeLists.txt | Same project is simpler -- shares OPENVR_ROOT, OPENVR_LIB variables, single build command |

**Installation:**
No new dependencies. Everything is already vendored in `extern/openvr/`.

## Architecture Patterns

### Recommended Project Structure (addition)
```
src/
  spike/
    proximity_monitor.cpp    # Companion app source (single file)
```

### Pattern 1: OpenVR Background Application
**What:** A non-rendering OpenVR client that connects to a running SteamVR instance
**When to use:** When you need to observe VR system state without rendering
**Example:**
```cpp
// Source: extern/openvr/headers/openvr.h lines 5818-5843
#include <openvr.h>

vr::EVRInitError eError;
vr::IVRSystem* pSystem = vr::VR_Init(&eError, vr::VRApplication_Background);
if (!pSystem) {
    printf("VR_Init failed: %s\n", vr::VR_GetVRInitErrorAsEnglishDescription(eError));
    return 1;
}
// Use pSystem->GetTrackedDeviceActivityLevel(), GetControllerState(), PollNextEvent()
// ...
vr::VR_Shutdown();
```

**Key detail:** `VRApplication_Background` (3) will NOT start SteamVR if not running, and will NOT keep it running if everything else quits. This is exactly right for a monitoring tool.

`VRApplication_Utility` (4) would NOT work -- it explicitly says "Init should not try to load any drivers" and only provides utility interfaces (IVRSettings, IVRApplications), not IVRSystem.

### Pattern 2: Proximity State Monitoring (Three Signals)
**What:** Three independent ways to detect proximity state from client side
**When to use:** During spike to determine which handles affect proximity

**Signal 1: Activity Level (recommended primary)**
```cpp
// Source: extern/openvr/headers/openvr.h line 2337, enum at 1029-1037
vr::EDeviceActivityLevel level = pSystem->GetTrackedDeviceActivityLevel(0);
// k_EDeviceActivityLevel_UserInteraction (1) = proximity active
// k_EDeviceActivityLevel_Standby (3) = proximity inactive (after timeout)
// k_EDeviceActivityLevel_UserInteraction_Timeout (2) = transitioning
```

**Signal 2: Button State (legacy API, may still work)**
```cpp
// Source: extern/openvr/headers/openvr.h lines 2432, 1052, 1076
vr::VRControllerState_t state;
if (pSystem->GetControllerState(0, &state, sizeof(state))) {
    bool proxActive = (state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_ProximitySensor)) != 0;
    // bit 31 = k_EButton_ProximitySensor
}
```

**Signal 3: Events (asynchronous notification)**
```cpp
// Source: extern/openvr/headers/openvr.h lines 780-781, 2401
vr::VREvent_t event;
while (pSystem->PollNextEvent(&event, sizeof(event))) {
    if (event.eventType == vr::VREvent_TrackedDeviceUserInteractionStarted) // 103
        printf("PROXIMITY ON (event)\n");
    if (event.eventType == vr::VREvent_TrackedDeviceUserInteractionEnded)   // 104
        printf("PROXIMITY OFF (event)\n");
}
```

### Pattern 3: CMake Target for Companion App
**What:** Add a new executable target to existing CMakeLists.txt
```cmake
# Spike companion app - monitors proximity state from client side
add_executable(beyond_spike_monitor
    src/spike/proximity_monitor.cpp
)
target_include_directories(beyond_spike_monitor PRIVATE ${OPENVR_INCLUDE_DIR})
target_link_libraries(beyond_spike_monitor PRIVATE ${OPENVR_LIB})

# Copy openvr_api.dll next to the executable (required at runtime)
add_custom_command(TARGET beyond_spike_monitor POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        "${OPENVR_ROOT}/bin/win64/openvr_api.dll"
        "$<TARGET_FILE_DIR:beyond_spike_monitor>"
)
```

### Anti-Patterns to Avoid
- **Using VRApplication_Utility:** Does not load drivers, cannot access IVRSystem for device queries
- **Using VRApplication_Scene:** Would try to become a rendering app, requires frame submission
- **Polling too fast without sleep:** Wastes CPU; 100ms interval is sufficient for detecting state changes during manual spike testing
- **Not calling VR_Shutdown:** Leaks the VR client connection

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Named pipe client | Custom pipe code | Reuse pattern from beyond_prox_ctl.exe | Already proven, same pipe protocol |
| OpenVR init/shutdown | Custom DLL loading | `VR_Init` / `VR_Shutdown` from openvr.h | Handles version negotiation, vrclient.dll discovery |

## Common Pitfalls

### Pitfall 1: Missing openvr_api.dll at Runtime
**What goes wrong:** Companion app crashes or fails to start with missing DLL error
**Why it happens:** Client apps need `openvr_api.dll` next to the executable. The driver DLL does not -- it's loaded by vrserver which provides the API. Client apps link against `openvr_api.lib` which is an import lib for the DLL.
**How to avoid:** CMake POST_BUILD command copies `extern/openvr/bin/win64/openvr_api.dll` to the output directory
**Warning signs:** "The code execution cannot proceed because openvr_api.dll was not found"

### Pitfall 2: VR_Init Fails Because SteamVR Not Running
**What goes wrong:** `VRApplication_Background` returns error if SteamVR is not running (by design -- it won't start SteamVR)
**Why it happens:** Background apps are passive monitors
**How to avoid:** Start SteamVR first, then launch the companion app. Print clear error message on init failure.
**Warning signs:** `VRInitError_Init_HmdNotFound` or `VRInitError_Init_NotInitialized`

### Pitfall 3: Activity Level Has Timeouts, Not Instant Transitions
**What goes wrong:** After setting proximity=false, activity level doesn't immediately go to Standby
**Why it happens:** SteamVR has built-in timeouts: UserInteraction -> UserInteraction_Timeout (0.5s) -> Idle (10s) -> Standby (5s configurable). See openvr.h line 1025-1036.
**How to avoid:** Monitor ALL intermediate states. When probing, set proximity=true first, wait for UserInteraction, then set false and wait for transition. Use events (signal 3) for immediate notification.
**Warning signs:** State appears "stuck" at UserInteraction_Timeout or Idle

### Pitfall 4: GetControllerState Deprecated
**What goes wrong:** GetControllerState for device 0 (HMD) may return false or empty state
**Why it happens:** The function is deprecated in favor of IVRInput. HMD is not a "controller". However, the proximity sensor bit may still be populated for backward compatibility.
**How to avoid:** Use it as a secondary signal, not primary. Activity level is more reliable for HMD proximity.
**Warning signs:** GetControllerState returns false for device index 0

### Pitfall 5: CLI Response Buffer Too Small for Probe Output
**What goes wrong:** Truncated probe results
**Why it happens:** Already addressed in Plan 01 (buffer increased to 512)
**How to avoid:** Already mitigated

### Pitfall 6: Coordinated Timing Between Driver Probe and Monitor
**What goes wrong:** Monitor misses state change because it wasn't polling when the probe fired
**Why it happens:** Probe runs all 20 handles in rapid succession; some handle changes may be overwritten before the monitor polls
**How to avoid:** Two-phase approach: (1) Run probe_proximity first to find which handles return err=0, (2) Use test_handle to individually test each successful handle while monitor polls. The per-handle test is the definitive check.
**Warning signs:** Probe shows handles with err=0 but monitor never detects a change

## Code Examples

### Complete Companion App Structure
```cpp
// Source: Synthesized from openvr.h API definitions (lines referenced inline)
#include <openvr.h>
#include <cstdio>
#include <windows.h>

int main()
{
    // Initialize as background app (openvr.h line 1601)
    vr::EVRInitError eError;
    vr::IVRSystem* pSystem = vr::VR_Init(&eError, vr::VRApplication_Background);
    if (!pSystem) {
        fprintf(stderr, "VR_Init failed: %s\n",
                vr::VR_GetVRInitErrorAsEnglishDescription(eError));
        return 1;
    }

    printf("Connected to SteamVR. Monitoring HMD proximity...\n");
    printf("Press Ctrl+C to exit.\n\n");

    vr::EDeviceActivityLevel lastLevel = vr::k_EDeviceActivityLevel_Unknown;
    uint64_t lastButtonState = 0;

    while (true) {
        // Signal 1: Activity level (openvr.h line 2337)
        vr::EDeviceActivityLevel level = pSystem->GetTrackedDeviceActivityLevel(0);
        if (level != lastLevel) {
            const char* names[] = {"Unknown(-1)","Idle","UserInteraction",
                                   "UserInteraction_Timeout","Standby","Idle_Timeout"};
            int idx = (int)level + 1; // shift -1 -> 0
            printf("[ActivityLevel] %s (%d)\n",
                   (idx >= 0 && idx < 6) ? names[idx] : "?", (int)level);
            lastLevel = level;
        }

        // Signal 2: Button state (openvr.h lines 2432, 1052)
        vr::VRControllerState_t state = {};
        if (pSystem->GetControllerState(0, &state, sizeof(state))) {
            uint64_t proxBit = state.ulButtonPressed &
                               vr::ButtonMaskFromId(vr::k_EButton_ProximitySensor);
            if (proxBit != lastButtonState) {
                printf("[ButtonState] ProximitySensor bit=%s\n",
                       proxBit ? "PRESSED" : "released");
                lastButtonState = proxBit;
            }
        }

        // Signal 3: Events (openvr.h lines 780-781)
        vr::VREvent_t event;
        while (pSystem->PollNextEvent(&event, sizeof(event))) {
            if (event.eventType == vr::VREvent_TrackedDeviceUserInteractionStarted)
                printf("[Event] UserInteractionStarted (device %u)\n",
                       event.trackedDeviceIndex);
            if (event.eventType == vr::VREvent_TrackedDeviceUserInteractionEnded)
                printf("[Event] UserInteractionEnded (device %u)\n",
                       event.trackedDeviceIndex);
            if (event.eventType == vr::VREvent_ButtonPress)
                printf("[Event] ButtonPress button=%u (device %u)\n",
                       event.data.controller.button, event.trackedDeviceIndex);
            if (event.eventType == vr::VREvent_ButtonUnpress)
                printf("[Event] ButtonUnpress button=%u (device %u)\n",
                       event.data.controller.button, event.trackedDeviceIndex);
        }

        Sleep(100); // 100ms poll interval
    }

    vr::VR_Shutdown();
    return 0;
}
```

### CMake Addition
```cmake
# Add after the beyond_prox_ctl target in CMakeLists.txt

# Spike proximity monitor (OpenVR client app)
add_executable(beyond_spike_monitor
    src/spike/proximity_monitor.cpp
)
target_include_directories(beyond_spike_monitor PRIVATE ${OPENVR_INCLUDE_DIR})
target_link_libraries(beyond_spike_monitor PRIVATE ${OPENVR_LIB})

# Client apps need openvr_api.dll at runtime
add_custom_command(TARGET beyond_spike_monitor POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E copy_if_different
        "${OPENVR_ROOT}/bin/win64/openvr_api.dll"
        "$<TARGET_FILE_DIR:beyond_spike_monitor>"
)
```

### Coordinated Spike Test Protocol
```
Step 1: Start SteamVR (HMD on head)
Step 2: Launch beyond_spike_monitor.exe -- confirm it connects and shows initial state
Step 3: Run: beyond_prox_ctl "probe_proximity"
        -- Records which handles return err=0 (candidates)
Step 4: For each candidate handle N:
        Run: beyond_prox_ctl "test_handle N 1"  (set true)
        -- Watch monitor for ActivityLevel/Button/Event changes
        Run: beyond_prox_ctl "test_handle N 0"  (set false)
        -- Watch monitor for state reverting
Step 5: If a handle triggers proximity changes in BOTH directions:
        -- That's the /proximity handle. Record it.
        -- GO decision: handle probing is viable
Step 6: If no handle triggers changes:
        -- NO-GO: UpdateBooleanComponent doesn't propagate cross-driver
        -- Record finding, defer to alternative strategy
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Button API (TrackedDeviceButtonPressed) | Input 2.0 (CreateBooleanComponent/UpdateBooleanComponent) | OpenVR 1.x | Old API removed from IVRServerDriverHost_006; Input 2.0 is the only way to update input components from driver side |
| GetControllerState for buttons | IVRInput action-based system | OpenVR 1.x | GetControllerState still works for legacy apps but is deprecated; activity level is the modern proximity indicator |

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Manual spike testing (no automated unit tests for hardware interaction) |
| Config file | N/A |
| Quick run command | `beyond_spike_monitor.exe` (manual observation) |
| Full suite command | N/A -- spike is manual |

### Phase Requirements to Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| SPIKE-01 | Probe commands exist | Build verification | cmake --build build --config Release | Already exists |
| SPIKE-02 | Probe results documented | Manual | `beyond_prox_ctl "probe_proximity"` | N/A |
| SPIKE-03 | Discovered handle changes proximity | Manual observation | `beyond_spike_monitor.exe` + `beyond_prox_ctl "test_handle N 1"` | Wave 0 |
| SPIKE-04 | Go/no-go decision | Human judgment | N/A (documented in summary) | N/A |

### Wave 0 Gaps
- [ ] `src/spike/proximity_monitor.cpp` -- companion app source
- [ ] CMakeLists.txt update -- new target `beyond_spike_monitor`
- [ ] Build and verify both targets compile

## Open Questions

1. **Does GetControllerState work for device index 0 (HMD)?**
   - What we know: The function is deprecated, and HMD is not a controller. The proximity sensor bit (31) is defined in the button enum.
   - What's unclear: Whether SteamVR populates this field for HMD device or only for actual controllers.
   - Recommendation: Include it as signal 2, but rely on ActivityLevel (signal 1) as primary. The spike will reveal which signals actually work.

2. **Does UpdateBooleanComponent from a different driver actually propagate to client-side state?**
   - What we know: UpdateBooleanComponent returns err=0 for some handles (from the probe). D-04 confirmed CreateBooleanComponent fails cross-driver, but Update might work since it uses a global handle, not a container.
   - What's unclear: Whether SteamVR's input system routes the state change to client APIs when the calling driver didn't create the component.
   - Recommendation: This is THE question the spike answers. The companion app is built precisely to detect this.

3. **Are component handles sequential starting at 1?**
   - What we know: D-08 says lighthouse driver creates exactly 2 components on HMD: `/input/system/click` then `/proximity`. If handles are sequential per-device starting at 1, handles would be 1 and 2.
   - What's unclear: Handle numbering scheme -- could be global across all devices, could encode device index, could start at a different base.
   - Recommendation: The probe covers 1-20 which should be sufficient. The companion app confirms which handle is the right one.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| openvr_api.lib | Companion app link | Yes | 2.5.1 (vendored) | -- |
| openvr_api.dll | Companion app runtime | Yes | 2.5.1 (extern/openvr/bin/win64/) | -- |
| CMake | Build system | Yes | VS2022 BuildTools bundled | -- |
| MSVC | C++ compiler | Yes | VS2022 BuildTools | -- |
| SteamVR | Runtime testing | Yes (user's system) | -- | Cannot test without it |

**Missing dependencies with no fallback:** None

## Sources

### Primary (HIGH confidence)
- `extern/openvr/headers/openvr.h` (vendored, v2.5.1) -- All API definitions: VR_Init, EVRApplicationType, IVRSystem::GetTrackedDeviceActivityLevel, GetControllerState, VRControllerState_t, EDeviceActivityLevel, EVRButtonId, PollNextEvent, VREvent types
- `extern/openvr/src/README` -- Client binding library usage (DLL requirement)
- `extern/openvr/bin/win64/openvr_api.dll` -- Confirmed available for client apps
- `CMakeLists.txt` -- Existing build structure, OPENVR_ROOT/LIB variables
- `src/ctl/main.cpp` -- Existing CLI tool pattern (pipe client, no OpenVR dependency)
- `src/driver/device_provider.cpp` lines 753-789 -- Existing probe/test_handle implementations

### Secondary (MEDIUM confidence)
- Phase 11.1 CONTEXT.md D-08/D-09/D-10 -- Spike investigation targets and coordinated flow design

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- all libraries are vendored and already in use
- Architecture: HIGH -- API surface is directly readable from openvr.h, patterns are straightforward
- Pitfalls: HIGH -- DLL requirement documented in openvr README, timeouts documented in openvr.h enum comments
- Open questions: MEDIUM -- whether cross-driver UpdateBooleanComponent propagates is unknown (that's what the spike tests)

**Research date:** 2026-03-26
**Valid until:** 2026-04-26 (stable -- OpenVR API rarely changes)

## Project Constraints (from CLAUDE.md)

- Windows environment only -- use Windows-compatible terminal commands
- CMake 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`
