Concept: Slot Bindings

An authored radial option and a player-bound one look identical on screen and are built completely differently. This page explains why, because almost every question about the bindable wheel is really a question about that difference.

Where the types live
The wheel owns its whole binding model, in CrimsonUI under Radial/: FCrimsonRadialBinding, FCrimsonRadialSlotDefinition, the store UCrimsonRadialBindingComponent, the provider base UCrimsonRadialBindingProvider and UCrimsonRadialBindingProvider_Tag, UCrimsonRadialBindingLibrary (maps a provider's FCrimsonRadialBindingDisplay + FCrimsonRadialBindingLiveState onto a FCrimsonRadialSlice), the picker, and UCrimsonRadialBindingDragDropOperation. It shares no types with any other plugin. (CrimsonCore - requires all Crimson plugins) UCrimsonCoreRadialBindingProvider_SlotBinding wraps action-bar providers so abilities, items and equipment can be bound to a wheel with the same identity as on the action bar.

Why a binding is not a slice

An authored option is a FCrimsonRadialSlice. It carries its own visuals and an Instanced UCrimsonRadialAction saying what choosing it does. That is exactly right for a designer typing options into an asset: one array element is one complete, self-contained option.

It is useless for a player's choice, for one reason: an Instanced UObject cannot round-trip through a save file. Whatever the player picked has to come back next session, and an object authored inline on an asset cannot.

So the two are separated. What the player picks is stored as a small, plain FCrimsonRadialBinding. The live slice is rebuilt from it every time the wheel opens and never stored at all.

text
FCrimsonRadialBinding { ProviderId, Data }
| stored in the save
v
the provider resolves it
|
v
FCrimsonRadialSlice { icon, name, count, cooldown, action }
rebuilt on open, never stored

A binding is two fields. ProviderId is a gameplay tag naming who understands it. Data is an FInstancedStruct that provider defined - an ability's trigger tag, an item definition, an equipment slot. Nothing in CrimsonUI ever looks inside `Data`, which is what lets the wheel carry things this plugin has never heard of.

An invalid ProviderId is the empty slot
There is no separate 'is empty' flag and no sparse array. A slot with no ProviderId is empty, and the layout array is always dense and sized to the page.

The four questions a provider answers

A provider is the only thing in the system that knows what a category of bindable thing actually is. It answers four questions and nothing else needs to know the answers.

VerbCalled whenAnswers
GatherBindableEntriesThe picker opensWhat may the player choose right now?
ResolveBindingThe wheel opensWhat does this saved binding look like today - and does it still exist?
QueryBindingState10 Hz while openStack count, cooldown, availability.
ExecuteBindingThe player confirmsDo the thing.

That split mirrors UCrimsonRadialAction's GetDisplayContent / QueryState / Execute deliberately. The extra verb is Gather, which is the one thing an editable wheel needs and an authored one does not: somebody has to enumerate the options before a player can pick from them.

The expensive work - loading an icon, formatting a name - belongs in ResolveBinding, which runs once per slot per open. QueryBindingState runs ten times a second, so it should only touch things that actually change.

You may only need two of them
ResolveBinding defaults to gathering and searching for a match, so a provider with a small catalog implements GatherBindableEntries and ExecuteBinding and stops. Override it once your catalog is big enough that a linear scan per slot per open starts to matter.

One action object for the whole wheel

Resolved slices still need an Action, because that is how UCrimsonRadialMenuWidget runs things. A single UCrimsonRadialAction_Binding is created per menu and assigned to every bound slice.

It works because it holds no per-slice state. It reads the binding back out of Slice.Payload - which already existed to 'carry an item handle without CrimsonUI knowing the type' - and routes to the right provider by ProviderId. Eight bound slices share one object instead of allocating an action apiece on every open.

A provider that wants its own behavior per entry can still set OutSlice.Action itself during ResolveBinding; the shared action is only installed where the provider left it empty.


Stale bindings

The item was sold. The ability was unlearned. The asset was deleted. ResolveBinding returns false and the layout's ECrimsonStaleBindingPolicy decides what the slot does.

PolicyResultCost
KeepDisabled (default)Grayed out with a reason, still in place.A dead icon the player has to clear themselves.
ClearBack to a placeholder.The arrangement changed without the player asking.
HideDropped from the wheel entirely.Changes the slice count, so every remaining slice moves.

The default keeps the binding, because a wheel's whole value is muscle memory - the player learns that healing is at twelve o'clock and stops reading it. A layout that quietly rearranges itself between sessions destroys exactly the thing that made it worth having.

Hide is more disruptive than it sounds
Slice angles are derived from the slice count. Hiding one slot rotates and resizes every other slot on the wheel.

Note what is not stale: an item the player has none of, and an equipment slot that is currently empty. The player will restock and re-equip, so those resolve normally and are grayed out by QueryBindingState instead.

Resolution itself never writes to the store. Clear is applied by the menu widget afterwards, because resolution is a pure read that a HUD bar might also be running - and a second view of the same layout must not silently rewrite the player's save.


Where the bindings live, and who may change them

UCrimsonRadialBindingComponent is server-authoritative. The layout container replicates COND_OwnerOnly - nobody but the owning client has any use for another player's wheel, and sending it would leak what they have equipped. Clients call the same functions and are routed through Server_ RPCs; the owning client rebuilds from PostReplicatedReceive, once per replication batch rather than once per changed slot.

There is deliberately no client-side prediction. Every reason a write can be rejected - bad index, locked slot, filter mismatch - is checkable on the client before it sends, so an optimistic local apply would have nothing to roll back on the one path that can actually fail. One round trip on a menu edit is the correct trade.

Validation here is a formality
Binding a slot on your own wheel grants no power, and using the binding still goes through the normal authoritative path. The _Validate functions exist to drop malformed indices, not to stop an exploit.

Pages are just more layouts

Monster Hunter World ships four radial sets of eight slots, cycled while the wheel is open. That needs no new concept here: each page in a UCrimsonRadialLayoutDefinition carries its own LayoutId, so the store stays a flat map from layout tag to bindings and turning a page is reading a different tag.

A single-page wheel is one array entry. A carry does not survive a page turn - the slot it came from is no longer on screen, so there would be nowhere to put it back.


See also

  • How-To: Build a Player-Editable Wheel - the recipe
  • Concept: Radial Selection & Slice State - how the wheel itself picks a slice
  • API Reference -> Slot bindings