Skip to content

C ABI contract

The behavioral contract for the extern "C" surface a host links against — the one seam between a frontend and the engine. It states what must be true, not how it is implemented.

Where it lives: core-cabi/src/lib.rs (the definitions) and core-cabi/include/rlx_core.h (the C mirror a host compiles against, and the per-function reference). Using it: Embedding the core walks the lifecycle with a host example. Governing ADRs: 0001 (the seam exists), 0003 (the v1 surface and the freeze rationale), extended by 0006, 0008, 0013, 0110 and 0117. How the surface got here is at the end, under Provenance.

Invariants

  • The current surface (RLX_ABI_VERSION = 6) MUST be exactly these fifteen functions and no others: rlx_abi_version, rlx_create, rlx_free, rlx_push_samples, rlx_attach_window, rlx_render, rlx_render_dt, rlx_resize, rlx_cycle_scene, rlx_load_presets, rlx_get_presets, rlx_select_preset, rlx_set_debug, rlx_get_metrics, rlx_set_now_playing. Each addition since v1 carries its own ADR: rlx_load_presets (v2, ADR-0006), rlx_set_debug + rlx_get_metrics + RlxMetrics (v3, ADR-0008), rlx_render_dt (v4, ADR-0013), rlx_set_now_playing (v5, ADR-0110), rlx_get_presets + rlx_select_preset (v6, ADR-0117). (core-cabi/src/lib.rs)
  • A preset index is snapshot-scoped, not a stable ID. An index passed to rlx_select_preset is an absolute position in the list the same handle’s rlx_get_presets reported, with no rlx_load_presets in between; a host that persists a choice across runs persists the name and re-resolves it against a fresh snapshot. Stable IDs are a future ABI decision, not a reinterpretation of these.
  • A string crossing the boundary MUST be copied on receipt. rlx_set_now_playing copies the bytes before it returns and never retains the caller’s pointer, so the caller may free or reuse the buffer immediately. This is the one rule the C++ side cannot verify and must be able to rely on; getting it wrong is a use-after-free that will not reproduce reliably.
  • RLX_ABI_VERSION MUST change only when the shape of the extern "C" surface changes (a signature, a function, an error code) — never as a side effect of an application version bump. It is a separate axis from the app version (ADR-0005).
  • The C header core-cabi/include/rlx_core.h MUST match core-cabi/src/lib.rs signature-for-signature, including the RLX_ABI_VERSION literal and the RlxMetrics field layout; the shim compiles against the header separately from the Rust crate, so a drift is a link/runtime error, not a compile error.
  • Every pointer crossing the boundary MUST be validated where it enters the core (null checks, handle validity); the hot path downstream trusts it. Validate at the boundary, trust inside. (CLAUDE.md non-negotiables)
  • A panic MUST NOT cross the boundary: every entry point catches unwinds and maps them to RLX_ERR_PANIC (-5) rather than unwinding into C++. (core-cabi/src/lib.rs module doc)
  • rlx_push_samples MUST NOT allocate, block, lock a contended mutex, log, or do file I/O — it runs on the caller’s audio-delivery thread. It hands samples to the ring and returns. (CLAUDE.md; see 0002-ring-determinism)
  • The threading contract MUST hold in both directions: rlx_push_samples from at most one thread (the host’s audio/visualisation thread), every other call from at most one thread (the host’s UI/render thread), and never concurrently with rlx_create/rlx_free on the same handle. The two roles meet only at the lock-free ring. (core-cabi/src/lib.rs, mirrored in the header)
  • No allocation crosses the boundary. RlxMetrics is caller-allocated plain data; it leads with struct_size + abi_version so later fields can append without an ABI bump (forward-extensible by size).
  • Widening the surface (a sixteenth function, a changed signature) is an ADR-worthy event, not a casual edit. The seam stays minimal: create / free / push / attach / render / resize / cycle-scene / load-presets / read-roster / select-preset / debug / metrics / now-playing. The thirteenth was added under exactly that ruleADR-0110 first, RLX_ABI_VERSION bumped in the same phase, and the cost it carried (the text feature’s cosmic-text tree in the DLL) measured against NFR §4 rather than assumed. The fourteenth and fifteenth were added the same wayADR-0117 first, and it rejected the conventional four-function enumeration (count / name-at / current / select) precisely because two suffice.

Scenarios

  • WHEN the C++ shim calls rlx_abi_version() THEN it returns 6, which the shim compares against the header’s RLX_ABI_VERSION as core_abi >= RLX_ABI_VERSION: it refuses a core older than the one it was built against and accepts an equal or newer one. Forward compatibility is deliberate and it is what the size-guarded rlx_get_metrics buys — a newer core appends fields the shim does not read, and the functions the shim does call are stable, so a version bump does not have to be a lockstep redeploy of the pair. A refusal is not fatal: the shim logs to the foobar console and disables preset loading and diagnostics, leaving the render path alone.
  • WHEN rlx_create succeeds THEN it returns an opaque non-null handle that every other call (except rlx_abi_version) takes as its first argument; WHEN it fails THEN it returns null and no handle is leaked.
  • WHEN rlx_push_samples is called with a valid handle and a sample buffer THEN the samples are copied into the lock-free ring and the call returns immediately, without touching the render path.
  • WHEN rlx_push_samples is called with a null handle or null buffer THEN it is rejected at the boundary with RLX_ERR_INVALID_ARG (no deref of the invalid pointer), not passed inward.
  • WHEN rlx_render_dt(handle, dt) is called THEN the frame advances by the host’s real elapsed seconds, so animation is frame-rate independent; WHEN the legacy rlx_render(handle) is called THEN it is exactly rlx_render_dt(handle, 1.0/60.0) — the fixed-step wrapper kept for v1-era callers (ADR-0013).
  • WHEN a render call is made before rlx_attach_window THEN it returns RLX_ERR_NO_WINDOW rather than rendering or panicking.
  • WHEN rlx_load_presets is called before a window is attached THEN the parsed set is held pending and installed as soon as rlx_attach_window creates the renderer, and the call still reports the loaded count (ADR-0006).
  • WHEN rlx_get_presets is called with buf_len = 0 (or a null buf) THEN it returns the total byte count the full list needs and writes nothing into the buffer; WHEN it is called with a buffer at least that large THEN every installed name is written in roster order as UTF-8, each followed by one 0x00; WHEN the buffer is smaller than the needed count THEN nothing is written, so a short buffer never yields a partial list a host could render as the whole roster (ADR-0117).
  • WHEN rlx_get_presets is called with a non-null out_current_index THEN it receives the index the show is going to — the dissolve’s target while a transition is in flight, matching rlx_cycle_scene’s convention — or -1 on an empty roster; it is filled on sizing calls too, and left untouched when the call returns an error.
  • WHEN rlx_select_preset(handle, i) is called with an i the same handle’s rlx_get_presets reported THEN the show dissolves to that preset exactly as rlx_cycle_scene does, and a following rlx_get_presets reports i as the current index immediately rather than when the dissolve finishes; WHEN i is negative or past the end THEN it returns RLX_ERR_INVALID_ARG and nothing changes — a stale index is a host bug worth signalling, not worth a wrap or a panic.
  • WHEN either v6 call is made before rlx_attach_window THEN it returns RLX_ERR_NO_WINDOW, because the roster installs at attach (ADR-0006’s pending-set rule) and an empty list would read to a user as “this build ships no presets”.
  • WHEN rlx_get_metrics is called with a caller-allocated RlxMetrics THEN the core stamps struct_size with the byte count it actually wrote and abi_version with RLX_ABI_VERSION, so a host built against an older header reads a prefix rather than garbage (ADR-0008).
  • WHEN rlx_set_now_playing is called with valid UTF-8 THEN the core copies it, fades a banner in over the visuals, holds it, and fades it out on its own — the host says what is playing and never when to stop, so there is no clear call and none is needed. The string is conventionally artist - title; the core splits on the first - and draws a single line without one.
  • WHEN rlx_set_now_playing is called with the string that is already set THEN nothing happens and the banner does not re-trigger, so a host may call it on every metadata notification it receives.
  • WHEN rlx_set_now_playing is called with a null handle, a null pointer, a zero length, or bytes that are not valid UTF-8 THEN it returns RLX_ERR_INVALID_ARG at the boundary rather than passing them inward; WHEN it is called before rlx_attach_window THEN it returns RLX_ERR_NO_WINDOW. (Zero length is a caller error here, not a clear — the Rust-side Renderer::set_now_playing treats an empty string as “clear”, but no host needs that over the ABI, because the banner is transient.)
  • WHEN rlx_set_now_playing is called THEN it MUST be from the host’s playback/UI callback and never from the visualisation_stream thread: the copy allocates, which that thread must never do. This is the same distinction rlx_push_samples draws from the other direction.
  • WHEN rlx_free is called on a handle THEN all core resources for that handle are released and the handle MUST NOT be used again.
  • WHEN the extern "C" surface must change shape THEN RLX_ABI_VERSION is bumped and a new ADR records the addition — the app version (ADR-0005) is untouched by this.

Known gaps / honest nulls

  • Rust-side FFI coverage exists; the C++ shim is still untested in CI. core-cabi/tests/ffi.rs (the path moved with ADR-0072; this line said core/tests/ffi.rs until Plan 0107) drives rlx_createrlx_load_presetsrlx_render_dt / rlx_set_debug / rlx_get_metricsrlx_free across the boundary, including the null-argument error paths and the version handshake. What remains uncovered is the C++ side: plugin-foobar/ is not built in CI (it needs the foobar SDK + MSVC), so header-vs-ffi.rs drift is caught only by a local plugin build.

  • One test attaches a real window, and it can silently skip. v6’s roster surface only exists after rlx_attach_window, so Plan 0107 added a #[cfg(windows)] test that opens a hidden STATIC window and drives the real attach path — the first coverage that entry point has had. When no adapter can present to that window the test prints a reason and returns green rather than failing, because its subject is the ABI and not the GPU. So a machine where attach fails proves nothing about the roster claims, and a CI run is not evidence they hold; the assertions ran on a hardware-GPU dev box at the phase’s close.

  • The component’s size is a dated series, and the series is what to read. A single before/after pair reads as a fact about the component; what a reader needs is the trend, because the cap is what the trend runs into. Release x64, measured on the dev box, against NFR §4’s 12,582,912 B soft cap for the component. Every row after this one is a copy out of a build log: packaging/foobar/build-component.ps1 prints the length in these units on every build.

    Measured atfoo_ritmolux.dllthe shipped componentrlx_core_c.dll — cdylib, built not shippedMoved by (shipped)
    Plan 0097, before text6,774,784 B6,720,512 B
    Plan 0097, after text8,879,104 B8,824,320 B+2,104,320 B (+31.1 %)
    2026-08-18, Plan 0107’s close9,279,488 B9,218,048 B+400,384 B (+4.5 %)
    2026-09-01, Plan 0141 Phase 29,789,952 B9,714,688 B+510,464 B (+5.5 %)

    The text step is the largest single one and it bought a font system: core-cabi enables rlx-core/text so the banner has one at all (ADR-0110 rejected the 31-glyph quad font, which cannot spell Björk and fails by painting a silent blank). ADR-0110’s Alternative A stays unused.

    The headroom is 2,792,960 B, and the component is at 77.8 % of cap. The unit ambiguity that made this figure unstatable is gone: NFR §4 wrote “~10 MB … plugin DLL in the same ballpark”, which reads two ways 4.9 % apart and covers the plugin by assumption rather than by measurement. ADR-0159 gives the component a cap of its own, in bytes, derived from what it contains.

    Read the percentage as a direction, not a budget. 2,792,960 B is not 2,792,960 B of permission; what it says is how many more steps of the text step’s size can land before someone has to have a conversation. The cap admits exactly one, by construction. The reader who no longer has to remember any of this is the one running a release: the recipe prints the length and warns above 11,324,620 B, so the figure arrives where a human is already reading output.

    Neither movement after the text step was attributed as it landed. Both are attributed below, after the fact, by two bisects of the same shape.

    Note which number governs: the shim links the staticlib into foo_ritmolux.dll, so the cdylib is a proxy — close here, but not the artifact NFR §4’s plugin cap is about.

    Where the growth after Plan 0097 came from. Bisected 2026-09-01 by building only core-cabi at four points and reading rlx_core_c.dll, every point built the same way so the deltas compare:

    Pointrlx_core_c.dllMoved by
    Plan 0097’s close (ed200df)8,822,784 B
    Release after Plan 0102’s close (817ef2c)8,822,784 B0 B
    Release after Plan 0100’s close (f7b0c34)9,198,592 B+375,808 B
    Plan 0107’s close (2c8f588)9,204,736 B+6,144 B

    Plan 0100’s MilkDrop conversion work is 98.4 % of that window — 375,808 B of 381,952 B — and the suspicion that named it is confirmed rather than assumed. Plans 0105, 0099 and 0102 together moved nothing measurable, and Plans 0101, 0108 and 0107 together moved 6,144 B, which independently confirms Plan 0107 was not the cause. Nothing here is over cap and nothing is being repaired: the MilkDrop runtime is shipped capability and this only says what it weighs.

    Where the growth after 2026-08-18 came from: the preset library, not the code. Bisected 2026-09-02 the same way, at every chore: Release in the window — 34 points, 33 steps, all under rustc 1.97.1 (8bab26f4f 2026-07-14), no other lane building. The cdylib moved +509,952 B, which tracks the shipped component’s +510,464 B closely enough to confirm the proxy. The window’s ten interpretable steps:

    Step lands atFollows the close ofMoved by
    5931128 (2026-08-29)Plan 0104+122,368 B
    c9ad2f3 (2026-08-30)Plans 0115, 0134+99,328 B
    fd8645f (2026-08-26)Plan 0113+59,392 B
    beec5de (2026-08-28)Plan 0123+51,712 B
    171e3f6 (2026-08-27)Plan 0121+48,128 B
    5a3c502 (2026-08-26)Plan 0114+32,256 B
    ebbed7d (2026-08-27)Plan 0087+30,720 B
    d42b86d (2026-08-31)Plan 0144+23,040 B
    64a0986 (2026-08-28)Plan 0122+20,480 B
    e729341 (2026-08-31)Plan 0125−27,648 B

    No single step is a majority — the largest is 24.0 % — and the growth is not distributed either: two steps clear 66,560 B and twelve steps moved exactly 0 B. What is dominant is a cause rather than a step. presets/*.toml went from 185,563 B to 525,603 B across the same window, 40 presets to 81, and core/build.rs embeds every one of them verbatim by include_str! (ADR-0022) — so +340,040 B of the +509,952 B, or 66.7 %, is preset text. The two largest steps are both preset-adding closes and nothing else about them is unusual; e729341 shrinks because Plan 0125 shared the scenes’ GPU boilerplate, which is the same mechanism running backwards.

    Nothing here is over cap and nothing is being repaired — the library is shipped capability and this only says what it weighs. But the driver is now named and it is structural: the library renews by replacement cohorts (ADR-0089), so this column tracks the preset count more than it tracks the code, and the recipe’s warning will be reached by curation rather than by a feature.

    What the bisect establishes about rust-lld, and what it does not. This machine links Rust output with rust-lld (ADR-0147, a machine-local override adopted 2026-08-29) and every row above the last predates it, so the cdylib column spans two linkers. Rebuilding Plan 0097’s baseline under the override lands 1,536 B from the number recorded at that plan’s close — no linker-sized effect at that point. That is one point, and it is not enough to call the column one series, because the other point that can be checked the same way disagrees by far more: the series table records 9,218,048 B at Plan 0107’s close and rebuilding 2c8f588 gives 9,204,736 B, a gap of 13,312 B under the same cargo build --release -p rlx-core-cabi. Where that gap comes from is not established — the linker override is one candidate and the rustc version in use on each date is another, and neither was recorded at the time, so neither can now be ruled in or out.

    So the working noise floor for this column is ~13 KB, and numbers under it are not readings. The first bisect’s +6,144 B row is one: it is consistent with Plan 0107 having added nothing, which is what its own diff shows, but it does not measure that and should not be quoted as if it did. The second bisect’s twenty-three omitted steps are the rest — twelve of them exactly 0 B, the others between 512 B and 11,264 B — which is why its table above lists ten steps and not thirty-three. Both attributions are clear of the floor by an order of magnitude and are unaffected. foo_ritmolux.dll never had the linker question at all: MSVC link.exe links it from the staticlib either way.

    A bisect records its rustc -V alongside the bytes. It costs one line at bisect time and it is the difference between a gap that can be reasoned about and one that cannot. The second bisect did, and every one of its 34 points is rustc 1.97.1 (8bab26f4f 2026-07-14) — so nothing in that column is a toolchain artefact, which is what lets a 512 B step be read as small rather than as unknown.

    Re-measure at every release, not when a dependency is added. A dependency-shaped trigger cannot catch this growth and did not: everything after Plan 0097 arrived as code added to core/ behind the ABI, with no new crate anywhere. The release is the trigger that fires without anyone remembering, because packaging/foobar/build-component.ps1 produces the shipped DLL on every v* tag — the number already exists at that moment. Add a row when it has moved more than ~100 KB since the last one.

    The trigger is written down where a releaser will meet it, in Releasing — a trigger stated only here would be the same defect as the dependency-shaped one it replaces, since nobody opens the C ABI spec to cut a release. And taking the number is no longer a duty performed from memory: the recipe reads its own output’s Length, prints it in these units on every build, and warns above 11,324,620 B — a warning and never a Die, because the cap is soft (ADR-0159; the ask is design-backlog 0177). What is still a person’s is the row: the build states the figure, and copying it into the table above when it has moved is the half no build can do, so the table is only as current as the last person who added one.

  • The header is a hand-maintained mirror. Nothing mechanically checks core-cabi/include/rlx_core.h against core-cabi/src/lib.rs (no cbindgen, per ADR-0003’s keep-it-small posture). The invariant above is enforced by review, not by a gate.

  • This spec covers the contract shape and boundary discipline, not the per-call payload semantics of rendering (frame timing, surface format) — those live with the render/scene subsystem and the wgpu layer.

Provenance

How this surface reached its current shape. None of it is a rule — the invariants above are — and it is here so a reader who wants the history has it in one place rather than in the header.

Reconciled through Plan 0107, surface at v6, reconciled 2026-08-17.

ADR-0117 added rlx_get_presets and rlx_select_preset and moved RLX_ABI_VERSION from 5 to 6, so the foobar menu can list the roster and pick from it instead of cycling. Both are thin wrappers over core methods the standalone’s browse overlay had used since Plan 0008; no new core capability crossed with them.

The surface had widened once before, at v5: ADR-0110 added rlx_set_now_playing. Widening it is the ADR-worthy event the invariants call for, not a casual edit.

Paths were re-pointed at Plan 0061’s close (2026-08-08). ADR-0072 moved the definitions and the header out of core/ into the rlx-core-cabi crate — where it compiles changed, the surface did not. That crate sits outside the workspace default-members, so the conformance suite runs under --workspace or -p rlx-core-cabi and not under a bare cargo nextest run.

Built from a8ce055 at version 0.115.0. This site tracks main and is not versioned per release.