fix(ui): restore retail magic shortcut bar

Port the retail horizontal ItemList empty-slot padding and share UIItem shortcut-number graphics with the status toolbar. Preserve all authored face children on compound DAT buttons so the three-piece Cast control reflows and renders as one complete button.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-15 19:11:07 +02:00
parent 0527325b25
commit e3605672bb
12 changed files with 466 additions and 62 deletions

View file

@ -531,7 +531,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Steps 05 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The favorite bar now ports retail horizontal empty-cell padding, shared UIItem 19 shortcut overlays, and the Cast button's three independently reflowed DAT face segments. Spell rows resolve UIItem prototype `0x10000343`; the component page now instantiates LayoutDesc `0x21000033` category/component templates `0x10000466/67`, including localized headings, exact row textures and columns, owned/desired counts, selection, and the 0..5000 desired-value contract. The authored scrollbars, exact filters/display ordering, learned-spell drag, and shared server-authoritative delete dialog are connected. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.

View file

@ -165,6 +165,43 @@ Selecting a spell updates its name and current spell. Cast invokes
retail spell input actions change tab/selection; the cast-current action casts
the current selection. Favorite edits are sent to ACE.
The visible row is still an ordinary `UIElement_ItemList`; it is not a
spell-specific strip painted by `gmSpellcastingUI`:
```text
UIElement_ItemList::UpdateEmptySlots @ 0x004E3700
if list is visible and FixedSlots (0x10000015) == -1:
read row/column constraints 0x5F and 0x60
when the horizontal constraint is unbounded:
occupiedWidth = itemCount * cellWidth
append floor((listWidth - occupiedWidth) / cellWidth) empty UIItems
when the list shrinks, remove only trailing UIItems in ItemSlot_Empty state
never remove an occupied trailing item merely because it exceeds the viewport
SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0
for each UIItem in list order:
if index < 9: SetShortcutNum(item, index, ghosted=false)
else: SetShortcutNum(item, -1, ghosted=false)
UIElement_UIItem::SetShortcutNum @ 0x004E1590
occupied spell shortcut -> regular digit array 0x10000042
empty padding cell -> empty digit array 0x1000005E
```
Thus the first nine visible cells use the same blue 19 UIItem overlays as the
main status toolbar, including empty cells, while later cells retain only the
authored `ItemSlot_Empty` background. The count is based on the reflowed list
width (639 pixels at the 800-pixel design parent, 439 pixels inside the live
600-pixel combat page, yielding 13 whole 32-pixel cells).
The Cast button is likewise fully DAT-authored rather than a synthesized red
rectangle. Element `0x100000B2` is 75 by 32 pixels and its face is three stateful
children authored against a 79-pixel design parent: left `0..31`, middle
`32..46`, and right `47..78`. Apply each child's independent retail edge policy
after the button reflows; at 75 pixels they become `0..31`, `32..42`, and
`43..74`. Collapsing child-authored button media to the first child renders only
the left cap and is not retail behavior.
Outbound character events (`CM_Character`):
```text

View file

@ -552,8 +552,12 @@ public static class DatWidgetFactory
// Spellbook school/level filters are Type-1 buttons with no parent media:
// their 13x13 child carries Normal/Highlight art. Keep that child as the
// face of this retained leaf instead of consuming and losing it.
ElementInfo? face = info.StateMedia.Count == 0
? FindStatefulFaceChild(info)
ElementInfo[] authoredFaces = info.StateMedia.Count == 0
? FindStatefulFaceChildren(info)
: [];
ElementInfo? face = authoredFaces.Length == 1 ? authoredFaces[0] : null;
IReadOnlyList<ElementInfo>? faceSegments = authoredFaces.Length > 1
? authoredFaces
: null;
string? label = ResolveAuthoredString(info, stringResolve);
@ -576,7 +580,7 @@ public static class DatWidgetFactory
if (labelInfo.FontDid != 0u && fontResolve is not null)
labelFont = fontResolve(labelInfo.FontDid) ?? elementFont;
var button = new UiButton(info, resolve, face)
var button = new UiButton(info, resolve, face, faceSegments)
{
Label = label,
LabelFont = labelFont,
@ -638,11 +642,16 @@ public static class DatWidgetFactory
}
private static ElementInfo? FindStatefulFaceChild(ElementInfo info)
=> info.Children.FirstOrDefault(child =>
=> FindStatefulFaceChildren(info).FirstOrDefault();
private static ElementInfo[] FindStatefulFaceChildren(ElementInfo info)
=> info.Children.Where(child =>
child.StateMedia.Count != 0
&& child.StateMedia.Keys.Any(childState =>
info.States.Values.Any(parentState =>
string.Equals(parentState.Name, childState, StringComparison.Ordinal))));
string.Equals(parentState.Name, childState, StringComparison.Ordinal))))
.OrderBy(child => child.ReadOrder)
.ToArray();
private static string? ResolveAuthoredString(
ElementInfo info,

View file

@ -45,6 +45,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly Action<uint> _useItem;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiShortcutSlotGraphics? _shortcutGraphics;
private readonly UiElement[] _tabs;
private readonly UiElement[] _groups;
private readonly UiItemList?[] _lists;
@ -77,7 +78,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiElement[] groups,
UiItemList?[] lists,
UiButton cast,
UiElement endowmentHost)
UiElement endowmentHost,
UiShortcutSlotGraphics? shortcutGraphics)
{
_spellbook = spellbook;
_casting = casting;
@ -89,6 +91,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_useItem = useItem;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_shortcutGraphics = shortcutGraphics;
_tabs = tabs;
_groups = groups;
_lists = lists;
@ -139,7 +142,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Action<uint> useItem,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite)
Action<int, uint>? removeFavorite,
UiShortcutSlotGraphics? shortcutGraphics = null)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
@ -162,7 +166,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
resolveItemDragIcon, useItem, selection,
addFavorite, removeFavorite,
tabs, groups, lists, cast, endowmentHost);
tabs, groups, lists, cast, endowmentHost, shortcutGraphics);
}
public void AddFavorite(uint spellId)
@ -278,14 +282,17 @@ public sealed class SpellcastingUiController : IRetainedPanelController
list.CatalogDropped = (payload, x, _) =>
{
if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
DropSpellbookShortcut(
shortcut, targetTab, DropPosition(list, x, favorites.Count));
};
using (list.DeferLayout())
{
list.Flush();
list.Columns = 9;
list.SingleRow = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.EmptySlotFactory = () => CreateEmptyFavoriteSlot(list, targetTab);
list.FillVisibleEmptySlots = true;
foreach (uint spellId in favorites)
{
uint id = spellId;
@ -313,10 +320,59 @@ public sealed class SpellcastingUiController : IRetainedPanelController
list.AddItem(slot);
}
}
StampShortcutOverlays(list);
}
SelectTab(_activeTab);
}
private UiCatalogSlot CreateEmptyFavoriteSlot(UiItemList list, int targetTab)
{
int shortcutIndex = list.GetNumUIItems();
UiCatalogSlot? slot = null;
slot = new UiCatalogSlot
{
SpriteResolve = list.SpriteResolve,
EmptySprite = _shortcutGraphics is { EmptySprite: > 0u } graphics
? graphics.EmptySprite
: 0x060074CFu,
Dropped = payload =>
{
int position = Math.Max(0, list.IndexOf(slot!));
int favoriteCount = _spellbook.GetFavorites(targetTab).Count;
position = Math.Min(position, favoriteCount);
if (payload is SpellFavoriteDragPayload favorite)
DropFavorite(favorite, targetTab, position);
else if (payload is SpellbookShortcutDragPayload shortcut)
DropSpellbookShortcut(shortcut, targetTab, position);
},
};
ConfigureShortcutOverlay(slot, shortcutIndex);
return slot;
}
private void StampShortcutOverlays(UiItemList list)
{
for (int i = 0; i < list.GetNumUIItems(); i++)
{
UiItemSlot? slot = list.GetItem(i);
if (slot is null) continue;
ConfigureShortcutOverlay(slot, i);
}
}
private void ConfigureShortcutOverlay(UiItemSlot slot, int index)
{
slot.RegularDigits = _shortcutGraphics?.RegularDigits;
slot.GhostedDigits = _shortcutGraphics?.GhostedDigits;
slot.EmptyDigits = _shortcutGraphics?.EmptyDigits;
if (_shortcutGraphics is { EmptySprite: > 0u } graphics)
slot.EmptySprite = graphics.EmptySprite;
if (index < 9)
slot.SetShortcutNum(index, ghosted: false);
else
slot.ClearShortcutNum();
}
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
@ -343,12 +399,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_selected[targetTab] = payload.SpellId;
}
private static int DropPosition(UiItemList list, int localX)
private static int DropPosition(UiItemList list, int localX, int favoriteCount)
{
int position = list.CellWidth <= 0f
? list.GetNumUIItems()
? favoriteCount
: (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
return Math.Clamp(position, 0, list.GetNumUIItems());
return Math.Clamp(position, 0, favoriteCount);
}
private void OnSpellbookChanged() => _favoritesDirty = true;
@ -381,9 +437,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
for (int i = 0; i < list.GetNumUIItems(); i++)
if (list.GetItem(i) is UiCatalogSlot slot)
slot.Selected = slot.EntryId == _selected[tab];
}
_endowmentSlot.Selected = _endowmentItemId != 0u
&& _endowmentSelected[_activeTab];
}
}
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();

View file

@ -167,6 +167,7 @@ public sealed class RetailUiRuntime : IDisposable
private GameplayConfirmationController? _gameplayConfirmationController;
private RetailItemConfirmationController? _itemConfirmationController;
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
private UiShortcutSlotGraphics? _shortcutSlotGraphics;
private bool _disposed;
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
@ -558,11 +559,13 @@ public sealed class RetailUiRuntime : IDisposable
return;
}
var (regular, ghosted, empty) = LoadToolbarDigits();
UiShortcutSlotGraphics shortcutGraphics = LoadShortcutSlotGraphics();
ToolbarRuntimeBindings b = _bindings.Toolbar;
ToolbarController = Layout.ToolbarController.Bind(
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
regular, ghosted, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
shortcutGraphics.RegularDigits, shortcutGraphics.GhostedDigits,
shortcutGraphics.EmptyDigits, b.ItemInteraction,
b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
@ -671,7 +674,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.UseItem,
_bindings.Magic.Selection,
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite);
_bindings.Magic.RemoveFavorite,
LoadShortcutSlotGraphics());
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
@ -1012,9 +1016,13 @@ public sealed class RetailUiRuntime : IDisposable
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
}
private (uint[] Regular, uint[] Ghosted, uint[]? Empty) LoadToolbarDigits()
private UiShortcutSlotGraphics LoadShortcutSlotGraphics()
{
if (_shortcutSlotGraphics is not null)
return _shortcutSlotGraphics;
uint[]? regular = null, ghosted = null, empty = null;
uint emptySprite = 0u;
lock (_bindings.Assets.DatLock)
{
var layout = _bindings.Assets.Dats.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
@ -1031,6 +1039,10 @@ public sealed class RetailUiRuntime : IDisposable
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyNumber)
&& emptyNumber.StateDesc?.Properties is { } emptyProps)
empty = ReadDataIds(emptyProps, 0x1000005Eu);
emptySprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
CombatUiController.LayoutId,
SpellcastingUiController.FavoriteListId);
}
regular ??=
@ -1043,8 +1055,13 @@ public sealed class RetailUiRuntime : IDisposable
0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u,
];
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: regular={regular.Length}, ghosted={ghosted.Length}, empty={empty?.Length ?? 0} entries.");
return (regular, ghosted, empty);
_shortcutSlotGraphics = new UiShortcutSlotGraphics(
regular, ghosted, empty, emptySprite);
Console.WriteLine(
$"[D.5.1] shared shortcut graphics ready: regular={regular.Length}, " +
$"ghosted={ghosted.Length}, empty={empty?.Length ?? 0}, " +
$"slot=0x{emptySprite:X8}.");
return _shortcutSlotGraphics;
}
private static uint[]? ReadDataIds(

View file

@ -34,6 +34,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
{
private readonly ElementInfo _info;
private readonly ElementInfo _mediaInfo;
private readonly FaceSegment[] _faceSegments;
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
private readonly HashSet<uint> _availableStates = new();
private bool _pressed;
@ -175,12 +176,12 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (stateId == UiStateInfo.DirectStateId)
{
if (!_mediaInfo.States.ContainsKey(stateId) && !_mediaInfo.StateMedia.ContainsKey(""))
if (!TryFindState(stateId, out _) && !HasStateMedia(""))
return false;
ActiveState = "";
return true;
}
if (_mediaInfo.States.TryGetValue(stateId, out var state))
if (TryFindState(stateId, out var state))
{
ActiveState = state.Name;
return true;
@ -188,7 +189,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
string stateName = UiButtonStateMachine.StateName(stateId);
if (string.IsNullOrEmpty(stateName))
stateName = RetailUiStateIds.StateName(stateId);
if (!string.IsNullOrEmpty(stateName) && _mediaInfo.StateMedia.ContainsKey(stateName))
if (!string.IsNullOrEmpty(stateName) && HasStateMedia(stateName))
{
ActiveState = stateName;
return true;
@ -202,10 +203,14 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
public UiButton(
ElementInfo info,
Func<uint, (uint tex, int w, int h)> resolve,
ElementInfo? mediaInfo = null)
ElementInfo? mediaInfo = null,
IReadOnlyList<ElementInfo>? faceSegments = null)
{
_info = info;
_info = info;
_mediaInfo = mediaInfo ?? info;
_faceSegments = faceSegments is null
? []
: faceSegments.Select(static segment => new FaceSegment(segment)).ToArray();
_resolve = resolve;
ClickThrough = false; // buttons are interactive — opt OUT of click-through
@ -213,9 +218,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// Retail layouts commonly declare an empty Normal_pressed descriptor while
// supplying art only for Normal/Highlight. Treating that property-only state
// as drawable briefly blanks the button during mouse-down.
foreach (string stateName in _mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
if (_faceSegments.Length == 0)
AddAvailableStates(_mediaInfo);
else
foreach (FaceSegment segment in _faceSegments)
AddAvailableStates(segment.Info);
ToggleBehavior = info.TryGetEffectiveBool(0x0Bu, out bool toggle) && toggle;
RolloverEnabled = info.TryGetEffectiveBool(0x13u, out bool rollover) && rollover;
@ -233,7 +240,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
// DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState ("").
if (!string.IsNullOrEmpty(info.DefaultStateName))
ActiveState = info.DefaultStateName;
else if (_mediaInfo.StateMedia.ContainsKey("Normal"))
else if (HasStateMedia("Normal"))
ActiveState = "Normal";
// else ActiveState stays "" (DirectState)
@ -258,24 +265,32 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// Returns 0 if neither exists.
/// Mirrors <see cref="UiDatElement.ActiveMedia()"/>.
/// </summary>
private uint ActiveFile()
=> _mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: _mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
private uint ActiveFile(ElementInfo mediaInfo)
=> mediaInfo.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
: mediaInfo.StateMedia.TryGetValue("", out var d) ? d.File : 0u;
protected override void OnDraw(UiRenderContext ctx)
{
uint file = ActiveFile();
if (file != 0)
if (_faceSegments.Length != 0)
{
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
foreach (FaceSegment segment in _faceSegments)
DrawFace(ctx, ActiveFile(segment.Info), segment.Rect(Width, Height));
}
else
{
uint file = ActiveFile(_mediaInfo);
if (file != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
var (tex, tw, th) = _resolve(file);
if (tex != 0 && tw != 0 && th != 0)
{
// Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped
// UI texture). Matches ImgTex::TileCSI; no Stretch mode exists.
float faceWidth = FaceWidth > 0f ? FaceWidth : Width;
float faceHeight = FaceHeight > 0f ? FaceHeight : Height;
ctx.DrawSprite(tex, FaceLeft, FaceTop, faceWidth, faceHeight,
0, 0, faceWidth / tw, faceHeight / th, Vector4.One);
}
}
}
@ -302,6 +317,79 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
private void DrawFace(UiRenderContext ctx, uint file, UiPixelRect rect)
{
if (file == 0 || rect.Width <= 0 || rect.Height <= 0)
return;
var (texture, textureWidth, textureHeight) = _resolve(file);
if (texture == 0 || textureWidth == 0 || textureHeight == 0)
return;
// Same tiled/cropped media path as UiDatElement. Segment geometry is
// first reflowed by its own four-edge retail layout policy.
ctx.DrawSprite(texture, rect.X0, rect.Y0, rect.Width, rect.Height,
0f, 0f, (float)rect.Width / textureWidth, (float)rect.Height / textureHeight,
Vector4.One);
}
private void AddAvailableStates(ElementInfo mediaInfo)
{
foreach (string stateName in mediaInfo.StateMedia.Keys)
if (UiButtonStateMachine.TryStateId(stateName, out uint stateId))
_availableStates.Add(stateId);
}
private bool HasStateMedia(string stateName)
{
if (_faceSegments.Length == 0)
return _mediaInfo.StateMedia.ContainsKey(stateName);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.StateMedia.ContainsKey(stateName))
return true;
return false;
}
private bool TryFindState(uint stateId, out UiStateInfo state)
{
if (_faceSegments.Length == 0)
return _mediaInfo.States.TryGetValue(stateId, out state!);
foreach (FaceSegment segment in _faceSegments)
if (segment.Info.States.TryGetValue(stateId, out state!))
return true;
state = null!;
return false;
}
private sealed class FaceSegment
{
private readonly UiPixelRect _original;
private readonly UiLayoutPolicy? _layout;
public FaceSegment(ElementInfo info)
{
Info = info;
_original = UiPixelRect.FromPositionAndSize(
(int)info.X, (int)info.Y, (int)info.Width, (int)info.Height);
if (info.HasOriginalParentSize)
_layout = new UiLayoutPolicy(
info.Left, info.Top, info.Right, info.Bottom,
_original,
UiPixelRect.FromPositionAndSize(
0, 0, (int)info.OriginalParentWidth, (int)info.OriginalParentHeight));
}
public ElementInfo Info { get; }
public UiPixelRect Rect(float parentWidth, float parentHeight)
{
if (_layout is null)
return _original;
return _layout.Apply(
_original,
UiPixelRect.FromPositionAndSize(0, 0, (int)parentWidth, (int)parentHeight));
}
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
@ -393,6 +481,11 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
UpdateVisualState();
}
internal int FaceSegmentCount => _faceSegments.Length;
internal IReadOnlyList<UiPixelRect> FaceSegmentRectsForTest()
=> _faceSegments.Select(segment => segment.Rect(Width, Height)).ToArray();
public void OnGlobalUiTime(double nowSeconds)
{
if (!_hotClicking || !HotClickEnabled)

View file

@ -36,6 +36,9 @@ public sealed class UiCatalogSlot : UiItemSlot
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
protected override bool IsShortcutOccupied => EntryId != 0u;
public override bool IsEmptySlot => EntryId == 0u;
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
@ -102,6 +105,11 @@ public sealed class UiCatalogSlot : UiItemSlot
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
// Spell favorites are UIElement_UIItems too. Retail's
// SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same
// SetShortcutNum path used by physical toolbar shortcuts.
DrawShortcutOverlay(ctx);
if (!SelectionBehindContent)
DrawSelection(ctx);

View file

@ -82,6 +82,10 @@ public sealed class UiItemList : UiElement
public int GetNumUIItems() => _cells.Count;
/// <summary>Returns this cell's current retained-list position, matching
/// UIElement_ListBox::WhatNum. -1 means the cell is not owned by this list.</summary>
public int IndexOf(UiItemSlot cell) => _cells.IndexOf(cell);
public UiItemSlot? GetItem(int index)
=> index >= 0 && index < _cells.Count ? _cells[index] : null;
@ -128,6 +132,24 @@ public sealed class UiItemList : UiElement
/// <summary>Fixed cell height in grid mode (pairs with CellWidth).</summary>
public float CellHeight { get; set; }
/// <summary>
/// Lay cells left-to-right in one row. Retail favorite spell lists use the
/// horizontal UpdateEmptySlots branch (row-count attribute 0x5F is -1), so
/// the number of visible cells is determined by width rather than the nine
/// shortcut-number overlays.
/// </summary>
public bool SingleRow { get; set; }
/// <summary>
/// Maintain a tail of empty UIItems that fills the visible horizontal extent.
/// Port of UIElement_ItemList::UpdateEmptySlots @ 0x004E3700.
/// </summary>
public bool FillVisibleEmptySlots { get; set; }
/// <summary>Creates an empty cell for <see cref="FillVisibleEmptySlots"/>.
/// Null uses a plain physical UiItemSlot.</summary>
public Func<UiItemSlot>? EmptySlotFactory { get; set; }
/// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
/// <paramref name="columns"/> columns.</summary>
public static int RowCount(int cellCount, int columns)
@ -177,7 +199,11 @@ public sealed class UiItemList : UiElement
return;
}
int cols = Columns < 1 ? 1 : Columns;
UpdateEmptySlots();
int cols = SingleRow
? Math.Max(1, _cells.Count)
: Columns < 1 ? 1 : Columns;
int cellH = (int)MathF.Round(CellHeight);
// Drive the shared scroll model from the current geometry, then re-clamp the offset to
@ -201,6 +227,35 @@ public sealed class UiItemList : UiElement
}
}
/// <summary>
/// Retail's horizontal branch pads to floor(listWidth / cellWidth), and when
/// the viewport shrinks removes only empty cells from the tail. Occupied cells
/// are never discarded merely because they extend beyond the viewport.
/// </summary>
private void UpdateEmptySlots()
{
if (!FillVisibleEmptySlots || !SingleRow || CellWidth <= 0f)
return;
int visibleCellCount = Math.Max(0, (int)MathF.Floor(Width / CellWidth));
while (_cells.Count < visibleCellCount)
{
UiItemSlot cell = EmptySlotFactory?.Invoke() ?? new UiItemSlot();
cell.SpriteResolve ??= SpriteResolve;
if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite;
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
}
while (_cells.Count > visibleCellCount && _cells[^1].IsEmptySlot)
{
UiItemSlot cell = _cells[^1];
_cells.RemoveAt(_cells.Count - 1);
RemoveChild(cell);
}
}
protected override bool ClipsChildren => CellWidth > 0f;
public void Flush()

View file

@ -192,6 +192,17 @@ public class UiItemSlot : UiElement
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty).</summary>
public uint[]? EmptyDigits { get; set; }
/// <summary>
/// Whether this retained cell represents an occupied UIItem for shortcut-number
/// presentation. Physical item cells use their weenie id; non-weenie catalog
/// cells override this with their own identity instead of forging an item GUID.
/// </summary>
protected virtual bool IsShortcutOccupied => ItemId != 0;
/// <summary>True when this cell is an empty slot that a retail item list may
/// add/remove while maintaining its visible empty-cell tail.</summary>
public virtual bool IsEmptySlot => ItemId == 0;
/// <summary>Set the slot's shortcut position and ghosted state so the correct digit
/// is drawn. Call with index 0..8 for the top row.</summary>
public void SetShortcutNum(int index, bool ghosted)
@ -213,8 +224,9 @@ public class UiItemSlot : UiElement
/// </summary>
internal uint[]? ActiveDigitArray()
{
bool occupied = ItemId != 0;
return occupied ? (ShortcutGhosted ? GhostedDigits : RegularDigits) : EmptyDigits;
return IsShortcutOccupied
? (ShortcutGhosted ? GhostedDigits : RegularDigits)
: EmptyDigits;
}
// ── Events / draw ─────────────────────────────────────────────────────────
@ -296,20 +308,7 @@ public class UiItemSlot : UiElement
// empty (ItemId == 0) → background digit set 0x1000005e, stance-independent
// Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn
// full-cell Alphablend so the transparent region is invisible.
if (ShortcutNum >= 0 && SpriteResolve is not null)
{
var arr = ActiveDigitArray();
if (arr is not null && ShortcutNum < arr.Length)
{
uint did = arr[ShortcutNum];
if (did != 0)
{
var (tex, _, _) = SpriteResolve(did);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
}
DrawShortcutOverlay(ctx);
// Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical
// UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells
@ -381,4 +380,25 @@ public class UiItemSlot : UiElement
}
}
}
/// <summary>Draws the shared retail shortcut-number layer. Catalog spell cells
/// call this after their icon layer so magic favorites and physical shortcuts
/// use the same UIItem presentation path.</summary>
protected void DrawShortcutOverlay(UiRenderContext ctx)
{
if (ShortcutNum < 0 || SpriteResolve is null)
return;
uint[]? digits = ActiveDigitArray();
if (digits is null || ShortcutNum >= digits.Length)
return;
uint did = digits[ShortcutNum];
if (did == 0)
return;
var (texture, _, _) = SpriteResolve(did);
if (texture != 0)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}

View file

@ -0,0 +1,12 @@
namespace AcDream.App.UI;
/// <summary>
/// Shared DAT presentation assets for UIItem shortcut cells. Both gmToolbarUI and
/// gmSpellcastingUI use UIElement_UIItem::SetShortcutNum @ 0x004E1590, so their
/// digit arrays and empty-cell prototype must come from one resolved asset set.
/// </summary>
public sealed record UiShortcutSlotGraphics(
uint[]? RegularDigits,
uint[]? GhostedDigits,
uint[]? EmptyDigits,
uint EmptySprite);

View file

@ -11,6 +11,60 @@ public sealed class SpellcastingUiControllerTests
{
private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
[Fact]
public void ImportedFixture_UsesRetailEmptyCells_ShortcutDigits_AndCompleteCastButton()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
ApplyAnchors(layout.Root);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
uint[] regular = [101u, 102u, 103u, 104u, 105u, 106u, 107u, 108u, 109u];
uint[] ghosted = [201u, 202u, 203u, 204u, 205u, 206u, 207u, 208u, 209u];
uint[] empty = [301u, 302u, 303u, 304u, 305u, 306u, 307u, 308u, 309u];
var graphics = new UiShortcutSlotGraphics(regular, ghosted, empty, 0x060074CFu);
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { }, shortcutGraphics: graphics)!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
Assert.True(list.SingleRow);
Assert.True(list.FillVisibleEmptySlots);
Assert.Equal(439f, list.Width);
Assert.Equal(13, list.GetNumUIItems());
UiCatalogSlot favorite = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
Assert.Equal(42u, favorite.EntryId);
Assert.Equal(0, favorite.ShortcutNum);
Assert.Same(regular, favorite.ActiveDigitArray());
for (int i = 1; i < 9; i++)
{
UiCatalogSlot slot = Assert.IsType<UiCatalogSlot>(list.GetItem(i));
Assert.True(slot.IsEmptySlot);
Assert.Equal(i, slot.ShortcutNum);
Assert.Same(empty, slot.ActiveDigitArray());
}
for (int i = 9; i < 13; i++)
Assert.Equal(-1, list.GetItem(i)!.ShortcutNum);
var cast = Assert.IsType<UiButton>(
layout.FindElement(SpellcastingUiController.CastButtonId));
Assert.Equal((525f, 75f), (cast.Left, cast.Width));
Assert.Equal(3, cast.FaceSegmentCount);
Assert.Equal(
[
new UiPixelRect(0, 0, 31, 31),
new UiPixelRect(32, 0, 42, 31),
new UiPixelRect(43, 0, 74, 31),
],
cast.FaceSegmentRectsForTest());
}
[Fact]
public void ImportedFixture_BindsAllTabs_AndTracksEquippedEndowment()
{
@ -167,7 +221,8 @@ public sealed class SpellcastingUiControllerTests
Spellbook spellbook,
ClientObjectTable objects,
Action<uint> useItem,
Action<int, int, uint>? addFavorite = null)
Action<int, int, uint>? addFavorite = null,
UiShortcutSlotGraphics? shortcutGraphics = null)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
@ -178,7 +233,17 @@ public sealed class SpellcastingUiControllerTests
useItem,
new SelectionState(),
addFavorite ?? ((_, _, _) => { }),
(_, _) => { });
(_, _) => { },
shortcutGraphics);
}
private static void ApplyAnchors(UiElement parent)
{
foreach (UiElement child in parent.Children)
{
child.ApplyAnchor(parent.Width, parent.Height);
ApplyAnchors(child);
}
}
private static IEnumerable<UiElement> Descendants(UiElement root)

View file

@ -61,6 +61,38 @@ public class UiItemListTests
Assert.Equal((17, 9), (received.Value.X, received.Value.Y));
}
[Fact]
public void RetailHorizontalEmptySlots_FillViewport_AndShrinkOnlyEmptyTail()
{
var list = new UiItemList
{
Width = 130f,
Height = 32f,
CellWidth = 32f,
CellHeight = 32f,
SingleRow = true,
FillVisibleEmptySlots = true,
EmptySlotFactory = () => new UiCatalogSlot(),
};
list.Flush();
list.AddItem(new UiCatalogSlot { EntryId = 42u });
list.LayoutCells();
Assert.Equal(4, list.GetNumUIItems());
Assert.Equal((0f, 32f, 64f, 96f),
(list.GetItem(0)!.Left, list.GetItem(1)!.Left,
list.GetItem(2)!.Left, list.GetItem(3)!.Left));
list.Width = 65f;
list.LayoutCells();
Assert.Equal(2, list.GetNumUIItems());
Assert.Equal(42u, Assert.IsType<UiCatalogSlot>(list.GetItem(0)).EntryId);
list.Width = 16f;
list.LayoutCells();
Assert.Single(list.Children); // the occupied cell is never discarded
}
[Fact]
public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport()
{