Wheel Entries
Abilities and spirit power share one twelve-sector wheel, and it is the only way either is triggered (artifact abilities are abilities, see Skills that need a key); the two client hotbars - and their separate configuration screens - have been deleted. The framework lives in com.iafenvoy.mxt.screen.wheel, the contents in com.iafenvoy.mxt.screen.wheel.content. The wheel is a main wheel plus pages read from what you carry, strung together by one continuous cell numbering: the main wheel is the twelve cells you arrange (numbers 0..11), the pages behind it (main hand / off hand / artifacts / contract beast) come from your gear and from the Beast Taming Bell in your hand and are numbered from 12 on, the arrow keys turn the pages, and R always opens the main one (see The main wheel, the pages behind it, and cell numbers); the contract orders on that last page go through the same selection and trigger, and right-clicking with the bell opens straight onto it.
The framework does not decide what is on the wheel. WheelMenuProvider answers "what does this source contribute right now" (entries(player, source), where source is a WheelSource; the answer may be longer than one page, and the framework pages it). A provider is registered per source id with WheelMenuContent.register(source, provider), and WheelContent.register() registers one shared instance for all five built-in sources from client setup, so a content mod adds a page of its own instead of replacing the built-in ones (the old single slot, where the last registration won, is gone). WheelMenuEntry is the entry contract:
| Member | Meaning |
|---|---|
WheelEntryKind kind() | Which kind the entry is (the built-in ids are mxt:ability, mxt:aura and mxt:behavior): the tooltip's first line, the pool it appears in, and which side of the server dispatch triggers it. mxt:behavior is a contract beast order (follow / wander / stay / recall) whose id is a code-side ContractBehavior id rather than a registry entry. |
Identifier id() | The entry's identity is that ability's own registry id (ns:path, for example mxt_test:bound_flight), see Skills that need a key. |
Component title() | The name drawn in the middle of the wheel. |
Optional<IconReference> icon() | The icon drawn inside the sector; a texture or an item, and optional (an entry without one draws its name). |
int accentColor() | The strip along the bottom of the cell in the editor (spirit power blue, ability gold; a skill that needs a key takes green, grey or violet by its state). |
List<Component> tooltip(Player) | Kind, name and the numbers the definition carries, with one last line saying where the entry came from (a learned skill / a skill of a named item / another source, see wheel.mxt.tooltip.source.*); rebuilt on demand, because those numbers depend on the player right now. |
long cooldownTicks(Player) / boolean usable(Player) | Ticks left, 0 meaning ready; an unusable entry gets the cooldown sheet over its sector's icon and says "On cooldown 4.3s" in the middle. It deliberately does not stop the trigger from being sent - whether it may be used is the server's call. |
void onSelected(WheelSelection) | The trigger callback (the use key or a left click); the wheel stays open across it, so an implementation may open a screen of its own and may be called again for another cell. The WheelSelection carries the cell's number and the source it was read from. |
public record AbilityWheelEntry(Identifier id, Ability definition) implements WheelMenuEntry {
@Override public WheelEntryKind kind() { return WheelEntryKinds.ABILITY; }
@Override public Component title() { return DefinitionText.name(this.id, "ability"); }
@Override public Optional<IconReference> icon() { return this.definition.icon(); }
@Override public void onSelected(WheelSelection selection) { WheelTrigger.send(this, selection.source()); }
}The main wheel, the pages behind it, and cell numbers
The wheel is a main wheel plus pages read from what you carry, strung together by one continuous cell numbering. The source of a page is api/WheelSource, an interface rather than an enum; the five built-ins are registered first in runtime/wheel/WheelSourceTypes, and page order is registration order, which is also the numbering order:
| Source id | Name | Where its contents come from | Stored? |
|---|---|---|---|
mxt:configured | Main wheel | The twelve cells the player arranged, in the wheel_layout attachment | Yes |
mxt:main_hand | Main hand | The active abilities the main-hand item grants right now, plus the skills that item declares as an artifact | No, read live |
mxt:off_hand | Off hand | The active abilities the off-hand item grants right now, plus the skills it declares | No, read live |
mxt:curios | Artifacts | The active abilities the equipped Curios items grant right now, plus the skills those artifacts declare | No, read live |
mxt:contract | Contract beast | The orders the beast the held Beast Taming Bell is tuned to takes (the bell's mxt:contract_bell component remembers it) | No, read live |
A content mod implements WheelSource and registers its page with WheelSourceTypes#register (the first registration of an id wins; registration happens during mod setup and the registry is only read on the game thread afterwards). Display names still come from the same language keys, wheel.mxt.source.* (a content mod may also answer with a component of its own). A WheelSource answers id(), displayName(), configured() (whether the page is the player's own saved layout), grantSources(entity) (which grant ids the page is made of, empty for a page that does not read the ledger), equipment(entity) (which stacks the page reads carriers from) and offers(entity, kind, id) (whether this source holds that entry right now).
Page order is not a choice: it is registration order.
- Cells are numbered straight through the whole wheel: the main wheel is
0..11, and the cells read from what you carry follow from12on. - A page is twelve cells: each source takes
ceil(entries / 12)pages, and none at all while it holds nothing (the main wheel always takes its one). So "one secondary wheel is not enough, open another": fifteen abilities take two pages - three cells on the second and nine empty ones - and nothing is dropped. - Pages come and go with the gear, so the numbers behind them shift. A number is a place ("which cell"), not an identity - which is exactly what is wanted: putting an item away leaves the number alone, and taking it out again points the same number at the same skill.
- The page list only exists on the client (
screen/wheel/WheelMenuContent#pages, oneWheelPageeach); the server never learns how many pages there are.
A derived source reads the ability grant ledger (the SourceLedger inside AbilityAttachment), which was already counted by source (an equipment slot records mxt:equipment/<slot>/<item>, Curios records mxt:curios_equipment); the way those ids are written lives in runtime/Sources (the one place the framework's own source ids are built), shared by the granting side and the wheel. The arithmetic of reading and deciding lives in runtime/wheel/WheelSources: abilities(entity, source) is everything one source contributes right now (only what needs a key, in id order, never cut short - paging is the client's business); which stacks the page reads carriers from is the source's own equipment(entity) (both hands plus Curios for the main wheel, see Skills that need a key), and whether this source holds an entry right now is its own offers(entity, kind, id) (WheelSources#offers only screens out a null kind and a kind that holds no entry at all). Nothing about a derived source is stored: swap the item or take the artifact off and those pages are gone in the same breath.
R opens the wheel when any cell of any page holds anything, so an empty main wheel cannot hide the pages behind it.
Skills that need a key (Togglable)
The rule is one sentence: everything a player has to press for counts as a skill and goes on the wheel. Any ability type that implements the Togglable interface is such a thing - implementing the interface is the whole of saying "put me on the wheel" (the design is in research/40_能力与法器能力合并设计.md). There are five implementations today, all ordinary ability types: mxt:active (a press casts it), a channel (mxt:channelled: a press starts a channel, paying for itself through the full cast pipeline), a targeted cast (mxt:targeted: a press runs one full cast, marks out a set of targets with target_selector and runs the payload ability's one-target half on each of them), a switch (mxt:flight_control: on takes off, off lands) and a one-shot (mxt:storage: the press opens the artifact's own storage box and leaves nothing behind). The interface leaves three questions to the implementation:
| Method | Who asks it, and what for |
|---|---|
Optional<Boolean> state(ToggleContext) | Whether there is an on/off state at all and which side it is on; empty means a one-shot (the storage is). Both sides ask: the client draws it, the server decides from it. |
boolean gated(ToggleContext) | Whether this press goes through the shared gate first (grant, condition, costs, cooldown). Defaults to true; turning a switch off costs nothing and a cast type (mxt:active / mxt:channelled / mxt:targeted) pays inside its own transaction, so those answer false. |
Result activate(ToggleContext) | The key was pressed, answering with Result(changed, failure, failedResource) - "did anything happen, why not, and which resource ran short". The eighteen Failure values carry the same names and meanings as AbilityService.Failure (six are a press's own: NOT_OWNED / ALREADY_SET / UNAVAILABLE / NO_CARRIER / NO_VEHICLE / CANNOT_MOUNT), and the wheel looks them up in one table, actionbar.mxt.ability.failure.*, for both the action bar and the log. Server only. |
The rules that follow:
- An entry's identity is that ability's own registry id (such as
mxt_test:bound_flight): an active ability granted by a book and a switch given by an artifact are the same kind of cell on the wheel and share one id space (the samemxt:ability). Layout validation asks whether that id resolves right now, so a submitted layout cannot smuggle in an invented cell. - The state belongs to the implementation, not to a data-pack field: flight reads the carrier's own
FlightAttachment(one per entity; the wheel is only open to players), the storage has none. The cell is a report of it - a switch is green while it is on, grey while it is off, a one-shot is violet (AbilityWheelEntry) - with a tooltip that says "State: on / off" when there is a state, and whose last line names where the entry came from: "Learned skill" as soon as onemxt:grant/...source holds it (learning is what survives putting the item down), otherwise "Skill of X" while a carrier can be named (the artifact's own name first, the item's display name when no artifact definition claims it, tinted with the item's quality colour and gold when it has none), and "Other source" when neither holds (wheel.mxt.tooltip.source.learned/.item/.other; the oldwheel.mxt.tooltip.artifactis gone). The cell draws the skill's name rather than the artifact's item: two skills of one artifact would otherwise be two identical sword icons, and that last line is where the artifact is named. - It appears on the page whose equipment declares it (in id order), and in the editor's right-hand pool while
WheelContent#optionstakes it (a learned skill, or a skill a Curios charm declares), which is what lets it be pinned to any main-wheel cell. A skill only a main-hand or off-hand item declares is not in that pool (WheelSources#handOnly: every source holding it is anmxt:equipment/...one), so it stays on that item's own page - a hand is whatever is picked up next. Those twelve cells store ability ids, so a pinned cell reads both hands plus Curios (a backpack is not "on you") - the same stacks a derived page reads; resolving a cell goes throughWheelContent#pool, which counts whoever granted the entry, so a cell pinned before this rule changed still draws and the server still honours it. - Pressing it only says "this cell was pressed": the request carries no direction and no action, and the server asks the implementation (see Triggering). (The optional
enabledonWheelActionC2SPayloadis that "request one named state" form, for screens and scripts; the wheel itself always leaves it empty. The oldFlightToggleC2SPayloadwas merged into it.) - The storage cell opens a vanilla chest menu:
MxtMenus.ARTIFACT_STORAGEregisters a plainChestMenu(with the row count in theIMenuTypeExtensiondata) and the client registers the vanillaContainerScreen, so the window has no menu class and no screen class of its own. Its container isruntime/artifact/ArtifactStorageContainer, a live view that reads the artifact's component and writes the whole contents back on every change; it deliberately holds no stack, so the moment the artifact leaves the playerstillValidis false and the per-tick menu check closes the window. Slot count comes from the definition: rounded up to whole rows of nine, at most six rows = 54 slots, so capacity and window are always the same number.
Where the twelve cells come from
- The player's own layout: the
wheel_layoutplayer attachment (WheelLayoutAttachment) holds one twelve-cellWheelLayout; each cell is aWheelSlot(aWheelEntryKindplus an id, where the kind itself is stored by id) and an empty one uses theWheelSlot.EMPTYsentinel. The same attachment carries anarmedfield - the number of the chosen cell (anint, see Keeping the selection). The attachment syncs to its owner. Only the main wheel is in it; the pages behind it are not stored. - The editor (
WheelConfigurationScreen) edits that layout: six columns of spirit power and six of "abilities", each scrolling on its own (the right pool isWheelContent#options: the skills the body was taught -mxt:grant/...sources, that is a technique, a spirit root, a physique or a realm - plus the skills the artifacts in the Curios slots declare, while a skill only a main-hand or off-hand item declares is left out because it has a page of its own), with one shared row of twelve cells underneath;Escapesaves and closes. Closing sends aWheelLayoutC2SPayload- the whole layout, never a per-cell edit - and re-sends the number (the page list was just replaced). Only the main wheel is edited there: the pages behind it depend on what is carried, so the editor neither shows nor previews them (the screen carries no explanatory text: the twelve cells sit against the divider, and the line under them names each sector's own key as[Z], showing nothing for a slot that has none). Opening it asks nothing of the server - the/wheelclient command, or thekey.mxt.wheel_configurationkey (unbound by default) - so there is no S2C payload and no round trip for it. - The server validates and stores it:
WheelService.sanitizeforces the size to twelve, asks each cell's kind whether that idexists(access, id), normalizes what does not to an empty cell, and writes it back to the attachment. Only the asking player's own attachment is ever written. A cell's kind is stored and read by id, and read leniently: a cell written as"artifact"in an older layout reads asmxt:ability(what the oldARTIFACTbecame when artifacts and abilities were merged), a bare name such asabilitygets themxt:namespace, and an id nobody knows reads as an empty cell - so no player save has to be migrated. - Before anything is saved: what the client reads is twelve empty cells - nothing is filled in (the old default fill, first six spirit powers into cells 0-5 and first six abilities into 6-11, has been removed). What sits on the wheel is entirely the player's choice; the price is that with the whole wheel empty the wheel does not open on
R(there is nothing to choose), so a fresh save starts by putting entries on it with/wheel. - Resolving into entries:
WheelContent.entries(player, source)looks each of a source's entries up in the currently available pool (the main wheel in the available auras and granted abilities, a derived source in the table it just read); an entry that is gone (a revoked grant, a deleted definition, a spirit power the player can no longer pay for) is an empty cell, while the saved layout is left alone so it comes back when the grant does. This is the opposite of what the old hotbar did - it backfilled from the runtime list, which let a player's muscle memory drift without them touching anything.
What a sector draws
Each sector draws its entry's icon() on the ring (an item or a texture, 16px); an entry without one draws its name instead - IconRenderer.fit takes as much of title() as fits and puts it exactly where the icon would have been, so a wheel filled with icon-less entries shows no blank sectors. The label's width is one sector's arc at that radius minus a gap, so two neighbouring names cannot run into each other. The whole name is always in the middle of the wheel and in the tooltip. The ring itself is drawn by an ApricityUI page (assets/apricityui/apricity/mxt/wheel/, shipped inside the mod jar and read straight from the resource pack): an item icon goes through the page's <item>, a texture icon through <texture>, and only an entry with neither falls back to its name.
The twelve cells of the editor work the same way: an icon if there is one, otherwise the start of the name (IconRenderer.renderOrName) - a 22px cell holds two ideographs, and the tooltip has the rest.
Choosing and using are two keys
key.mxt.wheel (R by default) only chooses: hold it and the pointer picks the sector; letting go (mode = HOLD) or pressing it again (TOGGLE) only closes the wheel, and triggers nothing; opening always returns to the main wheel. key.mxt.wheel_use (V by default, new) does the using: while the wheel is up it spends the sector the pointer is on and leaves the wheel standing (so several sectors can be spent in one hold), and while the wheel is down it spends the one remembered from last time. A left click is the same as the use key.
The wheel is current state, and the cell number is the whole of what is remembered (WheelSelectionState holds page plus number): the ring draws the current page, the HUD grid draws the whole wheel, the twelve slot keys act on the current page, and the use key while the wheel is down acts on whatever cell the number stands for now. Switching is key.mxt.wheel_previous / key.mxt.wheel_next (left / right arrow keys by default, rebindable):
It works with the wheel up or down, wrapping at both ends by default (Client Settings → Wheel Menu → Wrap Pages is on by default; turned off, the pages stop at either end instead of jumping), and every turn says "Wheel 2/3: Main hand" on the action bar - the pages behind the main one depend on what is carried, so once the item is put away those pages are gone and without the line you cannot tell which page you are on.
Whether it wraps or parks is the caller's decision:
WheelSelectionState#stepPage(delta, wrap)is the only place a page number changes, andwrapcomes from Client Settings → Wheel Menu → Wrap Pages (on:Math.floorModcomes back around; off:Mth.clampparks inside0..last page). Both page keys and the scroll wheel arrive through the sameWheelMenuController#stepPage.The mouse wheel turns pages while the wheel is open (up for the previous page, down for the next):
WheelMenuScreen#mouseScrolledand the two keys shareWheelMenuController#stepPage(turn, refresh, and that one action-bar line), and the scroll is swallowed only when it was handled. Client Settings → Wheel Menu → Scroll Turns Pages is on by default; turned off, the screen hands the scroll back tosuper, and this path exists only while the wheel is up (with it down there is no "current page" to turn).Turning the page leaves the number alone: a turn is not aiming, so nothing is published either (the number did not change).
With another screen open (chat, inventory, ...) it does nothing, exactly like the other wheel keys.
Two more places say which page it is: a line above the ring ("Wheel 2/3: Main hand", with the two switch keys named at the end), while the HUD grid writes nothing at all.
The current selection is the cell number (
screen/wheel/WheelSelectionState'snumber,-1meaning no cell has ever been settled on): while the wheel is open the pointed cell is written only when it holds something - pointing at an empty cell writes nothing and leaves the choice on the last cell really selected (the old "pointing at nothing is choosing nothing" rule is gone); opening the wheel callsselectDefault(), which writes the first cell that holds anything, so the wheel always has a selection andnumbercan only be-1after login, beforearmedis restored or before this session opens the wheel. It survives the wheel closing, andClientPlayerNetworkEvent.LoggingInclears it so thearmednumber the server remembered can be put back (see Keeping the selection). That state also holds every page as resolved for this client tick (pages(), twelve cells each,nullfor an empty one), because the HUD's grid reads every cell of every page each frame while resolving walks the aura registry and the formulas that decide which auras can burst; it therefore happens once per client tick (the controller refreshes again right after a turn, so the frame and the HUD cannot disagree), so a revoked grant, a deleted definition or a spirit power the player can no longer pay for turns that cell into "no target" within a tick and the use key does nothing.A choice that lands nowhere falls to the first cell holding anything (
WheelMenuContent#effective, with the newfirstHeld):number < 0(nothing selected at all) and a number that lands inside the existing cells but on an empty one (which only the main wheel has) now both stand for the first cell that holds anything; a number that addresses a cell which does not exist (its page is gone, or that page holds fewer cells than it did) keeps the older rule and stands for the last cell holding anything. With nothing in any cell of the wheel all three answerNONE, the one and only case of "no target". The number itself is never rewritten in any branch, so when the pages come back the same number points at the same skill again. Only one place writes the number (selectSector), and it returns as soon as that cell resolves to no entry - a cell that does not exist (the empty tail of a page read from what you carry has no frame at all) and an existing empty one both count as "nothing here", so the choice stays where it was; whether the pointer has a target is the screen's own question (selection(...)answersnullfor an empty cell). The gold frame is drawn on what the number stands for now,Vspends that, and the number that travels is the one that was chosen.These keys and the two switch keys are read raw (
WheelMenuControllerpollsInputConstants/GLFW) rather than throughKeyMapping: anyScreenmakes vanilla callKeyMapping.releaseAll(), and releasing the wheel key while still holding the use key makesMouseHandler#grabMouse()callKeyMapping.setAll(), which sets the use mapping down again from the physical key and would present that as a fresh press - one trigger too many. One edge detector per key is what prevents it. With the wheel closed the use key additionally requires that no screen is open, so typing avin chat cannot cast anything.A key that could do nothing gives one notice, not two: with "the whole wheel is empty" it says "Nothing is on the wheel yet" - the empty branch of
open(...)does not open the wheel, anduse(...)on a wheel with nothing to offer says the same line, both only when there is no target at all. The olduse(...)branch that reminded you to hold the wheel key and point at a cell when nothing had ever been selected (the language keyactionbar.mxt.wheel.no_selection, now deleted from both language files) is gone: opening the wheel settles the first cell that holds anything, so that state no longer exists. Pointing at an empty cell says nothing - that branch lives inusePointed(...)(selection(...)answersnull) and stays silent, and it only concerns the pointer, never the saved choice. It goes through the client action bar (Minecraft#gui#setOverlayMessage) and sends no packet; with another screen open the keys stay silent - that is typing in chat or looking through your inventory, not a request.Every one of the twelve slots has a key (
key.mxt.wheel_slot.1....12, all unbound by default, in a "MiXianTu: Wheel Slots" category of their own):MxtKeyMappings.WHEEL_SLOTSis filled by aforloop in the static block, indexibeing celliof a page while the number isi + 1(the editor numbers the cells from1straight up, clockwise). The order inside the category comes from the key'sorder: the vanilla key list sorts withArrays.sort→KeyMapping#compareTo, which comparesorderfirst and only falls back to the translated label, so without one the list reads1, 10, 11, 12, 2, ...; each slot key is given its sector number as that order (KeyMappingHoldergained an overload forwarding to the vanilla five-argument constructor), which keeps the numbers unpadded and the order fixed in every language. Pressing a slot key selects that cell of the current page and spends it right away (the same as pointing at it and pressingV):useSlotKey(...)works out the number (page * 12 + sector), takes that cell, callsWheelSelectionState.selectSector(sector)so the HUD highlight follows and the number persists across sessions, and thenentry.onSelected(...)sends the trigger; a cell with nothing in it does nothing. The behaviour lives inWheelMenuController's raw polling rather than in anonStateChangecallback:setAll()would invent a press, which for a cast means casting something nobody asked for, and raw polling also lets a slot key work while the wheel is open.The
release_to_selectclient setting is gone (letting go no longer picks anything);mode(hold / toggle) stays.WheelSelection.Methodwent fromRELEASE/CLICKtoKEY/CLICKand now only says whether the request came from the keyboard or the mouse.The middle of the wheel says "Press
Vto use" while the entry can be used (naming whatever key is actually bound) and "On cooldown 4.3s" while it cannot - the remaining time is read from the tick the cooldown ends on (the synced attachment has it) and written byWheelDuration.secondsas seconds with one decimal, always, the same way the cooldown and cast-time lines in a tooltip are written.A movable HUD element, the Wheel Grid (
screen/wheel/WheelSelectionEntry, layout keywheel.selection), is always four columns wide and as many rows as its cells need, growing downward: it is a view of the whole wheel. Only the main wheel draws empty cells (those twelve frames are the layout the player arranged), while a page read from what is carried draws only the entries it really contributes - three skills are three cells, with no run of empty frames after them - so a page boundary is no longer necessarily a row boundary. Each cell draws the icon or the start of the name with a strip of the kind's colour along the bottom, and an empty cell is just an empty frame; a cell on cooldown carries the vanilla item-cooldown sheet - a 50% white sheet over the remaining fraction of the icon box, draining downward as the wait runs out (the remainder comes from the entry'scooldownTicksand the whole length from itscooldownLength: an ability's last actually-grantedmxt:cooldownlength, an aura's fixed fire interval; an entry that cannot name the length gets the whole box covered) - while a cell that is unusable for any other reason is washed over. The cell the chosen number stands for right now is outlined in gold, the same gold the editor uses for a picked option (the pointed sector on the ring is a darker shade of grey rather than a gold frame). Its size is computed from its contents every frame (layoutWidth/layoutHeightare dynamic andrefreshPlacementreports the change withsetSize, so a block that grew past the window is pulled back in), it starts at the left of the window, halfway down, and it writes nothing above itself: the ring is what says which page is up. Without it, using the key while the wheel is closed would be a blind cast - and it also shows at a glance what every cell holds.
Keeping the selection
What the server remembers is one cell number, never an entry: cells are numbered straight through the wheel and pages come and go with the gear, so a number is a place - it does not expire when its pages do, and when they come back the same number points at the same skill. It lives in the armed field of the same wheel_layout attachment (Optional<Integer>, empty meaning nothing was ever chosen), and screen/wheel/content/WheelSelectionSync owns both directions:
- Restoring, once per login: the client reads the synced attachment and writes the number into
WheelSelectionState(restore) - it resolves nothing, checks nothing and warns about nothing, because "this place holds nothing at the moment" is a legitimate state - which cell the number stands for is worked out by the fallback rule above (see Choosing and using are two keys), and it always lands on a cell that holds something. The attachment is sent after the player joins, and "not here yet" looks exactly like "this player has never chosen anything" for the first ticks, which is why the attachment is consulted each tick until it arrives; once the session has a choice of its own it is never consulted again. - Publishing, on change only: a
WheelSelectionC2SPayload(Optional<Integer>) goes out when the number changes - not while the wheel is down, not when a page is turned, and not while the pointer stays inside one cell. Saving a layout re-sends it by force, because the page list was just replaced. - The server only checks the range:
WheelService.sanitizeArmedaccepts0..MAX_ARMEDand treats anything else as "nothing chosen", resolving nothing and warning about nothing - it cannot tell whether that cell holds anything, and should not try (that depends on what the player is carrying). The attachment syncs to its owner, so what the client restores from is the number the server accepted.
Triggering
There is exactly one channel: WheelActionC2SPayload(source, kind, id, enabled) - which source, which kind, which id, resolved by the client from the cell it stood on at that moment, plus an optional enabled. The source and the kind travel as Identifiers now, and the server resolves them with WheelSourceTypes.byId / WheelEntryKinds.byId: an unknown id is simply refused as "that entry is no longer on the wheel" (the same actionbar.mxt.wheel.stale_entry line on the action bar) rather than failing the packet, which is what lets a content mod add a page or a kind without a protocol version of its own. WheelService.trigger first demands that the named source still holds the entry (the source's own offers(...): the saved layout for the main wheel, the current grants and the stacks that page reads for a derived source) and drops the whole request when it does not, and only then hands the press to kind.trigger(player, source, id) - the three built-in implementations stay in WheelService (press / burst / order) and the mxt:ability / mxt:aura / mxt:behavior kinds forward to them, so a kind a content mod registered is served by its own code instead of by a branch here. The directed form goes through kind.directed(player, source, id, wanted) (false by default; only a switch-like kind implements it), which is the path taken when the request carries an enabled value, because such a request names one entry by id and has no cell behind it:
ABILITY(mxt:ability) →runtime/ability/AbilityActivationService, the server's one entry point for a press:mxt:activebecomes a cast there (re-checking the grant, the conditions, the costs, the cooldown and the cast time),mxt:targetedruns one full cast as well (picking targets first and then running the payload on each, withNO_TARGET/NOT_APPLICABLEwhen it reaches nobody), whilemxt:flight_controlandmxt:storageeach do their own thing (take off or land, open the box). A refusal carries the real reason the implementation gave: the eighteenTogglable.Failurevalues share their names withAbilityService.Failure(conditions unmet, roots do not match its element, not enough of a resource, no charges left, still on cooldown, misconfigured numbers, no carrier, nothing in hand can fly, no target matches, the ability it names cannot act on a target, cannot mount, ...), naming the resource when one ran short; the wheel looks each name up in one table,actionbar.mxt.ability.failure.*, and reports it on the action bar and in the log ("Use failed: reason" for a press, "Cast failed: reason" for an ability that is not pressable, and "That entry is no longer on the wheel" when the id no longer resolves or the source has let the entry go);BEHAVIOR(mxt:behavior) →runtime/creature/ContractBehaviorService, the server's one entry point for an order: it re-reads the beast off the bell and then walks "bound → owner → implementsContractOperations→ the order is on that creature'sbehaviors()list → the creature'sonBehaviorSelected→ written to the record (a recall goes throughContractService.requestRecallinstead)", reporting any refusal through the contract text table and announcing a success as "Ordered the beast: the order" on the action bar.AURA(mxt:aura) →SpiritBurstService.fireOnce, which re-checks the element, the use condition, the cooldown and the stored value before firing oneSpiritBurstEntity.
WheelEntryKind is an interface now too (in api/), and its four built-in kinds are registered first in runtime/wheel/WheelEntryKinds: mxt:empty (the kind that holds no entry at all, which is how an empty cell is spelled), mxt:ability, mxt:aura and mxt:behavior. A content mod implements WheelEntryKind and registers its kind with WheelEntryKinds#register (the first registration of an id wins); it answers id(), displayName(), holdsEntry(), exists(access, id) (whether an id still names something a cell may keep) and trigger(player, source, id), with directed(player, source, id, wanted) defaulting to false (only a stateful kind such as a switch implements it). The old ARTIFACT is gone, and a cell written as "artifact" in an old layout is read as mxt:ability by the codec - so no player save has to be migrated. A behaviour cell only ever appears on the mxt:contract page (it is not part of the main wheel's editable pool), so nothing the editor saves ever contains mxt:behavior; a hand-edited cell that does simply resolves to an empty one.
Two limits are deliberate: page order is not a choice - it is registration order, and the five built-ins are registered first, so the main wheel is always the first page; and the entries of a kind a content mod registers do not appear yet in the configuration screen's two pools (the six spirit-power columns and the six ability columns).
The main wheel goes through that check too: the layout is submitted by the client anyway, so this is not an anti-cheat measure but what makes "this entry really comes from the source you named" true of every source - clear a cell in the editor while still holding V and that request is refused. The number plays no part in a trigger: it only answers "which cell".
There is no cancel. Using triggers once, so there is no release gesture left to cancel a pending cast or stop a burst with - letting go of the wheel key only closes the wheel - and a channelled ability can no longer be interrupted by its caster.
On the data-pack side, mxt:active has no slot field (removed 2026-09-25) - where an entry sits is the player's twelve-cell layout and was never part of the skill's definition; an old pack writing "slot": "..." is silently ignored (as of 2026-09-27, the same rule as an unknown key; the "known key this type never reads" case), so delete the line.