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 (
ConfigurethroughReady) is markedFailedand skipped for every later stage of its own boot — a plugin that failsInitializedoes not get aReadycall. - Every plugin that transitively depends on a
FailedorDisabledplugin is itself markedDisabled, 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 atInitializeorReadywill already have completed its ownConfigure/RegisterServicesby the time the cascade reaches it. A cascade discovered while a stage is still in progress (e.g. the dependency fails atConfigureitself) 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 areDisabled; only the amount of the plugin's own code that executed differs, not the final state. Shutdownis attempted for every plugin that entered the boot list, regardless of final state —Loaded,Failed, orDisabledby cascade — on the theory that whatever ran before the failure (e.g. aRegisterServicesthat 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) beforeBootever started — because there is nothing for it to have registered. AShutdownthat itself throws is caught and logged, never propagated, and never stops another plugin's ownShutdown.- All of this is logged at
LogCategory.Module, with the plugin's id, so a throwing plugin is visible in the dev menu (viaIPluginCatalog) 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:
ModuleRunner.Create(Modules).Boot(...)— first-party modules, exactly as before this ADR. Unaffected by anything plugin-related; still a hard failure on any error.PluginDiscoveryfinds everyPluginsubclass;PluginManifest.Validate()disables the malformed ones (§2).- The plugin boot runner resolves the well-formed plugins' boot order using
ModuleDependencyResolver, alongside the already-booted modules — this is what lets a plugin declareDependsOn/DependsOnIdsagainst a first-party module and have it enforced the same way a module-to-module dependency is. Unlike a first-partyModuleRunner.Createcall, 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 (theModuleDependsOnPlugindirection rule from §3) still is. - The plugin boot runner (§5) boots the ordered, well-formed plugins through the five stages
against the same
ServiceContainerfirst-party modules registered into — a plugin callsAddSingleton,Replace, subscribes onIEventBus, and registers HUD panels viaIHudRegistryexactly as a module would, because it is resolving and registering against the same registry instance. IPluginCatalog(built from every plugin's final state) is registered into the same container, alongsideApplejackBootstrap.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.RequiresFrameworkVersionenforcement (§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.