Plan 034·Architecture·SpaceMusic·Draft
Spout-bridged textures locally. Streaming for remote. Same UI binary against either. Both halves auto-detect each other; a QR in the engine's render window onboards a tablet in seconds.
Plan 033 addressed the in-process Avalonia composition issues and chose Option B (CPU-bitmap cache) as the right model. Several debugging sessions later we've reached the structural ceiling that plan named but didn't solve: the UI and the renderer share one render thread. Concretely:
SmSkiaRenderTarget via the new InCacheRender flag in SmTopLevelImpl) was a symptom of the same coupling — two render engines sharing one GPU device and one thread will keep producing this class of bug as we add features.Independently, SpaceMusic has a product requirement to be controllable from other machines — tablets, remote control surfaces, eventually cloud-hosted instances streamed back to a thin client. We cannot meet that requirement with the current architecture because every UI is an Avalonia layer composited inside the running engine process.
Both problems have the same answer: split the engine and the UI into separate processes with a well-defined IPC contract.
Locally they live on the same machine and share GPU textures via Spout (Windows shared D3D11 resources). Remotely they communicate over WebSocket for state and WebRTC for streamed GPU surfaces. The local case becomes a degenerate of the remote case — same protocol, same UI code, just a different host URL.
Today: one vvvv process owns Stride rendering AND the UI. Avalonia's compositor runs on Stride's render thread. The UI window paints into the same swapchain Stride uses. They cannot run independently because they share hardware-level state.
Target: two processes, each with its own window.
Crucially: scrolling the UI at full screen 4K cannot slow Stride down. And the same UI binary works whether it runs on the same machine as the engine or on a tablet across the network.
Both processes reference the same SMCodeGen output (ParameterIds.g.cs, ParameterHierarchy.g.cs, ChannelNodes.g.cs, the ProjectModel / EnvironmentModel / etc. record graph). The engine's channel hub is the source of truth; the UI maintains a mirror of the same hub kept in sync over the transport. Same generated types → same binding logic — the UI's parameter-row rendering code in CsvPageView is unchanged from today; it just talks to a remote hub instead of the local one via the existing IChannelTransport abstraction.
Practical consequences that fall out for free:
Serializer choice: MemoryPack (Cysharp). Source-generated, zero-allocation, roughly 4–10× faster than MessagePack-CSharp on .NET-to-.NET payloads. Works in Avalonia.Browser (AOT-compatible, requires <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the WASM project). Use end-to-end for channel-value serialization across the WS transport; the existing VL.Serialization.MessagePack stays for any cross-platform wire-format compatibility we may have already shipped (e.g. Centrifugo legacy clients), but new work standardises on MemoryPack.
Most of the pieces already exist or are mostly built. This plan is more about wiring them together than inventing new tech.
| Piece | Where it lives today | Role in the new architecture |
|---|---|---|
| Channel hub auto-discovery | SpaceMusic.Centrifugo/SmChannelBridge.cs | Engine-side channel publisher. Iterates _hub.Channels, forwards all public channels. Production-grade. |
| Local WebSocket transport | SpaceMusic.LocalServer/LocalWebSocketServer.cs | Minimal HttpListener + WebSocket foundation. Will host /hello discovery + /qr.png + /ws/ channel endpoint on the same listener. |
| Centrifugo transport | SpaceMusic.Centrifugo/ | Production transport for remote UIs that traverse NAT via a relay. |
| Channel transport abstraction | IChannelTransport.cs | Lets us swap WS / Centrifugo without changing call sites. UI exe selects implementation at startup based on discovery result. |
| CSV → parameter spec generator | SMCodeGen/Program.cs | Already emits ParameterIds.g.cs / ChannelNodes.g.cs. Both processes reference the same generated types. |
| Universal Plugin Data | SpaceMusic.Out.Basic.vl + plugin slots | Already aggregates ~10–15 Stride.Graphics.Texture preview slots. The natural feed for Spout publishing and (Phase 4) WebRTC track production. |
| Stride shared textures | TextureOptions.Shared + standard D3D11 shared-handle path | Mark preview textures as Shared at creation and they're Spout-ready. |
| Skia ↔ shared D3D11 | VL.Skia/src/FromSharedHandle.cs | The Skia-side pattern for consuming a shared D3D11 handle as an SKImage. We mirror this in the UI process. |
| Existing Avalonia/Skia UI | SmAvaloniaLayerV2, AppShell, TexturePresenter | Becomes the body of the standalone UI exe instead of an embedded layer. Lifts out of vvvv with relatively little change. |
| TexturePresenter mouse back-channels | TexturePresenter.cs:33-141 | MouseX/Y/Buttons/Wheel/IsOver sub-channels with normalized [0..1] coords. Wire contract for interactive previews already exists — zero engine plugin changes needed. |
| Existing Avalonia WASM client | SpaceMusic.Pro.Browser → pro.spacemusic.tv | net8.0-browser, Avalonia.Browser 11.3.4, SkiaSharp WASM. Hosts the full AppShell. The QR scan target — already deployed, already working against Centrifugo. |
| Existing WASM channel transport | CentrifugoSdkTransport.cs | IChannelTransport over Centrifuge SDK, confirmed working in Avalonia.Browser. |
| QR code library | vvvv-binaries/nugets/ZXing.Net.0.16.8 | Already vendored, no new dependency. QRCodeWriter.encode(url, 256, 256) → byte matrix → Stride.Graphics.Texture sprite. |
| Plan 033 architecture lessons | docs/Plans/033 | All the "what works / what doesn't" research carries over verbatim. |
AppShell against a remote (or loopback) channel hub.IChannelProvider implementation backed by LocalWebSocketTransport for the UI side (engine side already has the matching infrastructure)./hello discovery endpoint, port-file hint, EngineTokenService.SmEngineInfoOverlay).SKImage.SmWebRtcPublisher + the WASM DOM-overlay JS bridge (deferred to Phase 4).Two reads of the same picture. The first shows the shape: SpaceMusic is a central core surrounded by four plugin categories (the circle) with the UI sitting below (the rectangle) — the same outline as the SpaceMusic logo. The plan is, in essence, drawing the dashed line that separates the two halves into independent processes. The second diagram drills into the wiring once that line exists.
Two processes, two windows, one channel hub mirrored across them. Texture transport adapts to locality: zero-copy shared D3D11 (Spout) on the same machine, encoded video (WebRTC) over the wire when remote. The UI never knows the difference at the API level.
Local case: both processes on one machine. Spout uses zero-copy shared D3D11 textures. WebSocket uses loopback. End-to-end texture latency ~1 frame.
Remote case: engine on host machine or cloud, UI on tablet/laptop. WebRTC encodes texture frames with hardware H.264/AV1. WebSocket targets a public WSS endpoint (or Centrifugo relay). Total latency 30–80 ms.
Three launch modes from one engine binary, no rebuild required:
SpaceMusic.UI.Standalone.exe as a child process with --engine ws://localhost:<port>/ws/?token=<...> so it skips discovery entirely. Engine tracks the child's PID. Engine ignores clean exit (user closed the UI window → engine keeps running, falls back to QR display). Engine restarts the child on crash up to N times with backoff. Engine close → child receives a clean disconnect → child shows RemoteConnectDialog.pro.spacemusic.tv?engine=<wss>&token=<...>&fp=<cert> → connects. QR disappears once any client is connected, reappears when all clients disconnect.--engine wss://<host>:<port>/.... UI's EngineDiscovery honours an explicit --engine arg above all probes.Single-instance enforcement: each engine writes %LOCALAPPDATA%/SpaceMusic/engine.<pid>.port on startup containing { port, pid, startedUtc } and removes it on shutdown. UIs scan for any existing port files first; if more than one is recent, the UI presents a picker. This also supports multiple engines on one box (different Spout sender prefixes, different ports, separate QR codes).
Token model (v1): engine generates a per-launch GUID, scoped to the LAN session. Same token for all clients. QR encodes it. Acceptable risk because token rotates per launch and scope is single LAN session. Per-client token issuance and revocation lands in a later iteration.
Eight phases, each independently demoable. The hardest pieces (Spout interop, WebRTC) sit deepest in the sequence — earlier phases each unlock visible product progress with minimal new technology risk.
Add a SpaceMusic startup mode that runs the engine without instantiating the Avalonia UI layer: no SmAvaloniaLayerV2, no SkiaRenderer window for hosting Avalonia. Stride keeps its render window — this is the engine's own face now, showing the scene plus (later) the QR overlay. The existing SmChannelBridge + LocalWebSocketServer runs from startup so any client can connect. A new SmEngineInfoOverlay component is wired but inert in this phase (just draws engine name + port) — full QR rendering arrives in Phase 2c.
Concretely a new Start - Headless.bat script with vvvv launch args that load SpaceMusic.vl with the UI subtree disabled. No code changes required — just a patch-level toggle.
Create a new SpaceMusic.UI.Standalone .NET project. Avalonia-based, single window using the existing AppShell and CsvPageView directly (both already in SpaceMusic.UI.Core / SpaceMusic.UI.Pro with no Stride dependency).
The single load-bearing new component is the UI-side IChannelProvider implementation backed by LocalWebSocketTransport: it lets CsvPageView and TexturePresenter use channels exactly as today, but the underlying transport hops to the engine over a socket instead of touching an in-process hub. Adding this to Phase 2a unblocks both parameter writes AND the mouse round-trip (Phase 3.5) — same plumbing, different channel paths.
Save/load scene against the local mirror; UI caps its own framerate at 30 Hz idle, 60 Hz during pointer interaction.
/hello, port-file, tokens)Replace Phase 2a's hardcoded connection URL with auto-discovery. Extend LocalWebSocketServer with GET /hello returning an EngineDescriptor JSON. EnginePortFile writes %LOCALAPPDATA%/SpaceMusic/engine.<pid>.port. EngineTokenService issues a per-launch GUID. EngineDiscovery runs three probes in parallel — port-file scan, loopback port scan, Spout SenderNamesMMF enumeration. First probe to answer wins; transport selection follows from the descriptor.
Build SmEngineInfoOverlay: renders a QR code sprite into the Stride render window using ZXing.Net (already vendored, no new dep). The overlay auto-hides when any client connects.
URL format encoded in the QR (frozen now so it doesn't change in Phase 4):
https://pro.spacemusic.tv/?engine=wss://<lan-ip>:<port>/ws/&token=<guid>&fp=<sha256(serverCert)[:8]>
Add the child-process launcher mode: when the engine is launched as a standalone .exe (double-click flow), it spawns SpaceMusic.UI.Standalone.exe --engine ws://localhost:<port>/ws/?token=<...> as a child process. --no-ui flag disables auto-spawn for true server-mode deployments.
Modify the engine's Universal Plugin Data aggregation so the texture slots are created with TextureOptions.Shared. Add a new SmSpoutPublisher component (Stride-side node) that registers each shared texture with the Spout SDK under a stable name. Active sender names surface in /hello's transports.spoutSenders[].
On the UI side, add a SmSpoutConsumer that opens the named senders, gets a D3D11 shared handle, and exposes them as SKImage via the FromSharedHandle pattern. TexturePresenter now draws the Spout-sourced SKImage as part of its own visual tree — the texture is part of the cache, not an overlay drawn on top. The whole SmAvaloniaLayerV2 / SmSkiaRenderTarget gating problem evaporates.
Essentially free once Phase 2a and Phase 3 are in place. Refactor TexturePresenter → RemoteTexturePresenter: subscribe to Avalonia pointer events, fill PendingMouseX/Y/Buttons/Wheel/IsOver fields identically to today, MouseChannelFlusher DispatcherTimer coalesces moves (60 Hz pointer-over, 30 Hz idle), discrete events bypass the timer and publish immediately.
FitMode coordinate fix: normalize coords relative to the fitted texture rect, not the control bounds. Today's code feeds plugins coords for the whole control including letterbox bars; the new code feeds true texture-space UVs.
Measured latency budget: loopback ClientWebSocket round-trips small frames in <1 ms. Slider drag loop is dominated by the Stride frame boundary (4–16 ms). WebSocket is fast enough; named-pipes are not required.
The WASM client target already exists. SpaceMusic.Pro.Browser ships to pro.spacemusic.tv via .github/workflows/deploy-wasm.yml. Phase 4 work reduces to three pieces:
SmWebRtcPublisher: take Universal Plugin Data textures, encode via NVENC (FFmpeg.AutoGen — already in our dependencies), push as WebRTC tracks via SIPSorcery. Advertise track IDs on the channel hub under sm:<path>.WebRtcTrackId.SpaceMusic.Pro.Browser): wwwroot/webrtc-bridge.js owns one RTCPeerConnection per engine, exposes attachTrack(streamId) returning a <video> element. WebRtcPreviewBridge.cs uses [JSImport]/[JSExport] to bridge .NET ↔ JS. TexturePresenter branches on browser platform: if WebRtcTrackId present, absolute-position the <video> overlay with pointer-events: none so Avalonia still receives pointer events for the interactive-preview round trip.LocalWebSocketTransport for the WASM client: targets the engine's LocalWebSocketServer directly when the QR URL has ?engine=wss://.... Token passed as query parameter (avoids browser-WASM Authorization header quirks).Bundle size budget: current pro.spacemusic.tv build is ~14 MB cold, <1 s warm via PWA cache.
Once Phases 1–4 are stable in real use: remove SmAvaloniaLayerV2 and all the surrounding compositor gating from SpaceMusic.UI.Stride. Remove the SkiaRenderer window. Delete SmSkiaRenderTarget, SmSkiaGpuRenderSession, SmTopLevelImpl's in-process rendering plumbing. Update launch scripts so default = headless engine + UI process pair.
The four flash/perf fixes from 2026-06-01 (InCacheRender gate, _confirmedDestRects/Clips, HasAnyPresenterMovedSinceCache, no-placeholder-when-texture-set) all become obsolete and are deleted.
CsvPageView, AppShell, UiFactory, focus/scroll/popup/text-input behaviour, theming, IME. 3–5 days to lift.SpaceMusic-instance1.PluginIO).--no-ui flag.AppShell already encapsulates most of this.<video> for WebRTC tracks works but has sharp edges: positioning under Avalonia transforms (forbid non-translate transforms on TexturePresenter ancestors — assert in DEBUG); z-order with Avalonia popups (hide overlays while popups are open); browser autoplay policies (first frame paint must happen after user gesture).MouseX/Y ignores letterbox bars; Phase 3.5 fixes that. Audit existing plugin patches that read .MouseX before flipping.buzzing-bubbling-bubble plan envisions UI plugins as headless plugins inside the engine. With this architecture, every UI is its own process, not a plugin. The plugin concept becomes "transport route" instead. Worth aligning vocabulary.pro.spacemusic.tv with the engine URL pre-filled, connects within 5 seconds (cold) or <1 second (warm/PWA-cached).SpaceMusic.Centrifugo/SmChannelBridge.cs — auto-discovery of all public channelsSpaceMusic.LocalServer/LocalWebSocketServer.cs — extends with /hello + /qr.pngSpaceMusic.Channels/Channels/IChannelTransport.cs — abstractionSpaceMusic.Channels/Channels/LocalWebSocketTransport.cs — existing loopback transportSpaceMusic.Channels/Channels/ChannelMessage.cs — wire envelope (switching value serialiser to MemoryPack)SpaceMusic/Output Plugins/SpaceMusic.Out.Basic.vl — Universal Plugin Data accessSpaceMusic.UI.Core/Controls/AppShell.axaml.cs — root shell that lifts to the standalone exeSpaceMusic.UI.Pro/CsvPageView.cs — CSV-driven parameter row renderingSpaceMusic.UI.Stride/Controls/TexturePresenter.cs — refactored in Phase 3 / Phase 3.5SpaceMusic.UI.Stride/SmAvaloniaLayer.cs:391-447 — UpdateTextureMouseState reference for mouse normalisationSpaceMusic.UI.Stride/InputMapping.cs — Stride → Avalonia button mapping; mirrored for symmetrySpaceMusic.Pro.Browser/SpaceMusic.Pro.Browser.csproj — net8.0-browser, Avalonia.Browser, SkiaSharp WASMSpaceMusic.Pro.Browser/Program.cs — withApplicationArgumentsFromQuery already pipes URL paramsSpaceMusic.UI.Remote/Channels/CentrifugoSdkTransport.cs — working WASM transport referenceSpaceMusic.UI.Remote/Channels/CentrifugoChannelProvider.cs — drop-in IChannelProvider.github/workflows/deploy-wasm.yml — existing deployment to GitHub PagesSMCodeGen/Program.cs — same generated types used by engine + UI exe + WASM clientVL.StandardLibs/VL.Skia/src/FromSharedHandle.cs — canonical Skia ↔ D3D11 shared-handle patternVL.StandardLibs/VL.Stride.Windows/src/SpoutCSharp/SpoutSender.cs — SenderNamesMMF + sender registration patternvvvv-binaries/nugets/ZXing.Net.0.16.8 — vendored QR library (no new dep)docs/Plans/033-avalonia-architecture.md — prior thinking; this plan supersedes the in-process Option B/C end-stateSpaceMusic.UI.Stride/Platform/SmSkiaRenderTarget.cs — the InCacheRender gate added 2026-06-01; instructive for why process separation removes the whole class of bugs