You are viewing a potentially older version of this package. View all versions.
seneaL-SeneaL_API-1.2.0 icon

SeneaL API

Public API for Valheim UI mods: notices, dialogs and HUD elements, with a bridge that routes the game's own windows to any UI that registers as a provider.

Date uploaded a day ago
Version 1.2.0
Download link seneaL-SeneaL_API-1.2.0.zip
Downloads 2213
Dependency string seneaL-SeneaL_API-1.2.0

This mod requires the following mods to function

denikson-BepInExPack_Valheim-5.4.2202 icon
denikson-BepInExPack_Valheim

BepInEx pack for Valheim. Preconfigured and includes unstripped Unity DLLs.

Preferred version: 5.4.2202

README

SeneaL API

Discord Support the author

Pages: Thunderstore · Hexium

Provider: SeneaL UI (Thunderstore · Nexus Mods · Hexium).

A small public library for Valheim UI mods, and a bridge that sends the game's own windows to whatever UI mod you run.

  • For players: install it together with a UI mod that supports it (SeneaL UI). Mods that show the game's standard messages and windows then look the same as the rest of your interface. Without such a UI mod nothing changes: every request falls back to the vanilla window.
  • For modders: one place to show notices, ask the player a question and register your HUD elements, with no dependency on a specific UI mod.
  • For UI authors: register one provider per feature and you receive every request, from the API and from the game's own calls.

How it works

your mod ── Notices.Show / Dialogs.Confirm / HudElements.Add ──┐
                                                               ├─► provider (a UI mod) ─► its own window
game / any mod ── MessageHud.ShowMessage, UnifiedPopup.Push ───┘            │
                  TextInput.RequestText  (bridge)                          └─ declines or missing ─► vanilla window

All calls run on the Unity main thread.

Using it from your mod

Add a soft dependency and keep every API call in a method that runs only when the API is loaded:

[BepInDependency("seneaL.valheim.api", BepInDependency.DependencyFlags.SoftDependency)]
public class MyMod : BaseUnityPlugin
{
    private static bool HasApi => BepInEx.Bootstrap.Chainloader.PluginInfos.ContainsKey("seneaL.valheim.api");

    public static void Hello()
    {
        if (HasApi)
            ShowWithApi("Hello");
        else
            Player.m_localPlayer?.Message(MessageHud.MessageType.TopLeft, "Hello");
    }

    private static void ShowWithApi(string text) => SeneaL.API.Notices.Show(text);
}

If you only use the game's own methods you need nothing: the bridge already routes them.

Quick start: a complete mod

One file, one soft dependency, all four features. Everything that touches SeneaL.API sits in a separate class, so the mod loads and works when the API is not installed.

using BepInEx;
using UnityEngine;
using BepInEx.Bootstrap;

[BepInPlugin("my.mod.guid", "My Mod", "1.0.0")]
[BepInDependency("seneaL.valheim.api", BepInDependency.DependencyFlags.SoftDependency)]
public class MyMod : BaseUnityPlugin
{
    public static bool HasApi => Chainloader.PluginInfos.ContainsKey("seneaL.valheim.api");

    private void Start()
    {
        if (HasApi)
            ApiPart.Register();
    }

    private void Update()
    {
        if (HasApi && Input.GetKeyDown(KeyCode.F9))
            ApiPart.Ask();
    }
}

internal static class ApiPart
{
    private const string Owner = "my.mod.guid";

    public static void Register()
    {
        if (Api.Has("slots"))
            CustomSlots.Add(Owner, new SlotDef("charm") { Title = "Charm", Mode = SlotMode.Stored });

        if (Api.Has("notices"))
            Notices.Show("My Mod is ready", NoticeKind.TopLeft);
    }

    public static void Ask()
    {
        Dialogs.Confirm("Reset the counter?", "The value will be lost.", yes: () => Notices.Show("Counter reset", NoticeKind.Center));
    }
}

using SeneaL.API; goes at the top of the file that uses these types. Api.Has(...) is true only when the feature exists in the installed API version, so it also protects you from an older API.

Notices

Notices.Show("$msg_example", NoticeKind.Center);
Notices.Show("Picked up", NoticeKind.TopLeft, amount: 3, icon: sprite);
Notices.Unlock(icon, "Title", "Description");
Notices.Biome("Meadows", stinger: true);
Notices.Shown += notice => { };

Notice.Text is localised, Notice.Raw is what was passed, Notice.Vanilla is true when it came from a game call.

Dialogs

Dialogs.Confirm("Delete?", "This cannot be undone.", yes: () => Delete());
Dialogs.Message("Done", "Saved.");
Dialogs.Input("Name", "current", submit: name => Rename(name), validate: s => s.Length > 0);

Each call returns the dialog. IsOpen is false when the vanilla window owns it, and Close() closes it.

More than two answers: Dialogs.Choose takes up to four buttons. The first Primary button is the main one, CancelIndex (the last button by default, -1 = none) is what Esc picks. Set Centered = true on any dialog to center its text.

var d = Dialogs.Choose("Friend request", "Ragnar wants to be your friend.",
    new DialogButton("Accept", Accept, primary: true),
    new DialogButton("Decline", Decline),
    new DialogButton("Later", Later));

d.Centered = true;
d.CancelIndex = 2;

Without a provider (or with one that does not know DialogKind.Choice) the game's yes / no window shows the first two buttons (one button: the game's warning window). Probe with Api.Has("dialogs.choice"). A dialog provider gets ChoiceDialog with Buttons and answers with Choose(index) or Cancel(); a provider that cannot draw it returns false from Show.

HUD elements

var element = HudElements.Add("my.mod.guid", "timer", "Timer", timerRect);
HudElements.Remove(element);

A full example: a small counter that you build yourself and hand to the UI.

using TMPro;
using UnityEngine;
using UnityEngine.UI;

internal static class KillCounter
{
    private static TextMeshProUGUI _text;
    private static HudElement _element;
    private static int _kills;

    public static void Create()
    {
        var root = new GameObject("MyKillCounter", typeof(RectTransform)).GetComponent<RectTransform>();

        root.SetParent(Hud.instance.m_rootObject.transform, false);
        root.anchorMin = root.anchorMax = root.pivot = new Vector2(0f, 1f);
        root.anchoredPosition = new Vector2(40f, -200f);
        root.sizeDelta = new Vector2(180f, 36f);

        root.gameObject.AddComponent<Image>().color = new Color(0f, 0f, 0f, .45f);

        var label = new GameObject("Text", typeof(RectTransform)).GetComponent<RectTransform>();

        label.SetParent(root, false);
        label.anchorMin = Vector2.zero;
        label.anchorMax = Vector2.one;
        label.offsetMin = label.offsetMax = Vector2.zero;

        _text = label.gameObject.AddComponent<TextMeshProUGUI>();
        _text.alignment = TextAlignmentOptions.Center;
        _text.fontSize = 18f;
        _text.raycastTarget = false;

        _element = HudElements.Add("my.mod.guid", "kills", "Kill counter", root);

        Refresh();
    }

    public static void Add()
    {
        _kills++;

        Refresh();
    }

    private static void Refresh() => _text.text = "Kills: " + _kills;
}

Without SeneaL UI the counter simply sits where you put it. With it the player finds "Kill counter" (tagged as coming from a mod) in the HUD editor and can move, scale, fade, attach and hide it.

A UI provider can then move, resize, fade and attach the element and keep its place in the player's profile. The element keeps living in your hierarchy: the provider only changes its position, scale and alpha. Set element.Busy = true while something is going on in it, so that an "auto-hide when idle" setting does not hide it at that moment. Use HudElements.Remove when the element goes away; a destroyed RectTransform is dropped by itself. element.Placed is true while the player has placed the element by hand in the HUD editor: if your mod positions the element itself (for example next to the minimap), skip that while Placed is true so the player's place wins. HudElements.Find(owner, id) returns an element you or somebody else registered. Probe with Api.Has("hud.placed").

Skins, settings and look

Your element can be customised in the HUD editor like the UI mod's own elements. You decide what the player may change; the provider draws the controls, keeps the values in the player's profile (and in exported profile codes) and gives them back to you. You apply them, because the element is yours.

_element = HudElements.Add("my.mod.guid", "kills", "Kill counter", root);

_element.Looks = HudLooks.Panel | HudLooks.Background | HudLooks.Transparency | HudLooks.TextSize | HudLooks.Caption;

_element.AddSkin("plain", "Plain");
_element.AddSkin("runes", () => Localization.instance.Localize("$my_skin_runes"), runesPreview);

var shape = _element.AddChoice("shape", "Shape", "Round", "Square", "Diamond");
var pad = _element.AddSlider("pad", "Padding", 0f, 20f, 1f, 8f, v => Mathf.RoundToInt(v) + " px");
var glow = _element.AddColor("glow", "Glow", new Color(1f, .8f, .3f));
var icon = _element.AddToggle("icon", "Skull icon", true);

pad.Section = () => "Spacing";
icon.Enabled = () => _element.Skin != "plain";

_element.StyleChanged += e => {
    var c = e.Style.PanelOr(new Color(0f, 0f, 0f));

    c.a = e.Style.PanelAlpha(.45f);
    _back.color = c;
};

_element.SkinChanged += e => ApplySkin(e.Skin);
_element.OptionChanged += o => Rebuild();
  • Looks turns on the provider's standard rows. Panel and Accent are colors the player may set (Style.HasPanel / HasAccent are false while the player keeps your default, so use PanelOr(yourColor)), Background is the opacity of your panels (0 = only text and icons), Transparency is already resolved against the global interface transparency. Style.PanelAlpha(a) multiplies your alpha by both. TextSize makes the provider resize the TextMeshPro texts under your element (scale anything else with Style.TextScale), Caption lets the player write a caption that the provider draws next to the element. Opacity, size, rotation, attachment and hiding are always there.
  • Skins are named looks you draw yourself. The first one is the default; element.Skin is the id of the current one, SetSkin(id) changes it from code.
  • Settings: AddToggle, AddChoice (up to four names show as a switch, more as a list), AddSlider (min, max, step, optional text for the value), AddColor (an empty value means your default, option.HasColor tells whether the player picked one, option.Color is always usable), AddText. Each returns a HudOption: set Tab (HudTab.Look, Content, Colors, Behavior; the default follows the kind), Section (a header; settings with the same section stay under one header) and Enabled. Read Bool, Index, Number, Color or Text; Value is the stored text. Labels are functions or $tokens, so they follow the game language.
  • Add skins and settings right after HudElements.Add. At the end of that frame the provider loads the player's stored values into them and your Changed handlers run with them, so subscribe before that and treat the value in the Add… call as the default: a value you Set in the same frame is replaced by the player's stored one. Adding a setting with an id that exists replaces it; RemoveOption(id) drops one. Changing Looks, skins or settings later is fine: the provider rebuilds the element's settings (HudProvider.Updated).
  • option.Set(…) changes a value from your code (your own config, a key bind); the provider stores it as if the player did it. Bad values are refused (Set returns false).
  • Without a provider nothing is drawn: Style keeps its defaults (white, 1, 0, 1), the first skin is current and every setting keeps the value you gave it, so the same code works without the UI mod.

Probe with Api.Has("hud.look"), Api.Has("hud.options") and Api.Has("hud.skins").

Gauges

For a value with a maximum (rage, endurance, a timer, a charge) you do not need to draw anything: hand the value to the provider and it draws a gauge in its own style, puts it into its HUD editor and lets the player choose how it looks.

_rage = HudGauges.Add("my.mod.guid", "rage", "Rage");

_rage.Shape = GaugeShape.Arc;
_rage.Shapes = new[] { GaugeShape.Arc, GaugeShape.Ring, GaugeShape.Bar, GaugeShape.Square };
_rage.SetFill(new Color(.9f, .35f, .2f));
_rage.AddStop(.25f, new Color(.5f, .5f, .5f));
_rage.Warn = .9f;
_rage.Readout = GaugeReadout.Percent;
_rage.Icon = rageSprite;
_rage.Anchor = new Vector2(.5f, .2f);

_rage.HostedChanged += g => _myBar.SetActive(!g.Hosted);
_myBar.SetActive(!_rage.Hosted);

_rage.Set(rage, maxRage);
  • Set(value, max) (or Value, Max) every frame or when it changes: it costs nothing, the provider reads it. Fraction is Value / Max clamped to 0..1. Put Busy = true while the gauge must stay visible; a changing value counts as busy for a moment by itself.
  • Shape is the default shape, Shapes the shapes the player may switch to (all five by default). Length and Thickness size a bar or column, Diameter a ring, half ring or square, in pixels at 1080p; the player can change them.
  • Colors: SetFill(color) is the normal color. AddStop(at, color) colors the gauge while Fraction <= at (the lowest matching stop wins); with Blend = true the colors run smoothly from stop to stop. The player can recolor each stop and the fill in the HUD editor. ColorAt(fraction) gives the same color for your own fallback bar.
  • Warn (0..1): below it the gauge pulses (the player can switch that off). Readout: none, the value, value / max or percent; Text replaces it with your own text while it is not null. Icon sits next to the bar or inside the ring. Visible = false hides it.
  • Anchor (fraction of the screen) and Offset (pixels) are where it starts; the player moves it from there like any HUD element. gauge.Element is the HudElement the provider made for it (null while not hosted), for example to check Placed.
  • Hosted is true while a provider draws the gauge. Without SeneaL UI (or another provider) it stays false: show your own bar and use ColorAt to color it. HostedChanged tells you when that changes. Remove it with HudGauges.Remove(gauge).

Probe with Api.Has("hud.gauges").

Theme

Build your own window or HUD piece from the look of the installed UI mod: its fonts, its panel, button, field, row and tab sprites and its colors. Without a theme provider every call returns nothing, so keep your own look as the fallback.

var sprite = Themes.Sprite(ThemePart.Window);

if (sprite != null) {
    image.sprite = sprite;
    image.type = Image.Type.Sliced;
    canvasScaler.referencePixelsPerUnit = Themes.PixelsPerUnit;
}

var font = Themes.Font(ThemeFont.Body);

if (Themes.TryColor(ThemeColor.Accent, out var accent))
    title.color = accent;

Themes.Changed += Rebuild;

Parts: Window, Inner, Card, Button, ButtonHover, Primary, PrimaryHover, Field, FieldFocus, Row, RowHover, RowOn, Tab, TabHover, TabOn, Track, Chip, Separator. Fonts: Body, Strong, Title. Colors: Text, Muted, Accent, Good, Bad, Warn, OnPrimary (text on a Primary button), Shade (dim behind a window). Sprites are 9-sliced: set Image.type = Sliced and give the canvas the provider's PixelsPerUnit (or set Image.pixelsPerUnitMultiplier to match), otherwise the corners come out twice as big or too small. Themes.Changed fires when the provider or its look changes. Probe with Api.Has("theme"). Themes.Icon("map-pin") returns an icon by its Phosphor name (white, tint it with Image.color), feature theme.icons.

Mod settings

Show your BepInEx settings in the UI mod's own settings window (SeneaL UI: F7). Keys (KeyboardShortcut) go to its key list with clash warnings, the rest (bool, enum, numbers with an AcceptableValueRange) to its mods page under your mod's name. The entries stay yours: the UI only reads and writes Entry.BoxedValue.

ModSettings.Add("my.mod.guid", "My Mod", openKey, () => Loc("Open the window"));
var s = ModSettings.Add("my.mod.guid", "My Mod", mode, () => Loc("Mode"), () => Loc("What the mode does"));
s.Options = () => new[] { Loc("Off"), Loc("Push to talk"), Loc("Open") };
s.Visible = () => serverAllowsIt;
s.Hold = true;

Label, Tip and Options are functions so they follow the game language. Hold marks a key that is held (no clash warning). ModSettings.Changed fires when entries come or go; call ModSettings.Refresh() after your labels change. Probe with Api.Has("settings").

Character screen tabs

Add a tab to the character screen. The UI mod replaces the game's character panel with its own row of tabs (SeneaL UI: Texts, Skills, Achievements, Trophies, PvP, Unlocks), so a button that a mod puts on the game's panel is hidden there; a tab registered here is drawn in that row in the mod's style instead. A click calls your Open, which opens your own window.

var tab = CharacterTabs.Add("my.mod.guid", "quests", () => Loc("Quest Log"), QuestLog.Open);
tab.IconName = "book-open";
tab.Order = 50;
tab.Active = () => QuestLog.IsOpen;
tab.Badge = () => QuestLog.Unread;
tab.Tip = () => Loc("Your quests and contracts");
tab.Visible = () => QuestLog.Unlocked;

IconName is any Phosphor icon name (or set Icon to your own sprite). Active lights the tab while your window is open, Badge puts a number on it, Order sorts the extra tabs (they come after the UI mod's own ones). CharacterTabs.Hosted is true while a UI mod draws the tabs, so you can hide your own button on the game's panel then. Remove with CharacterTabs.Remove(owner, id) or RemoveAll(owner). Probe with Api.Has("tabs").

Docked containers

Some containers are not chests: a backpack pocket, a portal or ship storage a mod opens from an item. Tell the UI mod which ones are yours and it shows them docked under the inventory, like a worn backpack, instead of a chest window on the side.

ContainerDocks.Add("my.mod.guid", "pockets", c => c.GetComponent<MyPocketProxy>() != null, c => Loc("Portal pocket"));

Match gets every container the player opens; keep it cheap (a component check or a cached name). The optional title replaces the container's own name in the docked header. Docked containers are also left out of the UI mod's chest automation (smelter feeding, chest links). ContainerDocks.Hosted tells you a UI mod draws them, so you can skip your own dock then. Probe with Api.Has("containers.dock").

Slots

Add your own inventory slots next to the equipment. The UI draws them, stores the items with the character and handles drag and drop, and you decide what fits and what happens when an item comes or goes.

var slot = CustomSlots.Add("my.mod.guid", new SlotDef("ring3")
{
    Title = "Third ring",
    Hint = "A ring from My Mod.",
    IconName = "diamond",
    Mode = SlotMode.Worn,
    Place = SlotPlace.Doll,
    EquipTime = -1f,
    Order = 10,
    Accepts = item => item.m_shared.m_name == "$item_my_ring",
    Visible = () => MyMod.RingsUnlocked,
    Changed = change => MyMod.Refresh(change.Old, change.New)
});

var worn = CustomSlots.Get("my.mod.guid", "ring3");
CustomSlots.Set("my.mod.guid", "ring3", null);
  • IconName: the picture of the empty slot, by the name of a Phosphor icon (regular weight), for example "diamond", "flask", "sword", "shield-chevron"; all 1512 icons are built into the UI and shown in one picture, so you ship nothing. Copy the name from the site (ph- and .svg are ignored). Icon takes your own Sprite instead and wins over IconName. Without either, the UI guesses from the id and title (ring, neck, bag, quiver) and otherwise draws a spark. An unknown name keeps the default and is logged. Probe with Api.Has("slots.icon").
  • Stored: the slot only holds the item, you read it with Get and act on it yourself.
  • Worn: the item counts as equipped while it sits in the slot: m_equipped is set and its equip status effect and effects are played and removed by the host. Extra effects (visuals, stats) are yours, in Changed.
  • Using an item the slot accepts (right click in the inventory, a hotbar key, the gamepad) puts it into the slot instead of letting the game equip it: an empty accepting slot first, otherwise it swaps with the item in the first accepting slot. Worn slots take every accepted item this way, Stored slots only items the game could equip. Set EquipOnUse = false to keep the game's own use for that slot. Keep Accepts narrow: a slot that accepts every Utility item also takes the belts. Probe with Api.Has("slots.use").
  • EquipTime: how long putting an item into a Worn slot or taking it out takes when the player does it (right click, hotbar key, gamepad, drag and drop). -1 (the default) uses the item's own m_equipDuration, like the game does for armor; 0 is instant; a positive value is that many seconds. The game's own equip bar, animation and cancel are used. CustomSlots.Set is always instant. Probe with Api.Has("slots.time").
  • Place: where the UI draws the slot. Auto (the default) puts Stored and Worn slots into the Mod slots row under the equipment and Linked slots onto free places of the equipment block; Row always uses the row; Doll puts the slot onto a free place of the equipment block, which grows wider when it needs more places; slots that do not fit on the screen stay in the row. The player can override this in the UI settings. Probe with Api.Has("slots.place").
  • A Worn item keeps its equip status effect while the game equips and unequips its own items, also when a vanilla item carries the same effect.
  • Changed runs for every fill, empty and swap, and once for each item that is already in a slot when the character loads, so one handler covers both.
  • Slots belong to the local player. Up to 16 slots are shown, ordered by Order; a slot keeps its place in the row between sessions. If a slot is removed or its mod is gone, the item goes back to the inventory.
  • The items live in a separate inventory kept in the character data, not in the main grid: they are not counted in the carry weight, and they go into the grave when the character dies.

Linked slots and AzuEPI

Pick Worn, not Linked, for a new equipment slot of your own (a ring, an amulet). Linked adds no slot to the character: it shows an item that the game or another mod already equips, and putting an item into it calls the game's EquipItem. A ring with the item type Utility then goes into the belt slot and takes the belt off.

A Linked slot does not store an item: it shows an item that lives elsewhere, normally in the player's inventory, and you tell the API how to find it. Putting an item into the slot equips it through the game, taking it out unequips it, and Changed fires whenever the item in it changes.

CustomSlots.Add("my.mod.guid", new SlotDef("tome")
{
    Title = "Tome",
    Mode = SlotMode.Linked,
    Source = player => player.GetInventory().GetEquippedItems().Find(i => i.m_shared.m_name == "$item_my_tome"),
    Accepts = item => item.m_shared.m_name == "$item_my_tome"
});

A cell that holds an item without wearing it (a potion cell, a key ring, your own storage cell) cannot be filled with EquipItem. Give such a slot Put and Take: the UI calls Put(player, item) when the player drops an item into the slot and Take(player, item) when they take it out, instead of equipping and unequipping. Return true when you moved the item. Source still tells where the item is. Probe with Api.Has("slots.handlers").

CustomSlots.Add("my.mod.guid", new SlotDef("potion.0")
{
    Title = "Potion",
    Mode = SlotMode.Linked,
    Source = player => MyCells.ItemIn(player, "potion.0"),
    Accepts = item => MyCells.Accepts("potion.0", item),
    Put = (player, item) => MyCells.Put(player, "potion.0", item),
    Take = (player, item) => MyCells.TakeOut(player, "potion.0")
});

If Azumatt's AzuEPI is installed, SeneaL API reads the equipment slots that other mods add through AzuEPI.API.AddSlot once a second and mirrors each of them as a Linked slot owned by Azumatt.AzuExtendedPlayerInventory. AzuEPI keeps deciding what fits and what is equipped; a UI provider only shows the slot and moves the item. This needs nothing from the mod that calls AddSlot. Bridge / AzuEPI in the config turns it off.

A UI provider that hands the inventory over to AzuEPI sets CustomSlots.AzuManaged = true: the bridge stops mirroring, mirrored slots are removed, and the stub routing only forwards AddSlot to the real AzuEPI instead of emulating it, so that AzuEPI itself shows those slots.

A mod that embeds the AzuEPI API stub into its own dll (the stub methods do nothing, so it never reaches AzuEPI) is handled by a second small plugin inside SeneaL-API.dll (SeneaL API Early, GUID __seneaL.valheim.api.early; BepInEx loads plugins without dependencies in GUID order, so it starts before every other plugin). It finds the embedded AzuEPI.API and AzuExtendedPlayerInventory.API types in any assembly that is loaded and routes IsLoaded, AddSlot (all four overloads) and RemoveSlot here:

  • IsLoaded() answers true when the real AzuEPI or SeneaL UI is installed, false otherwise (the mod keeps its own fallback).
  • AddSlot goes to the real AzuEPI when it is installed (and is then mirrored like any other of its slots), and becomes a Linked slot owned by AzuEPI.emulated otherwise. Two mods that add the same slot name share one slot, like in AzuEPI.
  • Mods the UI mod already supports itself are left alone, so that their behaviour does not change: Bridge / AzuEPIStubIgnore lists their assembly names (Jewelcrafting, BowsBeforeHoes, BetterArchery, AdventureBackpacks and EpicLoot by default; a config that still holds an older default list is moved to this one). Remove a name to route that mod too. Bridge / AzuEPIStubs = false turns the routing off. Both need a game restart.

Older versions shipped this part as a separate file in BepInEx/patchers/SeneaL-API; it is not needed any more and can be deleted (the log says so while it is still there).

Writing a provider

public sealed class MyNotices : NoticeProvider
{
    public override bool Show(Notice notice)
    {
        return true;
    }
}

Notices.Register("my.ui.guid", new MyNotices());

Return true when you showed it and false to let the vanilla window handle it. A notice you showed is written to the game's message log for you; set notice.Log = false first if you write your own log line. Registration: highest priority wins, ties go to the latest, Unregister(owner) falls back to the previous provider, registering the same owner again replaces its entry. Dialogs that were open in a provider that goes away are shown again by the next provider, or by the vanilla window. DialogProvider also gets Hide, called when a dialog closes; HudProvider gets Added and Removed, and receives every element that was registered before it.

A SlotProvider stores the items: Added and Removed tell it about the slots, Get and Set read and fill them, and it reports changes with CustomSlots.Report.

Provider examples

A dialog provider: draw your own window, call the dialog's own methods when the player answers.

public sealed class MyDialogs : DialogProvider
{
    public override bool Show(Dialog dialog)
    {
        var window = MyWindow.Create(dialog.Title, dialog.Text);

        switch (dialog)
        {
            case ConfirmDialog confirm:
                window.AddButton(confirm.YesText ?? "Yes", confirm.Yes);
                window.AddButton(confirm.NoText ?? "No", confirm.No);

                break;
            case MessageDialog message:
                window.AddButton(message.OkText ?? "OK", message.Ok);

                break;
            case InputDialog input:
                window.AddField(input.Value, input.Placeholder, input.CharLimit, text =>
                {
                    if (input.IsValid(text))
                        input.Submit(text);
                });
                window.AddButton("Cancel", input.Cancel);

                break;
        }

        return true;
    }

    public override void Hide(Dialog dialog) => MyWindow.CloseFor(dialog);
}

Dialogs.Register("my.ui.guid", new MyDialogs(), priority: 10);

A HUD provider: you get every element, now and later, and move it as you like. The element stays a normal Unity object in the owner's hierarchy.

public sealed class MyHud : HudProvider
{
    public override void Added(HudElement element)
    {
        MyEditor.Track(element.Key, element.Title, element.Rect);
    }

    public override void Removed(HudElement element)
    {
        MyEditor.Forget(element.Key);
    }

    public override void Updated(HudElement element)
    {
        MyEditor.Rebuild(element.Key, element.Looks, element.Skins, element.Options);
    }
}

HudElements.Register("my.ui.guid", new MyHud());

A HUD provider that supports the look settings draws a control for each of element.Looks, element.Skins and element.Options, stores the values (option.Value is a text that option.Set takes back; option.Changed tells you when the mod changes one itself), and after the look values change writes them into element.Style and calls element.Restyle(). Updated comes when the mod changes any of that after Added; it can come several times in one frame, so rebuild once per frame.

A theme provider: hand out what you have, return null / false for the rest. Call Themes.Refresh() when your look changes.

public sealed class MyTheme : ThemeProvider
{
    public override float PixelsPerUnit => 50f;

    public override Sprite Sprite(ThemePart part) => part == ThemePart.Window ? MySkin.Panel : null;

    public override TMP_FontAsset Font(ThemeFont font) => MyFonts.Regular;

    public override bool TryColor(ThemeColor color, out Color value)
    {
        value = color == ThemeColor.Accent ? MyColors.Gold : Color.white;

        return color == ThemeColor.Accent || color == ThemeColor.Text;
    }
}

Themes.Register("my.ui.guid", new MyTheme());

A slot provider: keep the items, tell the API when something changes.

public sealed class MySlots : SlotProvider
{
    private readonly Dictionary<string, ItemDrop.ItemData> _items = new Dictionary<string, ItemDrop.ItemData>();

    public override void Added(SlotDef slot) => MyBar.AddCell(slot);

    public override void Removed(SlotDef slot)
    {
        _items.Remove(slot.Key);

        MyBar.RemoveCell(slot);
    }

    public override ItemDrop.ItemData Get(SlotDef slot)
    {
        _items.TryGetValue(slot.Key, out var item);

        return item;
    }

    public override bool Set(SlotDef slot, ItemDrop.ItemData item)
    {
        if (item != null && !slot.Allows(item))
            return false;

        _items.TryGetValue(slot.Key, out var old);

        _items[slot.Key] = item;

        CustomSlots.Report(new SlotChange(slot, old, item));

        return true;
    }
}

CustomSlots.Register("my.ui.guid", new MySlots());

Common mistakes

  • Calling SeneaL.API types from a method of your main class when the API is optional: the type is resolved when the method is compiled to native code, and a missing assembly throws. Keep all API calls in a separate class, as in the quick start.
  • Using Notices.Show from a thread other than Unity's main thread.
  • Forgetting Visible on a slot that should only exist at some point: an always-visible slot stays in the row.
  • Expecting Accepts to stop the player taking the item out: it only decides what may be put in.
  • Registering a provider before the objects it needs exist: providers receive earlier HUD elements and slots when they register, so register in Start, not in Awake.

Use Api.Has("notices"), Api.Has("dialogs"), Api.Has("hud"), Api.Has("slots"), Api.Has("slots.linked"), Api.Has("azuepi"), Api.Has("hud.placed"), Api.Has("slots.use"), Api.Has("slots.time"), Api.Has("slots.place") and Api.Contract to probe the version.

Config

Bridge / Notices and Bridge / Dialogs turn the bridge for the game's own calls off. Debug / Verbose logs every routed request.

Version history

  • 1.2.0 HUD elements get skins, their own settings and the provider's look settings (Looks, Style, AddSkin, AddToggle…); gauges (HudGauges) drawn by the provider.
  • 1.1.0 character screen tabs (CharacterTabs) and docked containers (ContainerDocks).
  • 1.0.1 Linked slots can store and take out items through the mod's own Put / Take handlers.
  • 1.0.0 first version: notices, dialogs, HUD elements and the bridge.

CHANGELOG

1.2.0

  • HUD elements can be customised by the player as fully as the UI mod's own: a mod declares what the player may change and the UI provider shows it in its HUD editor, stores it in the player's profile and hands the values back. Features hud.look, hud.options, hud.skins.
    • element.Looks (HudLooks: Panel, Accent, Background, Transparency, TextSize, Caption, All) turns on the provider's standard look settings for the element. The resolved values arrive in element.Style (HudStyle: panel and accent colors, background, transparency with the global value already applied, text scale, size) and element.StyleChanged fires when they change. With TextSize the provider also resizes the TextMeshPro texts under the element; with Caption it draws the caption itself.
    • element.AddSkin(id, title, preview) adds named skins; the player picks one, element.Skin / CurrentSkin tell which, SkinChanged fires. The first skin is the default.
    • element.AddToggle / AddChoice / AddSlider / AddColor / AddText add the mod's own settings (HudOption: tab, section, label, enabled check, value format). Values are checked (slider range and step, color hex, text length), option.Changed and element.OptionChanged fire on every change, from the player or from option.Set in the mod.
    • HudProvider.Updated(element) tells a provider that looks, skins or options of an element changed after it was added.
  • Gauges: HudGauges.Add(owner, id, title) hands a value (Set(value, max)) to the UI provider, which draws it in its own style as a bar, column, ring, half ring or square (Shape, Shapes offered to the player), with color stops (AddStop(at, color), Blend), a pulse below Warn, a readout (Readout, Text), an Icon, a default place (Anchor, Offset) and size (Length, Thickness, Diameter). The provider puts it into its HUD editor, where the player picks the shape, size, readout and stop colors. Hosted is false (and HostedChanged fires) while nobody draws it, so the mod shows its own bar then. Feature hud.gauges.
  • One file: the package no longer has a patchers folder, everything is in plugins/SeneaL-API/SeneaL-API.dll. Some mod managers (Vortex, some Gale setups) put the patcher folder over the plugin folder, and then the game did not find SeneaL API at all ("missing dependencies: seneaL.valheim.api"). The AzuEPI stub routing now starts from a second small plugin in the same dll (SeneaL API Early, GUID __seneaL.valheim.api.early), which BepInEx loads before every other plugin, so it still reaches the stubs before their mods start. An old BepInEx/patchers/SeneaL-API left from an earlier version still works for that run and the log asks to delete it.

1.1.0

  • Character screen tabs: CharacterTabs.Add(owner, id, title, open) puts a tab into the UI mod's tab row on the character screen (title, Phosphor IconName or own Icon, Order, Visible, Active while the mod's window is open, a Badge number, Tip); a click calls the mod's Open. CharacterTabs.Hosted tells the mod that a UI mod draws the tabs. Feature tabs.
  • Docked containers: ContainerDocks.Add(owner, id, match, title) marks containers that a mod opens as pockets (backpacks, portal or ship storage); the UI mod docks them under the inventory instead of showing a chest window and keeps them out of its chest automation. Feature containers.dock.

1.0.1

  • Linked slots: SlotDef.Put and SlotDef.Take (Func<Player, ItemData, bool>) let a mod store and take out the item itself instead of the game's EquipItem / UnequipItem, for cells that hold items without wearing them (potions, keys, a mod's own storage cells). Without them a Linked slot works as before. Feature slots.handlers.

1.0.0

  • Mod settings: ModSettings.Add puts another mod's BepInEx entries into the UI mod's settings window (keys into its key list with clash warnings, switches, choices and sliders into its mods page); labels, tips and choice names are functions so they follow the language. Feature settings.

  • Theme: Themes.Icon(name) returns an icon by its Phosphor name. Feature theme.icons.

  • Dialogs: Dialogs.Choose opens a dialog with up to four buttons (DialogButton: text, click, primary), CancelIndex sets what Esc picks; Dialog.Centered centers the text of any dialog. Without a provider the game's yes / no window takes the first two buttons. Feature dialogs.choice.

  • Theme: Themes hands out the installed UI mod's fonts, 9-sliced sprites (window, card, button, field, row, tab, track, chip) and colors, so another mod can build its own window that looks like the rest of the interface; without a provider every call returns nothing and the mod keeps its own look. Feature theme.

First version.

New:

  • Notices: show top-left, center, unlock and biome banners through Notices, with a vanilla fallback when no UI provider is registered.
  • Dialogs: confirm, message and text input windows through Dialogs, with a vanilla fallback.
  • HUD elements: any mod can register a RectTransform with HudElements so a UI provider can move, resize and style it.
  • Providers: a UI mod registers one provider per feature; dialogs open in a provider that goes away are shown again by the next one or by the vanilla window, and dialogs left over from the previous scene are dropped.
  • Slots: mods add their own inventory slots with CustomSlots (Stored or Worn, with an accept check, visibility, order and a change callback); the UI provider stores, draws and persists them.
  • Linked slots: SlotMode.Linked with a Source shows an item that lives somewhere else (usually the inventory) in a slot; putting an item in equips it through the game (an item already in the slot is unequipped first, so the new one takes that slot), emptying the slot unequips it, and Changed fires when the item changes. Contract 2.
  • AzuEPI bridge: when Azumatt's AzuEPI is installed, the equipment slots other mods add through its API are mirrored as Linked slots, so a UI provider shows them next to the slots of this API. AzuEPI keeps running them. Bridge / AzuEPI turns it off.
  • AzuEPI API stubs: a small patcher (BepInEx/patchers/SeneaL-API) loads before the plugins and routes the AzuEPI API stub that some mods carry inside their own dll (all its methods do nothing, so such a mod never reaches AzuEPI) to this API. IsLoaded() answers true when AzuEPI or SeneaL UI is installed, AddSlot goes to the real AzuEPI if it is installed and is emulated as a Linked slot (merged by name, like AzuEPI does) otherwise. Mods the UI mod already supports itself (Jewelcrafting, BowsBeforeHoes, BetterArchery, EpicLoot and AdventureBackpacks) are left alone by default: Bridge / AzuEPIStubIgnore; Bridge / AzuEPIStubs turns it off. Needs a game restart. A slot that reaches this API through AzuEPI or a stub carries the name of the mod that added it in SlotDef.Origin.
  • HUD placement: HudElement.Placed is set by the UI provider while the player has put the element at a place of their own, so a mod that moves its element itself can leave it alone then; HudElements.Find(owner, id) returns one element. Feature hud.placed.
  • Slot origin: the API now notes which mod adds a slot at the moment of the AddSlot call (a hook on the real AzuEPI API and the stub calls), so SlotDef.Origin names the right mod even when the mod passes a prefab name instead of functions or keeps its functions in a helper dll.
  • CustomSlots.AzuManaged: a UI provider that gives the inventory to AzuEPI turns the AzuEPI mirroring and the stub emulation off, so AzuEPI shows the slots of other mods itself.
  • Slots: SlotDef.IconName names a Phosphor icon (regular weight, e.g. "flask") for the empty slot; the UI provider draws it, Icon (a Sprite) still wins. Feature slots.icon.
  • Slots: using an item that a mod slot accepts (right click, hotbar key, gamepad) puts it into that slot instead of the game's own equipment slot (a ring of type Utility used to replace the belt). SlotDef.EquipOnUse = false keeps the game's use. Feature slots.use. The README says when to pick Worn and when Linked.
  • Slots: SlotDef.EquipTime (Worn slots wait for the game's equip bar: the item's own time by default, 0 for instant, or seconds) and SlotDef.Place (Auto, Row or Doll: the slot can sit on the equipment block instead of the Mod slots row). Features slots.time and slots.place.
  • Bridge: the game's own MessageHud, UnifiedPopup and TextInput calls are routed to the registered providers, so every mod that uses the vanilla windows gets the provider's look. Each bridge can be turned off in the config.

Fixed:

  • Dialogs: the text input and popup bridge runs after other mods' prefixes and stays out when one of them already took the call over (XPortal opens its own portal window from TextInput.RequestText; a second, empty name window used to open over it).