ABI Compatibility
The host ↔ plugin boundary is a COM-like binary ABI. Honoring it is what lets
a plugin built with any compatible compiler load into the host regardless of how
the host was built, with no shared C++ runtime. Everything in <framelift/Abi.h>
exists to enforce it.
The five rules
Every type that crosses the DLL boundary must follow these. Violations cause undefined behavior — crashes or silent corruption — especially when host and plugin use different compilers or runtimes.
1. Interfaces only
Every exchanged object is a pure abstract class: single inheritance, only
virtual methods, no data members, no inline non-virtual logic that touches
object layout. Declare methods noexcept — exceptions must never propagate across
the boundary.
The FRAMELIFT_INTERFACE(ClassName) macro deletes copy/move on a boundary
interface; put it at the top of your interface body.
2. POD-only signatures
No std::string, std::vector, std::function, std::variant,
std::optional, std::unique_ptr, or by-value non-trivial structs in virtual
parameters or return types. Use instead:
| Need | Pattern |
|---|---|
| String input | const char* (NUL-terminated, caller keeps ownership) |
| String output | int Get(char* buf, int cap) — returns length excl. NUL; buf=nullptr queries size |
| Collection output | enumeration callback void(*)(const T*, void* ud) + void* ud |
| Callback | function pointer + void* userdata (not std::function) |
| Non-trivial return | out-pointer parameter |
| Exchanged data | C POD struct (fixed-size char arrays, numeric fields) |
The author-side helpers in <framelift/ContextHelpers.h> / <framelift/HotkeyHelpers.h>
wrap these patterns back into lambdas and std::string on the plugin side,
where it is safe because that code compiles into your DLL and never crosses the
boundary.
3. Explicit IDs, not typeid
Every interface declares static constexpr const char* InterfaceId = "..."; and
every event static constexpr const char* EventId = "...";. Service lookup and
pub/sub key on these constants. Never use typeid(T).name() across a DLL
boundary — it is not stable across compilers.
4. Documented string lifetime
const char* parameters and event fields are valid only for the duration of
the call/callback, in both directions. Anything that must outlive the call is
copied into the receiver's own storage.
5. Versioned by a single integer
The ABI is a single integer, FRAMELIFT_ABI_VERSION in <framelift/ModuleABI.h> —
not a major.minor.patch tuple. The host and every plugin are built from one
source tree in lockstep, so the version's only job is to catch a stale binary. The
entry macro bakes the value your plugin was compiled against — alongside its
identity — into the embedded Q_PLUGIN_METADATA JSON, which the host reads
first (via QPluginLoader::metaData()), before touching any vtable. The host
accepts the plugin only when:
plugin.abiVersion == host.abiVersion
An exact match. A mismatch means a stale binary to rebuild, not a version to negotiate.
Bump FRAMELIFT_ABI_VERSION only on a break to the core load-bearing handshake:
- the
IPlugininterface or its IID, - the embedded plugin metadata shape,
- a host-called interface (
IModule), - the bootstrap surface of
IModuleContext(the service registry + pub/sub).
Everything else is a capability surface: host functionality is exposed as
small, independently discovered service interfaces. Adding, changing, or removing
one is not a break — a consumer discovers it with ctx.GetService<T>() and
degrades gracefully when it returns nullptr, so it never bumps the version.
Capability discovery instead of version negotiation
There is no "minor" to negotiate. New host capabilities ship as new interfaces, never as appends to existing ones, so old and new plugins coexist by construction:
IModuleContextis a tiny frozen bootstrap (service registry + pub/sub).- Settings, the plugin catalogue, and paths are services (
ISettingsStore,ISettingsRegistry,IPluginCatalog,IAppPaths). - Media playback and the window are interface families
(
IMediaPlayback/IAudioControl/… andIAppWindow), each fetched independently. A new playback knob is a new small interface, not an append.
How the gate plays out
- At load time: the host reads the embedded
Q_PLUGIN_METADATAand requires an exact version match. An incompatible plugin is skipped and logged — never invoked. - At configure time:
find_package(FrameLiftSdk)is gated on the ABI version (ExactVersion). Building against the wrong SDK fails in CMake before you produce a DLL.
Together these mean an out-of-date plugin fails loudly and early instead of corrupting memory at runtime.
Practical implications
- You do not link any third-party UI, logging, image, or JSON library — none of those types appear at the boundary. A plugin needs only a C++23 compiler, CMake, and Qt.
- You are not required to match the host's compiler or its standard-library build flags, because no standard-library types are shared across the edge.
- Fetch every host capability with
ctx.GetService<T>()and null-check it. When you define your own services or events, the same rules apply — keep signatures POD, give each type a unique ID, and add capabilities as new interfaces.