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
Pluginsubclasses, 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 aGameModule."
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 coexist —
ModuleRunner.Boot(no catch) and the plugin boot runner (catch and quarantine per stage). A reader ofGameModule.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/IsPluginexist 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.Modulesstays 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.csusesSystem.Reflectionover 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-argumentCreateoverload (a new candidate depending on a prior wave'sLoadedplugin 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 ownShutdown; a second wave'sBuildCatalog()stays scoped to that wave alone) inUnitTests/Core/Plugins/PluginRunnerTests.cs;AggregatePluginCatalog's append-only, snapshot-at-read-time, no-dedup-across-waves behavior inUnitTests/Core/Plugins/AggregatePluginCatalogTests.cs. - Stage 2 hotload wiring:
ApplejackBootstrap's wave bookkeeping,RediscoverPlugins(), and the static[Event("hotloaded")]handler are not unit-tested — excluded fromOfflineTests.csprojfor the same reason the rest of that file already is (realScene/GameObjectSystem/[Event]surface, per03_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.