# Phase 16: VRChat OSC Bridge - Research

**Researched:** 2026-04-19
**Domain:** Windows daemon process — OSCQuery + mDNS discovery, OSC/UDP parsing, named-pipe client, driver-managed subprocess lifecycle (Job Object), LED frame coalescing
**Confidence:** HIGH overall; MEDIUM on exact OSCQuery discovery mechanics (see Open Question 1)

## Summary

Phase 16 delivers `beyond_backglow_ctl.exe`, a Windows daemon launched by `DeviceProvider::InitBackglow()` and tethered to the driver via a Windows Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. The daemon advertises itself via OSCQuery + mDNS (`_oscjson._tcp` and `_osc._udp` on 127.0.0.1), receives VRChat avatar parameter OSC on a loopback-only UDP port, maps 31 float params (`BackglowR0..9`, `BackglowG0..9`, `BackglowB0..9`, `BackglowBri`) into a coalesced 90 Hz frame, and emits `backglow fill` + `backglow bri` commands to the existing named pipe `\\.\pipe\beyond_proximity_ctl`. All architectural decisions are locked in CONTEXT.md — research focuses on what the planner needs to execute those decisions: library selection inside Claude's Discretion areas, Win32 API call sequences, OSCQuery minimum surface, pitfalls, and validation wiring.

The daemon has **no OpenVR dependency**. Pure Win32 (Winsock UDP + a minimal HTTP responder + named pipe client + Job Object receiver + three internal threads). It tolerates being killed at any moment: the driver restarts it with exponential backoff (1s → 2s → 4s, 3-strike cap per session). Graceful shutdown is a two-phase handshake (sentinel on the exit-signal channel, 250 ms wait, then Job Object close as hard guarantee). A scripted 4-second white-fade startup animation serves as a visible "daemon connected, pipe working, LEDs wired" smoke test on every driver load.

**Primary recommendation:** Build the daemon as three threads — OSC listener, 90 Hz pipe writer, mDNS/HTTP responder — sharing atomic per-LED RGB + Bri state. Use `oscpp` (ISC, header-only, already selected project-wide) for OSC decode, `mjansson/mdns` (public domain, 2 files: `mdns.h` + `mdns.c`) for mDNS advertising, and a hand-rolled ~150-line Winsock-based HTTP responder for the OSCQuery HTTP surface (only `GET /` and `GET /?HOST_INFO` are actually required; no external HTTP library needed). Hook the daemon into `DeviceProvider::InitBackglow()` success tail; close the Job Object handle from `DeviceProvider::Cleanup()` before `WSACleanup()`.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions (31 decisions)

**Daemon lifecycle**
- **D-01:** No separate "Enable Backglow" VRSettings toggle. Driver being loaded = backglow attempts to function. Refines `VRCH-01` / ROADMAP success criterion #1.
- **D-02:** Daemon spawned at the end of `DeviceProvider::InitBackglow()` on the success path. If backglow enters degraded-disabled state, daemon is NOT spawned.
- **D-03:** Kernel-enforced parent-kill via Windows Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. `CreateJobObject` → `SetInformationJobObject(JobObjectExtendedLimitInformation)` → `CreateProcess` + `AssignProcessToJobObject`. Job handle owned by `DeviceProvider`.
- **D-04:** Crash recovery: watchdog thread monitors daemon handle via `WaitForSingleObject`. Exponential backoff 1s → 2s → 4s, capped at 3 respawns per session. Uptime ≥ 60s resets counter. After cap → log ERR once, stop respawning.
- **D-05:** Daemon exe deployed alongside `BeyondProximity.dll` + `beyond_prox_ctl.exe` under external driver directory. `scripts/deploy-backglow-dev.ps1` updated to copy it.
- **D-06:** Graceful stop: write sentinel exit command, wait ~250 ms, then close Job Object handle. Order avoids hard-killing mid-UDP-send where possible; Job Object is the hard guarantee.

**Avatar OSC parameter surface**
- **D-07:** Per-LED from day 1. 10 LEDs × 3 channels + 1 global brightness = 31 avatar parameters.
- **D-08:** Parameter names PascalCase, zero-based LED index: `BackglowR0..9`, `BackglowG0..9`, `BackglowB0..9`, `BackglowBri`. Daemon subscribes to `/avatar/parameters/Backglow[R|G|B][0-9]` and `/avatar/parameters/BackglowBri`.
- **D-09:** All 31 parameters declared `Synced = false` (unsynced / local-only). 0 bits of sync budget consumed; full IEEE 754 precision retained. Avatar prefab contract (Phase 17) MUST document this.
- **D-10:** Value ranges: all 31 floats `[0.0, 1.0]`. Daemon clamps out-of-range values. Bri = 0.0 is functionally "off".
- **D-11:** No master Enable bool, no per-LED enable bools. Slider for `BackglowBri`; contacts drive per-LED RGB.

**OSC → pipe mapping + throttling**
- **D-12:** Daemon coalesces OSC updates and emits pipe commands at ≈90 Hz (~11 ms tick). Overrides Phase 14 D-11. Planner MUST retune `LedController::m_maxFps` default and cv-wait timeout to target 90 Hz.
- **D-13:** Brightness as separate `backglow bri <0-255>` pipe command — daemon computes `round(BackglowBri * 255)` and emits when integer changes. Driver-side ceiling clamp remains authoritative.
- **D-14:** Per-LED color dispatch: each tick, compute 30-byte frame via `round(channel * 255)`, assemble 10 hex strings, emit `backglow fill <h0> <h1> … <h9>`. If byte-identical to last sent → skip tick (8-bit dedup).
- **D-15:** Silence fade. No OSC for 3s → linear ramp from current `BackglowBri` integer to 0 over ~500 ms, emit `backglow bri N` at 90 Hz. Hit 0 → emit `backglow off`. Any OSC arrival before ramp reaches 0 cancels and resumes normal.
- **D-16:** Startup animation. On daemon launch + pipe connect, emit ~4-second white fade: `backglow fill FFFFFF…×10` once, ramp `backglow bri` 0→128 over 1s, hold briefly, ramp 128→0 over 3s, then `backglow off`. After animation, idle until first real OSC.
- **D-17:** Hardcoded param→LED mapping in daemon. No JSON config. Static table: `{BackglowR|G|B}{N}` → `frame[N].{r|g|b}`.

**OSC transport + discovery**
- **D-18:** VRChat OSCQuery + mDNS advertise is the primary receive path. Embedded HTTP server (`GET /`, `GET /{param-path}`) + mDNS-SD advertisement for `_oscjson._tcp` and `_osc._udp` on dynamically-bound localhost ports. Coexists with VRCOSC, OpenShock, VRCFaceTracking.
- **D-19:** Fallback to fixed UDP 9001 if OSCQuery / mDNS setup fails. WARN + legacy bind on `127.0.0.1:9001`. If 9001 is busy → ERR + `exit(1)`; driver respawn cap then applies.
- **D-20:** All UDP and HTTP binds loopback-only (`127.0.0.1`). No `0.0.0.0`. No LAN exposure. Avoids firewall prompts.
- **D-21:** OSC message filter: daemon ignores any address not matching `/avatar/parameters/Backglow*`. Malformed OSC dropped silently. In-memory counter per drop reason (not surfaced in Phase 16).
- **D-22:** mDNS + OSCQuery library = Claude's Discretion. Must be header-only / permissively licensed. OSCQuery HTTP server may be built directly on Winsock.

**File layout**
- **D-23:** New source tree `src/backglow_ctl/`: `main.cpp`, `osc_server.h/.cpp`, `mdns_advertise.h/.cpp`, `param_map.h/.cpp`, `pipe_client.h/.cpp`, `startup_anim.h/.cpp`, `silence_fade.h/.cpp` (silence_fade may be folded into osc_server or param_map — planner's call).
- **D-24:** `src/driver/device_provider.cpp` gains a `SpawnBackglowDaemon()` path. Watchdog thread tracks daemon handle + backoff.
- **D-25:** `CMakeLists.txt` adds new target `beyond_backglow_ctl` (executable, links oscpp + Winsock + HTTP/DNS api). Target wrapped in `ENABLE_BACKGLOW`.
- **D-26:** `LedController::m_maxFps` retuned from 30 → 90. Writer-thread 33 ms cv-wait → ~11 ms. Planner verifies no starvation of proximity reader thread or serial contention.

### Claude's Discretion
- mDNS library choice (Windows DNS-SD vs header-only `mdns.h` vs other). D-22.
- OSCQuery HTTP port allocation strategy (bind port 0 → OS-assigned vs probe 9000+N).
- Exact daemon log sink: stderr-only vs rolling file (e.g. `%LOCALAPPDATA%\Beyond Backglow\daemon.log`).
- Watchdog thread ownership (new thread on DeviceProvider vs reuse of existing pipe-poll loop in RunFrame).
- Graceful-exit signaling mechanism between driver and daemon (stdin close vs WM_CLOSE on hidden window vs dedicated "ctl" named pipe vs Windows Event handle).
- Exact startup-anim easing curve (linear suggested; ease-in-out acceptable).
- Silence-fade timer implementation (wall-clock vs performance counter; monotonic).
- `oscpp` receive buffer size (2 KB default likely sufficient).
- Pipe-client reconnect cadence if driver pipe server briefly disappears.
- Packaging of daemon's OSCQuery JSON endpoint payload structure beyond minimum VRChat requires.

### Deferred Ideas (OUT OF SCOPE)

**Phase 17 scope**
- Unity avatar prefab with VRCContactReceiver components (`VRCH-03`).
- Reference VRChat world with spatial colored light zones (`VRCH-04`).
- Avatar expression menu slider for `BackglowBri`.

**Post-v3.0 ergonomics**
- Runtime daemon kill-switch (`backglow daemon off` pipe command).
- JSON config file for custom param→LED mapping.
- `backglow status` daemon metrics extension (OSC messages/sec, drop count, last-received timestamp).
- Non-VRChat OSC sources (Resonite / TouchOSC / custom senders).
- Multi-headset sync (`ECOS-02`).
- Screen-sampling / ambilight mode (`ADVN-01`).
- Preset / effect library (`ADVN-02`).
- Avatar-menu self-control (`ADVN-03`).
- OSCQuery parameter discovery advertising full backglow namespace for other inspectors.

**Rejected during discussion**
- Separate "Enable Backglow" VRSettings toggle.
- Uniform-fill-only mapping.
- 8-bit synced-param encoding.
- RGB-packed-into-one-float synced param.
- Fixed 9001 + fail-if-busy as primary path.
- Timeout-based silence → instant off.
- Startup state = "send nothing, leave untouched".
- Brightness pre-multiplied into RGB on daemon side.

</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| VRCH-01 | Bridge daemon launched/stopped by driver DLL (refined by D-01: no separate toggle — driver-load drives lifecycle), listens for VRChat avatar OSC | Job Object lifecycle (Standard Stack §Daemon Lifecycle), OSCQuery+mDNS transport (Standard Stack §OSC Discovery), fallback 9001 (D-19), Code Examples §1–3 |
| VRCH-02 | Bridge maps avatar float params to LED pipe commands | Parameter schema (Standard Stack §Parameter Map), 90 Hz coalescing pipeline (Architecture Patterns §Pattern 5), Code Examples §4–5 |

The ROADMAP's original wording of VRCH-01 ("Enable Backglow toggle in VRSettings") is refined by CONTEXT D-01 to "driver-load drives daemon lifecycle." Planner should cite D-01 when closing VRCH-01 so the traceability table in REQUIREMENTS.md reflects the refinement.
</phase_requirements>

## Project Constraints (from CLAUDE.md)

Directives extracted from the project CLAUDE.md that the planner MUST honor:

- **Windows-only environment.** All terminal commands and tooling must work on Windows. Bash syntax (not cmd.exe) inside the harness is fine, but paths and binaries are Windows.
- **CMake build command:** `"C:/Program Files/Microsoft Visual Studio/2022/Community/Common7/IDE/CommonExtensions/Microsoft/CMake/CMake/bin/cmake.exe" --build build --config Release`. Use this exact path in any build step.
- **Deploy script:** `scripts/deploy-backglow-dev.ps1` — close SteamVR first (script enforces this). Copies artifacts from `build/driver/BeyondProximity/bin/win64/` to the external driver folder. MUST be extended to also copy `beyond_backglow_ctl.exe` (D-05).
- **SteamVR external driver folder:** `C:/Program Files (x86)/Steam/steamapps/common/Bigscreen Beyond Driver/bin/BeyondProximity/bin/win64/` — daemon lives here alongside DLL + CLI.
- **vrserver log for diagnostics:** `C:/Program Files (x86)/Steam/logs/vrserver.txt` — grep `Backglow:` for init, spawn, watchdog, pipe connect lines. SMOKE templates must cite this path.
- **Owl messaging for subagents:** If the planner dispatches subagents for parallel waves, every subagent gets an ID and runs `/owl listen <id>`. Parent uses `/owl send` for coordination.

## Standard Stack

### Core (new for Phase 16)

| Library / Component | Version | Purpose | Why Standard |
|---|---|---|---|
| `oscpp` | master @ 2024-ish commit [ASSUMED current; last commit visible on GitHub; no tagged releases], header-only, ISC license [VERIFIED: https://github.com/kaoskorobase/oscpp] | OSC packet decode for avatar parameter messages | Already selected project-wide in `.planning/research/STACK.md`. Header-only C++11 drops into `extern/oscpp/include/oscpp/`. Windows-supported per repo CI config. Zero networking baggage — pairs with Winsock. |
| `mjansson/mdns` | v1.4.3 (June 2023) [VERIFIED: https://github.com/mjansson/mdns] | mDNS-SD advertise for `_oscjson._tcp` + `_osc._udp` service types on 127.0.0.1 | Two files (`mdns.h` + `mdns.c`; repo markets as header-only, technically header+source). Public domain (Unlicense). Explicit Windows support with compile instructions. Minimal surface — advertise + one query handler. |
| Winsock2 (`ws2_32.lib`) | Windows SDK | UDP recvfrom for OSC on 127.0.0.1:N; TCP listener for OSCQuery HTTP | Already linked into driver (Phase 15 D-17, DDP). Daemon runs its own `WSAStartup`/`WSACleanup` — independent of driver's. |
| Win32 Named Pipe client API | Windows SDK | Pipe client to `\\.\pipe\beyond_proximity_ctl` | Already proven in `src/ctl/main.cpp`. Same `CreateFileA` + `SetNamedPipeHandleState(PIPE_READMODE_MESSAGE)` + `WriteFile` + `ReadFile` pattern. No new learning needed. |
| Win32 Job Objects | Windows SDK | Kernel-enforced parent-kill of daemon (D-03) | `CreateJobObjectW` / `SetInformationJobObject` / `AssignProcessToJobObject`. `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` is the exact flag that ties daemon lifetime to job-handle lifetime. [VERIFIED: Microsoft Learn — Job Objects, 2025-07-14 update] |

### Supporting

| Component | Purpose | When to Use |
|---|---|---|
| Hand-rolled HTTP responder on Winsock TCP | Serve minimal OSCQuery JSON responses — `GET /` returns root JSON, `GET /?HOST_INFO` returns `{OSC_PORT, OSC_TRANSPORT, NAME}` | Always. The OSCQuery surface VRChat actually reads is small enough that pulling civetweb/libmicrohttpd adds more build complexity than it saves. ~150 lines. |
| `std::atomic<uint8_t>` array (31 entries) inside `param_map.cpp` | Hold the 30 RGB slots + 1 Bri slot; written by OSC thread, read by 90 Hz pipe-writer thread | Lock-free path — each float-to-u8 slot is independent. Matches `HidDevice::m_lastProxDistance` atomic-pattern proof in existing code. |
| `std::thread` × 3 inside daemon | (1) OSC UDP recv loop; (2) 90 Hz pipe writer; (3) mDNS responder / HTTP accept — or fold HTTP+mDNS into one | The driver uses the same plain `std::thread` pattern in `LedController` and `HidDevice`; keep the daemon consistent. |
| `std::chrono::steady_clock` | Monotonic timer for 90 Hz tick, silence-fade ramp, startup-anim sequencing | Monotonic clock immune to system time changes (user flagged wall-clock vs steady as Claude's Discretion — steady is the safe pick). |

### Alternatives Considered (and rejected)

| Instead of | Could Use | Tradeoff — why we're NOT using it |
|---|---|---|
| `oscpp` (C++ header-only) | `tinyosc` (281 stars, pure C) | tinyosc requires compiling a `.c` file; oscpp drops into `extern/` as pure headers. Matches project's header-only dependency policy already set in Phases 14–15. |
| `oscpp` | `liblo` (POSIX-oriented, full stack) | liblo bundles its own networking layer (POSIX-centric), heavier and couples transport to the library. We already have Winsock. |
| `oscpp` | `oscpack` | Unmaintained (last update years ago); bundles its own networking. |
| `mjansson/mdns` | Windows DNS-SD API (`DnsServiceRegister` in `dnsapi.dll`) | DNS-SD works but adds `dnsapi.lib` dep and requires Windows 8+. `mjansson/mdns` is cross-platform, trivially auditable (~1000 LOC), public domain, and matches the vendored-header pattern from `oscpp`. `DnsServiceRegister` is a valid fallback if `mjansson/mdns` hits an unforeseen issue. |
| `mjansson/mdns` | Apple Bonjour SDK | Heavy runtime dep (users would need Bonjour installed); rejected. |
| Hand-rolled HTTP | `civetweb` (single-file) | civetweb is 10K+ LOC for features we don't need (Lua, WebSockets, TLS, large file uploads). Our OSCQuery HTTP surface is 2 static endpoints. |
| Hand-rolled HTTP | `cpp-httplib` (header-only) | Adds a large header (~9K LOC). Works, but overkill — we're serving 2 endpoints with fixed-size JSON responses. Acceptable fallback if hand-rolled parser is rejected during code review. |
| `ENABLE_BACKGLOW`-wrapped target in existing `CMakeLists.txt` | Separate sub-project under `extern/backglow_ctl/` | Adds repo-layout churn. Existing flag already wraps all src/led/ — continuing the pattern is zero-cost. |

### Installation

```bash
# 1. Vendor oscpp (header-only) — likely already in extern/oscpp from Phase 14/15 research prep; verify:
#    ls extern/oscpp/include/oscpp/  # should show client.hpp, server.hpp, etc.
#    If absent:
#    cd extern && git clone --depth=1 https://github.com/kaoskorobase/oscpp.git

# 2. Vendor mjansson/mdns (public domain, 2 files):
#    cd extern && git clone --depth=1 https://github.com/mjansson/mdns.git
#    Copy mdns.h into src/backglow_ctl/ OR extern/mdns/mdns.h; include in one .cpp.

# 3. CMake additions (ENABLE_BACKGLOW gated):
#    add_executable(beyond_backglow_ctl
#        src/backglow_ctl/main.cpp
#        src/backglow_ctl/osc_server.cpp
#        src/backglow_ctl/mdns_advertise.cpp
#        src/backglow_ctl/param_map.cpp
#        src/backglow_ctl/pipe_client.cpp
#        src/backglow_ctl/startup_anim.cpp)
#    target_include_directories(beyond_backglow_ctl PRIVATE extern/oscpp/include)
#    target_link_libraries(beyond_backglow_ctl PRIVATE ws2_32 iphlpapi)
#    # iphlpapi is for GetAdaptersAddresses — mjansson/mdns uses it on Windows
```

**Version verification:**

- `oscpp`: no tagged releases; track a pinned commit hash for reproducibility. The planner should `git rev-parse HEAD` after cloning and record that hash in CMake or a version-manifest file. [VERIFIED: project has no tags; rely on commit hash]
- `mjansson/mdns`: release v1.4.3 is the latest as of mid-2023; repository shows minimal recent activity but the code is small and audited. [VERIFIED: GitHub tags page]

**No new runtime deps** (no DLLs shipped beyond what Windows already provides — `ws2_32.dll`, `kernel32.dll`, `iphlpapi.dll`).

## Architecture Patterns

### Recommended Project Structure (D-23)

```
src/backglow_ctl/
├── main.cpp                 # argv parsing, top-level lifecycle, thread orchestration
├── osc_server.h/.cpp        # UDP recv + oscpp decode + OSC-address dispatch to ParamMap
├── mdns_advertise.h/.cpp    # mjansson/mdns service registration for _oscjson._tcp + _osc._udp
├── http_oscquery.h/.cpp     # Minimal OSCQuery HTTP responder (GET / + GET /?HOST_INFO)
├── param_map.h/.cpp         # 31 atomic slots, float-to-u8 conversion, frame assembly, Bri tracking
├── pipe_client.h/.cpp       # connect/reconnect to \\.\pipe\beyond_proximity_ctl, hex frame formatting
├── startup_anim.h/.cpp      # scripted 4s white fade (D-16)
└── silence_fade.h/.cpp      # 3s silence detection + 500ms bri ramp (D-15)
                              # (optional — may fold into osc_server or param_map per planner)
```

**Driver-side additions:**
- `src/driver/device_provider.cpp` gets new methods:
  - `SpawnBackglowDaemon()` — create Job Object, CreateProcess, AssignProcessToJobObject, start watchdog thread. Called from `InitBackglow()` success tail (D-02, D-24).
  - `StopBackglowDaemon()` — signal graceful exit, wait ~250 ms, close job handle (D-06). Called from `Cleanup()` BEFORE `WSACleanup()` and BEFORE `CleanupDriverLog()`.
  - `WatchdogThreadFunc()` — `WaitForSingleObject(daemonProcessHandle, INFINITE)`, checks exit code, applies backoff policy (D-04).
- Three new `DeviceProvider` members: `HANDLE m_hBackglowJob`, `HANDLE m_hBackglowProcess`, `std::thread m_watchdogThread`, `std::atomic<int> m_respawnCount`, `std::atomic<bool> m_bStopWatchdog`.

### Pattern 1: Driver-Managed Daemon Lifecycle via Windows Job Object

**What:** Kernel-enforced "if the driver dies, the daemon dies with it." No orphaned daemons.

**When to use:** Always in Phase 16 — D-03 locks this in. The pattern also applies to any future long-lived child process the driver spawns.

**Full call sequence:**

```cpp
// Source: VERIFIED against https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
//   and https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject

// Step 1: Create the job object.
HANDLE hJob = CreateJobObjectW(NULL, NULL);  // unnamed, default security
if (!hJob) { /* log GetLastError() */ return; }

// Step 2: Configure JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = {};
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(hJob, JobObjectExtendedLimitInformation,
                             &jeli, sizeof(jeli))) {
    CloseHandle(hJob); return;
}

// Step 3: CreateProcess with suspended thread, so we can attach to job BEFORE
//         child runs a single instruction (avoids race where child creates
//         un-tracked grandchildren).
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
if (!CreateProcessW(daemonExePath.c_str(), NULL, NULL, NULL, FALSE,
                    CREATE_SUSPENDED | CREATE_NO_WINDOW,
                    NULL, NULL, &si, &pi)) {
    CloseHandle(hJob); return;
}

// Step 4: Attach the (suspended) child to the job.
if (!AssignProcessToJobObject(hJob, pi.hProcess)) {
    TerminateProcess(pi.hProcess, 1);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
    CloseHandle(hJob);
    return;
}

// Step 5: Release the child to run.
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);  // we never use the thread handle

// Keep pi.hProcess (for watchdog WaitForSingleObject) and hJob (for kill-on-close).
```

**Closing the handle kills the daemon:**
```cpp
// In Cleanup(), after graceful-exit signal + 250ms wait:
CloseHandle(hJob);  // kernel terminates any still-running processes in the job
CloseHandle(pi.hProcess);
```

**Critical gotcha — already investigated:** if vrserver.exe itself is ever placed under a parent job (some process launchers / AV tools do this), `AssignProcessToJobObject` could fail on Windows 7 (nested jobs require Windows 8+). bey-closer already targets 64-bit Windows 10+ so this is not a concern [VERIFIED: CMakeLists.txt L16-22 "64-bit only" + project targets SteamVR which requires Win10+].

### Pattern 2: Hidden (or Reused) Graceful-Exit Signal

**What:** Two-phase shutdown. Phase 1 nudges the daemon so it can close its sockets cleanly; Phase 2 is the hard kill via Job Object close.

**When to use:** Every `DeviceProvider::Cleanup()` path (D-06).

**Mechanism choice — Claude's Discretion per CONTEXT.md:** Five candidates, with recommended pick:

| Mechanism | Cost | Verdict |
|---|---|---|
| **stdin close → daemon reads EOF on stdin** | Daemon must have its stdin redirected to a pipe when spawned. Low complexity. Works exactly like Phase 13's HMDUtility pattern at `device_provider.cpp:1112-1115`. | **RECOMMENDED** — identical to proven pattern already in the codebase. |
| Named Event (`CreateEventW` / `SetEvent` / `WaitForSingleObject` in daemon) | Requires shared event name. Daemon polls/waits on event. More moving parts. | Acceptable fallback. |
| WM_CLOSE to hidden daemon window | Daemon needs a message pump — violates "three threads, no UI." | **REJECTED**. |
| Dedicated "ctl" named pipe (daemon → driver) | Flips the pipe direction. Adds a second pipe. Complexity without benefit. | **REJECTED**. |
| SIGINT equivalent via GenerateConsoleCtrlEvent | Flaky across console-subsystem/windowed processes. | **REJECTED**. |

**Recommendation: stdin-close.** Reuse the exact pattern from the HMDUtility helper at `device_provider.cpp:1041-1144`. Daemon main loop blocks on a small read thread that returns EOF when parent closes the write-end of the stdin pipe; that read-thread then flips an atomic stop flag and joins the three worker threads.

### Pattern 3: Respawn Watchdog with Exponential Backoff (D-04)

**What:** Separate background thread on the driver side owns the daemon process handle; reacts to process exit.

**Thread pseudocode:**
```
while not m_bStopWatchdog:
    wait = WaitForSingleObject(m_hBackglowProcess, INFINITE)
    if wait == WAIT_OBJECT_0:
        exitCode = GetExitCodeProcess(m_hBackglowProcess)
        CloseHandle(m_hBackglowProcess)
        m_hBackglowProcess = NULL

        if m_bStopWatchdog: break  # normal shutdown

        uptime = now - last_spawn_time
        if uptime >= 60s: m_respawnCount = 0  # reset counter — daemon was stable

        if m_respawnCount >= 3:
            DriverLog("Backglow: daemon respawn cap reached (3); giving up for this session")
            break

        backoff = [1000ms, 2000ms, 4000ms][m_respawnCount]
        Sleep(backoff)
        m_respawnCount += 1
        last_spawn_time = now
        SpawnBackglowDaemon()  # repeats CreateJobObject/CreateProcess/AssignProcessToJobObject
```

**Important:** SpawnBackglowDaemon creates a **new** job object each respawn. Do NOT reuse the old job handle after its sole process has exited — `AssignProcessToJobObject` semantics on a job whose kill-on-close already fired is undefined territory.

### Pattern 4: Three-Thread Daemon Internal Architecture

```
beyond_backglow_ctl.exe
 ├── Main thread
 │    ├── argv parse + logging setup
 │    ├── Winsock WSAStartup
 │    ├── resolve OSCQuery ports (probe 127.0.0.1:0 → OS-assigned)
 │    ├── connect to \\.\pipe\beyond_proximity_ctl
 │    ├── run startup_anim (D-16) synchronously on main thread
 │    ├── spawn OSC thread, Writer thread, mDNS/HTTP thread
 │    ├── read-EOF-on-stdin monitor (pattern 2)
 │    └── join threads → WSACleanup → exit(0)
 │
 ├── OSC thread
 │    ├── recvfrom(udpSock) with 500ms SO_RCVTIMEO
 │    ├── oscpp::server::Packet decode
 │    ├── route /avatar/parameters/Backglow* → ParamMap::Update
 │    ├── other addresses → increment drop counter, discard
 │    └── on recv timeout: check silence_fade state (D-15)
 │
 ├── Writer thread (90 Hz tick)
 │    ├── sleep_until(lastTick + 11ms)
 │    ├── ParamMap::Snapshot → 30-byte RGB frame + Bri u8
 │    ├── if Bri changed: pipe_client::WriteLine("backglow bri N")
 │    ├── if frame != lastFrame: pipe_client::WriteLine("backglow fill h0 h1 … h9")
 │    ├── if silence_fade hit zero: pipe_client::WriteLine("backglow off")
 │    └── track lastFrame, lastBri for dedup (D-14)
 │
 └── mDNS/HTTP thread (may be folded — HTTP accept is rare; mDNS lib has its own loop)
      ├── mdns_socket_listen() loop via mjansson/mdns
      ├── handle _oscjson._tcp and _osc._udp queries → respond with TCP + UDP ports
      ├── TCP accept() on OSCQuery HTTP port
      └── serve GET / and GET /?HOST_INFO
```

**Shared state (between OSC thread and Writer thread):**
```cpp
// param_map.h — lock-free via atomics on 8-bit quantized values
std::atomic<uint8_t> m_rgb[30];    // 10 LEDs × 3 channels, pre-quantized to u8
std::atomic<uint8_t> m_bri;        // 0..255
std::atomic<int64_t> m_lastOscNs;  // steady_clock nanos, for silence_fade (D-15)
```

Slight coherency note: the 30-byte frame snapshot is NOT atomic as a whole — Writer thread reads 30 atomic loads sequentially. Worst case is a visual tear across two 11ms frames, which is imperceptible for ambient lighting. Explicitly acceptable per D-07's "per-LED from day 1" design intent (no sync-relationship between slots).

### Pattern 5: 90 Hz Pipe Writer with Frame Dedup (D-12, D-14)

Reference reading for the planner: `src/led/led_controller.cpp` lines 69-175 — the existing Phase 14 writer-thread pattern with `cv.wait_for(33ms)`. Daemon's Writer thread is a simpler variant: fixed 11 ms tick, no cv, no inter-command gap (pipe is local and can take 90 Hz easily), ONLY dedup on the 30-byte frame + the Bri integer.

**Bandwidth sanity check (carry from CONTEXT D-12):** 70-byte `backglow fill` write × 90 Hz = 6.3 KB/s on local named pipe — negligible. USB Adalight 36 B/frame × 90 Hz = 3.24 KB/s vs ~11.5 KB/s at 115200 baud = 28% — fine. DDP at 90 Hz fine.

### Pattern 6: OSCQuery HTTP Responder (minimal surface)

**Verified mandatory endpoints** [CITED: OSCQuery Proposal https://github.com/Vidvox/OSCQueryProposal]:
- `GET /` → JSON describing the OSC address tree. Must include at least: `FULL_PATH: "/"`, `CONTENTS: { "avatar": { ... } }`. VRChat dispatches OSC to clients whose tree contains `/avatar` per [CITED: https://github.com/vrchat-community/osc/wiki/OSCQuery].
- `GET /?HOST_INFO` → JSON with `OSC_PORT` (our UDP receive port), `OSC_TRANSPORT: "UDP"`, `NAME: "Beyond Backglow"`.

**Minimum JSON for root:**
```json
{
  "DESCRIPTION": "Beyond Backglow LED bridge",
  "FULL_PATH": "/",
  "ACCESS": 0,
  "CONTENTS": {
    "avatar": {
      "FULL_PATH": "/avatar",
      "ACCESS": 0,
      "CONTENTS": {
        "parameters": {
          "FULL_PATH": "/avatar/parameters",
          "ACCESS": 0
        }
      }
    }
  }
}
```

Phase 16 does NOT need to enumerate the 31 BackglowR0..9/G0..9/B0..9/Bri slots in the JSON — VRChat sends ANY parameter at `/avatar/parameters/*` to any subscriber that advertises the `/avatar` subtree. [CITED: vrc-oscquery-lib Readme — "/avatar path will receive /avatar/change as well as /avatar/parameter/* messages"]. Param enumeration is Deferred per CONTEXT (post-v3.0 "OSCQuery parameter discovery advertising full backglow param namespace").

**HOST_INFO response:**
```json
{
  "NAME": "Beyond Backglow",
  "OSC_PORT": 12345,
  "OSC_TRANSPORT": "UDP",
  "OSC_IP": "127.0.0.1",
  "EXTENSIONS": { "ACCESS": true, "VALUE": true, "DESCRIPTION": true }
}
```

**Hand-rolled HTTP parser scope (~150 lines):**
1. `listen()` on TCP port; `accept()` loop.
2. `recv()` until `\r\n\r\n`; split into request line + headers.
3. Match on path substring ("/" vs "?HOST_INFO"); ignore other fields.
4. `send()` fixed response string with `Content-Length`, `Content-Type: application/json`, `Connection: close`.
5. Close socket. Done.

No need to handle chunked encoding, keep-alive, or method other than GET. VRChat never sends POST/PUT to OSCQuery servers.

### Anti-Patterns to Avoid

- **`0.0.0.0` binds.** D-20 locks loopback-only. Anything else triggers Windows firewall on first run and creates an attack-surface footgun.
- **Blocking stdin read on the main thread without a stop mechanism.** Must run in a dedicated thread so main can still join other threads after graceful exit.
- **Storing the Job Object handle across the driver-unload boundary.** Driver unload calls Cleanup, which closes the handle, which kills the daemon. This is correct. But do NOT expose the handle globally or store it in the registry / a file.
- **Per-message HTTP responses that allocate on the hot path.** Build both JSON responses as static `const char*` at daemon startup; serve them verbatim.
- **Calling `WSAStartup` inside the daemon AFTER spawning the first thread.** Main must finish all socket setup before spawning sibling threads that also use sockets.
- **Forgetting to retune `LedController::m_maxFps`.** CONTEXT D-26 is easy to miss; it's a one-line default change in `src/led/led_controller.cpp:78` (`m_cv.wait_for(lk, std::chrono::milliseconds(33), ...)` → `milliseconds(11)`) plus the default in the LedController header if exposed.
- **Non-monotonic clock for silence-fade.** `std::chrono::system_clock` can jump on NTP sync; use `steady_clock` only.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| OSC packet decoding (bundles, typetags, blobs, nested messages) | A custom parser "since we only use floats" | `oscpp` (header-only, ISC) | Even a float-only parser needs to handle typetag strings, length-prefix padding to 4-byte boundaries, bundle timetags, and malformed-packet safety. oscpp is 20 KB of tested headers. |
| mDNS-SD service advertising (answer ANY, SRV, TXT, PTR queries correctly; handle conflicts) | A Winsock-based mDNS responder | `mjansson/mdns` (public domain, ~1000 LOC) | Correct mDNS is 2000+ lines if you care about name conflict resolution, TTL decay, and DNS-SD conformance. |
| Waiting for a child process with a timeout | Raw `Sleep`-loop + `GetExitCodeProcess` polling | `WaitForSingleObject(handle, timeoutMs)` | HMDUtility uses the polling loop only because lighthouse_console has other constraints; for the daemon, `WaitForSingleObject` with a Job-Object wake-on-job-close is the clean path. |
| IEEE 754 float → u8 quantization for LEDs | Custom gamma / rounding schemes | `static_cast<uint8_t>(std::clamp(f, 0.f, 1.f) * 255.f + 0.5f)` | LEDs are 8-bit anyway; any fancier quantization is a Phase 17+ concern. |
| Exponential backoff logic | Custom ad-hoc `Sleep(1000)` retries | Static `const int backoff_ms[] = {1000, 2000, 4000}` table indexed by attempt count | D-04 spells out the exact values. Table is 3 entries. |
| Integer arithmetic for "round(channel × ceiling ÷ 255)" | Floating point conversions | `(v * ceiling) / 255u` (match existing `LedController::ApplyCeilingInPlace` at `src/led/led_controller.cpp:177`) | Already audited and brightness-ceiling-correct in Phase 14; daemon just matches. |

**Key insight:** The daemon looks simple from a distance ("small OSC-to-pipe bridge") but has 6-7 independent correctness concerns (process lifecycle, threading, OSC decode, mDNS, HTTP, pipe reconnect, silence fade). Picking two strong vendored libraries (`oscpp` + `mjansson/mdns`) reduces the custom-code surface by ~60%. Build the glue; vendor the protocols.

## Runtime State Inventory

Phase 16 is a **greenfield daemon phase** — no rename/refactor. All sections below answered explicitly per the research protocol, mostly empty as expected.

| Category | Items Found | Action Required |
|---|---|---|
| Stored data | None — no databases, no persisted files. Daemon is stateless across launches. CONTEXT D-17 explicitly rejects a JSON config file. | None. |
| Live service config | None that Phase 16 *changes* — but Phase 16 itself **registers** a live service into mDNS every session. The mDNS registration is ephemeral (dies with daemon process); no cleanup needed because Phase 16 is building it, not renaming it. VRChat's own OSCQuery registration is unrelated. | None. |
| OS-registered state | None. Daemon is NOT registered with Windows Service Manager, Task Scheduler, or startup registry keys. Lifecycle is 100% tied to driver DLL load via Job Object. | None. |
| Secrets/env vars | None. No credentials. `BACKGLOW_DAEMON_LOG` env var may be introduced for optional log-path override (Claude's Discretion — log sink), but it's a new name, no rename. | None. |
| Build artifacts | New target `beyond_backglow_ctl.exe` added to `build/driver/BeyondProximity/bin/win64/`. Deploy script already handles the directory; add a new `Copy-Item` line for the daemon exe (D-05). | Update `scripts/deploy-backglow-dev.ps1` (append one Copy-Item; match existing pattern at line 25). Update installer Inno Setup script to include daemon exe. |

**Nothing found in most categories** — the pattern is "spawn-a-new-process that owns its own ephemeral state." Confirmed by grep of CONTEXT.md decisions (no rename, no string replace, no migration of prior config).

## Common Pitfalls

### Pitfall 1: OSCQuery Discovery Mechanics are Under-Documented

**What goes wrong:** You build an OSCQuery HTTP server and mDNS advertise `_oscjson._tcp`, but VRChat never sends you avatar OSC. Or worse: VRChat sends to a port you didn't think you'd advertised.

**Why it happens:** VRChat's OSCQuery documentation (docs.vrchat.com/docs/oscquery) is terse and does not definitively state whether the library reads your UDP port from:
- A **`_osc._udp` mDNS advertisement** you publish alongside `_oscjson._tcp`, OR
- A **HOST_INFO query** over HTTP returning `OSC_PORT` + `OSC_TRANSPORT`, OR
- Both — whichever the server provides.

**Evidence surveyed:**
- OSCQuery Proposal [CITED: https://github.com/Vidvox/OSCQueryProposal] specifies `_oscjson._tcp` as the *only* mandatory service type.
- VRChat's reference library (`vrc-oscquery-lib`) has both `.WithTcpPort()` and `.WithUdpPort()` in its builder, and its sample code advertises both service types [CITED: https://github.com/vrchat-community/vrc-oscquery-lib].
- HOST_INFO is queried "only if the client software explicitly queries this attribute" per the OSCQuery Proposal [CITED: OSCQuery Proposal].
- Community library `Natsumi-sama/OscQueryLibrary` and `minetake01/vrchat_osc` both advertise **both** `_oscjson._tcp` AND `_osc._udp`.

**How to avoid:** Advertise **both** `_oscjson._tcp` (for the HTTP/JSON side) AND `_osc._udp` (for the UDP OSC receive port). Also implement `GET /?HOST_INFO` returning `OSC_PORT` + `OSC_TRANSPORT: "UDP"`. This triple-redundancy matches what all known shipping VRChat OSCQuery integrations do. Do not rely on just one of the three.

**Warning signs:**
- Daemon starts, HTTP responds correctly, mDNS advertises successfully, but VRChat emits no notification HUD and no `/avatar/parameters/*` traffic arrives.
- VRChat's own log (`%APPDATA%\..\LocalLow\VRChat\VRChat\output_log_*.txt`) doesn't mention discovering "Beyond Backglow".

**Phase to address:** Planner includes a UAT step that watches the daemon's log for the first `/avatar/parameters/Backglow*` packet when VRChat connects. Camera stream can confirm the visual (LEDs changing). If this step fails, the fallback is D-19 (fixed UDP 9001) — which is less capable but known to work for solo-tool users.

### Pitfall 2: Job Object Breakaway in Nested Job Scenarios

**What goes wrong:** Some Windows environments (enterprise AV, certain IDEs, Docker-on-Windows hosts) place vrserver.exe under a parent Job Object. On Windows 7/Server 2008 R2, `AssignProcessToJobObject` then fails because nested jobs aren't supported. On Windows 8+, nested jobs work but inherit restrictive flags from the parent that can prevent kill-on-close from firing.

**Why it happens:** vrserver.exe may be monitored by external tools. Our daemon inherits the job association chain by default.

**How to avoid:**
- Windows 10+ only target (already set per CMakeLists.txt L16-22). [VERIFIED]
- Spawn the daemon with `CREATE_BREAKAWAY_FROM_JOB` in dwCreationFlags. If the containing job permits breakaway (`JOB_OBJECT_LIMIT_BREAKAWAY_OK`), the daemon is unconstrained and our job takes full control. If the containing job denies breakaway, `CreateProcess` fails gracefully and we log + continue (daemon doesn't spawn; watchdog backoff eventually gives up; driver is still healthy).
- Accept a small risk: in adversarial nested-job scenarios the daemon might fail to spawn. This is acceptable — backglow degrades silently, proximity + IPD keep working.

**Warning signs:**
- `CreateProcess` fails with `ERROR_ACCESS_DENIED` (5) when flag combination is wrong.
- `AssignProcessToJobObject` fails with `ERROR_ACCESS_DENIED` (5).
- Daemon actually spawns but survives driver unload (kill-on-close did not fire).

**Phase to address:** Plan includes a smoke test where the tester force-terminates vrserver.exe via Task Manager and verifies within 2 seconds that `beyond_backglow_ctl.exe` is also gone (`Get-Process beyond_backglow_ctl -ErrorAction SilentlyContinue`).

### Pitfall 3: Firewall Prompt on First Daemon Launch

**What goes wrong:** If the daemon binds to anything other than `127.0.0.1` (e.g., defaults to `0.0.0.0`), Windows SmartScreen / Windows Defender Firewall prompts the user to allow the app through the firewall. During VR that prompt is invisible (locked behind HMD unless user removes headset). User gets stuck.

**Why it happens:** Windows treats UDP/TCP listeners bound to non-loopback addresses as network services; loopback-only binds don't trigger the prompt.

**How to avoid:** D-20 is the mitigation — **ALL** Winsock binds must explicitly `inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr)`, never `INADDR_ANY` / `0.0.0.0`. Verify in code review and smoke test.

**Warning signs:**
- Any `htonl(INADDR_ANY)` in daemon Winsock setup.
- First-launch UAT on a clean machine triggers the firewall dialog.

**Phase to address:** Planner includes grep check for `INADDR_ANY` in a verification task. Code review step flags any non-loopback bind.

### Pitfall 4: Winsock Global State Race Between Driver and Daemon

**What goes wrong:** Daemon and driver both run `WSAStartup` / `WSACleanup`. If one of them accidentally manages the other's Winsock state, sockets get torn down mid-operation.

**Why it happens:** Winsock refcounts globally per-process. But daemon and driver are **separate processes**, so their Winsock state is independent.

**How to avoid:** This pitfall is a non-issue by design — daemon is its own process. Just be explicit about it in code comments at the top of `main.cpp`: "this daemon runs its own WSAStartup/WSACleanup lifecycle, independent of the driver's."

**Warning signs:** None expected given the process boundary. Including this pitfall because it's an easy assumption to screw up if a future refactor ever merged the daemon into the driver DLL — which would violate ARCH §5's core boundary.

**Phase to address:** Code-review reminder. No functional work.

### Pitfall 5: Silence-Fade Overwrites Live OSC Updates

**What goes wrong:** Silence-fade timer fires the 500 ms ramp while a new OSC packet is simultaneously arriving. Writer thread emits `backglow bri N` for fade AND `backglow fill …` for new OSC — last-write-wins; depending on thread scheduling the daemon can ramp-down a frame the user just triggered.

**Why it happens:** Two independent state machines (silence-fade ramp + OSC-driven update) both write to the Writer's tick output.

**How to avoid:**
- On ANY OSC packet arrival in the OSC thread: atomically reset `m_lastOscNs` AND clear any `m_fadingDown` flag.
- Writer thread checks `m_fadingDown` before choosing between "emit from ParamMap" vs "emit ramped Bri". OSC arrival flips back to ParamMap path mid-ramp instantly (D-15 explicit requirement: "Arrival of any new OSC packet before the ramp reaches 0 cancels the ramp").
- Acceptable worst-case: one tick of stale ramped-down value, then the next tick uses live OSC state. 11 ms stutter is imperceptible.

**Warning signs:**
- User reports "LEDs briefly get dim, then jump back up" during active OSC streams.
- Writer thread runs both code paths in a single tick (bug).

**Phase to address:** Unit test in the Writer thread with a mocked ParamMap simulating silence-then-arrival. Validation Architecture §Test Framework below.

### Pitfall 6: Per-LED Pipe Write at 90 Hz Blocks on Pipe Server

**What goes wrong:** Writer thread writes to `\\.\pipe\beyond_proximity_ctl` at 90 Hz. If the pipe server (driver) is busy, `WriteFile` on the pipe can block. The daemon's Writer thread stalls; OSC thread continues stuffing ParamMap (fine, atomic), but user sees LEDs freeze.

**Why it happens:** Named pipes in message mode have server-side buffers. Under contention (driver's RunFrame is momentarily busy polling the pipe), the client's `WriteFile` blocks.

**How to avoid:**
- Open the pipe with `FILE_FLAG_OVERLAPPED` on daemon side and use `WriteFile` + `WaitForSingleObject` with a 100 ms timeout. On timeout, drop the frame and move on. Matches the defensive writer pattern from PITFALLS §1 (original research).
- Driver-side pipe server is `PIPE_NOWAIT` (verified at `device_provider.cpp:420` via grep). Non-blocking server + overlapped client = no hang.
- Measure actual observed latency under sustained 90 Hz writes during UAT; if consistently under 2 ms, the timeout is pure safety net.

**Warning signs:**
- `WriteFile` return from the daemon's pipe client takes > 5 ms under any normal condition.
- Daemon log shows "dropped pipe write (timeout)" lines during active VRChat session.

**Phase to address:** Planner includes instrumentation task — daemon logs histogram of pipe-write latencies on graceful shutdown. SMOKE UAT cross-references.

### Pitfall 7: oscpp Packet Boundary Assumptions in UDP

**What goes wrong:** VRChat can send `/avatar/parameters/*` OSC wrapped in an OSC bundle (multiple messages in one datagram). Daemon parser reads only the first message, drops the rest.

**Why it happens:** Real OSC traffic in VRChat mixes single-message packets and bundles. oscpp handles bundles correctly IF you call `packet.isBundle()` / iterate `server::Bundle::elements()`.

**How to avoid:** Follow oscpp's Server.hpp usage pattern exactly:
```cpp
using namespace oscpp::server;
Packet pkt(buf, size);
if (pkt.isBundle()) {
    Bundle bundle(pkt);
    for (auto& elem : bundle) {
        HandleElement(elem);  // recurses for nested bundles
    }
} else {
    HandleMessage(Message(pkt));
}
```

**Warning signs:**
- Only SOME avatar parameter changes land on LEDs, others don't — dependent on whether the sender batched them.

**Phase to address:** Reference code example in `osc_server.cpp` must handle bundles from day one; unit test with both a single-message packet and a bundle-of-three as fixtures.

## Code Examples

### Example 1: Job-Object Tethered Spawn (driver side)

```cpp
// Source: Microsoft Learn — https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
//         https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject
// In src/driver/device_provider.cpp, new method SpawnBackglowDaemon()

bool DeviceProvider::SpawnBackglowDaemon()
{
    // Resolve daemon path relative to this DLL.
    wchar_t dllPath[MAX_PATH] = {};
    HMODULE hMod = NULL;
    GetModuleHandleExW(
        GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
        reinterpret_cast<LPCWSTR>(&SpawnBackglowDaemon), &hMod);
    GetModuleFileNameW(hMod, dllPath, MAX_PATH);
    std::wstring daemonPath(dllPath);
    daemonPath = daemonPath.substr(0, daemonPath.find_last_of(L"\\/"));
    daemonPath += L"\\beyond_backglow_ctl.exe";

    m_hBackglowJob = CreateJobObjectW(NULL, NULL);
    if (!m_hBackglowJob) {
        DriverLog("Backglow: CreateJobObjectW failed (err=%lu)\n", GetLastError());
        return false;
    }

    JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = {};
    jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
    if (!SetInformationJobObject(m_hBackglowJob, JobObjectExtendedLimitInformation,
                                 &jeli, sizeof(jeli))) {
        DriverLog("Backglow: SetInformationJobObject failed (err=%lu)\n", GetLastError());
        CloseHandle(m_hBackglowJob); m_hBackglowJob = NULL;
        return false;
    }

    // Create stdin pipe for graceful-exit signal (Pattern 2).
    SECURITY_ATTRIBUTES sa = { sizeof(sa), NULL, TRUE };
    HANDLE hStdinRead = NULL, hStdinWrite = NULL;
    if (!CreatePipe(&hStdinRead, &hStdinWrite, &sa, 0) ||
        !SetHandleInformation(hStdinWrite, HANDLE_FLAG_INHERIT, 0)) {
        CloseHandle(m_hBackglowJob); m_hBackglowJob = NULL;
        return false;
    }

    STARTUPINFOW si = { sizeof(si) };
    si.dwFlags    = STARTF_USESTDHANDLES;
    si.hStdInput  = hStdinRead;
    si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);  // inherit — daemon logs may go to vrserver log
    si.hStdError  = GetStdHandle(STD_ERROR_HANDLE);
    PROCESS_INFORMATION pi = {};
    if (!CreateProcessW(daemonPath.c_str(), NULL, NULL, NULL, TRUE,
                        CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_BREAKAWAY_FROM_JOB,
                        NULL, NULL, &si, &pi)) {
        DWORD err = GetLastError();
        // Retry without BREAKAWAY if the containing job doesn't allow it.
        if (err == ERROR_ACCESS_DENIED) {
            if (!CreateProcessW(daemonPath.c_str(), NULL, NULL, NULL, TRUE,
                                CREATE_SUSPENDED | CREATE_NO_WINDOW,
                                NULL, NULL, &si, &pi)) {
                DriverLog("Backglow: CreateProcess failed twice (err=%lu)\n", GetLastError());
                CloseHandle(hStdinRead); CloseHandle(hStdinWrite);
                CloseHandle(m_hBackglowJob); m_hBackglowJob = NULL;
                return false;
            }
        } else {
            DriverLog("Backglow: CreateProcess failed (err=%lu)\n", err);
            CloseHandle(hStdinRead); CloseHandle(hStdinWrite);
            CloseHandle(m_hBackglowJob); m_hBackglowJob = NULL;
            return false;
        }
    }

    CloseHandle(hStdinRead);  // child owns its copy

    if (!AssignProcessToJobObject(m_hBackglowJob, pi.hProcess)) {
        DriverLog("Backglow: AssignProcessToJobObject failed (err=%lu)\n", GetLastError());
        TerminateProcess(pi.hProcess, 1);
        CloseHandle(pi.hThread); CloseHandle(pi.hProcess);
        CloseHandle(hStdinWrite);
        CloseHandle(m_hBackglowJob); m_hBackglowJob = NULL;
        return false;
    }

    ResumeThread(pi.hThread);
    CloseHandle(pi.hThread);

    m_hBackglowProcess = pi.hProcess;
    m_hBackglowStdinWrite = hStdinWrite;
    m_backglowSpawnTime = std::chrono::steady_clock::now();
    DriverLog("Backglow: daemon spawned (pid=%lu)\n", pi.dwProcessId);

    // Launch watchdog thread.
    m_watchdogThread = std::thread(&DeviceProvider::WatchdogThreadFunc, this);
    return true;
}
```

### Example 2: Graceful Stop Pattern (driver side)

```cpp
// In DeviceProvider::Cleanup(), inserted between ShutdownAllOff and WSACleanup.

void DeviceProvider::StopBackglowDaemon()
{
    m_bStopWatchdog.store(true);

    // Phase 1: close the stdin write-end so daemon reads EOF.
    if (m_hBackglowStdinWrite) {
        CloseHandle(m_hBackglowStdinWrite);
        m_hBackglowStdinWrite = NULL;
    }

    // Phase 2: bounded wait for graceful exit.
    if (m_hBackglowProcess) {
        WaitForSingleObject(m_hBackglowProcess, 250);
    }

    // Phase 3: close Job Object handle — kernel kills anything still running.
    if (m_hBackglowJob) {
        CloseHandle(m_hBackglowJob);  // triggers JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
        m_hBackglowJob = NULL;
    }

    if (m_hBackglowProcess) {
        CloseHandle(m_hBackglowProcess);
        m_hBackglowProcess = NULL;
    }

    if (m_watchdogThread.joinable()) {
        m_watchdogThread.join();
    }
}
```

### Example 3: OSC Message Dispatch (daemon side)

```cpp
// src/backglow_ctl/osc_server.cpp
// Source: oscpp usage pattern — https://github.com/kaoskorobase/oscpp/blob/master/include/oscpp/server.hpp

#include <oscpp/server.hpp>
#include "param_map.h"

void HandleOscMessage(const oscpp::server::Message& msg, ParamMap& map)
{
    const char* addr = msg.address();
    // Expected forms:
    //   /avatar/parameters/BackglowR3
    //   /avatar/parameters/BackglowBri
    static constexpr const char* kPrefix = "/avatar/parameters/Backglow";
    static constexpr size_t kPrefixLen = 27;
    if (std::strncmp(addr, kPrefix, kPrefixLen) != 0) return;  // D-21 filter

    const char* suffix = addr + kPrefixLen;  // "R3", "G9", "Bri"
    auto args = msg.args();
    if (!args.atEnd() && args.nextTag() != 'f') return;  // D-21 drop malformed
    const float v = args.float32();

    if (std::strcmp(suffix, "Bri") == 0) {
        map.SetBrightness(v);
        return;
    }

    // Parse "[RGB][0-9]"
    if (suffix[0] != 'R' && suffix[0] != 'G' && suffix[0] != 'B') return;
    if (suffix[1] < '0' || suffix[1] > '9') return;
    if (suffix[2] != '\0') return;

    const int channel = (suffix[0] == 'R') ? 0 : (suffix[0] == 'G') ? 1 : 2;
    const int ledIdx  = suffix[1] - '0';
    map.SetChannel(ledIdx, channel, v);
}

void HandleOscPacket(const uint8_t* buf, size_t size, ParamMap& map)
{
    oscpp::server::Packet pkt(buf, size);
    if (pkt.isBundle()) {
        oscpp::server::Bundle b(pkt);
        for (auto elem : b) {
            // oscpp supports nested bundles — recurse.
            HandleOscPacket(elem.data(), elem.size(), map);
        }
    } else {
        HandleOscMessage(oscpp::server::Message(pkt), map);
    }
    map.MarkOscArrived();  // resets silence-fade timer
}
```

### Example 4: Frame Assembly and Pipe Dispatch (daemon side)

```cpp
// src/backglow_ctl/pipe_client.cpp — Writer thread tick

void WriterThreadFunc(ParamMap& map, PipeClient& pipe)
{
    auto nextTick = std::chrono::steady_clock::now();
    std::array<uint8_t, 30> lastFrame{};  // init zero
    uint8_t lastBri = 0;
    bool first = true;

    while (!g_stop.load()) {
        std::this_thread::sleep_until(nextTick);
        nextTick += std::chrono::milliseconds(11);  // ~90 Hz

        // Handle silence-fade + startup-anim state here (omitted for brevity).

        std::array<uint8_t, 30> frame = map.SnapshotFrame();
        uint8_t bri = map.SnapshotBri();

        if (first || bri != lastBri) {
            char line[32];
            std::snprintf(line, sizeof(line), "backglow bri %u", (unsigned)bri);
            pipe.SendCommand(line);
            lastBri = bri;
        }

        if (first || frame != lastFrame) {
            // Assemble "backglow fill h0 h1 h2 h3 h4 h5 h6 h7 h8 h9"
            char line[128];
            int off = std::snprintf(line, sizeof(line), "backglow fill");
            for (int i = 0; i < 10; ++i) {
                off += std::snprintf(line + off, sizeof(line) - off, " %02X%02X%02X",
                                     frame[i*3+0], frame[i*3+1], frame[i*3+2]);
            }
            pipe.SendCommand(line);
            lastFrame = frame;
        }

        first = false;
    }
}
```

### Example 5: mDNS Dual-Service Advertise (daemon side)

Pseudo-outline; real call signature from `mjansson/mdns` will be filled in during implementation:
```cpp
// src/backglow_ctl/mdns_advertise.cpp — sketch
// Source: https://github.com/mjansson/mdns — see mdns.h and mdns.c examples/mdns.c

// At startup, after UDP OSC socket is bound on loopback:
//   int oscUdpPort  = <port OS assigned when we bound 127.0.0.1:0>;
//   int httpTcpPort = <port OS assigned when we bound 127.0.0.1:0>;
//
// mdns_socket_open_ipv4(addr);
// mdns_service_create_t osc_svc   = { "_osc._udp",    "Beyond Backglow", oscUdpPort };
// mdns_service_create_t query_svc = { "_oscjson._tcp", "Beyond Backglow", httpTcpPort };
// mdns_service_announce(...);  // periodic for both
//
// Handle queries in the mDNS thread loop: mdns_socket_listen -> mdns_discovery_answer.
```

The planner should pair-program with the mjansson/mdns example at `https://github.com/mjansson/mdns/blob/master/mdns.c` when writing this — the library exposes primitives, not a `Service` abstraction, so our dual-advertise glue is ~80 lines.

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| Fixed UDP 9001 for VRChat OSC output | OSCQuery + mDNS discovery with dynamic port, 9001 as legacy fallback | VRChat 2023.3.1 release ("VRChat implements the OSCQuery specification as of release 2023.3.1" [CITED: vrc-oscquery-lib readme]) | Phase 16 **primary path is OSCQuery** (D-18). Legacy 9001 (D-19) exists but is the worse outcome. |
| Synced avatar params (8-bit quantized, 256-bit budget) for external hardware | Unsynced avatar params (full float precision, zero budget) | Understanding matured in VRChat hardware ecosystem (Patstrap/ShockOSC/VRCFaceTracking all unsynced) | D-09 locks this. Phase 17 prefab MUST document. |
| OSC listener inside the driver DLL | OSC listener in separate process, IPC to driver via named pipe | Documented in `.planning/research/ARCHITECTURE.md` §5 and `.planning/research/PITFALLS.md` §4 (2026-04-05 research) | Phase 16 implements. Crashes in daemon do not affect vrserver. |
| 30 fps LED writer (Phase 14 D-11) | 90 fps LED writer (CONTEXT D-12/D-26) | User design intent — "LEDs should match HMD refresh rhythm" | Phase 14's `m_maxFps = 30` default is retuned. Bandwidth still fine per D-12 analysis. |

**Deprecated/outdated:**
- **Udon world→external OSC**: still not implemented in VRChat [CITED: https://feedback.vrchat.com/udon/p/osc-for-worlds-udon, noted "not available as of 2026-04" in ARCHITECTURE]. Avatar-parameter pipeline remains the only viable path. Phase 17 prefab needs this.
- **JSON-over-OSC for avatar params**: never a real thing; OSC 1.0 binary encoding is standard. oscpp handles it.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `oscpp` handles OSC bundles correctly with `Packet::isBundle()` / `Bundle::elements()` | Pitfall 7, Example 3 | Some avatar param updates missed if VRChat batches. Unit test catches. |
| A2 | `mjansson/mdns` can advertise two service types from one instance (`_oscjson._tcp` + `_osc._udp`) | Standard Stack, Example 5 | If not: advertise each via a separate `mdns_socket` — more threads, more code. Fallback = Windows `DnsServiceRegister`. |
| A3 | VRChat's OSCQuery library dispatches OSC to any client advertising `/avatar` subtree | Pattern 6 | If VRChat requires explicit enumeration of each `/avatar/parameters/BackglowR*` etc., the minimum JSON balloons from 30 lines to 250. Retrofit is an afternoon. |
| A4 | `CREATE_BREAKAWAY_FROM_JOB` lets our daemon escape any parent job that vrserver.exe might itself be under | Pitfall 2, Example 1 | If denied (containing job forbids breakaway), spawn fails gracefully; backglow degrades. Not catastrophic. |
| A5 | VRChat still uses ports 9000/9001 as legacy fallback when OSCQuery is unavailable | D-19, Standard Stack | Confirmed in docs.vrchat.com/docs/osc-overview (VRChat defaults to "receiving on 9000 and sending on 9001"). [VERIFIED] Low risk. |
| A6 | 90 Hz pipe writes (6.3 KB/s) do not contend with the driver's RunFrame pipe-poll loop | D-26, Pattern 5 | If contention is observed in UAT: reduce to 60 Hz (still smooth) or increase driver's pipe poll cadence. Observable during SMOKE. |
| A7 | Silence-fade ramping at 500 ms via Writer thread tick (45 ticks at 90 Hz) looks visually smooth | D-15, Pitfall 5 | If steppy: increase tick rate or switch to 16-bit Bri internally (D-13 stays at 8-bit to LedController). Low risk. |
| A8 | The `backglow fill <h0> … <h9>` command and existing `backglow bri N` pipe server surface handles 90 Hz sustained input | Pattern 5 | Phase 14 LedController D-11 was 33 ms cv-wait; D-26 retunes. If LedController starves OR frame-drops under 11 ms ticks: degrade the writer to 60 Hz. Observable. |
| A9 | The existing pipe server (`device_provider.cpp:420`) is configured `PIPE_NOWAIT` + message mode, and supports message-mode reads from a client | Pitfall 6 | If server-side is byte mode: multi-line responses truncate (already warned in `src/ctl/main.cpp` line 98-108); for daemon, we send one-line commands so no impact. |
| A10 | Phase 14 `LedController::WriterThreadFunc` can be retuned from 33 ms cv-wait to 11 ms cv-wait by changing `milliseconds(33)` to `milliseconds(11)` at `src/led/led_controller.cpp:78` | D-26 | Correct per file read. Verification: compile, run, measure. If the 20 ms `enforceGap` inside the writer (Phase 14 D-09 for JSON commands) dominates, 90 Hz will effectively be capped at 50 Hz for JSON ops. Adalight RGB frames are NOT subject to the 20 ms gap, so per-LED streaming hits 90 Hz; brightness JSON commands will stay 50 Hz max. This is acceptable — Bri changes infrequently. |

## Open Questions (RESOLVED)

1. **Does VRChat prefer `_osc._udp` advertisement, HOST_INFO attribute, or both?**
   - What we know: all community libraries advertise both; OSCQuery Proposal only mandates `_oscjson._tcp`.
   - What's unclear: whether VRChat's own client (`vrc-oscquery-lib`) actually *reads* `_osc._udp`, or relies entirely on HOST_INFO once it has the HTTP endpoint.
   - RESOLVED: advertise both AND populate HOST_INFO. Then during UAT, observe which one VRChat actually uses by temporarily disabling each in isolation. Log to the open-questions tracker.

2. **What OSCQuery TCP port allocation strategy is cleanest?**
   - Bind port 0 → OS-assigned ephemeral port. Simple but the port is volatile across daemon restarts, forcing VRChat to re-resolve via mDNS each time.
   - Probe 9000+N for N∈[0..9], pick first free. Gives stable-ish port during a session but risks collision with VRCOSC / other tools.
   - RESOLVED: port 0 → OS-assigned. Volatility is a non-issue because mDNS redistributes the new TXT on every (re)spawn within seconds.

3. **Should the daemon log to stderr (inherited by vrserver) or to a rolling file in `%LOCALAPPDATA%\Beyond Backglow\`?**
   - stderr-inherited: logs land in `vrserver.txt` — diagnostic-friendly, one place to look. Downside: mixes daemon logs with driver logs, can clutter.
   - Rolling file: cleaner segmentation but user must know to find a second log path.
   - RESOLVED: BOTH. Daemon writes to stderr (inherited) prefixed with `Backglow-daemon:` AND, if `%LOCALAPPDATA%\Beyond Backglow\` exists or can be created, appends to `daemon.log`. Diagnosticians can grep either.

4. **What is the failure mode when the driver pipe is temporarily unavailable (driver mid-reload, pipe server not yet created)?**
   - Daemon spawns via SpawnBackglowDaemon after `InitBackglow` but before `CreatePipeServer` in `Init()` (verified: InitBackglow is called at line 110, CreatePipeServer at line 114 — `device_provider.cpp`). There IS a window where daemon starts but pipe isn't ready yet.
   - RESOLVED: daemon pipe client must retry connection with 100 ms backoff up to ~5 s (50 attempts) before declaring startup failure. Startup animation is explicitly staged AFTER successful pipe connect (D-16) so it won't fire prematurely.
   - Alternative: move the `SpawnBackglowDaemon` call to AFTER `CreatePipeServer` in `DeviceProvider::Init()`. Cleaner. Planner decides.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| CMake 3.20+ | Build | ✓ | via MSVC 2022 bundled CMake at path in CLAUDE.md | — |
| MSVC 2022 C++17 compiler | Build | ✓ | bundled | — |
| Windows 10/11 SDK | Winsock, Job Objects, named pipes, iphlpapi | ✓ | via MSVC install | — |
| Inno Setup 6 | Installer packaging | ✓ (CMakeLists.txt line 151-157 locates ISCC) | — | Only needed for `package` target; daemon build doesn't require it |
| SteamVR runtime | UAT deploy target | ✓ (paths in CLAUDE.md) | — | — |
| VRChat (local install) | End-to-end UAT of OSC → LED path | ✗ (not verified in this research session) | — | Simulate via custom OSC sender (e.g., `osc_send.py` fixture); acceptable for most smoke tests |
| MagWLED-1 ESP32-C3 prototype hardware | Hardware UAT | ✓ (per Phase 14/15 context) | — | Camera stream + human verification |
| Camera stream viewer | Agent-in-the-loop UAT per user memory | ✓ `https://vdo.ninja/?view=JYMW97gq` | — | Human direct observation |

**Missing dependencies with no fallback:** None — VRChat is the only missing item, and simulated OSC is an acceptable substitute for most test paths.

**Missing dependencies with fallback:**
- VRChat end-to-end test can be replaced by a simple Python OSC fixture sending `/avatar/parameters/BackglowR0 = 1.0` etc. directly to the daemon's UDP port. Sufficient to verify the OSC → pipe → LED path. Real VRChat is the final-mile confidence check during SMOKE.

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | No unit-test framework currently in the project — it's a driver with manual SMOKE UAT per Phase 14/15 pattern. Phase 16 continues this. [VERIFIED by absence of gtest/catch2 in CMakeLists.txt, no `test/` directory] |
| Config file | None — add a SMOKE.md template matching 14-SMOKE.md / 15-SMOKE.md. |
| Quick run command | Deploy + launch SteamVR + tail vrserver log: `./scripts/deploy-backglow-dev.ps1; "C:/Program Files (x86)/Steam/steamapps/common/SteamVR/bin/win64/vrstartup.exe"`; in another shell: `Get-Content "C:/Program Files (x86)/Steam/logs/vrserver.txt" -Tail 50 -Wait \| Select-String Backglow` |
| Full suite command | Smoke UAT sequence in 16-SMOKE.md: each of the 10 success-criteria bullets has a manual step + pass/fail evidence (camera frame, log line, or process check). |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| VRCH-01a | Daemon spawns when driver loads | smoke | In vrserver.txt, `Backglow: daemon spawned (pid=N)` appears; `Get-Process beyond_backglow_ctl` returns a row | ❌ Wave 0: 16-SMOKE.md section "Daemon spawn" |
| VRCH-01b | Daemon dies when vrserver.exe force-killed | smoke | `Stop-Process vrserver -Force`; within 2s, `Get-Process beyond_backglow_ctl -ErrorAction SilentlyContinue` returns null | ❌ Wave 0: 16-SMOKE.md section "Job Object kill-on-close" |
| VRCH-01c | Daemon does NOT spawn when backglow is in degraded-disabled state | smoke | Unplug MagWLED-1 before SteamVR launch; vrserver.txt shows `Backglow: disabled (reason=…)` AND NOT `Backglow: daemon spawned` | ❌ Wave 0: 16-SMOKE.md section "Degraded state — no daemon" |
| VRCH-01d | Respawn backoff: daemon kill → driver respawns with 1/2/4s delays, caps at 3 | smoke | Kill daemon 4 times in rapid succession; verify timestamps in vrserver.txt show `1s 2s 4s` delays and final `respawn cap reached` ERR after 4th kill | ❌ Wave 0: 16-SMOKE.md section "Respawn backoff" |
| VRCH-01e | Startup animation visible on first spawn | smoke (camera) | User enables camera stream; observe 4s white fade after each SteamVR launch | ❌ Wave 0: 16-SMOKE.md section "Startup anim smoke" |
| VRCH-01f | Daemon listens on UDP (OSCQuery or fallback 9001) | automated | `netstat -ano | findstr :9001 | findstr LISTENING` OR inspect `mDNS browse _oscjson._tcp` using a tool like Bonjour Browser | ❌ Wave 0: 16-SMOKE.md section "OSC listener present" |
| VRCH-02a | Float param → LED color update within 1 frame | smoke (camera) | Python OSC fixture sends `/avatar/parameters/BackglowR0 1.0`; camera observes LED 0 turns red within ~50 ms | ❌ Wave 0: 16-SMOKE.md section "Per-LED mapping" + 16-osc-fixture.py |
| VRCH-02b | All 31 parameters route correctly | automated (if fixture) | Fixture iterates all 31 params, sets each to 0.5; daemon log shows 31 distinct `SetChannel`/`SetBrightness` dispatches | ❌ Wave 0: 16-SMOKE.md section "31-param sweep" |
| VRCH-02c | Silence fade after 3s no OSC | smoke (camera) | Fixture stops sending; ~3s later camera sees LEDs ramp to off over ~500ms | ❌ Wave 0: 16-SMOKE.md section "Silence fade" |
| VRCH-02d | Bri pipe command emitted only on integer change | inspect daemon log | Fixture sends `BackglowBri 0.5` 10 times; daemon log shows exactly ONE `backglow bri 127` emission | ❌ Wave 0: 16-SMOKE.md section "Bri dedup" |

### Sampling Rate
- **Per task commit:** Compile-only check — `cmake --build build --config Release` (no unit tests, but catches link errors and macro issues).
- **Per wave merge:** Deploy + SteamVR launch + tail vrserver.txt; confirm `Backglow: daemon spawned` and `Backglow: online` appear. No hardware UAT mandated until phase-gate.
- **Phase gate:** Full 16-SMOKE.md UAT green with hardware + (optional) real VRChat before `/gsd-verify-work`.

### Wave 0 Gaps
- [ ] `.planning/phases/16-vrchat-osc-bridge/16-SMOKE.md` — full UAT template modeled on 14-SMOKE / 15-SMOKE, with the test rows above
- [ ] `tests/osc_fixture/send_backglow.py` — Python OSC fixture using `python-osc` library for param sweep + silence simulation. Alternatively a C++ fixture using oscpp's client side.
- [ ] No test-framework install needed — project has no unit-test harness and Phase 16 is not the phase to introduce one.

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | no | Daemon ↔ driver pipe is local-user-scoped; no auth needed. |
| V3 Session Management | no | No sessions. |
| V4 Access Control | yes (light) | Named pipe ACL — pipe is created by driver (vrserver), only local users can connect. Phase 14 already set this. Daemon inherits the user session context. |
| V5 Input Validation | yes | OSC message filter (D-21): reject anything outside `/avatar/parameters/Backglow*`; clamp floats to `[0,1]`; drop malformed packets silently. |
| V6 Cryptography | no | Loopback traffic, no secrets in flight. |

### Known Threat Patterns for Windows daemon + UDP listener

| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Remote OSC injection (LAN attacker spoofs VRChat) | Tampering / Elevation | D-20 loopback-only bind eliminates. Plus D-21 address whitelist + range clamp. |
| Malformed OSC crashes parser | Denial of Service | oscpp is designed for untrusted input; Packet constructor checks bounds. We additionally wrap in a try/catch around `HandleOscPacket` and drop on any exception. |
| UDP flood fills memory | DoS | Recv path is synchronous, zero-alloc (fixed 2 KB buffer, no queue). Can't OOM. |
| Firewall prompt (social-engineering via noise) | Spoofing | D-20 avoids prompt entirely. |
| Daemon DLL hijack (daemon.exe loads malicious DLL from CWD) | Elevation | `CreateProcessW` with full absolute path (resolved from driver DLL location via `GetModuleFileName`). Daemon should also call `SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)` at startup. |
| Orphaned daemon after vrserver crash leaks OSC socket | Information disclosure (low) | Job Object kill-on-close (D-03) guarantees cleanup. |
| Named pipe command injection via crafted OSC | Tampering | Param name parsing is strict (whitelist R/G/B + single digit 0-9; reject all else). Hex string is assembled by snprintf with `%02X`, never user-controlled strings. |

## Sources

### Primary (HIGH confidence)
- Microsoft Learn — Job Objects: https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects [VERIFIED 2025-07-14 update]
- Microsoft Learn — AssignProcessToJobObject: https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject [VERIFIED]
- oscpp (header-only OSC library, ISC): https://github.com/kaoskorobase/oscpp [VERIFIED header-only, Windows supported]
- mjansson/mdns (public domain mDNS-SD): https://github.com/mjansson/mdns [VERIFIED v1.4.3, Windows supported, 2 files]
- OSCQuery Proposal spec (Vidvox): https://github.com/Vidvox/OSCQueryProposal [CITED for mandatory endpoints, FULL_PATH/TYPE/CONTENTS schema, HOST_INFO optional]
- VRChat OSCQuery wiki: https://github.com/vrchat-community/osc/wiki/OSCQuery [CITED — /avatar subtree triggers VRChat dispatch]
- VRChat OSC Avatar Parameters: https://docs.vrchat.com/docs/osc-avatar-parameters [CITED — port 9001 legacy output]
- VRChat OSC overview: https://docs.vrchat.com/docs/osc-overview [CITED — ports 9000/9001]
- vrc-oscquery-lib Readme: https://github.com/vrchat-community/vrc-oscquery-lib [CITED — advertises _oscjson._tcp]
- vrc-oscquery-lib getting-started: https://github.com/vrchat-community/vrc-oscquery-lib/blob/main/getting-started.md [CITED — builder with WithTcpPort + WithUdpPort]

### Project-internal (HIGH confidence)
- `.planning/research/ARCHITECTURE.md` §5 — separate-process daemon rationale, full pipeline Path B
- `.planning/research/PITFALLS.md` §1 (blocking-I/O), §4 (VRChat indirect path), §5 (safety ceiling — already handled by Phase 14)
- `.planning/research/STACK.md` — oscpp selection, Winsock + setupapi pre-linked
- `.planning/research/SUMMARY.md` — top risks, build-order rationale
- `src/led/led_controller.{h,cpp}` — Phase 14 writer-thread pattern, ceiling math at line 177, cv-wait at line 78 (D-26 retune target)
- `src/driver/device_provider.cpp` — InitBackglow at line 1332 (spawn hook point), Cleanup at line 152 (graceful-stop hook), HMDUtility pattern at line 1041 (graceful-exit reference), pipe server at line 420, WSAStartup/WSACleanup at lines 101/175
- `src/ctl/main.cpp` — pipe client pattern, `PIPE_READMODE_MESSAGE` flag

### Secondary (MEDIUM confidence)
- Natsumi-sama/OscQueryLibrary: https://github.com/Natsumi-sama/OscQueryLibrary — confirms dual-advertise pattern
- minetake01/vrchat_osc (Rust): https://github.com/minetake01/vrchat_osc — confirms dual-advertise pattern
- OpenShock ShockOSC VRC setup: https://wiki.openshock.org/guides/shockosc/avatar-setup-vrc — reference bridge implementation
- Patstrap: https://github.com/danielfvm/Patstrap — reference avatar-OSC hardware bridge
- VRChat OSCQuery library Issue #28 (mDNS localhost): https://github.com/vrchat-community/vrc-oscquery-lib/issues/28 — confirms 127.0.0.1 is supported but currently the only advertised IP in vrc-oscquery-lib

### Tertiary (LOW confidence / needs validation)
- Exact behavior of VRChat OSCQuery when a client advertises only `_oscjson._tcp` (no `_osc._udp`) and relies solely on HOST_INFO — flagged Open Question 1
- Stability of `mjansson/mdns` under Windows Firewall's link-local mDNS handling (repository has minimal recent activity) — may surface only during real-VRChat UAT

## Metadata

**Confidence breakdown:**
- Standard stack: **HIGH** — oscpp and mjansson/mdns both verified via repo browse; Win32 Job Objects verified against Microsoft Learn 2025-07-14.
- Architecture: **HIGH** — three-thread daemon model matches existing `LedController` / `HidDevice` precedent; Job Object lifecycle is textbook Win32.
- Pitfalls: **HIGH** — most pitfalls are well-documented; Pitfall 1 (OSCQuery discovery) remains MEDIUM because VRChat's docs are sparse and it's the single aspect that could bite during UAT.
- Phase requirements mapping: **HIGH** — VRCH-01 and VRCH-02 decompose cleanly into the test rows above; D-01 refines VRCH-01's wording and should be reflected in traceability.

**Research date:** 2026-04-19
**Valid until:** 2026-05-19 (OSCQuery/VRChat surface is stable; mjansson/mdns and oscpp are low-churn; re-verify only if VRChat releases a major OSCQuery library update)
