acdream/src/AcDream.App/UI/Layout/ToolbarController.cs
Erik 9aaf97e785 Revert "Campaign V slice V4a" - it lost world multisampling
This reverts ceec3bc4. Two independent reasons, either sufficient.

The rendering regression. The slice deleted TextRenderGlStateScope, which
saved GL_MULTISAMPLE and GL_SAMPLE_ALPHA_TO_COVERAGE on entry, disabled them
for the text pass, and restored them on exit (TextRenderGlStateScope.cs:111-112
and 153-154 at the parent commit). Its replacement bakes that state into the
text pipeline but nothing restores it, and GlGpuPassEncoder.Dispose does not
either. Every world renderer is still raw GL at this point in the campaign, so
from the first UI frame onward the world drew with multisampling disabled.

The offline pixel gate caught it: 1,791 of 563,200 compared pixels differed,
0.318% against a 0.001 threshold. The commit message attributed this to
wall-clock-driven ambient animation shifting phase, and committed through the
failure. That explanation does not survive its own control: capturing twice at
the reverted-to commit differs by 19 pixels and twice at the slice's own commit
by 8, while base-versus-head differs by 1,791 - a 224x gap that no shared-noise
source explains. An amplified difference image settles it visually: the changed
pixels are the silhouette edges of every tree, building and rock, with terrain
interiors, water and the entire UI untouched. That is the signature of losing
edge antialiasing, not of animated sprites.

This is the exact failure mode two existing memory notes already warn about -
a mid-frame renderer must set every GL state it uses rather than inherit it,
and issue #52's lesson that a rendering migration must audit per-pass GL state
before declaring itself done.

The scope. The brief was three small leaf renderers plus additive frame-
lifecycle wiring, roughly ten files. The commit changed 334 files with 3,665
insertions and 3,845 deletions, including 323 public-to-internal visibility
conversions across the App assembly, 55 test files, two retired conformance
tests, and a self-described temporary escape hatch for bridging raw-GL viewport
textures. Even without the regression, that is not separable into the part
worth keeping and the part worth dropping.

Reverting rather than patching because the good work here - the RHI frame
lifecycle wiring and a genuine render-state-cache staleness fix - is small
enough to redo cleanly against a tightened spec, while untangling it from 300+
files of unrelated churn is not.

Post-revert: Release build clean, App suite back to 3,843 passed / 3 skipped,
offline pixel gate passing at 19 differing pixels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:29:28 +02:00

775 lines
33 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data —
/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id,
/// populates them from the persisted PlayerDescription shortcuts
/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject
/// arrives (SetDelayedShortcutNum), and on click uses the bound item
/// (UseShortcut -> ItemHolder::UseObject -> use-item callback).
///
/// <para>
/// Retail reference: <c>gmToolbarUI::PostInit</c> grabs each slot widget by its
/// id, calls <c>UpdateFromPlayerDesc</c> to flush-and-bind shortcuts from the
/// PlayerDescription trailer, and hooks <c>OnEvent</c> for the Click case to fire
/// <c>UseShortcut</c>. The deferred-rebind path matches
/// <c>gmToolbarUI::SetDelayedShortcutNum</c> which re-tries binding after
/// <c>CreateObject</c> resolves a formerly-unknown guid.
/// </para>
/// </summary>
public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelController
{
// Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump).
// Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF).
private static readonly uint[] SlotIds =
{
0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB,
0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF,
0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB,
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
};
// SelectedObjectController owns the health/mana meters and both stack controls,
// including retail's initial-hidden state and selection-driven visibility.
// Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time.
// Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669)
// SetVisible's exactly one element depending on the incoming mode.
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint PanelIdAttribute = 0x10000029u;
private const uint UseButtonId = 0x1000019Du;
private const uint ExamineButtonId = 0x100001A5u;
private const uint AmmoIndicatorId = 0x10000194u;
private const uint InventoryButtonId = 0x100001B1u;
private static readonly uint[] PanelButtonIds =
{ 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId };
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly UiButton? _inventoryButton;
private readonly UiButton? _useButton;
private readonly UiButton? _examineButton;
private readonly UiButton? _ammoIndicator;
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly ShortcutStore _store;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Action<uint> _useItem; // guid → fire UseObject
private readonly Action<ShortcutEntry>? _sendAddShortcut;
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private readonly Func<uint> _selectedObjectId;
private readonly SelectionState? _selection;
private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private uint _ammoObjectId;
private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps ghosting.
// Occupancy branch (decomp 229481):
// occupied → regular 0x10000042 / ghosted 0x10000043
// empty → background digit 0x1000005e (stance-independent)
private uint[]? _regularDigits;
private uint[]? _ghostedDigits;
private uint[]? _emptyDigits;
private bool _shortcutsGhosted;
private ToolbarController(
ImportedLayout layout,
ClientObjectTable repo,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState,
uint[]? regularDigits,
uint[]? ghostedDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
{
_repo = repo;
_combatState = combatState;
_store = shortcuts ?? throw new ArgumentNullException(nameof(shortcuts));
_iconIds = iconIds;
_dragIconIds = dragIconIds;
_useItem = useItem;
_regularDigits = regularDigits;
_ghostedDigits = ghostedDigits;
_emptyDigits = emptyDigits;
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_selection = selection;
_playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer;
for (int i = 0; i < SlotIds.Length; i++)
{
_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList;
if (_slots[i] is { } list)
{
WireClick(list);
list.PrimaryItemPressed = PressItem;
list.ExamineItemRequested = ExamineItem;
// B.1 drag-drop spine: this controller is the drop handler for every
// toolbar slot list; each cell knows its slot index + that it's a
// shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler).
list.RegisterDragHandler(this);
list.Cell.SlotIndex = i;
list.Cell.SourceKind = ItemDragSource.ShortcutBar;
list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory)
}
}
// Cache the four mutually-exclusive combat-mode indicator elements.
// Retail gmToolbarUI::ListenToElementMessage @ 0x004BEE90 handles
// Click (message 1) from ANY id 0x10000192..0x10000195 by calling
// ClientCombatSystem::ToggleCombatMode. Exactly one is visible, but all
// four retain the same command binding as stance changes swap the art.
for (int i = 0; i < CombatIndicatorIds.Length; i++)
{
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
if (_combatIndicators[i] is UiButton button)
button.OnClick = toggleCombat;
}
_ammoIndicator = layout.FindElement(AmmoIndicatorId) as UiButton;
if (_ammoIndicator is not null)
{
_ammoIndicator.LabelFont = ammoFont;
_ammoIndicator.LabelColor = System.Numerics.Vector4.One;
}
foreach (uint elementId in PanelButtonIds)
{
if (layout.FindElement(elementId) is UiButton panelButton
&& panelButton.TryGetEnumAttribute(PanelIdAttribute, out uint panelId))
_panelButtons.Add((panelId, panelButton));
}
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
if (_inventoryButton is not null)
{
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
// UiButton owns the retained drop-target seam; the panel owns policy.
_inventoryButton.ItemDragAcceptSprite = 0x060011F7u;
_inventoryButton.OnItemDragOver = InventoryButtonDragOver;
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
}
_useButton = layout.FindElement(UseButtonId) as UiButton;
_examineButton = layout.FindElement(ExamineButtonId) as UiButton;
if (_useButton is not null)
_useButton.OnClick = () => _itemInteraction?.UseSelectedOrEnterMode(_selectedObjectId());
if (_examineButton is not null)
_examineButton.OnClick = () => _itemInteraction?.ExamineSelectedOrEnterMode(_selectedObjectId());
// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669):
// exactly one indicator visible at a time. Default to NonCombat (peace) — the player
// always spawns in peace mode; retail has not yet called SetVisible when PostInit runs.
SetCombatMode(CombatMode.NonCombat);
// Wire live combat-mode changes if a CombatState was provided.
if (_combatState is not null)
_combatState.CombatModeChanged += SetCombatMode;
if (_selection is not null)
_selection.Changed += OnSelectionChanged;
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
// to our 18 shortcut guids — else every creature spawn in a busy zone
// needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern).
repo.ObjectAdded += OnRepositoryObjectChanged;
repo.ObjectUpdated += OnRepositoryObjectChanged;
repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared;
_store.Changed += Populate;
RefreshAmmo();
RefreshUseButton();
}
private void OnRepositoryObjectChanged(ClientObject obj)
{
if (IsAmmoRelated(obj))
RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId))
Populate();
if (obj.ObjectId == _selectedObjectId())
RefreshUseButton();
}
private void OnRepositoryObjectMoved(ClientObjectMove move)
{
uint player = _playerGuid?.Invoke() ?? 0u;
if (move.ItemId == _ammoObjectId
|| (player != 0
&& (move.Previous.ContainerId == player
|| move.Current.ContainerId == player
|| move.Previous.WielderId == player
|| move.Current.WielderId == player)))
RefreshAmmo();
if (IsShortcutGuid(move.ItemId))
Populate();
}
private void OnRepositoryCleared()
{
RefreshAmmo();
Populate();
RefreshUseButton();
}
private void OnSelectionChanged(SelectionTransition _)
=> RefreshUseButton();
private void RefreshUseButton()
{
if (_useButton is null)
return;
// Retail normally leaves state 1 active when selectedID == 0 so the
// click can enter generic TARGET_MODE_USE. acdream intentionally
// ghosts that empty state per the connected UX requirement; every
// selected-object classification below is the exact retail predicate.
_useButton.Enabled =
_itemInteraction?.IsToolbarUseEnabled(_selectedObjectId()) == true;
}
private bool IsAmmoRelated(ClientObject obj)
{
uint player = _playerGuid?.Invoke() ?? 0u;
return obj.ObjectId == _ammoObjectId
|| (player != 0 && obj.ObjectId == player)
|| (player != 0
&& (obj.WielderId == player || obj.ContainerId == player)
&& (obj.CurrentlyEquippedLocation
& (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0);
}
private void RefreshAmmo()
{
uint player = _playerGuid?.Invoke() ?? 0u;
IReadOnlyList<ClientObject> placements = player != 0
? _repo.GetEquippedBy(player)
: Array.Empty<ClientObject>();
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements);
_ammoObjectId = result.ObjectId;
if (_ammoIndicator is not null)
{
_ammoIndicator.Label = result.IsVisible
? result.DisplayCount.ToString(System.Globalization.CultureInfo.InvariantCulture)
: null;
}
}
/// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids
/// (i.e., present in the live <see cref="AcDream.Core.Items.ShortcutStore"/>).
/// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn.
/// </summary>
private bool IsShortcutGuid(uint guid)
{
for (int s = 0; s < ShortcutStore.SlotCount; s++)
if (_store.Get(s) == guid) return true;
return false;
}
/// <summary>
/// Create and bind a <see cref="ToolbarController"/> to <paramref name="layout"/>.
/// Calls <see cref="Populate"/> immediately (binds whatever items are in the repo now).
/// Returns the controller so the caller can call <see cref="Populate"/> again
/// if the shortcut list is refreshed outside the repo-event path.
/// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="repo">Live item repository — must stay alive for the controller's lifetime.</param>
/// <param name="shortcuts">
/// Runtime-owned retail shortcut manager. The toolbar borrows this exact
/// mutable owner; it never reconstructs another slot map.
/// </param>
/// <param name="iconIds">Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle.</param>
/// <param name="useItem">Callback fired when a bound slot is clicked; receives the item guid.</param>
/// <param name="combatState">
/// Optional live combat state — when provided, the toolbar subscribes to
/// <see cref="CombatState.CombatModeChanged"/> and updates the four mutually-exclusive
/// combat-mode indicator elements accordingly.
/// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator).
/// </param>
/// <param name="regularDigits">
/// Regular digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element
/// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id.
/// Null if the dat lookup failed (no digits drawn). Retail reference:
/// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
/// </param>
/// <param name="ghostedDigits">Ghosted digit DID array (property 0x10000043, same element).</param>
/// <param name="emptyDigits">
/// Empty-slot background digit DID array (property 0x1000005e, stance-independent).
/// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum
/// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state).
/// Null if the dat lookup failed (empty slots draw no digit, which is safe).
/// </param>
public static ToolbarController Bind(
ImportedLayout layout,
ClientObjectTable repo,
ShortcutStore shortcuts,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Action<uint> useItem,
CombatState? combatState = null,
uint[]? regularDigits = null,
uint[]? ghostedDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
regularDigits, ghostedDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, selection, playerGuid, sendPutItemInContainer, ammoFont,
dragIconIds);
c.Populate();
return c;
}
/// <summary>
/// Binds all seven authored panel buttons by DAT panel id. Buttons whose panel
/// is not registered are ghosted, matching retail unavailable-panel behavior.
/// </summary>
public void BindPanelButtons(Func<uint, bool> isAvailable, Action<uint> togglePanel)
{
ArgumentNullException.ThrowIfNull(isAvailable);
ArgumentNullException.ThrowIfNull(togglePanel);
foreach (var (panelId, button) in _panelButtons)
{
bool available = isAvailable(panelId);
button.Enabled = available;
button.OnClick = !available ? null : () =>
{
if (ReferenceEquals(button, _inventoryButton)
&& _itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return;
togglePanel(panelId);
};
}
}
public void SetPanelOpen(uint panelId, bool open)
{
// gmToolbarUI::RecvNotice_SetPanelVisibility @ 0x004BD300 maps visible
// to state 6 (Highlight) and hidden to state 1 (Normal).
foreach (var entry in _panelButtons)
{
if (entry.PanelId != panelId || !entry.Button.Enabled) continue;
entry.Button.TrySetRetailState(open
? UiButtonStateMachine.Highlight
: UiButtonStateMachine.Normal);
return;
}
}
/// <summary>
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
/// each shortcut entry that has a resolved item in the repository.
/// Entries whose item is not yet in the repo are silently skipped here; the
/// <c>ObjectAdded</c> event re-fires this method when the item arrives
/// (matching retail's <c>SetDelayedShortcutNum</c> deferred-rebind path).
/// The Runtime-owned <see cref="AcDream.Core.Items.ShortcutStore"/> is the
/// authoritative in-session slot map.
/// </summary>
public void Populate()
{
foreach (var list in _slots) list?.Cell.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
{
ShortcutEntry? entry = _store.GetEntry(slot);
uint guid = entry?.ObjectId ?? 0u;
if (guid == 0) continue;
var list = _slots[slot];
if (list is null) continue;
var item = _repo.Get(guid);
if (item is null) continue; // deferred: ObjectAdded re-calls Populate
uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
uint dragTex = _dragIconIds?.Invoke(
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
list.Cell.SetItem(guid, tex, entry, dragTex);
}
// Re-stamp slot number labels after any item change.
// Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481):
// occupied → regular 0x10000042 / ghosted 0x10000043; empty → background 0x1000005e.
// The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511).
RestampShortcutNumbers();
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_SetCombatMode</c>
/// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four
/// mutually-exclusive combat-mode indicator elements and hide the other three.
/// Called at bind-time with <see cref="CombatMode.NonCombat"/> (the player
/// always starts in peace mode) and subsequently whenever
/// <see cref="CombatState.CombatModeChanged"/> fires.
/// </summary>
public void SetCombatMode(CombatMode mode)
{
// Index → mode mapping matches CombatIndicatorIds declaration order:
// 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic.
bool[] show =
{
mode == CombatMode.NonCombat,
mode == CombatMode.Melee,
mode == CombatMode.Missile,
mode == CombatMode.Magic,
};
for (int i = 0; i < _combatIndicators.Length; i++)
{
if (_combatIndicators[i] is { } e)
e.Visible = show[i];
}
// The bool passed to SetShortcutNum is m_bShortcutGhosted, not a peace/war
// selector. Retail ghosts physical-item shortcuts only in Magic mode.
// Retail ref: gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610.
_shortcutsGhosted = mode == CombatMode.Magic;
RestampShortcutNumbers();
}
/// <summary>
/// Push digit-array references and shortcut-number state into every slot cell.
/// Top row (indices 08): SetShortcutNum(i, _shortcutsGhosted) — numbers 19 always shown
/// (the digit is ALWAYS visible, SetVisible(1) at decomp 229511; only the sprite
/// SOURCE differs by occupancy — see UIElement_UIItem::SetShortcutNum decomp 229481).
/// Bottom row (indices 917): ClearShortcutNum() — retail shows no numbers there.
/// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
/// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621).
/// Occupancy → source: occupied → regular 0x10000042 / ghosted 0x10000043;
/// empty → background 0x1000005e (decomp 229481/229493).
/// </summary>
private void RestampShortcutNumbers()
{
for (int i = 0; i < _slots.Length; i++)
{
var cell = _slots[i]?.Cell;
if (cell is null) continue;
cell.RegularDigits = _regularDigits;
cell.GhostedDigits = _ghostedDigits;
cell.EmptyDigits = _emptyDigits;
if (i < 9)
cell.SetShortcutNum(i, _shortcutsGhosted); // top row: slot labels 19 always shown
else
cell.ClearShortcutNum(); // bottom row: no slot labels
}
}
/// <summary>
/// Wire the <see cref="UiItemSlot.Clicked"/> callback on a slot cell so that
/// clicking a bound item fires <see cref="_useItem"/> with the slot's current guid.
/// Mirrors retail's <c>gmToolbarUI</c> click → <c>UseShortcut</c> dispatch.
/// </summary>
private void WireClick(UiItemList list)
{
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0)
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(list.Cell.ItemId);
else
_useItem(list.Cell.ItemId);
}
};
}
private bool PressItem(uint itemId)
{
if (_itemInteraction?.OfferPrimaryClick(itemId)
is not null and not ItemPrimaryClickResult.NotActive)
return true;
if (_selectItem is not null)
_selectItem(itemId);
else
_selection?.Select(itemId, SelectionChangeSource.Toolbar);
return false;
}
private void ExamineItem(uint itemId)
{
if (_selectItem is not null)
_selectItem(itemId);
else
_selection?.Select(itemId, SelectionChangeSource.Toolbar);
_itemInteraction?.ExamineSelectedOrEnterMode(itemId);
}
private static ItemDragAcceptance InventoryButtonDragOver(ItemDragPayload payload)
=> payload.ObjId != 0 && payload.SourceKind != ItemDragSource.ShortcutBar
? ItemDragAcceptance.Accept
: ItemDragAcceptance.None;
private void HandleInventoryButtonDrop(ItemDragPayload payload)
{
// gmToolbarUI::HandleDropRelease @ 0x004BE7C0 inventory-button branch:
// only fresh physical items are placed into the player's main inventory.
// A shortcut alias remains neutral and its remove-on-lift result stands.
if (payload.ObjId == 0 || payload.SourceKind == ItemDragSource.ShortcutBar)
return;
uint player = _playerGuid?.Invoke() ?? 0u;
if (player == 0 || _repo.Get(payload.ObjId) is null)
return;
if (_itemInteraction is not null)
{
Func<bool> dispatch = () =>
{
if (_sendPutItemInContainer is null)
return false;
_sendPutItemInContainer(payload.ObjId, player, 0);
return true;
};
if (_itemInteraction.IsOwnedByPlayer(payload.ObjId))
_itemInteraction.TryDispatchPendingBackpackPlacement(
payload.ObjId,
player,
0,
InventoryRequestKind.PutInContainer,
dispatch);
else
_itemInteraction.TryDispatchInventoryRequest(
InventoryRequestKind.Pickup,
payload.ObjId,
dispatch);
return;
}
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
}
/// <summary>
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
/// is one-shot and precedes both normal use and selection.
/// </summary>
public bool UseShortcut(int slot, bool use)
{
if ((uint)slot >= _slots.Length || _slots[slot]?.Cell is not { } cell)
return false;
uint itemId = cell.ItemId;
if (_itemInteraction?.IsTargetModeActive == true)
{
if (itemId != 0)
_itemInteraction.OfferPrimaryClick(itemId);
else
_itemInteraction.CancelTargetMode();
return true;
}
if (itemId == 0)
return false;
if (use)
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(itemId);
else
_useItem(itemId);
}
else
{
_selectItem?.Invoke(itemId);
}
return true;
}
/// <summary>
/// Auto-slot form of retail <c>gmToolbarUI::CreateShortcutToItem @
/// 0x004BDAC0</c>, used by the global CreateShortcut action. It rejects
/// stuck/non-player objects, non-player creatures, unowned objects,
/// duplicates, and a full bar before emitting AddShortcut.
/// </summary>
public bool CreateShortcutToItem(uint itemId)
{
if (itemId == 0 || _repo.Get(itemId) is not { } item)
return false;
var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u);
bool isPlayer = itemId == _itemInteraction?.PlayerGuid
|| (flags & PublicWeenieFlags.Player) != 0;
if ((flags & PublicWeenieFlags.Stuck) != 0 && !isPlayer)
return false;
if ((item.Type & ItemType.Creature) != 0 && !isPlayer)
return false;
if (_itemInteraction?.IsOwnedByPlayer(itemId) != true)
return false;
for (int slot = 0; slot < _slots.Length; slot++)
if (_store.Get(slot) == itemId)
return false;
int empty = -1;
for (int slot = 0; slot < _slots.Length; slot++)
{
if (_store.IsEmpty(slot))
{
empty = slot;
break;
}
}
if (empty < 0)
return false;
var entry = new ShortcutEntry(empty, itemId, 0u);
_sendAddShortcut?.Invoke(entry);
_store.Set(entry);
return true;
}
// ── IItemListDragHandler (B.2 live handler) ──────────────────────────────
// Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list.
// Retail model (remove-on-lift / place-on-drop / no-restore):
// lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately)
// drop → AddShortcut (0x019C) + optional swap of evicted item into source
// off-bar release → the lift's removal stands (no restore)
// Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971
/// <inheritdoc/>
public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload)
{
// UIElement_ItemList::ItemList_BeginDrag @ 0x004E32D0 selects first; the toolbar's
// RecvNotice_ItemListBeginDrag handler then removes the shortcut alias.
if (payload.ObjId != 0 && _selectedObjectId() != payload.ObjId)
_selectItem?.Invoke(payload.ObjId);
// Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted
// shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a
// drop onto a slot. Off-bar release leaves it removed.
_sendRemoveShortcut?.Invoke((uint)payload.SourceSlot);
_store.Remove(payload.SourceSlot);
}
/// <inheritdoc/>
public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
=> payload.ObjId != 0 ? ItemDragAcceptance.Accept : ItemDragAcceptance.None;
/// <inheritdoc/>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
ShortcutDropSource source = payload.SourceKind == ItemDragSource.ShortcutBar
? ShortcutDropSource.ShortcutAlias
: ShortcutDropSource.FreshItem;
ShortcutEntry dragged = payload.Shortcut
?? new ShortcutEntry(payload.SourceSlot, payload.ObjId, 0u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanDrop(
_store.Snapshot(), source, payload.SourceSlot, targetCell.SlotIndex, dragged);
ApplyShortcutPlan(plan);
}
/// <summary>
/// Toolbar notice port for <c>gmToolbarUI::RecvNotice_FullMergingItem @ 0x004BE9B0</c>.
/// The owning inventory pipeline calls this when ACE confirms a source stack was fully merged.
/// </summary>
public void ReplaceFullyMergedShortcut(uint oldObjectId, uint newObjectId)
{
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
_store.Snapshot(), oldObjectId, newObjectId);
ApplyShortcutPlan(plan);
}
private void ApplyShortcutPlan(IReadOnlyList<ShortcutMutation> plan)
{
// Retail sends each shortcut event immediately before applying the same
// PlayerModule mutation (gmToolbarUI @ 0x004BD450/0x004BD9A0).
// Production commands mutate this exact Runtime-owned store; the
// idempotent local call also keeps isolated controller fixtures useful.
foreach (var mutation in plan)
{
if (mutation.Kind == ShortcutMutationKind.Remove)
{
_sendRemoveShortcut?.Invoke((uint)mutation.Slot);
_store.Remove(mutation.Slot);
}
else if (mutation.Entry is { } entry)
{
_sendAddShortcut?.Invoke(entry);
_store.Set(entry);
}
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var (_, button) in _panelButtons)
button.OnClick = null;
foreach (var indicator in _combatIndicators)
if (indicator is UiButton button)
button.OnClick = null;
if (_useButton is not null)
_useButton.OnClick = null;
if (_examineButton is not null)
_examineButton.OnClick = null;
if (_inventoryButton is not null)
{
_inventoryButton.OnItemDragOver = null;
_inventoryButton.OnItemDrop = null;
}
if (_combatState is not null)
_combatState.CombatModeChanged -= SetCombatMode;
if (_selection is not null)
_selection.Changed -= OnSelectionChanged;
foreach (UiItemList? list in _slots)
if (list is not null)
{
list.PrimaryItemPressed = null;
list.ExamineItemRequested = null;
}
_repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
_repo.ObjectMoved -= OnRepositoryObjectMoved;
_repo.Cleared -= OnRepositoryCleared;
_store.Changed -= Populate;
}
}