Skip to content
Version 2.11.0
↓ Get the launcher

Writing a Stellar plugin

This is the developer guide for writing a plugin against the framework. It covers project setup, the plugin lifecycle, the public service surface (IPluginServices), and — most importantly — how you draw UI: Stellar renders native uGUI from a declarative element tree you describe once. There is no IMGUI/OnGUI/GUI.Window in the plugin API; the framework owns rendering, layout, theming, input gating, and persistence.

For framework-internal architecture, see architecture.md. For the complete generated API reference of the plugin surface (every public interface, record, and enum), see the API reference — start at IPluginServices.

A plugin is a single .NET 6 (net6.0) class library that:

  • References the plugin SDK only: the Stellar.Abstractions package, plus Stellar.Plugin.InteropRefs (compile-time Unity / IL2CPP / BepInEx / HarmonyX stubs) and, if it talks to another plugin, Stellar.PluginContracts.
  • Exports exactly one public type implementing IStellarPlugin.
  • Is discovered at runtime when its DLL is dropped into <game_mini>/stellar/plugins/<PluginName>/.

Plugins never reference Stellar.Application, Stellar.Wire, Stellar.Infrastructure, Stellar.Host, or any Panda.* game assembly. Those are framework internals; the framework hides them behind the abstractions. The Unity / IL2CPP / BepInEx / HarmonyX surface comes only from the Stellar.Plugin.InteropRefs stubs, which are never copied into your output (the game supplies the real assemblies). If you patch game methods yourself, get your Harmony instance from Services.Harmony so it is unpatched when your plugin is disposed.

Create a net6.0 class library that references the SDK packages from NuGet.org. Each framework release publishes Stellar.Abstractions, Stellar.PluginContracts and Stellar.Plugin.InteropRefs at the framework’s version (2.11.0 today). Reference the version whose API you need (the CHANGELOG lists what each release added). The shape follows the shipping plugins (e.g. Stellar.PlayerHUD.csproj):

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<RootNamespace>MyMod</RootNamespace>
<AssemblyName>MyMod</AssemblyName>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Stellar.Abstractions" Version="2.11.0" />
<PackageReference Include="Stellar.Plugin.InteropRefs" Version="2.11.0" />
<!-- Only if you consume / provide an inter-plugin contract via Services.Exchange: -->
<!-- <PackageReference Include="Stellar.PluginContracts" Version="2.11.0" /> -->
</ItemGroup>
</Project>

Stellar.Plugin.InteropRefs wires its stubs in as Private=false references, so none of them land in bin/. Keep it even if you never touch UnityEngine: some Stellar.Abstractions signatures (e.g. IHarmonyHost.Create) name HarmonyX types. The declarative UI toolkit means most plugins never call Unity directly. Your lowercased AssemblyName is your plugin id: the framework keys your config and data folders on it, so don’t change it between releases.

public interface IStellarPlugin : IDisposable
{
string Name { get; }
}

Name is user-visible UI text, not a log string: the framework adopts it as your plugin’s display name the first time it constructs you, and shows it in Settings → Plugins, the per-plugin rate rows in Settings → Performance, and as the group header for your hotkeys in Settings → Hotkeys. Give it a human-readable name ("Combat Meter", not "StellarCombatMeterPlugin"); keep it short, since those columns are fixed-width and clip. Return empty and the framework falls back to your assembly’s short name.

The framework constructs your plugin once via constructor injection of IPluginServices, and calls Dispose() on shutdown or when the user disables the plugin in Settings → Plugins. Everything you do — registering windows/HUDs, subscribing to events, declaring hotkeys, owning colours — happens in the constructor; everything you registered must be released in Dispose().

IPluginServices is the single object handed to your constructor. It aggregates every framework capability as a sub-service (about fifty of them). The table covers the ones most plugins use. For the full list, including loadouts, the market, wardrobe, dungeon state, notifications, Lua and the inter-plugin Exchange, see the IPluginServices reference.

Sub-service What it gives you
Log (IPluginLog) Info / Warning / Error / Debug into BepInEx/LogOutput.log. Tag your lines [MyMod].
Framework (IFramework) Update event (fires at your plugin’s update rate, float deltaTime) + FrameCount + EffectiveUpdateRateHz + RequestUpdateRate(hz). See Update rate.
ClientState (IClientState) Session state (IsLoggedIn, CurrentSceneName, Login / Logout / SceneChanged) plus the client-phase and world/UI signals: Phase (+ PhaseChanged), IsWorldActive, UiState. See Phases and window visibility.
GameEvents (IGameEvents) Low-level escape hatch: Subscribe(fullTypeName, handler) returning an IDisposable.
PlayerState (IPlayerState) Polled local-player snapshot: IsAvailable, Name, Level, Profession, Health/MaxHealth, Stamina/MaxStamina, Position.
PlayerStats (IPlayerStats) Live character attribute snapshot (ATK, DEF, crit, etc.).
Chat (IChat) RecentMessages, MessageReceived event, and Send(target, text).
CombatSnapshot / CombatLookup / CombatEvents Polled combat state, per-entity buff/skill lookups, and the real-time CombatEventOccurred stream.
CombatSpec (ICombatSpec) Each entity’s active spec (sub-profession): from talent buffs when a player appears, otherwise inferred from casts.
PartySnapshot / PartyRoster / PartyEvents Party roster + member vitals, and MemberJoined / MemberLeft / MemberUpdated / PartyDissolved plus ready-check events.
Inventory (IInventory) Read-only module inventory + equipped set (1 Hz polled) + InventoryChanged; GetSelfGear() (own gear instances: actual rolls / refine / perfection / enchant, refreshed on full container syncs), GetLiveEquipped() (live gear + modules), and SelfGearChanged (fires on the network thread, see Threading).
EntityDetail (IEntityDetail) Per-AOI-entity broadcast detail: GetAttributes / GetAttribute (scalar attr map), GetEquipment (slot+itemId), GetFashion (worn cosmetics + dye colours), GetSocialSnapshot.
EntityContextMenu (IEntityContextMenu) Register items into the CombatMeter’s right-click row menu.
EntityPortrait (IEntityPortrait) The live 3D character portrait (show/hide/orbit/zoom/pan + render texture).
ModuleEquip (IModuleEquip) Install / uninstall equipment modules via the game’s own dispatcher.
GameData (IGameData) Read-only lookups over the game’s static tables (skills, buffs, items; Combat.GetAttribute is backfilled by a built-in EAttrType catalog — names, screen group, percent/flat format; Equip exposes gear rows, attr-lib roll ranges by lib id AND by row id, slot names).
Config (IPluginConfig) Per-plugin JSON config, organised into named sections.
Theme (ITheme) Active palette (Theme.Colors.*), semantic text helpers, and the colour registry.
NamedTheme (INamedTheme) Active preset + global font scale.
Hotkeys (IHotkeys) DeclareAction(...) to bind a keyboard shortcut to a callback.
Windows (IWindowHost) Register uGUI windows — both interactive panels (draggable, closable, themed chrome) and on-screen HUD overlays (borderless, Surface = SurfaceStyle.HudOverlay, position-persisted).
NativeUi (INativeUiHost) Inject your own uGUI into the game’s own UI anchors.
Launcher (ILauncher) Register a tile in the Stellar launcher menu.
GameAssets (IGameAssets) Async-load game-supplied icons by id: profession crests (atlas Sprite + UV), Battle Imagine, item (gear/cosmetic), skill and buff icons, or any asset by path (LoadByPath). Poll per frame; null until loaded. Pair with GameTextureElement.
Localization (ILocalization) Your plugin’s own UI text in the active language. See Localizing your plugin.
Harmony (IHarmonyHost) Create(suffix) a HarmonyLib.Harmony namespaced to your plugin and automatically unpatched on Dispose.

Event invocations happen on the Unity main thread, so you do not need to marshal back inside a handler. The one exception is Inventory.SelfGearChanged, which is raised on the network/sync thread (see Threading).

You do not call any rendering API. You describe your UI as a tree of immutable HudElement records and hand it to the framework, which builds real uGUI objects and owns their lifecycle, layout, theming, refresh, and input gating.

The pattern has two halves:

  • Display is pulled. Dynamic leaves carry a Func<...> the framework re-polls on its capped refresh (~10 Hz) and applies only when the value changed. A TextElement(() => $"HP {hp}") re-reads your field every refresh — you never push text into a widget.
  • Interaction is pushed. A ButtonElement(label, onClick) calls your Action when clicked; a ToggleElement/SliderElement calls your Set callback.

So your plugin’s job is: build the tree once in the constructor, keep your live state in fields, and let the Funcs surface it. Snapshot game state in your Framework.Update handler and let the tree read the snapshot.

Layout containers and leaves all derive from HudElement:

  • Layout: RowElement(children, Gap), ColumnElement(children, Gap), CellElement(child, Width/Weight) (table alignment), SpacerElement(Width/Height), SeparatorElement(Vertical), ScrollElement(child, Height).
  • Display: TextElement(Func<string>, Color, Emphasis, Width, Align, Shadow), BarElement(Func<float>, Fill, Label, Prefix), PillElement(Func<string>, Color), ImageElement, SwatchElement, GameTextureElement(Func<object?> texture, W, H, Func<UvRect>? uv) (game-asset icon box: feed it an IGameAssets.Load*Icon poll — invisible until the async load lands; Funcs run per frame while visible, keep them cache-reads), AccentRowElement(child, Stripe, Share) (tinted row wash + left stripe — e.g. the inspector’s Battle-Imagine rows).
  • Lists: ListElement(visibleCount, slots, Columns) for short lists; VirtualListElement(...) for large windowed lists; ConditionalElement(when, then, else) for show/hide branches.
  • Interaction (window-grade): ButtonElement(Func<string> label, Action onClick, Enabled, Style, Active, Width, Icon), ToggleElement(label, get, set), SliderElement(get, set, Min, Max), InputElement(get, submit, Width, OnChange), SelectableElement, ColorPickerElement.

TextElement carries four typography flags — Bold, Italic, Underline, Strikethrough — that work in every script the framework localizes into (Latin, Thai, CJK/kana, Hangul):

new TextElement(() => "Damage taken") { Bold = true },
new TextElement(() => _text.T("row.deprecated")) { Strikethrough = true },
  • Bold is a real bold typeface, never Unity’s synthetic (faux) bold — the framework ships a merged Latin+Thai bold face and resolves a system bold family for CJK, so bold Thai/Japanese stays crisp. Emphasis: true is the section-header preset (bold at header size); use Bold for inline bold at normal size.
  • Styled elements render through TextMeshPro, so TMP rich-text tags (<b> <i> <s>, colours, <size>) also work inside a STYLED element’s string. Prefer the flags for whole-element styling.
  • Scope: window surfaces (SurfaceStyle menu windows). HUD-overlay text (Shadow: true / SurfaceStyle.HudOverlay) ignores the style flags.

Every UI is a window. A read-only on-screen overlay is just a borderless window (WindowPanelStyle.Borderless, Surface = SurfaceStyle.HudOverlay) with no interactive controls; an interactive panel is a window with themed chrome. Both gate game input correctly while focused — there is no separate HUD path.

An on-screen HUD overlay (a borderless window)

Section titled “An on-screen HUD overlay (a borderless window)”

An overlay that sits over the world — an HP bar, a meter, a status strip — is a window, registered borderless with the HUD surface. IWindowHost.Register(WindowRegistration) returns an IWindowControl. Use WindowPanelStyle.Borderless (no frame), WindowCategory.HUD (the overlay draw layer), and Surface = SurfaceStyle.HudOverlay to get the native HUD look for Text / Bar / Pill leaves — shadowed text over the world, rounded HP-bar chrome, a transparent pill chip. EditModeDragOnly = true keeps it fixed during play and movable only in the Shift+` layout editor. This is the PlayerHUD shape — a level pill, a name, two animated bars, and a position readout, all wrapped in a ConditionalElement so it shows “Player not loaded” before you’re in-world:

private IWindowControl _hud = null!;
private PlayerSnapshot _snap; // your own struct, refreshed each tick
private void BuildHud()
{
_hud = _services.Windows.Register(new WindowRegistration(
Spec: new WindowSpec(
Id: "mymod.playerhud",
Title: "Player HUD",
DefaultRect: new WindowRect(40f, 120f, 220f, 0f), // Height 0 = content-sized
Category: WindowCategory.HUD,
Style: WindowPanelStyle.Borderless)
{
Surface = SurfaceStyle.HudOverlay, // native HUD look for Text/Bar/Pill leaves
Draggable = true,
EditModeDragOnly = true, // fixed in play, movable in the layout editor
// Required. Draw only in a gameplay world, and hide while a menu covers the HUD.
ShouldRender = () => _services.ClientState.Phase == GamePhase.World
&& (_services.ClientState.UiState & GameUIState.GameHudHidden) == 0,
},
Root: new ConditionalElement(
When: () => _snap.IsAvailable,
Then: new ColumnElement(new HudElement[]
{
new RowElement(new HudElement[]
{
new PillElement(() => $"Lv {_snap.Level}"),
new TextElement(() => _snap.Name ?? "(unknown)"),
}, Gap: 6f),
new BarElement(() => Frac(_snap.Health, _snap.MaxHealth), _hpSlot.Value,
() => $"{_snap.Health} / {_snap.MaxHealth}", Prefix: "HP"),
new TextElement(() => $"Pos {_snap.Position.X:0.0}, {_snap.Position.Z:0.0}"),
}, Gap: 4f),
Else: new TextElement(() => "Player not loaded"))));
}
private void OnUpdate(float dt)
{
var ps = _services.PlayerState;
_snap = new PlayerSnapshot { IsAvailable = ps.IsAvailable, Name = ps.Name,
Level = ps.Level, Health = ps.Health, MaxHealth = ps.MaxHealth, Position = ps.Position };
_hud.MarkDirty(); // optional hint — the framework polls regardless
}
private static float Frac(int v, int max) => max > 0 ? (float)v / max : 0f;

MarkDirty() is an optional “apply now” hint; forgetting it never freezes the overlay because the framework polls anyway. Canonical reference: Stellar.PlayerHUD.

IWindowHost.Register(WindowRegistration) returns an IWindowControl. A window has themed chrome (WindowSpec.Style), an optional close button, and drag/resize behaviour. Build the WindowSpec, give it a root element, and (optionally) leading/trailing title content + an OnClose:

private IWindowControl _window = null!;
private void BuildWindow()
{
_window = _services.Windows.Register(new WindowRegistration(
Spec: new WindowSpec(
Id: "mymod.main",
Title: "MyMod",
DefaultRect: new WindowRect(40f, 120f, 360f, 0f), // Height 0 = content-sized
Category: WindowCategory.Tools,
Style: WindowPanelStyle.GlassMenu)
{
Closable = true,
Draggable = true,
// Required. Draw only in a gameplay world (use `() => true` for a login-screen tool).
ShouldRender = () => _services.ClientState.Phase == GamePhase.World,
},
Root: new ColumnElement(new HudElement[]
{
new TextElement(() => _status, Emphasis: true),
new RowElement(new HudElement[]
{
new ButtonElement(() => "Greet", DoGreet),
new ToggleElement(() => "Verbose", () => _verbose, v => _verbose = v),
}, Gap: 6f),
new InputElement(() => _draft, OnSubmit, Width: 240f),
}),
OnClose: () => _window!.SetVisible(false))); // keep IsShown in sync with the ✕
}

IWindowControl lets you manage the live window: SetVisible(bool), IsShown, MarkDirty(), SetRect(...) / Rect, and Remove(). Wire OnClose to SetVisible(false) (as above) so the ✕ and any hotkey/rail toggle stay agreed about visibility. Canonical reference: Stellar.ChatTools — a multi-section window with a scrolling log, a channel selector, an input composer, and a conditional sub-panel.

Every WindowSpec carries a compiler-required Func<bool> ShouldRender — the single source of truth for whether the window draws (HUD overlays are windows too, so this covers them). The framework evaluates it each apply (~10 Hz) and enacts hide = !ShouldRender(); a hidden element skips its value pull entirely (zero Func cost while hidden). It is a pull, so it is always current — you do not push a visibility flag. Because it is required, omitting it fails the build (no default that would spam windows over the login screen).

You read whatever you want inside the predicate, via your captured _services. The three signals that matter live on IClientState:

Signal Type Use it for Across an in-world zone load
Phase GamePhase (Startup/TitleScreen/CharSelect/World) visibility — what to draw in ShouldRender stays World (window stays up)
IsWorldActive bool game-state access — guard raw reads in your Update dips false during the handshake — skip the read
UiState [Flags] GameUIState in-world UI detail (menu covering the HUD, cutscene, loading) None at the title screen

Phase is a signal the framework gates nothing on; it coexists with session state (IsLoggedIn/Login/Logout) and answers a different question (“which client screen”). Read Phase for the initial state (e.g. in your ctor) and subscribe to PhaseChanged (event Action<PhaseChange>, where PhaseChange is a readonly record struct(From, To)) for transitions — unsubscribe in Dispose, same hygiene as Framework.Update.

Typical ShouldRender values:

// Always-on chrome — visible in every phase, including boot:
ShouldRender = () => true;
// A login-screen tool (account switcher, server picker) — only once the login screen is up
// (`Startup` is the boot phase before the login view exists):
ShouldRender = () => _services.ClientState.Phase == GamePhase.TitleScreen;
// A gameplay window — only in a world scene:
ShouldRender = () => _services.ClientState.Phase == GamePhase.World;
// A gameplay HUD — in-world, and hidden while a full-screen menu covers the HUD:
ShouldRender = () => _services.ClientState.Phase == GamePhase.World
&& (_services.ClientState.UiState & GameUIState.GameHudHidden) == 0;

GameUIState is flat co-occurring flags (GameHud, FullScreenMenu, MainMenu, LineSelector, Dialogue, Cutscene, Loading, Matchmaking, Popup) plus preset masks (GameHudHidden, AnyMenu, Blocking) so you don’t memorize bits — prefer the masks. It is informational only.

Only if your plugin does raw game reads. The framework tick now runs UI/input every phase (that is what lets a window render at the title screen), but anything reading live game state must self-gate on IsWorldActive, because those reads corrupt the world-connect handshake while a scene transition is in flight:

private void OnUpdate(float dt)
{
if (!_services.ClientState.IsWorldActive) return; // raw game-state read below
// ... snapshot PlayerState / combat / inventory into your fields ...
}

A plugin that only draws UI, does HTTP, or reads framework-cached data touches no live game state and needs no gate — it just runs every phase. Gate on IsWorldActive, never on Phase/IsLoggedIn (both are true mid-transition, which is exactly when a raw read is unsafe).

SDK 2.0.0 is a breaking release. For each existing plugin:

  1. Bump every Stellar.* reference (Stellar.Abstractions, and the other Stellar.* SDK packages you use) to 2.0.0 or later.
  2. Migrate every HUD to a window. IHudHost / HudSpec / IHudHandle / HudAnchor are removed — the HUD path is now the window path. Replace Hud.Register(new HudSpec(...)) with Windows.Register(new WindowRegistration(new WindowSpec(..., WindowCategory.HUD, WindowPanelStyle.Borderless) { Surface = SurfaceStyle.HudOverlay, Draggable = true, EditModeDragOnly = true, ShouldRender = ... }, root)) (see “An on-screen HUD overlay” above). Surface = SurfaceStyle.HudOverlay reproduces the old borderless HUD look pixel-for-pixel; the returned IWindowControl replaces IHudHandle.
  3. Add a ShouldRender to every WindowSpec — the build fails until you do. For a window that used to be always-on, ShouldRender = () => true; for one that used HideUntilInWorld, ShouldRender = () => _services.ClientState.Phase == GamePhase.World.
  4. Delete HideUntilInWorld and AutoHideBehindGameMenus — both are removed. Fold the “hide behind a menu” behaviour into ShouldRender via UiState (see the gameplay-HUD example above).

Non-UI plugins (no WindowSpec) only need the version bump.

The toolkit ships shorthands for the most common multi-step patterns. Prefer them.

Registering a window and then declaring a hotkey that toggles it is the most common pairing, so IWindowHost has a combined overload — pass the WindowRegistration, a HotkeyAction, and your IHotkeys service:

_window = _services.Windows.Register(
new WindowRegistration(spec, root, OnClose: () => _window!.SetVisible(false)),
new HotkeyAction(
Id: "mymod.toggle",
Description: "Toggle MyMod window",
SuggestedDefault: new KeyBinding(StellarKeyCode.F12)),
_services.Hotkeys);

The returned IWindowControl manages the window; the hotkey is owned by the IHotkeys service for its lifetime, so you don’t track a separate IHotkeyAction handle for it.

When a colour is the same across every theme preset, skip the per-preset dictionary and use the single-value overload:

_accentSlot = _services.Theme.ColorRegistry.Register(
"MyMod.Highlight.Fill", "Highlight", ColorRgba.FromHex(0x4CC15Cffu));

IColorSlot is IDisposable; disposing it unregisters it from the registry. So _slot.Dispose() in your Dispose() is both the cleanup and the unregister — no separate Unregister(key) call needed.

IConfigSection.Save() persists and raises IPluginConfig.SectionChanged. When you’re writing because you reacted to that event (echo suppression), call SaveQuiet() instead — it persists without re-raising the event.

Framework.Update fires at your plugin’s own update rate, not a single global rate. By default every plugin “follows global” (the Stellar Update Rate slider in Settings → Performance, ~30 Hz), but the user can set a per-plugin rate and grant per-plugin permissions there. Read Framework.EffectiveUpdateRateHz if you need to know how often you’re currently ticking.

The framework runs a single variable-speed clock at max(global, every plugin's rate); expensive draw work stays gated to the global rate, so a faster plugin doesn’t make the whole HUD redraw faster — only your Update (and the market/exchange drain that Market calls go through) speed up.

For a brief latency-sensitive burst (e.g. polling a market listing the instant it appears), ask the framework to tick you faster, and dispose the scope to revert:

using var fast = _services.Framework.RequestUpdateRate(PerfControls.MaxUpdateRateHz); // 240 Hz cap
// ... your Update now fires up to ~240 Hz (realized at the frame rate) until `fast` is disposed ...

Rules:

  • It’s permission-gated. RequestUpdateRate returns an inert (no-op) scope unless the user set your plugin’s “Self-rate” (Settings → Performance) to Boost or Self-managed — so calling it is always safe, but may do nothing.
  • Always dispose (a using, or release it on your end-of-work event). Under Boost a held scope auto-expires after a 10 s safety cap (logged as a leak); “Self-managed” lets you hold it indefinitely. Prefer scoping the ramp tightly to the work that needs it.
  • Requests stack (max wins) and clamp to [10, 240]; the realized rate never exceeds the render frame rate.
  • A ramp costs game FPS while held (you’re crossing into managed more often) — keep it short.

The active theme is exposed via IPluginServices.Theme.

  • Read a theme colour when you want to match the framework’s palette: _services.Theme.Colors.Accent, .MenuMuted, .HudText, .TextMuted, etc. (Base / HUD / Menu colour facets are all reachable through Theme.Colors.) Read these directly — do not register a slot for them.
  • Own a colour that the user can customise in the theme editor: register it with IColorRegistry. The key is namespaced Owner.Concept.Property and must be unique. You supply a default per built-in preset (or one default for all):
_hpSlot = _services.Theme.ColorRegistry.Register(
"MyMod.HpBar.Fill", "HP bar", new Dictionary<ThemePreset, ColorRgba>
{
[ThemePreset.Default] = ColorRgba.FromHex(0x4CC15Cffu),
[ThemePreset.Dark] = ColorRgba.FromHex(0x52A35Effu),
[ThemePreset.Light] = ColorRgba.FromHex(0x46C85Effu),
[ThemePreset.Crimson] = ColorRgba.FromHex(0xE04848ffu),
});

Read the resolved colour via _hpSlot.Value (it honours the active preset and any user override). Cache the slot handle, not the value — Value re-resolves each read. Colour parameters that take a Func (TextElement.Color, PillElement.Color) follow a theme switch live when you pass () => _hpSlot.Value. BarElement.Fill is a plain ColorRgba, read once when the element is built. Dispose the slot in Dispose().

Each plugin gets one JSON file split into named sections. Read on construct, write when the user changes something, subscribe to react to external edits:

var section = _services.Config.GetSection("general");
_verbose = section.Get("verbose", false); // typed, never throws, returns default if absent
// ... later, on a user change:
section.Set("verbose", _verbose);
section.Save(); // flush to disk + raise SectionChanged

Supported value types: primitives (int, long, bool, string, float, double), arrays of primitives, and string-keyed dictionaries of primitives. For complex objects, use multiple keys. Subscribe to IPluginConfig.SectionChanged (the argument is the section name) to react to settings-window writes; use SaveQuiet() when you write in response to it.

Declare a bindable action and a callback. The framework resolves the binding from user config, falling back to your SuggestedDefault:

_toggleAction = _services.Hotkeys.DeclareAction(
new HotkeyAction(
Id: "mymod.toggle",
Description: "Toggle MyMod", // shown in Settings → Hotkeys
SuggestedDefault: new KeyBinding(StellarKeyCode.F11, ModifierKeys.Ctrl)),
callback: () => _window.SetVisible(!_window.IsShown));

DeclareAction returns an IHotkeyAction (IDisposable) — dispose it in Dispose(). If the hotkey just toggles a window, prefer the combined Windows.Register(..., HotkeyAction, IHotkeys) overload instead.

pan · zoom · search · click a box for its source

Every plugin MUST implement Dispose() correctly. The Settings → Plugins panel lets the user disable / re-enable any plugin at runtime by calling Dispose() then re-constructing it. If Dispose leaks a subscription, the re-enabled plugin’s handler fires twice on every event.

Release everything you acquired in the constructor:

  • -= every event handler you += (Framework.Update, ClientState.Login/Logout/SceneChanged/PhaseChanged, Chat.MessageReceived, CombatEvents.CombatEventOccurred, Inventory.InventoryChanged, Config.SectionChanged, …). Capture handlers in fields — inline lambdas (X += () => ...;) leak because -= can’t find the same delegate instance later.
  • Remove() every IWindowControl and every INativeUiElementHandle.
  • Dispose() every IColorSlot and every IHotkeyAction.
  • Dispose() every token returned by IGameEvents.Subscribe(...), ILauncher.Register(...), IEntityContextMenu.Register(...), Framework.Every(...) and Framework.RequestUpdateRate(...).
  • Harmony instances you got from Services.Harmony are unpatched for you.

A clean Dispose() for the window-plus-colours pattern:

public void Dispose()
{
_services.Framework.Update -= OnUpdate;
try { _services.Chat.MessageReceived -= OnMessage; } catch { /* swallow */ }
_hpSlot.Dispose();
_toggleAction.Dispose();
_window.Remove();
}

Disposal must not throw. Wrap any detach that might race framework shutdown in try { ... } catch { /* swallow */ }. Test soft-cycle correctness yourself: toggle your plugin off and on a few times in Settings → Plugins and confirm the log stays clean and behaviour matches a fresh load.

pan · zoom · search · click a box for its source
Surface Thread
IFramework.Update Unity main thread
Element Func/Action callbacks Unity main thread (during the framework’s poll/build)
IChat.MessageReceived Unity main thread (drained from an I/O queue once per Update)
IClientState / IPartyEvents / ICombatEvents Unity main thread
IInventory.SelfGearChanged Network/sync thread. Keep the handler minimal (set a volatile flag) and read game state later on your Update
IGameEvents subscriptions Same thread the underlying game event fires on — usually main

Apart from SelfGearChanged, you can assume single-threaded handlers. If you start your own threads, marshal back to the main thread before touching any framework or Unity state: Framework.Post(action) is the thread-safe way to run work on your next Update.

  • Tag every line with your plugin name: [MyMod], so the user can grep your output.
  • Don’t spam. Hot paths (Update, element Funcs, MessageReceived) should log at most once per state transition, not per invocation.
  • Use the right level. Info for normal flow, Warning for recoverable problems, Error for failures the user needs to see, Debug for diagnostics.

Stellar’s own UI ships in English, 日本語, ไทย, Bahasa Indonesia and Filipino; your plugin can too, via Services.Localization (ILocalization). It’s scoped to your plugin (like Log) — your keys never collide with another plugin’s.

1. Ship five catalogs. Add Lang/en.json, Lang/ja.json, Lang/th.json, Lang/id.json, Lang/fil.json to your project as embedded resources. en.json is the source of truth; the others are keyed by the same ids. Values may carry positional placeholders ({0}) so other languages can reorder them:

// Lang/en.json // Lang/ja.json
{ "history.title": "History", { "history.title": "履歴",
"dps.line": "{0} DPS" } "dps.line": "{0} DPS" }

In your .csproj:

<ItemGroup>
<EmbeddedResource Include="Lang/*.json" LogicalName="Lang.%(Filename)%(Extension)" />
</ItemGroup>

The framework auto-discovers these at load — no registration code. It matches any resource name ending in Lang.<code>.json, so the default MSBuild logical name (<RootNamespace>.Lang.en.json) works too.

2. Resolve at draw-time. Call T / TFormat inside your element Func<string> labels so they re-render live when the user switches language:

var s = Services.Localization;
new TextElement(() => s.T("history.title"));
new TextElement(() => s.TFormat("dps.line", dps)); // string.Format on the active-language template

Resolution is active-language → English → the key literal: a key you forgot to translate falls back to English; a key that exists nowhere renders as the key itself (so it’s obvious in-game, never a crash). Services.Localization.Language is the active code ("en"/"ja"/"th"/"id"/"fil"), and LanguageChanged fires on a switch (subscribe only if you cache built text; draw-time labels need no handler). Plugins read the language — they never set it (that’s the framework’s Settings → Themes → Language control).

3. Validate before you commit. The framework never fails on a missing key, so check your catalogs yourself (a small script in CI is enough): every key your code passes to T / TFormat should exist in en.json, and every en.json key should exist in ja/th/id/fil. Copying the English text in as a placeholder is better than leaving a key out.

Stellar holds the same line Dalamud does — QoL, not exploitation:

  • Events are read-only. Observe PlayerState, Chat, combat, party, inventory; do not try to mutate game state by writing back through them.
  • No packet construction. Never assemble protobuf bytes, modify in-flight packet bodies, or send custom-built messages.
  • Game actions go through the game’s own dispatcher. IChat.Send, IModuleEquip install/uninstall and the other action services (loadout apply/save, market buy, party size, wardrobe) are permitted because the game’s code builds the request and runs its own validation (slot lock, type conflict, max-count). Supply the inputs; never short-circuit a lower layer to bypass a game-side check.
Terminal window
dotnet build MyMod.csproj -c Release

Copy bin/Release/MyMod.dll into its own folder under the game, with the game closed:

<game_mini>/stellar/plugins/mymod/MyMod.dll

Use a lowercase folder name (the launcher does), and keep exactly one copy. A second copy anywhere under stellar/plugins/, including an old backup folder, is a duplicate plugin id and only the first one found loads. The framework and SDK DLLs must not be copied next to your plugin; they live in BepInEx/plugins/Stellar.Framework/. See Getting started for installing the framework itself.

Launch the game as usual (on Linux the launcher must set WINEDLLOVERRIDES=winhttp=n,b, which BepInEx needs) and watch <game_mini>/BepInEx/LogOutput.log. [PluginHost] discovered: <YourType> means the framework found your plugin.

The plugins below are not in this repository. Stellar.DebugInfo and Stellar.AutoNav are in the samples/ folder of the public plugin registry; the shipping plugins each have their own source repo (e.g. StellarProtocol/StellarPlayerHUDPlugin), linked from the plugin gallery.

Plugin Purpose What to learn from it
Stellar.DebugInfo Minimal scene/frame readout Smallest scaffold; subscribing to ClientState events
Stellar.PlayerHUD HP/stamina/identity HUD from IPlayerState Borderless HUD-overlay window (Surface = SurfaceStyle.HudOverlay) + HudElement tree, snapshot-in-Update pattern, owned colour slots, hotkey toggle
Stellar.ChatTools Chat log + composer + whisper auto-reply IWindowHost multi-section window, the combined window+hotkey Register overload, IChat lifecycle, ScrollElement/InputElement/ConditionalElement
Stellar.AutoNav Autonomous navigation test fixture Advanced test-only pattern (not representative of normal plugins)