Writing a plugin
This is the practical guide: how to set up a plugin project, what the host expects from it, where it installs, and how the API is allowed to change. The API itself is described in plugin-api.md (game state, events, items, chat, trade, hotkeys, window, headless) and plugin-ui-markup.md (in-game panels).
Getting a plugin into other players' hands has three stages, and this page follows them in order:
- Build it — project setup, the entry point, and what the API promises.
- Test it — run it from your own plugins folder, then check it against the launcher's rules before anyone else sees it.
- Publish and list it — a GitHub release the launcher can install, and an entry in the plugin list so players can find it.
The exact field-by-field rules live in plugin-manifest.md. This page links to them rather than repeating them, so there is one place a rule can change.
The one rule
A plugin references AcDream.Plugin.Abstractions and nothing else from
OpenAC. Not AcDream.App, not AcDream.Runtime, not AcDream.Core. The
abstractions project is a small contract with no dependencies beyond the
.NET base library; everything a plugin can see or do goes through it. That
is what lets the same plugin run in the graphical client and in the
headless host, and what lets the client change underneath without breaking
you.
Project setup
A plugin is a .NET 10 class library in its own repository. Reference the contract so that it is compiled against but not copied next to your assembly; the host already ships it:
<ItemGroup>
<ProjectReference Include="$(OpenAcRoot)src\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
OpenAcRoot points at a checkout of this repository. Give it a default in
your Directory.Build.props and let people override it on the command line
with -p:OpenAcRoot=<path>.
Or against the contract package
AcDream.Plugin.Abstractions is packable, so a plugin can build against a
version of the contract instead of against a checkout. That is the reference a
plugin in its own repository wants: it pins the contract version the plugin
targets, and it needs no client sources to build.
<ItemGroup>
<PackageReference Include="AcDream.Plugin.Abstractions" Version="0.1.14"
ExcludeAssets="runtime" />
</ItemGroup>
ExcludeAssets="runtime" does for a package reference what Private=false
does for a project reference: compile against the contract, ship no copy of
it. The package carries the XML documentation, so your IDE shows the same text
the API reference does.
The package is not on a public feed. Every release from 0.1.13 on attaches it as an asset,
AcDream.Plugin.Abstractions.<version>.nupkg, with its SHA-256 beside it; put
the file in a folder and restore against that folder:
dotnet restore --source <folder>
Or produce one from a checkout, which is how you build against an unreleased contract:
dotnet pack <OpenAcRoot>/src/AcDream.Plugin.Abstractions -c Release -o <feed>
dotnet restore --source <feed>
A plugin developed inside a checkout of this repository should be built the package way at least once before it ships, so the reference it will actually use is exercised rather than assumed; extracting a plugin is the rest of that checklist.
Put a plugin.json next to your project and copy it to the output
directory:
{
"id": "yourname.yourplugin",
"displayName": "Your Plugin",
"version": "0.1.0",
"entryDll": "YourPlugin.dll",
"apiVersion": 1,
"kinds": ["Gameplay"]
}
id is the stable identity the host uses for storage, settings and
commands; do not rename it after release. kinds is Gameplay for a normal
plugin and RenderPack for a shader pack. Markup files for panels ship the
same way, as content copied to the output directory.
The entry point
Implement IAcDreamPlugin on one public class with a public parameterless
constructor. The host creates it with no arguments, then calls:
Initialize(IPluginHost host)once, before the character is in the world. Keep the host; everything is reached through it.Enable()when the plugin is switched on andDisable()when it is switched off or the client shuts down. Undo inDisablewhat you did inEnable: unsubscribe events, release hotkeys, stop timers.
IPluginHost gives you State, Events, Commands, Storage, Log,
Ui, Window, Clipboard, Hotkeys, WorldLines and Automation.
WorldLines draws lines in the world (a route, say) in layers the plugin
owns; a host without a window hands out no layer. Only the parts of lines
within 250 m of the camera are drawn, and a frame draws a fixed amount of
them: past that, the lines nearest the camera are kept. Automation is the
large surface: character, items, spells, combat, world objects, trade,
vendor, navigation, fellowship, login. Check IsAvailable on a surface
before relying on it; a host that cannot provide something returns an inert
value rather than throwing. Walks the client plans for you, and the rule that
one plugin drives the character at a time, are in navigation.md.
Installing and running
The host loads every immediate subdirectory of its plugins folder that
contains a plugin.json:
| Platform | Plugins folder |
|---|---|
| Windows | %LOCALAPPDATA%\OpenAC\plugins |
| macOS | ~/Library/Application Support/OpenAC/plugins |
| Linux | $XDG_DATA_HOME/openac/plugins (default ~/.local/share/openac/plugins) |
The plugins folder lives in the OpenAC install folder, which the launcher
settings show and can move; ACDREAM_ROOT_DIR (or --root-dir) names
another install folder for one run. What your plugin writes through
Storage lands in plugins/<id>/files/, inside its own folder: installs,
updates and removing the plugin's code never touch it, and a package that
ships its own top-level files folder is refused. Copy your assembly, its .deps.json, plugin.json and any markup into
one subdirectory, then start the client. Plugin log lines go to the
client's log with your plugin id as the prefix.
For a bot or a test that needs no window, the headless host
(acdream-headless) loads the same plugin folder and exposes the same
Automation surface; see the "Headless" section of plugin-api.md for the
few things that differ (no UI, no window, remote positions from the latest
server update).
Reloading while the client runs
When the client loads your plugin it reads the whole package into memory:
every assembly (with its .pdb when one sits beside it, so stack traces keep
file and line numbers) and every markup file. It runs that copy, never the
files, so nothing in your plugin's folder is held open, and an assembly or
panel your plugin loads later still comes from the same copy even if a newer
version has been written into the folder meanwhile.
Your build writes to its own output folder, not to the plugin folder, so copy
the output (assembly, .deps.json, .pdb, plugin.json, markup) into your
plugin's folder while the game runs; a post-build copy step does it for you.
When any of those files changes, the client waits until they have had no
writes for one second and then reloads your plugin. Other files -- logs or data
your plugin writes beside its code, anything in files/ -- do not count.
/plugin reload <id> reloads one plugin on demand, /plugin reload all every
plugin, in the chat box and on a headless session's console alike. A plugin
that failed to start is tried again the same way. The same happens when the
launcher updates your plugin while a game is running.
A reload switches the running copy off (Disable), then releases everything
the host handed it: panels, canvases and images; commands; hotkeys; handlers
on Events and Selection; chat filters, input interceptors and chat
handlers; the handlers on Automation.Trade, Automation.Vendor,
Automation.Equipment and Automation.Navigation, its walk and its pauses;
world labels and world lines; maps, HUDs and textures; loot classifiers;
status lines. Then it unloads the old assemblies, creates the new copy, and
calls Initialize and Enable as at startup. IPluginHost.IsHotReload is
true for that new copy. If the character is already in the world, the new
copy's LoginComplete handlers are called right after Enable, so a plugin
that sets up on login needs nothing extra.
Before the running copy is touched, the client reads the new plugin.json
and loads the new assembly. A new copy with a different id, a newer
apiVersion or a minHostVersion this client does not meet is refused, the
running copy keeps running, and chat says the update needs a restart or a
newer client. A render pack is never reloaded; it takes effect when the client
restarts.
What this means for your plugin:
- In-memory state does not survive a reload. Anything you want to keep,
save through
Storageand read back inInitializeor on login. - Stop your own threads and timers in
Disable, and cancel any work you started withTask.Run. The host cannot stop them for you. - The previous copy may stay in memory until the client restarts. It is
switched off either way; only its memory is not returned. Anything that
still refers to the old copy's types keeps it: a handler you added to a
.NET or operating-system event (
AppDomain.ProcessExit,SystemEvents, aFileSystemWatcheryou did not dispose), a thread still running, a static cache in another assembly -- and libraries you use that cache type information themselves, such as a JSON serializer that has serialized your types. The client watches the old copy for up to two minutes without slowing the game; if it is still there, the log and one chat line say so. Assembly.Locationis empty. Your assembly was loaded from memory, so it has no file. Read the files your package ships relative toIPluginHost.PluginDirectory(a render pack gets the same folder asIRenderPackRegistry.PluginDirectory). A relative markup path handed toUiis already read from your plugin's folder, soPath.Combine(".", "panel.xml")and plain"panel.xml"both work.
Checking it before you publish
acdream-plugincheck tells you whether the launcher would install your
plugin, using the launcher's own code: the same manifest parser, install
rules, zip safety checks, content policy and icon rules an install runs. It
cannot disagree with the launcher, because it is the launcher's code.
Run it from your OpenAC checkout (the one OpenAcRoot points at). It works
the same on Windows, macOS and Linux:
dotnet run --project <OpenAcRoot>/src/AcDream.PluginCheck -- <path>
<path> is either your release zip, which is what players actually
download and the check to run before publishing, or an unzipped plugin
folder, which is what a hand install looks like. Checking the zip also
verifies a .sha256 sidecar if one sits beside it.
OpenAC PluginCheck — solrlabs.buffbot-0.1.0-beta.2.zip
Mode: plugin .zip (a release asset)
[PASS] zip within size cap
[PASS] zip extracts safely
[PASS] plugin.json present
[PASS] plugin.json parses
[PASS] manifest satisfies install rules
[PASS] plugin content policy
[PASS] plugin icon
[PASS] sha256 sidecar
Verdict: this plugin would install.
A failure names the problem in the launcher's own words, the same message a player would see:
[FAIL] plugin.json parses
capability 'chat' note contains a link. Fix: Correct the problem named
above in plugin.json; ...
Checks stop at the first problem in the manifest, so fix it and run again
until the verdict passes. The exit code is 0 when the plugin would install,
1 when it would be refused, and 2 when the path is missing or unreadable.
Add --json for a single machine-readable line in CI, with nothing else
written to standard output.
Two things it cannot check from a local file, so check them yourself against
plugin-manifest.md: the GitHub release layout (tag
name, asset names, the prerelease flag, whether the release is marked
latest), and whether a particular client version falls inside your
minHostVersion/maxHostVersion/skipHostVersions range.
Publishing a release
The launcher installs a plugin from a GitHub release in your own repository. It never runs a downloaded file: it unzips, verifies the checksum, and stages the plugin disabled, so enabling it stays an explicit choice the player makes on the Plugins tab.
A release the launcher can install is stricter than a plugin that merely loads locally. The headlines:
- A namespaced
id(yourname.yourplugin), and a SemVerversionequal to the tag minus a leadingv. minHostVersionandhostsbecome required, not optional.- Tag
v<version>, on a public repository, not a draft. - Assets with exact names:
plugin.json,<id>-<version>.zip,<id>-<version>.zip.sha256, andicon.pngif the zip carries one. - Managed files only, by extension allowlist; no
runtimes/folder. - An optional
icon.pngat the zip root: PNG, exactly 64x64, at most 64 KiB, not animated. No icon is fine; a broken one refuses the whole install. - Declare capabilities for anything the player would want to know about — network access, chat, input automation. The launcher shows them before installing and asks again when an update changes them.
plugin-manifest.md has the full contract, the size and extraction caps, and how to publish a beta release for players who opt that plugin into the Beta channel.
Getting listed
A listed plugin appears in the launcher's Discover panel, so players can find and install it without being sent a link. The list lives at eriknihlen/openac-plugins.
To ask for a listing, open an issue on that repository with your plugin's
id, display name, author name, a one-line description, and the owner/name
of its GitHub repository. The list is published as a release asset by its
maintainer; there is no pull request to merge, which is deliberate — nothing
lands in the list that its maintainer did not put there.
What is checked before a plugin is listed:
acdream-plugincheckpasses against the release zip itself.- The repository is public, and the release layout is exactly as described.
- Declared capabilities match what the plugin actually does.
Beyond that mechanical bar, listing is at the maintainer's discretion: what a plugin does is looked at, a plugin can be declined, and one already listed can be blocked later — by id and version, with a reason players see. A blocked plugin is hidden from Discover, refused for install or update, and filtered out of every character's plugin list at launch. Being unlisted is not a judgement; a plugin distributed by link installs perfectly well through Add from URL.
What you can rely on
- Additive changes only. New capabilities arrive as new members with
default implementations, so a plugin built against an older contract
keeps compiling and keeps loading. A member that is unavailable on a host
returns
false,Unavailableor an empty value. - Every public member is documented from its XML comment; an undocumented member fails the client's build. Your IDE shows the same text.
- Commands are per plugin. Register verbs through
host.Commands; the host refuses a verb another loaded plugin already owns, so pick a prefix that is yours (/mt,/vt,/drakbotare taken). - One bot drives the character at a time. Two plugins can be installed together; when both want to move, fight or use items, expect the host to refuse the second request rather than interleave them.
Contributing to the API
If your plugin needs something the contract does not expose, open an issue or a pull request against OpenAC rather than reaching past the contract. The rules for an API change are in CONTRIBUTING.md: one implementation per operation, bound on both hosts, documented, tested, and additive.
Examples
- OpenAC-MagTools: an external plugin in its own repository, built and installed exactly as described above.
- openac-mosstank: a large external plugin, which uses the panel markup heavily.
samples/: render packs, theRenderPackkind.