---
phase: 16-vrchat-osc-bridge
verified: 2026-04-20T06:30:00Z
status: human_needed
score: 3/3 must-haves verified (automated); 3 behavioral items require human/hardware confirmation
overrides_applied: 0
human_verification:
  - test: "VRCH-01e — Startup animation visible (camera stream, 4s white fade)"
    expected: "All 10 LEDs fade up white over ~1s, hold ~250ms, ramp back to dark over ~3s. Log shows 'Backglow-daemon: startup anim begin' and '...startup anim end'."
    why_human: "Visual hardware behavior; requires MagWLED-1, SteamVR running, and VDO.Ninja camera stream — cannot be verified programmatically."
  - test: "VRCH-02a/b/c/d — VRChat avatar parameters drive LEDs (fixture end-to-end on hardware)"
    expected: "send_backglow.py --led 0 --r 1.0 makes LED 0 red within ~11ms; --sweep drives all 31 params; --silence 5 triggers 500ms fade after 3s; --bri-dedup produces exactly ONE backglow bri 127 pipe command."
    why_human: "End-to-end LED behavior requires MagWLED-1 + SteamVR + running daemon with pipe server active. Daemon pipe interaction (WriterThread -> PipeClient -> LedController) cannot be exercised without the full hardware stack."
  - test: "VRCH-01f — OSCQuery primary-path HTTP probe and mDNS discovery"
    expected: "Daemon log shows 'OSCQuery primary path: UDP=N HTTP=M'. curl http://127.0.0.1:M/ returns JSON with FULL_PATH / and /avatar subtree. curl '?HOST_INFO' returns OSC_PORT=N, OSC_TRANSPORT=UDP, OSC_IP=127.0.0.1."
    why_human: "Requires SteamVR running so the driver spawns the daemon (port-0 UDP + ephemeral HTTP assigned at runtime). mDNS multicast behavior on loopback cannot be verified without a running process. The VRCH-01f curl steps are the only way to verify the OSCQuery responder serves correct JSON."
---

# Phase 16: VRChat OSC Bridge Verification Report

**Phase Goal:** VRChat avatar parameter changes drive LED colors in real time via a driver-managed bridge process (daemon lifecycle driven by driver load per D-01; no separate VRSettings toggle)
**Verified:** 2026-04-20T06:30:00Z
**Status:** human_needed
**Re-verification:** No — initial verification

## Goal Achievement

### Observable Truths

| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Driver spawns beyond_backglow_ctl.exe automatically at InitBackglow success and terminates it via Job Object kill-on-close when SteamVR exits — no orphan daemons | VERIFIED | `SpawnBackglowDaemon()` wired at InitBackglow success tail (device_provider.cpp:1628); `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set at line 1680; `StopBackglowDaemon()` wired in Cleanup before `ShutdownAllOff` (line 178). Hardware UAT in 16-02-SUMMARY.md confirms: `Backglow: daemon spawned (pid=44236)` + Job Object kill verified (pid 44236 gone after Stop-Process vrserver -Force; new daemon 47884 spawned on vrserver restart). |
| 2 | VRChat avatar BackglowR0..9 / BackglowG0..9 / BackglowB0..9 / BackglowBri float parameters drive physical LEDs at 90 Hz within one frame | VERIFIED (code path); HUMAN NEEDED (hardware end-to-end) | osc_server.cpp: HandleOscMessage prefix-matches /avatar/parameters/Backglow, parses {R\|G\|B}{0-9} or Bri, calls SetChannel/SetBrightness. param_map.cpp: 30 atomic<uint8_t> slots, float-to-uint8 via lround(v*255). main.cpp: WriterThread ticks at steady_clock + 11ms (D-12), frame dedup (30-byte array compare), Bri dedup (uint8 compare). led_controller.cpp:78 cv-wait retuned to 11ms (D-26). Full pipeline code path is substantive. Hardware confirmation (LEDs turn red on --led 0 --r 1.0) is human-gated — see VRCH-02a in human verification section. |
| 3 | Bridge daemon discovers + exposes itself via OSCQuery + mDNS on loopback (primary), falls back to fixed UDP 127.0.0.1:9001 (D-19), never binds 0.0.0.0 (D-20) | VERIFIED (code path); HUMAN NEEDED (runtime probe) | main.cpp: BindLoopbackUdp(0) primary, inet_pton("127.0.0.1") only — zero INADDR_ANY matches across all src/backglow_ctl/ files. oscquery_http.cpp: TCP 127.0.0.1:0 bind, GET / and GET /?HOST_INFO responses pre-built. mdns_advertise.cpp: AnnounceBlock called for both _oscjson._tcp and _osc._udp with 127.0.0.1 saddr. Fallback path (D-19 fixed :9001) present in main.cpp:241. curl HTTP probe and mDNS browse are human-gated — see VRCH-01f in human verification section. |

**Score:** 3/3 roadmap success criteria verified at code level. 3 human verification items block PASSED status.

### Required Artifacts

| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `src/backglow_ctl/main.cpp` | Daemon entry, WSAStartup, UDP bind, pipe connect, startup anim, thread orchestration, stdin-EOF, graceful shutdown | VERIFIED | 293 LOC; WSAStartup + WSACleanup present; BindLoopbackUdp with inet_pton 127.0.0.1; SetDefaultDllDirectories(SYSTEM32); RunStartupAnimation; three threads (OscThread, WriterThread, StdinMonitorThread); tStdin.join() graceful path; mdns.Stop() + httpServer.StopAndJoin() in teardown |
| `src/backglow_ctl/osc_server.cpp` | OSC decode + D-21 whitelist + bundle handling | VERIFIED | 69 LOC; /avatar/parameters/Backglow prefix check; suffix parse for {R\|G\|B}{0-9} and Bri; OSCPP::Server bundle recursion via PacketStream; try/catch (std::exception + ...) for malformed-drop; MarkOscArrived on success |
| `src/backglow_ctl/param_map.cpp` | 31-slot atomic state store | VERIFIED | 45 LOC; std::atomic<uint8_t>[30] + atomic Bri + atomic lastOscNs; SetChannel, SetBrightness, SnapshotFrame, SnapshotBri, MarkOscArrived, NsSinceLastOsc all implemented; float-to-u8 via std::clamp + std::lround |
| `src/backglow_ctl/pipe_client.cpp` | PIPE_READMODE_MESSAGE connect, 100ms overlapped write | VERIFIED | 69 LOC; GENERIC_WRITE (updated for PIPE_ACCESS_INBOUND in Plan 02 fix); PIPE_READMODE_MESSAGE set post-connect; FILE_FLAG_OVERLAPPED; WaitForSingleObject(ov.hEvent, 100); CancelIo on timeout; 50-attempt retry loop |
| `src/backglow_ctl/oscquery_http.cpp` | Winsock HTTP 1.0 responder, static JSON | VERIFIED | 185 LOC; inet_pton 127.0.0.1; pre-built root + HOST_INFO JSON; GET / and GET /?HOST_INFO handling; 405 for non-GET; 404 for unknown; 250ms select accept loop; 1s SO_RCVTIMEO/SO_SNDTIMEO |
| `src/backglow_ctl/mdns_advertise.cpp` | Dual-service mDNS advertise | VERIFIED | 238 LOC; AnnounceBlock called for both _oscjson._tcp and _osc._udp; GoodbyeBlock on Stop for both; inet_pton 127.0.0.1 to mdns_socket_open_ipv4; hard-fail if either announce returns non-zero |
| `src/driver/device_provider.cpp` | SpawnBackglowDaemon, StopBackglowDaemon, BackglowWatchdogThreadFunc | VERIFIED | All three implemented; JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; AssignProcessToJobObject; CREATE_BREAKAWAY_FROM_JOB + ERROR_ACCESS_DENIED retry; GetModuleHandleExW/GetModuleFileNameW for absolute path; kBackoffMs[] = {1000,2000,4000}; respawn cap 3; 3-phase stop |
| `src/driver/device_provider.h` | 7 new VRCH-01 members + 3 method declarations | VERIFIED | m_hBackglowJob, m_hBackglowProcess, m_hBackglowStdinWrite, m_backglowWatchdogThread, m_bStopBackglowWatchdog, m_backglowRespawnCount, m_backglowSpawnTime; SpawnBackglowDaemon, StopBackglowDaemon, BackglowWatchdogThreadFunc declared |
| `src/led/led_controller.cpp` | cv-wait retuned 33ms -> 11ms (D-26) | VERIFIED | Line 78: `m_cv.wait_for(lk, std::chrono::milliseconds(11), ...` with comment "Phase 16 D-26: 90 Hz" |
| `CMakeLists.txt` | beyond_backglow_ctl executable target, ENABLE_BACKGLOW-gated | VERIFIED | add_executable(beyond_backglow_ctl ...) with 8 source files, ws2_32 + iphlpapi links, RUNTIME_OUTPUT_DIRECTORY to build/driver/BeyondProximity/bin/win64 |

### Key Link Verification

| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| device_provider.cpp InitBackglow success tail | SpawnBackglowDaemon() | Call at line 1628, inside `else` block after LedController::Start success | WIRED | grep confirms: line 1628 is after `m_backglowDisabled = false` + DriverLog "online via..." — success path only; all degraded early returns exit before this call |
| device_provider.cpp Cleanup | StopBackglowDaemon() | Call at line 178, before ShutdownAllOff at line 186 | WIRED | StopBackglowDaemon() precedes `if (m_pLedController) { m_pLedController->ShutdownAllOff(); }` — ordering correct per D-06 |
| SpawnBackglowDaemon | absolute daemon path | GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, &BackglowDllAnchor) + GetModuleFileNameW | WIRED | Static BackglowDllAnchor() used as address (works around member-function-to-LPCWSTR cast restriction); path stripped + "\\beyond_backglow_ctl.exe" appended |
| osc_server.cpp HandleOscMessage | param_map SetChannel/SetBrightness | Prefix match + suffix parse + dispatch | WIRED | SetChannel(ledIdx, channel, v) called for {R\|G\|B}{0-9}; SetBrightness(v) called for "Bri" |
| main.cpp WriterThread | pipe_client SendCommand | 11ms tick + SnapshotFrame/SnapshotBri + frame dedup + Bri dedup | WIRED | "backglow fill XXXXXX...XXXXXX" (10 hex triplets) + "backglow bri N" emitted to pipe.SendCommand on change |
| main.cpp OscThread | osc_server HandleOscPacket | recvfrom on 127.0.0.1:N -> HandleOscPacket(buf, n, map) | WIRED | line 54: `HandleOscPacket(buf, static_cast<std::size_t>(n), map)` inside recv loop |

### Data-Flow Trace (Level 4)

| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|--------------|--------|-------------------|--------|
| WriterThread (main.cpp) | lastFrame / frame (30-byte array) | map.SnapshotFrame() <- ParamMap::m_rgb[] <- SetChannel() <- HandleOscMessage() <- recvfrom UDP packet | Yes — atomic<uint8_t>[30] updated from live OSC floats via float-to-u8 | FLOWING |
| WriterThread (main.cpp) | bri / lastBri | map.SnapshotBri() <- ParamMap::m_bri <- SetBrightness() <- HandleOscMessage() | Yes — same flow as RGB | FLOWING |
| OscQueryHttpServer AcceptLoop | m_rootJson / m_hostInfoJson | Pre-built at Start() with actual oscUdpPort | Yes — OSC_PORT reflects the runtime-assigned UDP port | FLOWING |
| MdnsAdvertiser blk_tcp / blk_udp | httpTcpPort / oscUdpPort | Passed from main() getsockname() result after BindLoopbackUdp(0) | Yes — ephemeral ports from OS | FLOWING |

### Behavioral Spot-Checks

| Behavior | Evidence | Status |
|----------|----------|--------|
| No INADDR_ANY in daemon sources | `grep -c "INADDR_ANY" src/backglow_ctl/` returns 0 (per 16-01 SUMMARY + 16-03 SUMMARY verification gates) | PASS |
| JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE in spawn | device_provider.cpp:1680 confirmed in source | PASS |
| SpawnBackglowDaemon call count = 2 (InitBackglow + watchdog respawn) | lines 1628 (InitBackglow success tail) + 1804 (watchdog respawn) | PASS |
| StopBackglowDaemon call count = 1 (Cleanup only) | line 178 in Cleanup; no other call sites | PASS |
| cv-wait 11ms (no 33ms remaining) | led_controller.cpp:78 confirmed | PASS |
| Hardware daemon spawn + Job Object kill verified on real hardware | 16-02-SUMMARY.md UAT evidence: `Backglow: daemon spawned (pid=44236)`, VRCH-01a/01b PASS, `Backglow: daemon stopped gracefully` on clean exit | PASS |
| Startup anim visible on hardware | HUMAN NEEDED — see human verification item 1 |
| LED color update within 1 frame via fixture | HUMAN NEEDED — see human verification item 2 |
| OSCQuery curl probe | HUMAN NEEDED — see human verification item 3 |

### Requirements Coverage

| Requirement | Source Plans | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| VRCH-01 | 16-00, 16-02, 16-03 | Bridge daemon launched/stopped by driver DLL; listens for VRChat OSC | SATISFIED | SpawnBackglowDaemon (Job Object lifecycle) in 16-02; OSCQuery + mDNS in 16-03; daemon spawn/kill verified on hardware (16-02-SUMMARY.md). D-01 "no separate toggle" implemented: spawn conditioned only on InitBackglow success path. |
| VRCH-02 | 16-00, 16-01 | Bridge maps avatar float params (BackglowR/G/B/Bri) to LED pipe commands | PARTIALLY SATISFIED — code path complete; hardware end-to-end deferred to SMOKE UAT | osc_server.cpp maps all 31 params; WriterThread emits `backglow fill` + `backglow bri`; silence-fade + dedup implemented. Full hardware confirmation (VRCH-02a..d) deferred to 16-SMOKE.md walkthrough. |

**Orphaned requirements check (Phase 16 in REQUIREMENTS.md):** VRCH-01 and VRCH-02 are the only Phase 16 requirements per REQUIREMENTS.md traceability table. VRCH-03 and VRCH-04 are mapped to Phase 17. No orphaned requirements.

**Note on REQUIREMENTS.md wording vs implementation:** REQUIREMENTS.md VRCH-01 states "based on an 'Enable Backglow' VRSettings toggle." The plan (D-01) explicitly overrides this: the daemon is lifecycle-driven by driver load (no separate toggle). This is a documented intentional deviation — CONTEXT.md D-01 and all PLANs confirm the toggle was dropped by design decision before implementation began.

### Anti-Patterns Found

| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `src/driver/device_provider.cpp` | 1823-1840 | CloseHandle on m_hBackglowProcess before joining watchdog thread (16-REVIEW.md CR-01) | WARNING | Double-close race on graceful shutdown; watchdog also CloseHandles the same process handle (line 1774). Could crash vrserver on clean SteamVR exit. Not observed in UAT (hardware tests passed) but Win32 UB. |
| `src/backglow_ctl/main.cpp` | 46-52 | WSAECONNRESET not in retryable error set; single ICMP "port unreachable" kills OSC thread permanently (16-REVIEW.md WR-01) | WARNING | Silent OSC death; WriterThread continues ticking but no new frames arrive; LEDs fade to off and never recover without daemon restart. |
| `src/backglow_ctl/pipe_client.cpp` | 53-61 | Write timeout does not Close() pipe — stale m_hPipe persists (16-REVIEW.md WR-02) | WARNING | Repeated timeouts (90x/s) with no recovery path; IsConnected() stays true masking broken pipe. |
| `src/backglow_ctl/mdns_advertise.cpp` | 164 | g_impl unconditional overwrite on double Start(); no thread-exception cleanup (16-REVIEW.md WR-04) | WARNING | Memory/socket leak on double-call or std::thread construction failure. Daemon only calls Start() once in normal operation so risk is low. |
| `src/backglow_ctl/oscquery_http.cpp` | 106-110 | No exception guard on std::thread construction; listen socket leaked on throw (16-REVIEW.md WR-05) | WARNING | Socket leak if thread creation fails. Low probability on loopback. |
| `src/driver/device_provider.h` | 96-97 | m_hBackglowProcess / m_hBackglowStdinWrite not atomic<HANDLE> — data race between spawn and watchdog threads (16-REVIEW.md WR-03) | WARNING | C++ data race (UB); practically benign due to WaitForSingleObject happens-before, but not language-guaranteed. |

None of the anti-patterns represent functional placeholders or stubs. All are real implementations with identified hardening gaps documented in 16-REVIEW.md. None block the phase goal from being demonstrated on hardware.

### Human Verification Required

**All three items below are required before status can change to `passed`. These cannot be verified programmatically.**

#### 1. Startup Animation (VRCH-01e)

**Test:** Cold-launch SteamVR with MagWLED-1 connected. Ask user to enable the VDO.Ninja camera stream at `https://vdo.ninja/?view=JYMW97gq`. Observe HMD immediately after `Backglow: daemon spawned (pid=N)` appears in vrserver.txt.

**Expected:** All 10 LEDs fade from black to white (at ~50/255 ceiling brightness) over ~1 second, hold briefly (~250ms), then fade back to black over ~3 seconds. Total visible sequence ~4.25 seconds. Daemon stderr (via vrserver.txt inheritance) contains `Backglow-daemon: startup anim begin` and `Backglow-daemon: startup anim end`.

**Why human:** Visual hardware behavior on physical LEDs. Requires MagWLED-1 on COM11 + SteamVR running + camera stream. Cannot be verified from source code alone.

#### 2. Avatar Parameter to LED pipeline (VRCH-02a/b/c/d)

**Test:** With daemon running (daemon spawned after SteamVR launch, pipe connected), run:

- `py tests/osc_fixture/send_backglow.py --led 0 --r 1.0 --g 0.0 --b 0.0` (or `--port N` if OSCQuery primary path active)
- `py tests/osc_fixture/send_backglow.py --sweep`
- `py tests/osc_fixture/send_backglow.py --silence 5`
- `py tests/osc_fixture/send_backglow.py --bri-dedup`

**Expected:**
- VRCH-02a: LED 0 turns red within ~11ms (1 frame). LEDs 1-9 remain off.
- VRCH-02b: After --sweep, all 10 LEDs glow dim mid-grey (0.5 * 0.5 ceiling ≈ 12% per channel). Daemon log shows 31 dispatches.
- VRCH-02c: 3.5 seconds after --silence sends its initial frame, LEDs visibly ramp to dark over ~500ms. Log shows `silence-fade begin` then `silence-fade end -> off`.
- VRCH-02d: Exactly ONE `backglow bri 127` (or 128) line appears in vrserver.txt for the 10-shot bri-dedup burst.

**Why human:** End-to-end LED behavior requires active SteamVR session + MagWLED-1 + running daemon with pipe server. The WriterThread -> PipeClient -> LedController -> WLED path cannot be exercised without full hardware stack.

#### 3. OSCQuery HTTP probe and mDNS verification (VRCH-01f primary path)

**Test:** After SteamVR launch (daemon spawned), inspect daemon log for `OSCQuery primary path: UDP=N HTTP=M`. Then:

```
curl http://127.0.0.1:M/
curl "http://127.0.0.1:M/?HOST_INFO"
netstat -ano | findstr "127.0.0.1"
```

**Expected:**
- First curl: HTTP 200, JSON containing `"FULL_PATH":"/"` and `"avatar"` CONTENTS subtree.
- Second curl: HTTP 200, JSON containing `"OSC_PORT":N`, `"OSC_TRANSPORT":"UDP"`, `"OSC_IP":"127.0.0.1"`.
- netstat: two LISTENING rows — 127.0.0.1:N (UDP) and 127.0.0.1:M (TCP).

**Why human:** Requires the daemon to be running (driver-spawned or standalone) so ephemeral ports N and M are assigned by the OS. The static JSON correctness and OSCQuery protocol behavior cannot be verified without a live process.

---

## Gap Summary

No gaps blocking goal achievement were identified. All three roadmap success criteria are implemented in code with substantive, wired implementations. The `human_needed` status reflects that:

1. Hardware UAT for the full LED pipeline (VRCH-02a..d) and startup animation (VRCH-01e) requires the physical device + SteamVR session.
2. The OSCQuery HTTP responder (VRCH-01f) needs a running daemon to probe its ephemeral port.

The 16-02-SUMMARY.md hardware UAT already confirmed VRCH-01a (daemon spawn), VRCH-01b (Job Object kill-on-close within 2s), and VRCH-01c (no spawn in degraded state) on real hardware.

The 16-REVIEW.md identified 1 critical and 6 warnings (all robustness hardening items, none placeholder stubs). These are tracked issues but do not prevent the phase goal from being demonstrated.

---

_Verified: 2026-04-20T06:30:00Z_
_Verifier: Claude (gsd-verifier)_
