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.