// beyond_backglow_ctl.exe — VRChat avatar OSC -> backglow pipe bridge. // Phase 16 VRCH-01 (listener side) + VRCH-02 (mapping). // D-20 loopback-only, D-12 90 Hz writer (Task 3b), D-15 silence-fade (Task 3b), // D-16 startup-anim (synchronous, this task). #define WIN32_LEAN_AND_MEAN #include #include #include // WR-01 fix: SIO_UDP_CONNRESET #include #include "daemon_log.h" #include "mdns_advertise.h" // MdnsAdvertiser #include "osc_server.h" #include "oscquery_http.h" // OscQueryHttpServer #include "param_map.h" #include "pipe_client.h" #include "startup_anim.h" #include #include #include #include #include #include #include #include #pragma comment(lib, "ws2_32.lib") // Shared control flags. g_fadingDown is referenced by Task 3b's WriterThread. std::atomic g_stop{false}; std::atomic g_fadingDown{false}; static constexpr int kDefaultPort = 9001; // D-19 fallback static constexpr int kSilenceThresholdMs = 3000; // D-15 (used by Task 3b) static constexpr int kFadeDurationMs = 500; // D-15 (used by Task 3b) // ---------- OSC receive thread (D-20 loopback + D-21 whitelist via osc_server) ---------- static void OscThread(SOCKET sock, ParamMap& map) { uint8_t buf[2048]; // fixed buffer, zero-alloc hot path (D-21) while (!g_stop.load(std::memory_order_relaxed)) { sockaddr_in from{}; int fromLen = sizeof(from); int n = recvfrom(sock, reinterpret_cast(buf), sizeof(buf), 0, reinterpret_cast(&from), &fromLen); if (n == SOCKET_ERROR) { int err = WSAGetLastError(); // WR-01 fix: whitelist retryable errors. On Windows, a UDP recvfrom // can surface WSAECONNRESET when a previous sendto to this port // provoked an ICMP "port unreachable" reply — the kernel remembers // this and returns it on the NEXT recvfrom, which has nothing to do // with our current traffic. Similarly WSAEMSGSIZE just means the // inbound datagram exceeded our buffer; we keep reading. Failing // the thread on these silently kills OSC intake for the remainder // of the daemon's lifetime. if (err == WSAETIMEDOUT || err == WSAEWOULDBLOCK || err == WSAECONNRESET || err == WSAEMSGSIZE) { continue; } if (g_stop.load()) break; DaemonLog("recvfrom fatal err=%d — exiting OSC thread", err); break; } if (n <= 0) continue; // WR-01 (iter-3) fix: only cancel silence-fade when the packet actually // dispatched into ParamMap (i.e. hit a whitelisted Backglow address). // VRChat's non-backglow avatar param firehose would otherwise pin // g_fadingDown=false and defeat the D-15 silence-fade entirely. if (HandleOscPacket(buf, static_cast(n), map)) { g_fadingDown.store(false, std::memory_order_relaxed); // Pitfall 5 cancel fade } } // WR-06 fix: if OscThread exited for any reason other than g_stop, the // daemon is silently broken — WriterThread keeps running but never sees // new frames, eventually fading LEDs to zero. Escalate to global shutdown // so the driver's watchdog notices (via pipe close / daemon exit) and // respawns us instead of leaving the user in a half-dead state. if (!g_stop.load(std::memory_order_relaxed)) { DaemonLog("OSC thread exiting unexpectedly — signalling daemon shutdown"); g_stop.store(true, std::memory_order_relaxed); } } // ---------- Writer thread: 90 Hz tick + silence-fade + frame dedup + Bri dedup ---------- // Implements D-12 (~90 Hz cadence), D-13 (Bri integer dedup), D-14 (frame byte dedup), // D-15 (3 s silence -> 500 ms fade -> off), D-16 (gated: startup-anim completed // BEFORE worker threads launch, so first-tick emission is safe). static void WriterThread(ParamMap& map, PipeClient& pipe) { auto nextTick = std::chrono::steady_clock::now(); std::array lastFrame{}; uint8_t lastBri = 0; bool first = true; uint8_t fadeStartBri = 0; int fadeElapsedMs = 0; // WR-02 (re-review): consecutive pipe-down tick counter. At 90 Hz (~11 ms // per tick) we escalate to g_stop after ~5 s of sustained disconnect // (~450 ticks), mirroring the OscThread -> g_stop escalation pattern that // resolved the old WR-06 silent-OSC-death issue. When the daemon signals // g_stop and exits, the driver's watchdog observes the process exit and // respawns us — the only recovery path that doesn't require adding a // reconnect loop inside PipeClient. int pipeDownTicks = 0; constexpr int kPipeDownEscalateTicks = 90 * 5; // ~5 s at 90 Hz while (!g_stop.load(std::memory_order_relaxed)) { std::this_thread::sleep_until(nextTick); nextTick += std::chrono::milliseconds(11); // D-12 ~90 Hz if (!pipe.IsConnected()) { if (++pipeDownTicks >= kPipeDownEscalateTicks) { DaemonLog("pipe has been down for ~5s — signalling daemon shutdown " "so driver watchdog can respawn (WR-02)"); g_stop.store(true, std::memory_order_relaxed); break; } continue; } pipeDownTicks = 0; // D-15 silence-fade state machine. Gate: only enter fade if we currently // have non-zero Bri — no point fading an already-dark frame. const int64_t silenceNs = map.NsSinceLastOsc(); const int silenceMs = static_cast(silenceNs / 1000000); const bool shouldFade = silenceMs >= kSilenceThresholdMs && map.SnapshotBri() > 0; if (shouldFade && !g_fadingDown.load(std::memory_order_relaxed)) { g_fadingDown.store(true, std::memory_order_relaxed); fadeStartBri = map.SnapshotBri(); fadeElapsedMs = 0; DaemonLog("silence-fade begin bri=%u", (unsigned)fadeStartBri); } if (g_fadingDown.load(std::memory_order_relaxed)) { fadeElapsedMs += 11; // Linear ramp: bri = fadeStartBri * (1 - t/fadeDuration). const int ramp = (fadeStartBri * fadeElapsedMs) / kFadeDurationMs; int bri = fadeStartBri - ramp; if (bri < 0) bri = 0; if (bri != lastBri) { char line[32]; std::snprintf(line, sizeof(line), "backglow bri %d", bri); pipe.SendCommand(line); lastBri = static_cast(bri); } if (bri == 0) { pipe.SendCommand("backglow off"); g_fadingDown.store(false, std::memory_order_relaxed); DaemonLog("silence-fade end -> off"); } continue; // skip ParamMap emission while fading; OscThread flips g_fadingDown=false if live data arrives } // Normal path: emit Bri change (D-13 integer dedup) + frame change (D-14 30-byte dedup). const 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; } std::array frame; map.SnapshotFrame(frame); if (first || frame != lastFrame) { // Polymorphic per-LED fill: 10 hex triplets separated by spaces. // WR-04 (iter-3) fix: snprintf returns the number of characters it // WOULD have written, not what it actually wrote. If `off` grows // past sizeof(line), the subtraction `sizeof(line) - off` is // performed in size_t (unsigned) and underflows to a huge value, // letting the next snprintf write past the buffer. Today's inputs // top out at 83 bytes (13 header + 10*7 triplets) — safely under // 128 — but the idiom is fragile against future changes (padding, // more LEDs, etc.). Clamp before each subtraction and trip out of // the loop if we ever exceed the buffer. char line[128]; int off = std::snprintf(line, sizeof(line), "backglow fill"); if (off < 0) off = 0; for (int i = 0; i < 10; ++i) { if (off < 0 || off >= static_cast(sizeof(line))) break; const int remaining = static_cast(sizeof(line)) - off; const int wrote = std::snprintf(line + off, static_cast(remaining), " %02X%02X%02X", frame[i*3+0], frame[i*3+1], frame[i*3+2]); if (wrote < 0 || wrote >= remaining) { // Output truncated — stop filling rather than wrapping off. off = static_cast(sizeof(line)) - 1; break; } off += wrote; } line[sizeof(line) - 1] = '\0'; // belt-and-braces NUL pipe.SendCommand(line); lastFrame = frame; } first = false; } } // ---------- stdin-EOF monitor thread (D-06 graceful exit) ---------- static void StdinMonitorThread() { HANDLE hIn = GetStdHandle(STD_INPUT_HANDLE); if (hIn == INVALID_HANDLE_VALUE || hIn == NULL) { DaemonLog("stdin unavailable — graceful-exit via stdin disabled"); return; } char buf[64]; DWORD n = 0; while (!g_stop.load()) { if (!ReadFile(hIn, buf, sizeof(buf), &n, NULL)) { // IN-04 (iter-4) fix: log GetLastError() so we can distinguish the // expected ERROR_BROKEN_PIPE (parent closed the write end — normal // D-06 graceful shutdown) from unexpected errors like // ERROR_INVALID_HANDLE / ERROR_ACCESS_DENIED, which would hint at // a driver-side bug in the pipe handoff. DWORD err = GetLastError(); DaemonLog("stdin ReadFile failed err=%lu — initiating graceful shutdown", (unsigned long)err); g_stop.store(true, std::memory_order_relaxed); return; } if (n == 0) { // Unreachable on Windows for our pipe setup (parent-close surfaces // as !ReadFile with ERROR_BROKEN_PIPE), but kept as defense. DaemonLog("stdin closed (n=0) — initiating graceful shutdown"); g_stop.store(true, std::memory_order_relaxed); return; } } } // ---------- UDP bind on loopback (D-20) ---------- static SOCKET BindLoopbackUdp(int port) { SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (s == INVALID_SOCKET) return INVALID_SOCKET; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast(port)); // D-20: loopback-only bind; never the wildcard/any-interface address. if (inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) != 1) { closesocket(s); return INVALID_SOCKET; } if (bind(s, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) { int err = WSAGetLastError(); DaemonLog("UDP bind 127.0.0.1:%d failed err=%d", port, err); closesocket(s); return INVALID_SOCKET; } // WR-01 fix: disable the Windows-specific ICMP-unreachable-to-recvfrom // surfacing. Without this, a stray ICMP "port unreachable" from some other // peer (because a previous sendto of ours went somewhere dead) will trip // WSAECONNRESET on the next recvfrom. See WR-01 in the OscThread error // whitelist for the reception-side guard. // IN-02 (iter-3) fix: log the return value. Previously we ignored WSAIoctl // failure entirely, so a socket in a degraded state (setsockopt races, // LSP interference, etc.) would fall silently back to the OscThread // WSAECONNRESET whitelist — defense-in-depth still works, but the // operator had no signal that something upstream was wrong. Keep going on // failure since the whitelist covers the reception path. BOOL newBehaviour = FALSE; DWORD dummy = 0; if (WSAIoctl(s, SIO_UDP_CONNRESET, &newBehaviour, sizeof(newBehaviour), NULL, 0, &dummy, NULL, NULL) == SOCKET_ERROR) { DaemonLog("WARN: SIO_UDP_CONNRESET disable failed err=%d — " "OscThread whitelist will handle WSAECONNRESET at runtime", WSAGetLastError()); } // 500 ms recv timeout so OscThread can poll g_stop. DWORD tv = 500; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&tv), sizeof(tv)); return s; } int main(int argc, char** argv) { // Harden DLL search path (Security: daemon DLL hijack — T-16-01-05). SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32); DaemonLog("start"); WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) { DaemonLog("WSAStartup failed"); return 1; } // Primary path (D-18): bind port 0, advertise via OSCQuery + mDNS. SOCKET oscSock = INVALID_SOCKET; uint16_t boundUdpPort = 0; OscQueryHttpServer httpServer; MdnsAdvertiser mdns; bool primaryPath = false; // IN-03 (iter-4) fix: spell out the argv[1] contract. ANY argv[1] (not just // a numeric port) short-circuits OSCQuery + mDNS advertising and forces // the D-19 fallback path. argv[1] is then parsed as a numeric port via // std::atoi; non-numeric input (`--help`, etc.) yields atoi()==0 and falls // back to kDefaultPort=9001, still on D-19. No `--flag` parser exists — // this is intentional for smoke tests; production driver-launched daemons // never pass arguments (so the `argc < 2` branch is the primary path). if (argc < 2) { oscSock = BindLoopbackUdp(0); // OS-assigned if (oscSock != INVALID_SOCKET) { sockaddr_in bound{}; int bl = sizeof(bound); getsockname(oscSock, reinterpret_cast(&bound), &bl); boundUdpPort = ntohs(bound.sin_port); uint16_t httpPort = httpServer.Start(boundUdpPort, "Beyond Backglow"); if (httpPort != 0) { if (mdns.Start(boundUdpPort, httpPort, "Beyond Backglow")) { primaryPath = true; DaemonLog("OSCQuery primary path: UDP=%u HTTP=%u", (unsigned)boundUdpPort, (unsigned)httpPort); } else { DaemonLog("WARN: mDNS advertise failed — falling back to 9001"); httpServer.StopAndJoin(); } } else { DaemonLog("WARN: OSCQuery HTTP bind failed — falling back to 9001"); } if (!primaryPath) { closesocket(oscSock); oscSock = INVALID_SOCKET; } } } // Fallback path (D-19): fixed 127.0.0.1:9001 or argv-specified port. if (oscSock == INVALID_SOCKET) { int fallbackPort = kDefaultPort; if (argc >= 2) { fallbackPort = std::atoi(argv[1]); if (fallbackPort <= 0 || fallbackPort > 65535) fallbackPort = kDefaultPort; } oscSock = BindLoopbackUdp(fallbackPort); if (oscSock == INVALID_SOCKET) { DaemonLog("ERR: fallback UDP bind 127.0.0.1:%d failed — exiting (D-19)", fallbackPort); WSACleanup(); return 1; } boundUdpPort = static_cast(fallbackPort); DaemonLog("fallback path: listening on 127.0.0.1:%u (D-19 legacy)", (unsigned)boundUdpPort); } PipeClient pipe; if (!pipe.Connect()) { // Plan 01 PipeClient::Connect logs `pipe connect failed after 50 attempts (err=N)` // after exhausting the 100 ms * 50 attempt (~5 s) retry window. Main.cpp // does NOT swallow that diagnostic; it simply propagates the failure. DaemonLog("pipe connect failed — exiting"); mdns.Stop(); httpServer.StopAndJoin(); closesocket(oscSock); WSACleanup(); return 1; } // D-16 startup animation runs synchronously on main thread BEFORE workers start // so the writer thread (Task 3b) can observe a clean post-animation state. RunStartupAnimation(pipe); ParamMap map; std::thread tOsc(OscThread, oscSock, std::ref(map)); std::thread tWriter(WriterThread, std::ref(map), std::ref(pipe)); std::thread tStdin(StdinMonitorThread); // WR-02 (iter-3) fix: previously called tStdin.join() here, which parked // main() inside the StdinMonitorThread's blocking ReadFile until the driver // closed the stdin pipe. That made WR-06's escalation path (OscThread -> // g_stop on unexpected exit) a no-op: worker threads would set g_stop, but // main() stayed wedged on the join, so the process lived on and the driver // watchdog never observed an exit to trigger a respawn. Poll g_stop // directly so ANY thread asserting g_stop — stdin EOF, OscThread escalate, // WriterThread pipe-down escalate — actually tears the process down. while (!g_stop.load(std::memory_order_acquire)) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } // tStdin is still parked in a blocking ReadFile against the inherited // anonymous-pipe stdin. Detach it: the process is about to exit and the OS // will reap the thread with the process. Joining it would re-introduce // exactly the wedge this fix removes. if (tStdin.joinable()) tStdin.detach(); // Phase 16 Plan 03: stop OSCQuery before OSC socket close so HTTP thread // doesn't try to respond with a stale UDP port reference. mdns.Stop(); httpServer.StopAndJoin(); // Unblock OSC thread's recvfrom by closing the socket. closesocket(oscSock); if (tOsc.joinable()) tOsc.join(); if (tWriter.joinable()) tWriter.join(); pipe.Close(); WSACleanup(); DaemonLog("exit 0"); return 0; }