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.
What a plugin is
Section titled “What a plugin is”A plugin is a single .NET 6 (net6.0) class library that:
- References the plugin SDK only: the
Stellar.Abstractionspackage, plusStellar.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.
Project setup
Section titled “Project setup”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.
The single contract: IStellarPlugin
Section titled “The single contract: IStellarPlugin”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().
What’s in the toolbox: IPluginServices
Section titled “What’s in the toolbox: IPluginServices”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).
Drawing UI the uGUI way
Section titled “Drawing UI the uGUI way”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. ATextElement(() => $"HP {hp}")re-reads your field every refresh — you never push text into a widget. - Interaction is pushed. A
ButtonElement(label, onClick)calls yourActionwhen clicked; aToggleElement/SliderElementcalls yourSetcallback.
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.
Element vocabulary
Section titled “Element vocabulary”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 anIGameAssets.Load*Iconpoll — 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.
Text styling (every language)
Section titled “Text styling (every language)”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: trueis the section-header preset (bold at header size); useBoldfor 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 (
SurfaceStylemenu 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.
A window (interactive)
Section titled “A window (interactive)”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.
Phases and window visibility
Section titled “Phases and window visibility”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.
Do you need to gate your Update?
Section titled “Do you need to gate your Update?”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).
Migrating to SDK 2.0.0
Section titled “Migrating to SDK 2.0.0”SDK 2.0.0 is a breaking release. For each existing plugin:
- Bump every
Stellar.*reference (Stellar.Abstractions, and the otherStellar.*SDK packages you use) to2.0.0or later. - Migrate every HUD to a window.
IHudHost/HudSpec/IHudHandle/HudAnchorare removed — the HUD path is now the window path. ReplaceHud.Register(new HudSpec(...))withWindows.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.HudOverlayreproduces the old borderless HUD look pixel-for-pixel; the returnedIWindowControlreplacesIHudHandle. - Add a
ShouldRenderto everyWindowSpec— the build fails until you do. For a window that used to be always-on,ShouldRender = () => true; for one that usedHideUntilInWorld,ShouldRender = () => _services.ClientState.Phase == GamePhase.World. - Delete
HideUntilInWorldandAutoHideBehindGameMenus— both are removed. Fold the “hide behind a menu” behaviour intoShouldRenderviaUiState(see the gameplay-HUD example above).
Non-UI plugins (no WindowSpec) only need the version bump.
Convenience APIs
Section titled “Convenience APIs”The toolkit ships shorthands for the most common multi-step patterns. Prefer them.
Window + hotkey toggle in one call
Section titled “Window + hotkey toggle in one call”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.
Single-default colour registration
Section titled “Single-default colour registration”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));Disposing a colour slot
Section titled “Disposing a colour slot”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.
Quiet config save
Section titled “Quiet config save”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.
Update rate
Section titled “Update rate”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.
Temporarily ramping your own rate
Section titled “Temporarily ramping your own rate”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.
RequestUpdateRatereturns 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.
Theming
Section titled “Theming”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 throughTheme.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 namespacedOwner.Concept.Propertyand 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().
Settings (config)
Section titled “Settings (config)”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 SectionChangedSupported 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.
Hotkeys
Section titled “Hotkeys”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.
Lifecycle and the Dispose contract
Section titled “Lifecycle and the Dispose contract”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()everyIWindowControland everyINativeUiElementHandle.Dispose()everyIColorSlotand everyIHotkeyAction.Dispose()every token returned byIGameEvents.Subscribe(...),ILauncher.Register(...),IEntityContextMenu.Register(...),Framework.Every(...)andFramework.RequestUpdateRate(...).- Harmony instances you got from
Services.Harmonyare 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.
Threading
Section titled “Threading”| 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.
Logging conventions
Section titled “Logging conventions”- Tag every line with your plugin name:
[MyMod], so the user cangrepyour 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.
Infofor normal flow,Warningfor recoverable problems,Errorfor failures the user needs to see,Debugfor diagnostics.
Localizing your plugin
Section titled “Localizing your plugin”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 templateResolution 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.
The no-cheating boundary
Section titled “The no-cheating boundary”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,IModuleEquipinstall/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.
Build and deploy
Section titled “Build and deploy”dotnet build MyMod.csproj -c ReleaseCopy bin/Release/MyMod.dll into its own folder under the game, with the game closed:
<game_mini>/stellar/plugins/mymod/MyMod.dllUse 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.
Reference plugins
Section titled “Reference plugins”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) |