One plugin, two C runtimes: the Rust core and the Qt dock
The Castalong plugin you install is actually two DLLs: castalong.dll (the Rust core) and castalong_dock.dll (the control dock). That split isn't an aesthetic choice — it's forced by a C-runtime conflict, and working within it shaped how the two halves talk.
The /MT vs /MD problem
The core links LiveKit, whose prebuilt libwebrtc is compiled against the static C runtime (/MT). OBS and its Qt6 are compiled against the dynamic runtime (/MD). Loading both into one module and letting them share a CRT is undefined behaviour — you get mismatched allocators and heap corruption, the classic LNK2038 "mismatch detected for RuntimeLibrary" if you're lucky enough to catch it at link time.
So the code is partitioned by runtime. The Rust core is built /MT with crt-static to match libwebrtc. The Qt dock is a separate /MD DLL to match OBS and Qt. They never share CRT objects.
A deliberately narrow C ABI
The two halves communicate only over a small C interface: primitives and NUL-terminated UTF-8 strings, never CRT-allocated objects crossing the boundary. Each side owns what it allocates; strings are copied on receipt. The directions are clean:
- Dock → core: user actions (connect, take a guest, mute, talkback, graphics settings) arrive as C callbacks the core registers. They run on the Qt GUI thread, so each one just enqueues to the core's async runtime and returns immediately — it must never block the UI.
- Core → dock: the core pushes the entire UI model (connection status, guest roster, on-air map) as one JSON blob. It can be called from any runtime thread; the dock marshals it onto the Qt GUI thread before touching widgets. Thumbnails go over a separate call as raw BGRA, deep-copied synchronously.
Pushing the whole model as JSON rather than fine-grained widget mutations keeps the ABI tiny and the threading rules simple: one "render this state" entry point, no partial updates to reason about.
Talking to OBS without its SDK
The Rust core uses no generated bindings and no obs.lib. libobs is called via #[link(kind = "raw-dylib")], so the import is resolved at load time against the already-loaded OBS — nothing to ship or version-match at build time. The struct layouts (obs_source_info, the full obs_source_frame, and friends) are hand-transcribed from the OBS headers for the targeted version, including the prefix-by-size trick OBS uses to stay ABI-stable across releases. The frontend API (scenes, docks) is reached with runtime GetProcAddress, since it isn't always present.
It sounds fragile written down, but it buys a self-contained plugin with no build-time SDK dependency, and it's exactly what lets the same Rust source target Windows and Linux OBS from one codebase.