Skip to main content

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:

NeedPattern
String inputconst char* (NUL-terminated, caller keeps ownership)
String outputint Get(char* buf, int cap) — returns length excl. NUL; buf=nullptr queries size
Collection outputenumeration callback void(*)(const T*, void* ud) + void* ud
Callbackfunction pointer + void* userdata (not std::function)
Non-trivial returnout-pointer parameter
Exchanged dataC 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 IPlugin interface 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:

  • IModuleContext is 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/… and IAppWindow), 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_METADATA and 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.