Writing a plugin
A plugin is a normal .NET class library that references AbioticEditor.Plugins.Abstractions (the SDK) and ships as a .dll next to a plugin.json. This guide builds each of the three capability kinds. For the design and the why, see plugins.md.
1. The smallest plugin
using AbioticEditor.Plugins;
public sealed class HelloPlugin : IAbioticPlugin
{
public void Configure(IPluginRegistry registry, IPluginHost host)
{
host.Log.Info("hello from a plugin");
// register capabilities here
}
}The host scans your entry assembly for the single public, parameterless type implementing IAbioticPlugin, instantiates it, and calls Configure once. Register your capabilities on registry; keep Configure cheap (no heavy work, no UI).
2. The project file: the shared-assembly rule
Reference the SDK (and Core/MAUI if you use them) to compile, but don't ship them: the host provides them at runtime and unifies the types (see isolation). Output stays just your DLL + plugin.json.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<!-- Private=false + ExcludeAssets=runtime: compile against it, don't copy it. -->
<ProjectReference Include="...\AbioticEditor.Plugins.Abstractions.csproj"
Private="false" ExcludeAssets="runtime" />
<!-- Optional: Core gives you the typed catalogs + readers/writers. -->
<ProjectReference Include="...\AbioticEditor.Core.csproj"
Private="false" ExcludeAssets="runtime" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>Outside this repo you'd reference NuGet packages of the SDK/Core instead of project paths, but the
Private="false" ExcludeAssets="runtime"rule is the same.
3. The manifest
{
"id": "com.example.hello",
"name": "Hello",
"version": "1.0.0",
"author": "You",
"description": "What it does, in one line.",
"entryAssembly": "Hello.dll",
"minHostVersion": "1.0.0",
"capabilities": ["saveOperation"],
"enabled": true
}id must be globally unique and stable (it names your plugin's data directory and is how every host refers to you). entryAssembly is a bare file name in the plugin folder. capabilities is an advisory hint (saveOperation, consoleCommand, editorTool).
4. A save operation (edit a save)
using AbioticEditor.Core.PlayerSaves;
using AbioticEditor.Plugins;
using AbioticEditor.Plugins.Saves;
public sealed class MaxSkillsOperation : ISaveOperation
{
public string Id => "max-skills";
public string DisplayName => "Max Skills";
public string Description => "Raise every skill to a target level.";
public SaveKind AppliesTo => SaveKind.Player;
public IReadOnlyList<SaveOperationParameter> Parameters { get; } = new[]
{
new SaveOperationParameter("level", "Target level 1-20", DefaultValue: "10"),
};
public Task<SaveOperationResult> ExecuteAsync(ISaveOperationContext ctx, CancellationToken ct = default)
{
var level = Math.Clamp(int.Parse(ctx.GetParameter("level", "10")), 1, SkillCatalog.MaxLevel);
var targetXp = SkillCatalog.XpForLevel(level);
// data.Raw IS ctx.Save, so the writer mutates the very instance the host persists.
var data = PlayerSaveReader.ReadFrom(ctx.Save);
var updated = data.Skills.Select(s => s.Xp < targetXp ? s with { Xp = targetXp } : s).ToList();
var changed = updated.Zip(data.Skills).Count(p => p.First.Xp != p.Second.Xp);
if (changed == 0)
return Task.FromResult(SaveOperationResult.NoChange("already maxed."));
PlayerSaveWriter.ApplySkills(data, updated);
ctx.MarkChanged(); // <-- without this the host writes nothing
return Task.FromResult(SaveOperationResult.Ok($"raised {changed} skills", changed));
}
}Rules of the road:
- Mutate
ctx.Savein place, then callctx.MarkChanged(). If you don't mark a change, the host skips the write and the backup, which is how a "nothing to do" run stays clean. - Never write the file yourself. The host owns persistence,
.bakbackups, and the dry-run gate (ctx.IsDryRun). Do your full computation regardless so the reportedChangeCountis honest. - Don't lower over-cap values unless that's the point; respect what's already there.
- You can drop Core entirely and manipulate
ctx.Save.Properties(rawFPropertyTags) if you only want aUeSaveGamedependency; Core just gives you the typed catalogs and readers.
Run it:
abioticeditor plugins run max-skills path\to\Player_x.sav --param level=15
abioticeditor plugins run max-skills path\to\Player_x.sav --dry-run…or from the GUI: Settings → Manage Plugins → SAVE OPERATIONS → RUN (against the open save; the editor reloads afterward).
5. A console command (new CLI verb)
using AbioticEditor.Plugins;
using AbioticEditor.Plugins.Cli;
public sealed class SaveStatsCommand : IConsoleCommand
{
public string Name => "save-stats";
public string Description => "Print a quick summary of a save.";
public IReadOnlyList<PluginCommandArgument> Arguments { get; } = new[]
{ new PluginCommandArgument("save", "Path to the .sav") };
public IReadOnlyList<PluginCommandOption> Options { get; } = new[]
{ new PluginCommandOption("json", "Emit JSON", IsFlag: true) };
public Task<int> InvokeAsync(IConsoleCommandContext ctx, CancellationToken ct = default)
{
var path = ctx.RequireArgument("save");
ctx.Out.WriteLine(ctx.GetFlag("json") ? "{ ... }" : "summary ...");
return Task.FromResult(0); // 0 ok, 1 user error, 2 unexpected
}
}Register it with registry.AddConsoleCommand(new SaveStatsCommand()). It then appears in abioticeditor --help and runs as abioticeditor save-stats <save> [--json]. Write to ctx.Out/ctx.Error (abstract writers, so the command is unit-testable with a StringWriter).
6. An editor tool (UI panel)
A UI plugin takes a MAUI dependency (the SDK does not). Multi-target the MAUI heads the app targets and reference Microsoft.Maui.Controls with ExcludeAssets="runtime".
using AbioticEditor.Plugins;
using AbioticEditor.Plugins.Ui;
using Microsoft.Maui.Controls;
public sealed class DashboardTool : IEditorTool
{
public string Id => "dashboard";
public string Title => "Dashboard";
public string Glyph => "📊";
public object CreateView(IEditorToolContext ctx) // returns a MAUI View as object
{
var label = new Label();
void Refresh() => label.Text = ctx.ActiveSave is null
? "No save open."
: $"{ctx.ActiveSaveKind}: {ctx.ActiveSave.Properties?.Count} properties";
ctx.ActiveSaveChanged += (_, _) => Refresh();
Refresh();
return new ScrollView { Content = label };
}
}Register with registry.AddEditorTool(new DashboardTool()). The GUI lists it under Manage Plugins → TOOLS and hosts the view you return. ctx.ActiveSave is loaded lazily (no parse cost until you read it) and ActiveSaveChanged fires when the user switches files.
Lifetime / cleanup. When the tool panel closes the host disposes the context (which severs every ActiveSaveChanged subscription) and disposes the view and its BindingContext if they implement IDisposable. So if your view-model subscribes to ActiveSaveChanged, implement IDisposable and unsubscribe in Dispose(). This keeps the view-model (and any save it parsed) from outliving its panel:
public sealed class DashboardViewModel : INotifyPropertyChanged, IDisposable
{
private readonly IEditorToolContext _ctx;
public DashboardViewModel(IEditorToolContext ctx) { _ctx = ctx; _ctx.ActiveSaveChanged += OnChanged; }
private void OnChanged(object? s, EventArgs e) => Refresh();
public void Dispose() => _ctx.ActiveSaveChanged -= OnChanged;
}Full MVVM with XAML is supported; see the SaveInspector sample: a compiled ContentView (InspectorView.xaml, x:DataType for compiled bindings) bound to an InspectorViewModel (INotifyPropertyChanged, IDisposable, a Command, an ObservableCollection). The tool just returns new InspectorView(new InspectorViewModel(ctx)).
A UI tool is read-only by contract. To edit from a panel, register an
ISaveOperationand run it through the host's backup/write path rather than mutatingActiveSavedirectly.
7. A menu action (click-to-run menu item)
using AbioticEditor.Plugins;
using AbioticEditor.Plugins.Ui;
public sealed class HelloAction : IMenuAction
{
public string Id => "say-hi";
public string Title => "Say hello";
public string? Glyph => "👋";
public async Task InvokeAsync(IMenuActionContext ctx, CancellationToken ct = default)
=> await ctx.NotifyAsync($"Hello! Open save: {ctx.ActiveSavePath ?? "(none)"}");
}Register with registry.AddMenuAction(new HelloAction()). It appears in the GUI's Plugins menu and the Plugins panel. NotifyAsync shows the user a message without a UI dependency.
8. An event handler (trigger when something happens)
using AbioticEditor.Plugins.Events;
public void Configure(IPluginRegistry registry, IPluginHost host)
{
registry.AddEventHandler(PluginEvents.SaveWritten, e =>
host.Log.Info($"a plugin wrote {e.SavePath} ({e.Name})"));
}Handlers run when the host raises that event (see the table in plugins.md for the names and payloads). Keep them quick and non-throwing: the host logs and isolates failures, but a slow handler stalls the action that fired it.
9. A JavaScript plugin (no build step)
Set "runtime": "javascript" and "entryScript": "plugin.js" in the manifest, and write a .js file that registers capabilities on the abiotic host object. No project, no compile, just two files in a folder.
// plugin.js
abiotic.registerSaveOperation({
id: "rich-player",
displayName: "Rich",
appliesTo: "Player",
parameters: [ { name: "money", default: "9999" } ],
execute: function (ctx) {
ctx.player.money = parseInt(ctx.getParameter("money", "9999"), 10);
return { message: "done", changeCount: 1 }; // or a string, or nothing
}
});
abiotic.registerCommand({
name: "js-greet",
description: "Greet from JS",
options: [ { name: "name", description: "who", isFlag: false } ],
invoke: function (ctx) { ctx.print("Hi, " + ctx.getOption("name", "you")); return 0; }
});
abiotic.registerMenuAction({ id: "hi", title: "Say hi (JS)", invoke: function (ctx) { ctx.notify("hi"); } });
abiotic.on("save.written", function (e) { abiotic.log.info("saw " + e.name + " " + e.savePath); });The abiotic API: log.info/warn/error, registerSaveOperation, registerCommand, registerMenuAction, on(eventName, handler), plus hostKind and dataDirectory. Member access is case-insensitive, so use natural camelCase. Save operations get ctx.player for player saves (money get/set, setAllSkillLevels(n), skillCount, recipeCount); call ctx.markChanged() (or let ctx.player do it) so the host persists. console.log/warn/error are also available. The engine is sandboxed-by-constraint (no CLR reflection, bounded recursion/time) but, like managed plugins, is not a hard security boundary. See the HelloScript sample.
9b. A web tool (HTML / React UI)
A plugin can render its UI as a web page in a WebView instead of native controls, which is ideal for shipping a React (or any web) front-end. The page talks to the plugin over a bridge.
JavaScript (inline HTML pulling React from a CDN, no build step):
var HTML = `<!doctype html><html><head>
<script crossorigin="anonymous" src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head><body><div id="root"></div>
<script type="text/babel">
function App() {
const [d, setD] = React.useState(null);
React.useEffect(() => { abiotic.request({ type: "playerSummary" }).then(setD); }, []);
return <pre>{JSON.stringify(d, null, 2)}</pre>;
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script></body></html>`;
abiotic.registerWebTool({
id: "dash", title: "Dashboard", glyph: "⚛️",
html: HTML,
handleMessage: function (message, ctx) {
var req = JSON.parse(message);
if (req.type === "playerSummary") return ctx.playerSummaryJson();
return "{}";
}
});The page gets, from the host-injected bridge:
abiotic.request(obj)→ a Promise resolved by yourhandleMessage(message, ctx)(the message is the JSON ofobj); return a string (usually JSON).abiotic.log(msg)andabiotic.onEvent(fn).
ctx in handleMessage exposes activeSavePath, activeSaveKind, and playerSummaryJson(). To edit from a web tool, have the page request an action and run an ISaveOperation so the write keeps its backup; don't mutate the save from the page.
Offline / bundled apps. Instead of inline HTML, serve a folder (a production SPA build or a plain offline page): set rootDirectory (relative paths resolve against the plugin folder) and entryFile. See the WebStats sample (web/index.html, no CDN). For a managed plugin implement IWebTool and return WebToolContent.FromHtml(...) or WebToolContent.FromDirectory(dir).
CDN loads need internet at runtime and, for shipping plugins, should bundle the assets or pin Subresource Integrity hashes.
A full Vite + React app. The ReactAppDashboard sample is a real npm/Vite project (under app/) with package.json, JSX, and components, built to a single self-contained file the plugin serves. Two Vite settings make it work inside the editor's file:// WebView: base: "./" (relative asset URLs) and vite-plugin-singlefile (inline all JS/CSS, so there are no ES-module <script> requests the file:// origin would block). Build with cd app && npm install && npm run build; the plugin's rootDirectory: "app/dist" then serves it.
9c. Driving the app from a plugin (abiotic.ui / IHostUi)
Plugins can interact with the app, not just read saves. Every host gives host.Ui (an IHostUi); JavaScript plugins reach it as abiotic.ui:
abiotic.ui.showAlert("Title", "Message");
abiotic.ui.toast("Saved!");
abiotic.ui.runSaveOperation("max-skills"); // runs a registered op on the open save, then reloads
abiotic.ui.reloadSave();
abiotic.ui.openSettings();
abiotic.ui.openPlugins();These are fire-and-forget from JavaScript (the Jint engine is synchronous; the GUI marshals each onto the UI thread). In the CLI and tests host.Ui is a no-op, so the same plugin runs everywhere. A web tool's React UI drives the app by having its handleMessage call abiotic.ui.*. For example, the React "Max skills" button does abiotic.request({type:"runOperation"}), and the plugin's handler calls abiotic.ui.runSaveOperation(...), so a click in the web UI performs a real, backed-up edit. See ReactAppDashboard.
10. Build, install, test
# headless plugin
dotnet build plugins/MaxSkills -c Release
# UI plugin (needs the MAUI workload; build the head you run on)
dotnet build plugins/SaveInspector -c Release -f net10.0-windows10.0.19041.0Install by copying the build output's DLL + plugin.json into a folder under %LOCALAPPDATA%\AbioticEditor\plugins\<your-plugin>\. For development, point the editor at your build output directly:
# Windows PowerShell
$env:ABIOTIC_PLUGINS_DIR = "D:\path\to\plugins\MaxSkills\bin\Debug\net10.0"
abioticeditor plugins listabioticeditor plugins list / plugins info <id> show whether your plugin loaded and what it registered; a load failure appears there with its error. ABIOTIC_NO_PLUGINS=1 disables all plugin loading.
Host services (IPluginHost)
Reachable from Configure and every capability context:
| Member | Use |
|---|---|
Log | Info/Warn/Error → the editor log, tagged with your id |
DataDirectory | a writable, per-plugin folder for settings/caches |
HostKind | "gui" or "cli", to tailor behaviour without a framework reference |
HostVersion / SdkVersion | version checks at runtime |
Checklist
- [ ] One public
IAbioticPluginwith a parameterless constructor. - [ ] References use
Private="false" ExcludeAssets="runtime"; output is DLL +plugin.json. - [ ]
plugin.jsonhas a uniqueidand the correctentryAssemblyfile name. - [ ] Save operations mutate in place and call
MarkChanged(); they never write files. - [ ] Console command names don't collide with built-ins (they'd be skipped).
- [ ] UI tools return a
Microsoft.Maui.Controls.View; heavy reads are lazy.
