Skip to content

ADR-0010 — Runtime plugin loader

Status: ✅ Accepted Date: 2026-07-31 Supersedes: ADR-0002 — Module architecture Superseded by:


Context

ADR-0002 rejected a runtime plugin loader, accepting one named negative consequence: "Third parties cannot ship a module without recompiling the project." It named two revisit triggers in its Notes — (a) a third party wants to ship code without recompiling, (b) the extension interfaces have been stable across two milestones — and said either is a good trigger for "modules, plus a runtime plugin loader later."

Trigger (a) is now met: a server owner wants to install a plugin without recompiling Applejack. This ADR is that later decision.

Three findings shape what "later" turned out to mean, in practice:

The mechanisms a loader depends on were, until this milestone, unexercised. grep -rn "\.Subscribe<" Code returned zero hits before Phase 1 — the event bus had never had a subscriber. IServiceRegistry.Replace had zero callers. ModuleRunner.Shutdown() was never invoked — no scene-teardown hook had ever been wired. Phase 1 (this milestone, see Documentation/ROADMAP.md) closed all three: VehicleKeyMinter is now a real event subscriber, ModuleRunnerTests proves Replace end-to-end through a real Boot, and ApplejackBootstrap.Dispose() calls ModuleRunner.Shutdown() via a scene-teardown hook confirmed against the real S&box engine source (~/Projects/sbox-public/engine/Sandbox.Engine/Scene/). Building a loader on top of three untested extension points would have shipped the bugs to plugin authors; that risk is now closed rather than deferred.

DependsOn was Type-keyed, and Type does not survive an assembly boundary. A plugin in a separate S&box Library cannot cleanly express "I depend on the plugin applejack.economy-extras" using typeof(...). This is why identity (GameModule.Id, DependsOnIds) is the central architectural change this ADR makes, not an implementation detail — see 02_MODULE_MODEL.md §3.

Failure semantics must differ between modules and plugins. ModuleRunner.Boot deliberately does not catch — a first-party module failing at boot should stop the server, per 02_MODULE_MODEL.md §7. A third-party plugin failing must not: a server owner who installs one broken plugin should not lose the whole server. This is a genuine behavioural fork, not an oversight in ModuleRunner — see 23_PLUGIN_SYSTEM.md §5.

What is different from ADR-0001's rejected proposal: the lifecycle, service registry, and event bus already exist and are proven by six milestones of first-party modules built against exactly the extension surface a plugin would use (ADR-0002's "dogfooding" principle). The months of pre-gameplay loader work ADR-0002 rightly refused to pay up front is no longer the cost — this ADR adds a loader on top of infrastructure that would have existed either way.

Decision

We will add an in-repo runtime plugin loader: third-party Plugin subclasses, discovered by reflection from loaded assemblies, boot through the same five-stage lifecycle as first-party modules, with per-plugin failure isolation the first-party path does not have.

Concretely, staged:

Stage 1 (this ADR, Phase 2 of the plan) — in-repo loader. A plugin is a .cs file compiled into the project, discovered by PluginDiscovery scanning loaded assemblies for concrete Plugin subclasses. No recompile is needed to change which plugins run relative to first-party modules — a plugin ships as source dropped into a known location and picked up by discovery, not by editing ApplejackBootstrap.Modules. Full recompile is still needed to add the plugin's source to the project at all.

Stage 2 (Phase 3, gated on Stage 1 holding for a milestone) — Library-assembly plugins. PluginDiscovery extended to the assemblies S&box exposes for referenced Libraries, so a plugin ships as a compiled Library with no Applejack source changed or recompiled at all. This is the actual "no recompile" end state; Stage 1 is a real, useful waypoint, not a detour — it proves discovery, ordering, and failure isolation without also needing to answer Phase 3's open engine questions (does ResourceLibrary.GetAll<TConfig>() see a GameResource from a Library? do [Sync]/[Rpc.*] work on a Library Component? does hot reload re-run discovery?).

The four extension points stay exactly what ADR-0002 named — replace a service, subscribe to an event, add GameResource content, register a UI panel — a plugin uses precisely what a first-party module already uses, per 02_MODULE_MODEL.md §6. No new extension mechanism is introduced; only the ability to load a Plugin without adding it to ApplejackBootstrap.Modules.

Identity replaces Type as the resolution key. GameModule.Id (defaults to the CLR full type name — no first-party module changes) and DependsOnIds let a dependency be expressed across an assembly boundary. See 02_MODULE_MODEL.md §3.

Direction is enforced, one way only. 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. A plugin depending on a first-party module is the normal, expected case. Violation is a hard boot failure naming both, exactly like a missing dependency or a cycle.

Failure is isolated per plugin, not per server. A throwing plugin — at any of the five lifecycle stages — is quarantined: marked Failed, skipped in later stages, its Shutdown still attempted, every dependent transitively disabled naming the root cause. The server keeps booting. This is the deliberate, documented divergence from ModuleRunner.Boot's no-catch policy — see 13_ERROR_HANDLING.md and 23_PLUGIN_SYSTEM.md §5.

Reflection is scoped to one audited file. PluginDiscovery.cs is the only place in the codebase permitted to reflect over gameplay types — a named, singular carve-out of Standards/00_CODING_STANDARDS.md §10, guarded by a test asserting no other file uses System.Reflection over a gameplay type.

Explicitly out of scope for v1 (both stages): sandboxing, capability grants, and trust levels. A plugin runs with full framework access. A server owner installing a plugin is trusting its author completely; pretending otherwise would be worse than admitting it plainly here.

Alternatives considered

Do nothing — stay on ADR-0002, tell third parties to fork

What ADR-0002 chose, and correct at the time: the loader's dependencies (event bus, service replacement, shutdown) were unexercised, and building on top of them would have shipped untested infrastructure to plugin authors first. Rejected now because the trigger ADR-0002 itself named has occurred, and Phase 1 of this milestone closed the exact gap that made "do nothing" the right call before.

Full ADR-0001 plugin-first architecture

Move all gameplay into plugins, core hosts only. Attractive for the same reason ADR-0002 rejected it: it forces the extension API to be complete by construction. Rejected again, for the same reasons — it is a rewrite of six milestones of working, dogfooded first-party modules to solve a problem ("third parties can't ship code") that a loader added alongside those modules solves without the rewrite.

Library-assembly plugins only — skip the in-repo stage

Go straight to Phase 3's true "no recompile at all" mechanism. Rejected: Phase 3 has open engine questions (ResourceLibrary visibility across a Library boundary, [Sync]/[Rpc.*] on a Library Component, hot reload re-running discovery) that need an editor spike to answer, and this project has no confirmed editor session as of this ADR's date. Building discovery, identity, and failure isolation against the in-repo case first means those three genuinely hard problems (ordering, isolation, identity) get proven before also taking on the engine's open questions, rather than debugging both classes of problem at once.

Sandboxed / capability-scoped plugin execution

Restrict what a plugin can reach — no direct Scene access, no arbitrary service resolution, an explicit capability grant per plugin. Rejected for v1: no design for what a useful capability boundary would even look like in an S&box gameplay framework exists yet, and building one speculatively risks the same "machinery before demand" mistake ADR-0002 avoided for the loader itself. Explicitly named as future work, not silently dropped — see Notes.

Consequences

Positive

  • A server owner can install a plugin without recompiling Applejack (Stage 1: drop-in source discovered at boot; Stage 2: a compiled Library, no source at all).
  • The loader is built on infrastructure with real, tested first-party consumers — not speculative machinery validated only by the loader itself.
  • One broken plugin cannot take the server down; first-party modules keep the stricter fail-fast contract they always had.
  • The extension surface stays exactly what it was — no new mechanism for a plugin author to learn beyond "write a Plugin, not a GameModule."

Negative

  • A plugin has full framework access; there is no sandbox. A malicious or careless plugin can do anything a first-party module can. This is stated as accepted risk, not solved — see Notes.
  • Reflection is back, in exactly one file, after ADR-0002 removed it entirely. The carve-out is named and guarded by a test, but it is a real, if narrow, reopening of Standards/00_CODING_STANDARDS.md §10.
  • Two failure philosophies now coexistModuleRunner.Boot (no catch) and the plugin boot runner (catch and quarantine per stage). A reader of GameModule.Boot-shaped code must know which lifecycle they are in to reason about what happens on an exception.
  • Stage 1's in-repo plugins still require compiling Applejack once to add the plugin's source — the "true no recompile" state is Stage 2, not this ADR alone.

Neutral

  • GameModule.Id/DependsOnIds/IsPlugin exist on every module now, not only on plugins — the base type gained surface area every first-party module inherits but almost never overrides.
  • The module list in ApplejackBootstrap.Modules stays fixed at compile time and unchanged in shape; plugins are discovered and booted alongside it, not merged into it.

Compliance

  • Direction rule: unit-tested — a module depending on a plugin is a hard ModuleDependencyException, asserted directly (UnitTests/Core/Modules/ModuleDependencyResolverTests.cs).
  • Reflection carve-out: a guard test asserts no file other than PluginDiscovery.cs uses System.Reflection over a gameplay type.
  • Failure isolation: unit-tested per lifecycle stage, plus a deliberately-broken reference plugin (Phase 2f) proving the server still boots with it quarantined.
  • Editor verification: per Documentation/ROADMAP.md's convention, this work stays 🟡 Written until a real S&box editor session boots a scene with the reference plugin loaded and its HUD panel rendering — no part of this project has cleared that bar for any prior milestone either, so this is not a new bar being invented for plugins specifically.
  • Stage 2 wave model: unit-tested — PluginRunner's five-argument Create overload (a new candidate depending on a prior wave's Loaded plugin succeeds; depending on a prior wave's failed/disabled id fails exactly like an ordinary missing dependency; a prior-wave plugin never re-runs any lifecycle stage or gets shut down by a later wave's own Shutdown; a second wave's BuildCatalog() stays scoped to that wave alone) in UnitTests/Core/Plugins/PluginRunnerTests.cs; AggregatePluginCatalog's append-only, snapshot-at-read-time, no-dedup-across-waves behavior in UnitTests/Core/Plugins/AggregatePluginCatalogTests.cs.
  • Stage 2 hotload wiring: ApplejackBootstrap's wave bookkeeping, RediscoverPlugins(), and the static [Event("hotloaded")] handler are not unit-tested — excluded from OfflineTests.csproj for the same reason the rest of that file already is (real Scene/GameObjectSystem/[Event] surface, per 03_TESTING_STANDARDS.md §1) — and reviewed by reading instead. No editor or dedicated-server session has been available on this machine to exercise the real hotload path end to end (same limitation the Phase 3 spike below already names), so this piece stays 🟡 Written, the same bar the rest of this ADR's Compliance section already holds itself to, not a new one invented for it.

Notes

Revisit sandboxing/capability grants when a plugin author or server owner asks for untrusted plugin installs, or when a plugin ecosystem large enough that "trust the author completely" stops being a reasonable default emerges. Neither condition holds today.

Stage 2 status (2026-07-31): built, not yet editor-verified. The gate above held — Stage 1's contracts had gone unchanged since Phase 2 — and the spike below answered all three open engine questions favorably before any code was written, per this project's own "documentation precedes code" practice. The resulting implementation (AggregatePluginCatalog, PluginRunner's priorPlugins overload, ApplejackBootstrap's wave bookkeeping and [Event("hotloaded")] hook — see 23_PLUGIN_SYSTEM.md §9 and this ADR's Compliance section) is unit-tested wherever the offline harness can reach it. What remains is the same thing Stage 1 itself still lacks: a real editor/dedicated-server session to exercise the hotload path end to end.

Phase 3 spike, part 1 — source verification (2026-07-31)

No S&box editor session has ever been available on this machine, so per CLAUDE.md's "Verifying engine APIs: check the real source before guessing" practice, the three open engine questions named above were checked directly against ~/Projects/sbox-public before attempting any editor/compile confirmation. All three resolve favorably, with no engine-side blocker found for any of them.

Does ResourceLibrary.GetAll<TConfig>() see a GameResource from a Library? Yes, by construction. ResourceSystem.GetAll<T>() (engine/Sandbox.Engine/Resources/ResourceLibrary.cs) reads one ResourceIndex dictionary, populated by Register() with no per-assembly or per-package partition. PackageLoader's generic package-load path — used for every package, not an editor-only branch (engine/Sandbox.Engine/Services/Packages/PackageManager/PackageLoader.cs, LoadPackage, ~line 434) — calls FileSystem.Mounted.Mount(ap.FileSystem) for whatever it loads, and ResourceLoader.LoadAllGameResourceAsync scans that mounted filesystem and calls Register(). A referenced Library installs as exactly this kind of package (Sandbox.Tools/Editor/LibrarySystem/LibrarySystem.cs's Add() calls PackageManager.InstallAsync), so its compiled GameResource content lands in the same global index first-party content does.

Do [Sync]/[Rpc.*] work on a Library Component? Yes, for two independent reasons. [Sync] is [CodeGenerator(...)]-driven (engine/Sandbox.Engine/Scene/Networking/Sync.cs:7-8) — the getter/setter wrapping happens at the declaring assembly's own compile time, so a Library built by the normal S&box toolchain already has __sync_SetValue/__sync_GetValue baked into its own DLL; Applejack never needs to recompile to produce them. Separately, the runtime networking/property machinery discovers members through TypeLibrary, not a hardcoded reference to the main game assembly: GameInstanceDll's AssemblyEnroller.OnAssemblyAdded handler (engine/Sandbox.GameInstance/GameInstanceDll.cs:206-214) calls Game.TypeLibrary.AddAssembly(a.Assembly, true) for every package assembly loaded at runtime — the same handler that fires for any addon, Library included.

Does hot reload re-run PluginDiscovery? No — not automatically, and this is the one real gap Phase 3 has to close with new code, not just confirm. AppDomain.CurrentDomain.GetAssemblies() (what PluginDiscovery.Discover(IGameLog) scans) does reflect hotloaded assemblies — .NET surfaces every AssemblyLoadContext's assemblies through the single process-wide AppDomain, and LoadContext/IsolatedAssemblyContext (engine/Sandbox.Reflection/TypeLibrary/LoadContext.cs) is exactly such an ALC — but nothing calls Discover a second time today. Code/Core/Modules/ApplejackBootstrap.cs calls it exactly once, at boot; Code/ has zero hits for "hotload" (case-insensitive). The engine does expose the hook: GameInstanceDll.OnAfterHotload() (engine/Sandbox.GameInstance/GameInstanceDll.cs:108-113) broadcasts Event.Run("hotloaded") after every hotload in the real game/server runtime (not editor-only), and PackageManager.OnPackageInstalledToContext (engine/Sandbox.Engine/Services/Packages/PackageManager/PackageManager.cs:38) fires on a runtime package install. Phase 3 needs a handler — most naturally [Event("hotloaded")] — that re-runs Discover, diffs against the existing catalog, and boots only what's new, through the same per-plugin failure isolation Phase 2 already built.

What this does and doesn't settle. This is source verification, not the editor/dedicated-server compile-and-load confirmation the Decision section originally asked for: no Library has actually been built and referenced by Applejack.sbproj on this machine, so assembly-load ordering, compiled-GameResource binary compatibility across engine versions, and TypeLibrary's "dynamic" trust flag in practice all remain unconfirmed by execution. Per the applejack-milestone-strategy memory's untested next lever, ~/Projects/sbox-public/engine/Tools/SboxBuild's build-addons step could close that remaining gap without needing the graphical editor — worth running against a throwaway test Library before Phase 3 code lands for real.

Not revisited for: making the loader replace ApplejackBootstrap.Modules entirely. First-party modules are not plugins and should not pretend to be — the fixed, explicit list stays, per 02_MODULE_MODEL.md §4.