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) andcore-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_presetis an absolute position in the list the same handle’srlx_get_presetsreported, with norlx_load_presetsin 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_playingcopies 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_VERSIONMUST change only when the shape of theextern "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.hMUST matchcore-cabi/src/lib.rssignature-for-signature, including theRLX_ABI_VERSIONliteral and theRlxMetricsfield 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.rsmodule doc) rlx_push_samplesMUST 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_samplesfrom 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 withrlx_create/rlx_freeon 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.
RlxMetricsis caller-allocated plain data; it leads withstruct_size+abi_versionso 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 rule — ADR-0110 first,
RLX_ABI_VERSIONbumped in the same phase, and the cost it carried (thetextfeature’s cosmic-text tree in the DLL) measured against NFR §4 rather than assumed. The fourteenth and fifteenth were added the same way — ADR-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 returns6, which the shim compares against the header’sRLX_ABI_VERSIONascore_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-guardedrlx_get_metricsbuys — 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_createsucceeds THEN it returns an opaque non-null handle that every other call (exceptrlx_abi_version) takes as its first argument; WHEN it fails THEN it returns null and no handle is leaked. - WHEN
rlx_push_samplesis 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_samplesis called with a null handle or null buffer THEN it is rejected at the boundary withRLX_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 legacyrlx_render(handle)is called THEN it is exactlyrlx_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_windowTHEN it returnsRLX_ERR_NO_WINDOWrather than rendering or panicking. - WHEN
rlx_load_presetsis called before a window is attached THEN the parsed set is held pending and installed as soon asrlx_attach_windowcreates the renderer, and the call still reports the loaded count (ADR-0006). - WHEN
rlx_get_presetsis called withbuf_len = 0(or a nullbuf) 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 one0x00; 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_presetsis called with a non-nullout_current_indexTHEN it receives the index the show is going to — the dissolve’s target while a transition is in flight, matchingrlx_cycle_scene’s convention — or-1on 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 anithe same handle’srlx_get_presetsreported THEN the show dissolves to that preset exactly asrlx_cycle_scenedoes, and a followingrlx_get_presetsreportsias the current index immediately rather than when the dissolve finishes; WHENiis negative or past the end THEN it returnsRLX_ERR_INVALID_ARGand 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_windowTHEN it returnsRLX_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_metricsis called with a caller-allocatedRlxMetricsTHEN the core stampsstruct_sizewith the byte count it actually wrote andabi_versionwithRLX_ABI_VERSION, so a host built against an older header reads a prefix rather than garbage (ADR-0008). - WHEN
rlx_set_now_playingis 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 conventionallyartist - title; the core splits on the first-and draws a single line without one. - WHEN
rlx_set_now_playingis 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_playingis called with a null handle, a null pointer, a zero length, or bytes that are not valid UTF-8 THEN it returnsRLX_ERR_INVALID_ARGat the boundary rather than passing them inward; WHEN it is called beforerlx_attach_windowTHEN it returnsRLX_ERR_NO_WINDOW. (Zero length is a caller error here, not a clear — the Rust-sideRenderer::set_now_playingtreats an empty string as “clear”, but no host needs that over the ABI, because the banner is transient.) - WHEN
rlx_set_now_playingis called THEN it MUST be from the host’s playback/UI callback and never from thevisualisation_streamthread: the copy allocates, which that thread must never do. This is the same distinctionrlx_push_samplesdraws from the other direction. - WHEN
rlx_freeis 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 THENRLX_ABI_VERSIONis 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 saidcore/tests/ffi.rsuntil Plan 0107) drivesrlx_create→rlx_load_presets→rlx_render_dt/rlx_set_debug/rlx_get_metrics→rlx_freeacross 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.rsdrift 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 hiddenSTATICwindow 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.ps1prints the length in these units on every build.Measured at foo_ritmolux.dll— the shipped componentrlx_core_c.dll— cdylib, built not shippedMoved by (shipped) Plan 0097, before text6,774,784 B 6,720,512 B — Plan 0097, after text8,879,104 B 8,824,320 B +2,104,320 B (+31.1 %) 2026-08-18, Plan 0107’s close 9,279,488 B 9,218,048 B +400,384 B (+4.5 %) 2026-09-01, Plan 0141 Phase 2 9,789,952 B 9,714,688 B +510,464 B (+5.5 %) The
textstep is the largest single one and it bought a font system:core-cabienablesrlx-core/textso the banner has one at all (ADR-0110 rejected the 31-glyph quad font, which cannot spellBjörkand 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
textstep’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
textstep 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-cabiat four points and readingrlx_core_c.dll, every point built the same way so the deltas compare:Point rlx_core_c.dllMoved by Plan 0097’s close ( ed200df)8,822,784 B — Release after Plan 0102’s close ( 817ef2c)8,822,784 B 0 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: Releasein the window — 34 points, 33 steps, all underrustc 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 at Follows the close of Moved 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/*.tomlwent from 185,563 B to 525,603 B across the same window, 40 presets to 81, andcore/build.rsembeds every one of them verbatim byinclude_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;e729341shrinks 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 withrust-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 rebuilding2c8f588gives 9,204,736 B, a gap of 13,312 B under the samecargo build --release -p rlx-core-cabi. Where that gap comes from is not established — the linker override is one candidate and therustcversion 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 Brow 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.dllnever had the linker question at all: MSVClink.exelinks it from the staticlib either way.A bisect records its
rustc -Valongside 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 isrustc 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, becausepackaging/foobar/build-component.ps1produces the shipped DLL on everyv*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 aDie, 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.hagainstcore-cabi/src/lib.rs(nocbindgen, 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.