Skip to content

Plugin System

[KEY] — this document is the specification for the runtime plugin loader ADR-0010 decided to build. Read 02_MODULE_MODEL.md first — a plugin is a module with a different identity source and different failure semantics, not a parallel concept.


1. What a plugin is

A plugin is one class deriving from Plugin (Applejack.Core.Plugins), which itself derives from GameModule and reuses its five-stage lifecycle verbatim:

public abstract class Plugin : GameModule
{
    public abstract PluginManifest Manifest { get; }

    public sealed override string Id => Manifest.Id;
    public sealed override string Name => Manifest.DisplayName;
    public sealed override IReadOnlyList<string> DependsOnIds => Manifest.DependsOn;
    public sealed override bool IsPlugin => true;
}

A plugin author writes exactly two things: the manifest, and the lifecycle overrides doing real work — Configure, RegisterServices, Initialize, Ready, Shutdown, identical in shape to a first-party module's. See Guides/03_EXTENDING_THE_FRAMEWORK.md for a full worked example.

PluginManifest (Applejack.Core.Plugins) is the declared identity:

Field Meaning
Id Stable identity, unique across a server's installed plugins. Convention: lowercase, hyphenated ("richer-economy"), not a CLR type name.
DisplayName Human-readable name for logs, the dev menu, startup messages.
Version The plugin's own version.
Author For the dev menu and support requests.
DependsOn Ids of other plugins this one depends on. Becomes Plugin.DependsOnIds.
RequiresFrameworkVersion The extension-surface version this plugin was written against — see §7.

A plugin may additionally override GameModule.DependsOn (Type-based) to depend on a first-party module directly, alongside or instead of PluginManifest.DependsOn. Both flow into the same identity graph — see §3.

2. Manifest validation

A manifest is malformed if any of: Id/DisplayName/Author is empty, Version/ RequiresFrameworkVersion is missing, a DependsOn entry is empty, or DependsOn contains the plugin's own Id (declaring itself as its own dependency). PluginManifest.Validate() returns every problem found, not just the first, so a single log line can tell a server owner everything wrong with a manifest in one pass.

A malformed manifest disables that plugin. It does not throw. This is the load-bearing difference from ModuleDependencyException (a first-party module's misconfiguration is a hard startup failure): a plugin author's mistake must never be able to take a server down by itself. PluginDiscovery calls Validate() on every manifest it finds before the plugin ever enters the dependency graph; a non-empty result becomes a PluginState.Disabled entry naming every problem, logged at LogCategory.Module, and the plugin is excluded from resolution entirely — it does not occupy a node in the graph a well-formed plugin could depend on.

3. Module vs. plugin: identity and direction

Both are GameModule. What differs:

Module Plugin
Id source GetType().FullName (default) Manifest.Id
Declared in ApplejackBootstrap.Modules (fixed array) Discovered by PluginDiscovery
A missing/cyclic dependency Hard boot failure (server does not start) That plugin (and its transitive dependents) disabled; server boots
A lifecycle stage throws Hard boot failure (server does not start) That plugin (and its transitive dependents) quarantined; server boots
May depend on Modules and plugins, freely Modules and other plugins, freely
May be depended on by Modules and plugins Plugins only — not modules

The last row is the one hard rule ModuleDependencyResolver enforces at graph-build time, before either boot semantics above ever run: a first-party module may never depend on a plugin. The framework's own boot order must never be at the mercy of third-party code. Violation is ModuleDependencyException.ModuleDependsOnPlugin, naming both. The reverse — a plugin depending on a module — is not just allowed, it is the expected, common case (a plugin extending EconomyModule's behaviour has to depend on it).

See 02_MODULE_MODEL.md §3 for the identity mechanism (Id, DependsOnIds, IsPlugin) this rule is built on — it is shared machinery, not plugin-specific.

4. Plugin states

IPluginCatalog.Plugins (Applejack.Core.Plugins) exposes one PluginStatus per discovered plugin:

State Meaning Reason
Loaded Booted through every lifecycle stage without error. null
Disabled Never reached Loaded — either kept out of the boot graph entirely (malformed manifest, duplicate id, missing/cyclic dependency), or cascaded out mid-boot because a declared dependency reached Failed/Disabled at a later stage. Names the specific problem, or the root cause by id for a cascade.
Failed Boot began and this plugin's own lifecycle stage threw. Names the stage and the exception.

Disabled and Failed are distinct in whose code is at fault, not in how much of the plugin's own code ran: Failed means this plugin's own stage threw; Disabled means either nothing of it ever ran (kept out of the graph before boot started) or something it depends on failed — which, because boot is stage-major (§5), can happen after this plugin's own earlier stages already completed cleanly. A server owner debugging "why isn't my plugin working" still gets the right answer either way: Failed points at this plugin's own code, Disabled points at the manifest/graph or at another plugin named in Reason.

5. Failure isolation

ModuleRunner.Boot does not catch — per 02_MODULE_MODEL.md §7, an exception during a first-party module's boot is a hard startup failure by design. The plugin boot runner deliberately diverges: every stage, for every plugin, runs inside its own try/catch.

  • A plugin that throws at any stage (Configure through Ready) is marked Failed and skipped for every later stage of its own boot — a plugin that fails Initialize does not get a Ready call.
  • Every plugin that transitively depends on a Failed or Disabled plugin is itself marked Disabled, with a reason naming the root cause by id — not silently skipped, and not each one re-discovering the same failure independently. Because boot is stage-major (every stage completes for every plugin before the next stage starts for any of them — the same rule 02_MODULE_MODEL.md §2 uses for modules), a dependent cascaded out by a dependency that fails at Initialize or Ready will already have completed its own Configure/RegisterServices by the time the cascade reaches it. A cascade discovered while a stage is still in progress (e.g. the dependency fails at Configure itself) can still reach a not-yet-processed dependent before its own turn in that same stage, in which case genuinely nothing of it ran — both are Disabled; only the amount of the plugin's own code that executed differs, not the final state.
  • Shutdown is attempted for every plugin that entered the boot list, regardless of final stateLoaded, Failed, or Disabled by cascade — on the theory that whatever ran before the failure (e.g. a RegisterServices that partially succeeded, per the stage-major point above) may hold something worth releasing. The one case genuinely skipped is a plugin that never entered the boot list at all — disabled by discovery (§2) or by the dependency-resolution pass (§6.1) before Boot ever started — because there is nothing for it to have registered. A Shutdown that itself throws is caught and logged, never propagated, and never stops another plugin's own Shutdown.
  • All of this is logged at LogCategory.Module, with the plugin's id, so a throwing plugin is visible in the dev menu (via IPluginCatalog) and the log, but the server keeps booting.

First-party module boot is unaffected: ModuleRunner.Boot still runs first, still doesn't catch, still stops the server on any first-party failure. The plugin boot pass runs afterward, against the same ServiceContainer — see §6.

6. Discovery and boot integration

PluginDiscovery.cs is the only file in the codebase permitted to reflect over gameplay types — a single, named, audited carve-out of Standards/00_CODING_STANDARDS.md §10, guarded by a test asserting no other file uses System.Reflection over a gameplay type. It scans loaded assemblies for concrete Plugin subclasses and instantiates each via a parameterless constructor — a plugin author needs no registration step beyond the class existing.

Boot order, from ApplejackBootstrap.OnHostInitialize:

  1. ModuleRunner.Create(Modules).Boot(...) — first-party modules, exactly as before this ADR. Unaffected by anything plugin-related; still a hard failure on any error.
  2. PluginDiscovery finds every Plugin subclass; PluginManifest.Validate() disables the malformed ones (§2).
  3. The plugin boot runner resolves the well-formed plugins' boot order using ModuleDependencyResolver, alongside the already-booted modules — this is what lets a plugin declare DependsOn/DependsOnIds against a first-party module and have it enforced the same way a module-to-module dependency is. Unlike a first-party ModuleRunner.Create call, a graph problem here does not propagate as a fatal exception — see §6.1 for exactly how a plugin-caused problem is quarantined instead, and why a module-caused one (the ModuleDependsOnPlugin direction rule from §3) still is.
  4. The plugin boot runner (§5) boots the ordered, well-formed plugins through the five stages against the same ServiceContainer first-party modules registered into — a plugin calls AddSingleton, Replace, subscribes on IEventBus, and registers HUD panels via IHudRegistry exactly as a module would, because it is resolving and registering against the same registry instance.
  5. IPluginCatalog (built from every plugin's final state) is registered into the same container, alongside ApplejackBootstrap.BootOrder, for the dev menu.

ApplejackBootstrap.Modules — the fixed, compile-time first-party array — is unchanged in shape and unaffected. Plugins are discovered and booted alongside it, never merged into it; first-party modules are not plugins and should not pretend to be, per 02_MODULE_MODEL.md §4.

6.1 The plugin dependency-resolution pass

ModuleDependencyResolver.Resolve (§3, 02_MODULE_MODEL.md §3) always throws on a missing dependency, a duplicate id, a cycle, or the module-depends-on-plugin direction violation — that does not change for plugins. What changes is what the plugin boot runner does with the throw, using ModuleDependencyException.Kind and .InvolvedIds (structured, not Message-parsed) to decide whether to quarantine or propagate:

candidates := well-formed plugins from discovery (§2)
disabled   := discovery's own Disabled entries (malformed manifests)

loop:
    graph := firstPartyModules ++ candidates
    try:
        ordered := ModuleDependencyResolver.Resolve(graph)
        return (ordered.Where(IsPlugin), disabled)      // success -- done
    catch ModuleDependencyException as ex:
        if ex.Kind == ModuleDependsOnPlugin:
            rethrow                                      // a first-party module's own bug;
                                                           // fatal, same as any other
                                                           // first-party boot failure
        offending := candidates.Where(p => ex.InvolvedIds.Contains(p.Id))
        // offending is never empty: the first-party module subgraph alone already booted
        // successfully in step 1 of §6, so any MissingDependency/DuplicateId/Cycle reachable
        // here necessarily implicates at least one plugin.
        for p in offending:
            disabled.add(PluginStatus(p.Id, p.DisplayName, Disabled, ex.Message))
        candidates.remove(offending)
        continue loop   // bounded: each iteration removes >=1 candidate, or the graph
                         // has none left and Resolve trivially succeeds

This is why ModuleDependsOnPlugin is excluded from InvolvedIds-based quarantine and always rethrown: it means a first-party module — not a plugin — is misconfigured, and per §3's table that stays a hard boot failure regardless of which plugin it names.

Cycle's InvolvedIds names every module in the loop, which may include first-party modules transitively (a plugin depending on a module that, if the cycle were real, would have to depend back on the plugin) — but a real cycle through a module is structurally impossible once step 1 has already booted a clean first-party graph and the direction rule forbids a module depending on a plugin, so every id InvolvedIds names for a Cycle thrown at this stage is guaranteed to resolve to a plugin, not a module. The offending filter above is still correct even if this invariant were ever violated: it only ever removes plugins, matching candidates, never a first-party module.

Once the loop returns a clean ordered list, the lifecycle boot pass (§5) runs against exactly that list, in that order.

7. Extension surface and versioning

A plugin uses exactly the four extension points named in 02_MODULE_MODEL.md §6 — no new mechanism exists for plugins specifically. PluginManifest.RequiresFrameworkVersion is carried on every manifest from Phase 2 onward but not yet enforced against anything: there is exactly one framework version (the one a Stage 1, in-repo plugin is compiled against), so a version mismatch cannot occur until Phase 3's Library-assembly plugins make it possible to install a plugin built against an older framework than the running server. See 21_EXTENSION_SURFACE.md for what counts as the versioned surface and ADR-0010 §Decision for the staged Stage 1 / Stage 2 delivery this defers to.

8. What this does not cover

Per ADR-0010, explicitly out of scope for v1: sandboxing, capability grants, and trust levels. A plugin runs with full framework access — a server owner installing one is trusting its author completely.

Two things named elsewhere in this document are still deliberately unbuilt, not silently dropped:

  • PluginManifest.RequiresFrameworkVersion enforcement (§7) — needs a defined "current framework version" concept that doesn't exist anywhere in the codebase yet (21_EXTENSION_SURFACE.md territory). A version mismatch is now possible (§9 below), but nothing checks for one yet.
  • An in-game "install a plugin" flow — nothing here reacts to PackageManager.OnPackageInstalledToContext. §9's hotload path covers the case ADR-0010's Decision section actually describes (a Library recompiled and swapped in); installing a new package at runtime without a hotload is a different, narrower trigger nothing currently wires up.

9. Stage 2: late discovery (hotload)

Stage 1 (§1–§8) discovers and boots plugins exactly once, at ApplejackBootstrap.OnHostInitialize. Stage 2 — Library-assembly plugins, ADR-0010's actual "no recompile at all" end state — turned out to need almost no change to discovery itself: PluginDiscovery.Discover(IGameLog) already scans AppDomain.CurrentDomain.GetAssemblies(), and a referenced S&box Library's compiled assembly loads into that same process-wide AppDomain the moment the package that ships it loads — confirmed against the real engine source (engine/Sandbox.Reflection/TypeLibrary/LoadContext.cs, engine/Sandbox.Engine/Services/Packages/PackageManager/PackageLoader.cs), see ADR-0010's "Phase 3 spike, part 1" note for the full citations. If the Library is already loaded by the time OnHostInitialize runs, Stage 1's existing single-pass Discover call finds its plugins with zero code changes.

What Stage 1 genuinely cannot do: notice a Library that arrives after boot, via hotload — the normal way an S&box addon changes while a server keeps running. Nothing re-runs discovery, and there is nowhere for a newly-found plugin's status to go: IPluginCatalog's Stage 1 implementation, PluginCatalog, is deliberately immutable once built (§4's own home). Stage 2 adds exactly this — a re-discovery pass, triggered on hotload, that finds new plugins and boots only those, without touching anything already running.

9.1 The wave model

ApplejackBootstrap treats the initial boot as wave 0 and every subsequent hotload as another wave. Each wave is its own PluginRunner, resolved and booted independently:

wave N:
    discovery := PluginDiscovery.Discover(log)                 // sees every loaded assembly,
                                                                  // Library assemblies included
    known     := every plugin id from every earlier wave
    candidates := discovery.Plugins.Where(p => p.Id not in known)
    newlyDisabled := discovery.Disabled.Where(d => d.Id not in known)

    if candidates.Empty and newlyDisabled.Empty:
        return   // the common case -- nothing new arrived this hotload

    runner := PluginRunner.Create(Modules, priorLoadedPlugins, candidates, newlyDisabled, log)
    runner.Boot(configuration, services, services)
    catalog.Append(runner.BuildCatalog().Plugins)
    priorLoadedPlugins += runner's newly-Loaded plugins

priorLoadedPlugins — every plugin that reached PluginState.Loaded in an earlier wave — is threaded into the next wave's dependency graph alongside Modules, so a newly-arrived plugin can validly declare a dependency on one already running. A new candidate that depends on a plugin an earlier wave disabled or failed gets a MissingDependency from ModuleDependencyResolver with no new logic needed for it: a failed/disabled plugin is never added to priorLoadedPlugins, so from the new wave's graph it simply isn't there, and "missing dependency" is an accurate description. A plugin already Loaded is never re-added to the graph as a candidate in a later wave, so it never re-runs any lifecycle stage twice, no matter how many times hotload fires.

9.2 AggregatePluginCatalog — the one invariant this revises

§4/§6 describe IPluginCatalog as built once and read-only forever, via the immutable PluginCatalog. Stage 2 needs late-arriving statuses to be visible, so the catalog ApplejackBootstrap actually registers is AggregatePluginCatalog — still only decided by the plugin boot machinery, still append-only, but no longer frozen after wave 0. It is registered into the ServiceContainer exactly once, via the same AddSingleton<IPluginCatalog> every other service uses — never via Replace (IServiceRegistry.Replace is capped at one call per service, by explicit policy in 03_SERVICE_REGISTRY.md §2; hotload can fire arbitrarily many times, so a wave-per-Replace scheme does not fit and this document does not propose loosening that cap). Each wave calls an internal-only Append, reachable solely from Applejack.Core.Plugins/Applejack.Core.Modules. The revised invariant: no already-reported PluginStatus is ever changed or removed; new ones may be appended. A consumer holding the IPluginCatalog reference from wave 0 (the dev menu, say) sees later waves' plugins appear without needing to re-resolve anything.

PluginCatalog itself is unchanged and still does its original job: the immutable, per-wave snapshot PluginRunner.BuildCatalog() returns, which AggregatePluginCatalog.Append consumes.

9.3 The hotload hook

The engine broadcasts Event.Run("hotloaded") after every hotload, in the real game/server runtime, not just the editor (engine/Sandbox.GameInstance/GameInstanceDll.cs's OnAfterHotload). ApplejackBootstrap listens with a static [Event("hotloaded")] method:

[Event("hotloaded")]
private static void OnHotloaded() =>
    Game.ActiveScene?.GetSystem<ApplejackBootstrap>()?.RediscoverPlugins();

Static, not instance: Sandbox.Event's instance-registration path (Event.Register) sits on an internal class, unreachable from game code, but a static [Event] handler is dispatched unconditionally with no registration step (confirmed against engine/Sandbox.Event/EventSystem.cs's IsStatic branch of EventAction.Run). Reaching the live instance via Game.ActiveScene is the same accessor ApplejackBootstrap already uses elsewhere.

9.4 Shutdown

ApplejackBootstrap.Dispose() shuts every wave down in reverse wave order — the last wave booted is the first shut down — and each PluginRunner.Shutdown() still reverses its own plugin order internally (§5), so the overall order is a strict reverse of however boot actually happened across every wave.