SKSE Plugin Development/Plugin ABI
A plugin and the SKSE runtime are two separate programs, built at different times by different people The runtime DLL was compiled and shipped long before the plugin existed; the plugin is compiled long after, knowing nothing about the runtime's internals. Yet at game start-up they have to meet, agree they're compatible, and start calling each other's code. The thing that lets two independently-built binaries do that is a small contract called the plugin ABI (application binary interface).
The handshake in two phasesEdit
When the SKSE64 runtime finds a plugin .dll during start-up (see
boot-timeline for when this happens), it doesn't blindly run it. If the
plugin happened to be incompatible after an update, that would cause many
problems. The connection is made in two phases: first, a query where the plugin
declares to the SKSE runtime whether it is compatible; then the runtime commits
to the actual loading procedure.
Phase 1: Compatibility checkEdit
In this version of SKSE, the compatibility question is answered without executing a single instruction from the plugin. Instead of calling a function, the runtime reads a block of plain data the plugin exports.
Any plugin exports a filled-in
SKSEPluginVersionData
struct under the name SKSEPlugin_Version. It contains the plugin name, its version, and
which game versions it declares itself compatible with. The runtime opens the DLL
as a read-only image (LoadLibraryEx(..., LOAD_LIBRARY_AS_IMAGE_RESOURCE)) and
reads that exported block straight out of the file
(PluginManager.cpp)
- the DLL is loaded as data, not as code, so none of the DLL's functions run yet. It then runs the declaration through a compatibility check
(CheckPluginCompatibility)
and either accepts the plugin or rejects with a reason. This all happens before faulty code gets a chance to crash.
In this codebase there is no SKSEPlugin_Query function, the "query" phase is
a static data declaration. It should be noted that this is not universal across the xSE family. F4SE (Fallout 4, same author) uses the (same mechanism).
The older NVSE (New Vegas) does not: a plugin exports an NVSEPlugin_Query function that the loader resolves and calls, so plugin code runs during the check (xNVSE/NVSE).
Phase 2: The load callEdit
Once the plugin passes the check, the runtime actually loads the DLL as code and calls the entry point function:
bool SKSEPlugin_Load(const SKSEInterface * skse)
This is the one function a plugin must export
(declared as _SKSEPlugin_Load;
expected export),
found by name (see def-ordinal-exports). The runtime resolves it with GetProcAddress and calls it
(PluginManager.cpp),
handing the plugin a pointer to an SKSEInterface, which is the gateway to everything the runtime offers (see next section).
This is where a plugin does its setup work.
- A plugin may return
falsein order to bail out, provided it has not yet registered anything. The runtime will then unload the plugin. (PluginAPI.h). - There is also an optional, earlier entry point,
SKSEPlugin_Preload, for plugins that need to run before the game has built much of itself. That two-phase preload/load timing is explained on the boot-timeline page; for most plugins,SKSEPlugin_Loadis the one that matters.
The SKSEInterface handed to the pluginEdit
The single argument to SKSEPlugin_Load is a pointer to the
SKSEInterface
struct. It carries a few version fields and, more importantly, a handful of function pointers the runtime filled in for plugins to use:
| Member | What it provides |
|---|---|
skseVersion, runtimeVersion, editorVersion, isEditor |
Which host the plugin runs under (SKSE build, game version, and whether this is the game or the Creation Kit) |
QueryInterface(id) |
The gateway to every sub-system interface (see below) |
GetPluginHandle() |
A unique id for the calling plugin. Note: call it once and save the result. (See the rules of courtesy section.) |
GetReleaseIndex() |
The SKSE build's release index |
GetPluginInfo(name) |
Look up another loaded plugin by name |
The struct the runtime actually hands the plugin is a single static instance it
builds at start-up, with those function-pointer slots wired to its own internal
functions (g_SKSEInterface).
The important field is QueryInterface. On its own, SKSEInterface is small.
It doesn't expose Papyrus, serialization, Scaleform, and the rest directly. Instead a plugin calls skse->QueryInterface(id) with one of a fixed set of interface ids, and it hands back a pointer to that sub-system's interface, or null if the id isn't recognized (the dispatch switch).
There are seven interfaces, each exposing one feature the runtime offers to plugins:
| Interface id | Struct | What it opens up |
|---|---|---|
kInterface_Scaleform |
SKSEScaleformInterface |
Inject into the game's Scaleform (Flash) UI. Register a callback that builds objects under skse.plugins.<name>, and hook item data as it is extended to the inventory/favorites menus (PluginAPI.h)
|
kInterface_Papyrus |
SKSEPapyrusInterface |
Register native functions callable from Papyrus scripts, into the VM's class registry (PluginAPI.h)
|
kInterface_Serialization |
SKSESerializationInterface |
Read and write information into the SKSE co-save, with save/load/revert callbacks and handle/FormID resolution across saves (PluginAPI.h)
|
kInterface_Task |
SKSETaskInterface |
Queue a task for the game to run later. Either a general task (TaskDelegate) or a UI task (UIDelegate) (PluginAPI.h)
|
kInterface_Messaging |
SKSEMessagingInterface |
Inter-plugin messaging, plus SKSE lifecycle notifications (data-loaded, pre/post load-game, and so on) and access to SKSE's internal event dispatchers (PluginAPI.h)
|
kInterface_Object |
SKSEObjectInterface |
SKSE's managed-object services: the delay-functor manager, object registry, and persistent object storage (PluginAPI.h)
|
kInterface_Trampoline |
SKSETrampolineInterface |
Allocate space from SKSE's shared trampoline pools for code that installs hooks. (branch pool or local pool) (PluginAPI.h)
|
For a more detailed explanation on the interfaces, see the dedicated interface-catalog page.
Rules of courtesyEdit
The ABI contract comes with a few rules of courtesy the runtime documents inline in the header.
GetPluginHandleshould be called once and its result cached. It is only valid during query or load, and returns an invalid handle if called at any other time (PluginAPI.h).- Inter-plugin messaging (
RegisterListener/Dispatch) should not be attempted during plugin load, because the target plugin may not be loaded yet (PluginAPI.h).
Reading the header comments in full is worthwhile before relying on the interface.