Skip to content

Declarative Plugins

John Stewart edited this page Jul 27, 2022 · 9 revisions

from-4.0.0

stability-wip

Declarative plugins are SKSE plugins that utilize a system of integrating with SKSE by merely declaring blocks of code, rather than imperatively hooking into or registering for SKSE events. Declarative plugins are a feature imported from Fully Dynamic Game Engine's Trueflame static library, now brought natively into CommonLibSSE. The advantages of the declarative system are:

  • Simpler integration with the SKSE lifecycle and SKSE's message bus.
  • Inversion of control: you can declare multiple handlers for plugin load and messages which do not need to know anything about each other.
  • Less boilerplate code.

The foundation of the declarative plugin system is the existing declarative CMake integration, which allows CMake to generate the entities in the final DLL which SKSE requires to identify your SKSE plugin. You can extend the CMake integration to support the declarative system by adding the DECLARATIVE option to add_commonlibsse_plugin.

find_package(CommonLibSSE CONFIG REQUIRED)

add_commonlibsse_plugin(${PROJECT_NAME} DECLARATIVE
    SOURCES ${headers} ${sources}
)

Alternatively, the declarative plugin system can be enabled in code by using the macro UseSKSEPluginLoader, which should be placed in a single source file.

When the declarative system is enabled, you should remove your SKSEPlugin_Load function, since this function will be generated for you.

Handling SKSE Plugin Loading

Traditionally integration with SKSE is done by exporting a function named SKSEPlugin_Load, which is invoked by SKSE. This single function must handle all loading logic, and is responsible for initializing anything else in your plugin, requiring it to know of and call to any components in your project that need initialized.

In the declarative system, you can instead use the macro OnSKSEPluginLoad(), which generates a load handler. You can use this macro any number of times, across any number of separate source files (they should never be put into a header file, as this can result in name conflicts in the generated code). This macro takes an argument which is a signed 64-bit integer, indicating its load priority. Load handlers with a lower number are executed first. At priority 0, the declarative system will automatically invoke SKSE::Init() for you (you should not call it yourself in any of your handlers). Therefore, negative priorities are considered extra-early handlers, and should typically be used only to initialize logging so that you get the logging built into the declarative system starting at priority 0. Optionally, a second argument can be provided with the name of the const SKSE::LoadInterface& variable, received from SKSE.

OnSKSEPluginLoad(10) {
    // Runs first.
}

OnSKSEPluginLoad(20, skse) {
    // Runs second.
}

When SKSE loads your plugin, the declarative system will handle all of the work of invoking all the load handlers in the correct order, as well as initializing SKSE, and setting up declarative messaging (more on that below). The declarative loader includes logging, but runs all negatively-valued priority handlers before logging the plugin startup and continuing with the remaining handlers. For convenience, the following additional macros are provided:

  • OnSKSEPluginInit([varName]): Synonym for OnSKSEPluginLoad((std::numeric_limits<std::int64_t>::min)(), varName), therefore runs before any other logic in the declarative loader (should typically be used only to initialize logging).
  • OnSKSEPluginLoading([varName]): Synonym for OnSKSEPluginLoad(0, varName), therefore the first "normal" handlers that will run, after plugin startup is logged.
  • OnSKSEPluginLoaded([varName]): Synonym for OnSKSEPluginLoad((std::numeric_limits<std::int64_t>::max)(), varName), therefore runs after all other handlers.

If two handlers have the same priority, their order of execution relative to each other is undefined.

For the majority of plugins it should be sufficient (to emulate the traditional pre-AE behavior of most plugin authors, in which logging is initialized early in SKSEPlugin_Query), to only use a single OnSKSEPluginInit handler that initializes logging, and then use OnSKSEPluginLoaded for all other handlers.

Reporting Incompatibility

Unlike SKSEPlugin_Load, declarative handlers do not return any value. Any handler can throw an exception, which will indicate an error during loading. For AE runtimes, this results in a modal dialog from SKSE reporting the plugin failed to load. The special exception type SKSE::PluginIncompatible accomplishes the equivalent of returning false from SKSEPlugin_Load, which fails the plugin loading without an error message to the user.

Handling Messages

The declarative loader not only handles the lifecycle of the plugin, but also messages. A message handler is registered automatically to facilitate this (as a relative feature, CommonLibSSE NG supports registering multiple message listeners, so this does not prevent you from still registering listeners the imperative way). Message handlers use a priority system similar to that used for loading.

OnAnyPluginMessage("SKSE", 10, msg) {
    // Handle all message types.
}

OnAnyPluginMessage("SKSE", 20, msg) {
    // Handle all message types, after the above handler.
}

OnPluginMessage("SKSE", MessagingInterface::kDataLoaded, 15, msg) {
    // Only runs for kDataLoaded messages, between priorities 10 and 20.
}

This example shows how you can declaratively handle messages from SKSE (i.e. from the "SKSE" sender). The Any variant receives all messages, while the other only reacts to a specific single message type. Where the sender is "SKSE", you can simplify your handlers with the SKSE variant of the macros:

OnAnySKSEMessage(10, msg) {
    // Handle all message types.
}

OnAnySKSEMessage(20, msg) {
    // Handle all message types, after the above handler.
}

OnSKSEMessage(MessagingInterface::kDataLoaded, 15, msg) {
    // Only runs for kDataLoaded messages, between priorities 10 and 20.
}

Like loading handlers, there are aliases for certain priorities. On*MessageReceiving handlers are the minimum (most negative) priority, and run before all others, and On*MessageReceived is the maximum priority, running after all others. As with loading handlers, any number of message handlers can be declared in any number of source files (but they should not be used in header files).

Declarative Hooks

Declarative hooks allow for hooking call sites and functions declaratively, which embodies the hook as an object. The object's lifetime is the lifetime of the hook (i.e. destruction of the object unhooks). All hooks can also be manually unhooked/rehooked by reassignment or invoking the Detach() member function.

When a hook is active, applying the hook (operator()) will execute the original functionality that was hooked. If you are using a lambda as your hook, note that, due to the lambda existing in a context prior to the full definition of the hook, this will only work if the hook is a global or static variable. It is recommended that hooks be static variables of functions, and not global/static member variables, since the later will force eager instantiation and prevent mocking and testing of the hook.

Call/Branch Hooks

A call or branch hook is instantiated with the SKSE::BranchHook class, which is templated on the callee function type. The first parameter is the ID of the function where the call is made, and the second is the offset to the instruction within that function. The thirdis a function pointer for the hook. A lambda can be used for the hook, provided that it does not capture any ambient parameters.

SKSE::BranchHook<bool(RE::Actor*, bool)> Hook(RELOCATION_ID(123, 456), 0x10,
    [](auto* self, auto value) {
        return !Hook(self, value); // Hook inverts the return value;
    });

Every BranchHook has its own Trampoline space allocated automatically, and both validates and automates the use of the correct instructions for the hook. The target instruction will be validated to be hookable, and the correct instruction will be automatically used (i.e. you do not need to determine if it is 5 or 6 bytes, or a call or branch). Logging is also performed automatically.

Function Hooks

A function hook hooks the callee itself, rather than the call site, thus intercepting all calls to the function. Function hooks take the ID, offset, or function pointer to the function as the first parameter, and a function pointer/lambda for the hook as the second. As with other hooks, if a lambda is used, it must not capture any ambient variables.

SKSE::FunctionHook<bool(RE::Actor*, bool)> Hook(RELOCATION_ID(123, 456),
    [](auto* self, auto value) {
        return !Hook(self, value); // Hook inverts the return value;
    });

Internally, the FunctionHook is implemented using Microsoft's Detours library. Extensive logging support is included.

Virtual Function Hooks

A virtual function hook will hook a specific entry in a single virtual function table. Although this is simple and has been commonly done in the past (where function hooking was not supported by CommonLibSSE), it should be noted that these hooks are not inherited by subclasses, and so it is most commonly better to use a FunctionHook. Still, there may be cases where hooking a virtual function for single class in an object hierarchy only is desired. Virtual function hooks take the ID or offset of the vtable as their first argument, and the index of the function in the table as the second (this is the index, not the byte offset; therefore e.g. function index 3 maps to an offset of 24 bytes within the table). Furthermore, some vtables differ in their indices between SE/AE and VR, so care should be taken when making portable plugins.

SKSE::FunctionHook<void(RE::Actor*, RE::TESBoundObject*, bool, bool)> PlayPickUpSoundHook(RE::VTABLE_Actor, RE::Relocate(0xA3, 0xA4),
    [](auto* self, auto* object, auto pickup, auto use) {
        log::info("Picking up an object!");
        PlayPickUpSoundHook(self, object, pickup, use);
        log::info("Object picked up!");
    });