Add to the toolbar
Problem. Your building or island exists but the player has no way to select it.
Solution. InToolbar(...), with a locator that describes a position in the
existing toolbar tree by index.
using ShapezShifter.Flow.Toolbar;
.InToolbar(ToolbarElementLocator.Root().ChildAt(0).ChildAt(2).ChildAt(^1).InsertAfter())
Read that right to left: find the last child (^1) of the third child (2) of the
first child (0) of the root, and insert my entry after it.
The API, in full
It is deliberately tiny:
// ShapezShifter.Flow.Toolbar
IToolbarElementLocator Root();
IToolbarElementLocator ChildAt(this IToolbarElementLocator locator, Index index);
IToolbarEntryInsertLocation InsertBefore(this IToolbarElementLocator locator);
IToolbarEntryInsertLocation InsertAfter(this IToolbarElementLocator locator);
Index is the C# index type, so ^1 is "last", ^2 is "second from last", and plain
integers count from the start. Chain ChildAt as deep as the tree goes: root →
category → group → entry.
Finding the indices
There is no by-name lookup, which makes this the fiddliest part of adding content. Two approaches:
Count in-game. Open the toolbar and count categories left to right from zero, then count within the category. Tedious but immediate.
Export the game data. Press F1 and run debug.export-game-data; the JSON it
writes includes the content structure, which is easier to count accurately than the
on-screen toolbar.
Read the toolbar data. More reliable, and it survives you being wrong about what counts as a child. Dump the tree from a console command and read the real structure:
IParentToolbarElementData parent = ToolbarElementLocator.Root()
.ChildAt(0)
.FindElementParent(toolbarData);
FindElementParent(ToolbarData) is the extension the locators use internally
(ToolbarEntryExtensions), and it is public — walk it and log ids to map the tree once,
then hard-code the indices you found.
Separators are not counted — but only on half the path
FindElementParent filters ToolbarSlotSeparator out before indexing, so a separator
occupies a slot in Children that ChildAt cannot address and does not count.
That makes a dumped tree printing raw array positions disagree with what ChildAt means.
With a separator at raw position 4 of the root, Root().ChildAt(5) is not the sixth
top-level element — it is the seventh. Nothing looks broken when you get this wrong: the
entry appears, just in the wrong category.
Worse, the two halves of one path disagree. FindElementParent skips separators walking
down to the parent, but ToolbarEntryLocation's Before/After compute the final
insert index from parent.Children.Count(), which counts them:
// ToolbarEntryLocation.After
int num = index.IsFromEnd ? (parent.Children.Count() - index.Value) : index.Value;
So ChildAt(^1).InsertAfter() stays safe — it appends either way — but a numeric leaf
index lands one slot later for every separator ahead of it. Prefer ^1, and count with
separators excluded for every hop except the last.
Anchoring so it survives updates
Indices are positional, so a game update that inserts a building ahead of your anchor moves your entry. Two habits reduce the pain:
- Anchor at the end of a group with
ChildAt(^1).InsertAfter(). Appending after the last entry of a category is stable even when entries are added ahead of it, because new vanilla entries also land at the end. Both samples do exactly this. - Anchor in the category you belong to, not the one that happens to be adjacent. If the player expects your cutter next to cutters, a shifted index at least lands somewhere sensible.
Shifter ships no "insert into category by id" API. You can write one, though — see below.
Insert by name instead
IToolbarElementLocator only exposes IndexAtLevel and Depth, which is why everything
above is positional. But the interface that actually performs the insertion is handed the
entire tree:
public interface IToolbarEntryInsertLocation
{
void AddEntry(ToolbarData toolbarData, IToolbarElementData elementData);
}
Implement that yourself and you can search, create, or both — none of which a locator can express. A by-name insert is about ten lines:
sealed class InGroup : IToolbarEntryInsertLocation
{
private readonly string TitleId;
public void AddEntry(ToolbarData toolbarData, IToolbarElementData elementData)
{
ParentToolbarElementData group = Find(toolbarData.RootToolbarElement, TitleId);
IParentToolbarElementData target = group ?? (IParentToolbarElementData)toolbarData.RootToolbarElement;
target.InsertAtIndex(elementData, target.Children.Count());
}
}
Match on the title's TranslationId, not its displayed text. Every group carries a
LazyLocalizedText Title with a public Id, which is stable across game updates and
independent of the player's language:
string id = (element as ParentToolbarElementData)?.Title?.Id.Id;
Two traps in those ids:
- They end in
.title. The category isisland-toolbar.category-Rail, but the tree carriesisland-toolbar.category-Rail.title. Omit the suffix and you match nothing, silently. - Casing is inconsistent.
island-toolbar.category-RegularPlatform.titleandisland-toolbar.category-Rail.titleare capitalised;island-toolbar.category-converters.titleis not.
The same hook lets you create a group. CategoryToolbarElementData and
GroupToolbarElementData are plain [Serializable] classes with public fields, and
InsertAtIndex knows how to write Children back on both them and RootToolbarElementData:
CategoryToolbarElementData category = new()
{
Children = new IToolbarElementData[0],
Title = new LazyLocalizedText(new TranslationId("my-mod.toolbar.title")),
Icon = icon
};
Leave MechanicRequiredToUnlock at its default — ToolbarBuilder checks
!MechanicRequiredToUnlock.IsEmpty and substitutes AlwaysCompliantRule when it is empty,
so the category is simply always visible.
Make find-or-create idempotent: AddEntry runs once per registered entry, so a mod
adding five islands to one new group calls it five times and must get the same group each
time. Matching against the live tree also means two different mods asking for the same id
share a group without either knowing about the other.
Finally, a no-op AddEntry gives you something the builder chain otherwise refuses:
.InToolbar(...) is mandatory, so an entity that should exist as a definition without
being individually placeable — the turn pieces of a draggable path family, say — has
nowhere to go. A location that declines to insert is the way out.
Real examples
Building, inserted after the last entry of a cutter group
(DiagonalCutter):
.InToolbar(ToolbarElementLocator.Root().ChildAt(0).ChildAt(2).ChildAt(^1).InsertAfter())
Island, inserted into a sandbox category (SandboxIslands):
.InToolbar(ToolbarElementLocator.Root().ChildAt(5).ChildAt(4).ChildAt(^1).InsertAfter())
Gotchas
InToolbaris only available at one stage of the builder chain — after placement, before simulation. If the method is missing, you are at the wrong stage; see Add a building.- Toolbar entries are gated by research. A correctly placed entry that stays hidden usually means your unlock has not fired — check Add a research unlock.
- For deeper surgery than inserting an entry,
ToolbarInterceptorwithIToolbarDataRewirer/IToolbarModelRewirergives you the whole structure to rewrite (Architecture).