feat(ui): finish retail toolbar controls

Discover all seven panel launchers from their DAT panel-id attributes, route mounted panels through event-driven retained window state, and ghost unavailable panels. Port Use and Examine selection/target behavior with exact Appraise dispatch and retail cursor modes.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 12:11:53 +02:00
parent 21fefce0e0
commit d3d1c895a0
19 changed files with 478 additions and 72 deletions

View file

@ -1995,6 +1995,7 @@ public sealed class GameWindow : IDisposable
Objects,
playerGuid: () => _playerServerGuid,
sendUse: g => _liveSession?.SendUse(g),
sendExamine: g => _liveSession?.SendAppraise(g),
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
@ -11946,7 +11947,7 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_itemInteractionController?.IsTargetModeActive == true)
if (_itemInteractionController?.IsAnyTargetModeActive == true)
_itemInteractionController.CancelTargetMode();
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
@ -12092,7 +12093,7 @@ public sealed class GameWindow : IDisposable
// Target-use mode picks WITH self (retail: kit-heal yourself by
// clicking your own toon); plain selection keeps the self-exclusion.
var picked = PickWorldGuidAtCursor(
includeSelf: _itemInteractionController?.IsTargetModeActive == true);
includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true);
if (picked is uint guid)
{
@ -12133,7 +12134,7 @@ public sealed class GameWindow : IDisposable
}
else
{
if (_itemInteractionController?.IsTargetModeActive == true)
if (_itemInteractionController?.IsAnyTargetModeActive == true)
return;
_debugVm?.AddToast("Nothing to select");
}

View file

@ -113,9 +113,7 @@ public sealed class CursorFeedbackController
// window occludes the world (no found object → pending). The one
// UI-side source retail-style cells contribute is an occupied item
// slot's own item.
RetailCursorTargetMode targetMode = _itemInteraction?.IsTargetModeActive == true
? RetailCursorTargetMode.UseTarget
: RetailCursorTargetMode.None;
RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction);
uint hoverTarget = hover is null
? _worldTargetProvider?.Invoke() ?? 0u
: targetMode == RetailCursorTargetMode.UseTarget
@ -265,9 +263,18 @@ public sealed class CursorFeedbackController
private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot)
=> snapshot.TargetMode == RetailCursorTargetMode.None
&& _itemInteraction?.IsTargetModeActive == true
? RetailCursorTargetMode.UseTarget
: snapshot.TargetMode;
? ModeFromInteraction(_itemInteraction)
: snapshot.TargetMode;
private static RetailCursorTargetMode ModeFromInteraction(
ItemInteractionController? interaction)
=> interaction?.InteractionState.Current.Kind switch
{
InteractionModeKind.Use => RetailCursorTargetMode.Use,
InteractionModeKind.Examine => RetailCursorTargetMode.Examine,
InteractionModeKind.UseItemOnTarget => RetailCursorTargetMode.UseTarget,
_ => RetailCursorTargetMode.None,
};
private static CursorFeedbackKind KindForResize(ResizeEdges edges)
{

View file

@ -60,6 +60,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<uint> _playerGuid;
private readonly Func<long> _nowMs;
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendExamine;
private readonly Action<uint, uint>? _sendUseWithTarget;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
@ -90,6 +91,7 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint>? sendUseWithTarget,
Action<uint, uint>? sendWield,
Action<uint>? sendDrop,
Action<uint>? sendExamine = null,
Func<long>? nowMs = null,
Action<string>? toast = null,
InteractionState? interactionState = null,
@ -108,6 +110,7 @@ public sealed class ItemInteractionController : IDisposable
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_sendUse = sendUse;
_sendExamine = sendExamine;
_sendUseWithTarget = sendUseWithTarget;
_sendWield = sendWield;
_sendDrop = sendDrop;
@ -152,6 +155,9 @@ public sealed class ItemInteractionController : IDisposable
public bool IsTargetModeActive
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
public bool IsAnyTargetModeActive
=> _interactionState.Current.Kind != InteractionModeKind.None;
public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
@ -182,7 +188,8 @@ public sealed class ItemInteractionController : IDisposable
/// </summary>
public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid)
{
if (!IsTargetModeActive)
InteractionModeKind mode = _interactionState.Current.Kind;
if (mode == InteractionModeKind.None)
{
long now = _nowMs();
if (targetGuid != 0
@ -193,7 +200,25 @@ public sealed class ItemInteractionController : IDisposable
return ItemPrimaryClickResult.NotActive;
}
bool accepted = AcquireTarget(targetGuid);
bool accepted;
switch (mode)
{
case InteractionModeKind.Use:
ClearTargetMode();
accepted = ActivateItem(targetGuid);
break;
case InteractionModeKind.Examine:
ClearTargetMode();
accepted = targetGuid != 0 && _sendExamine is not null;
if (accepted)
_sendExamine!(targetGuid);
break;
case InteractionModeKind.UseItemOnTarget:
accepted = AcquireTarget(targetGuid);
break;
default:
throw new InvalidOperationException($"Unknown interaction mode {mode}.");
}
_consumedPrimaryClickTarget = targetGuid;
_consumedPrimaryClickMs = _nowMs();
return accepted
@ -204,6 +229,33 @@ public sealed class ItemInteractionController : IDisposable
public ItemPrimaryClickResult OfferSelfPrimaryClick()
=> OfferPrimaryClick(_playerGuid());
/// <summary>
/// Retail toolbar Use button: use the current selection immediately, or arm
/// one-shot <c>TARGET_MODE_USE</c> when no object is selected.
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
/// </summary>
public bool UseSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId != 0)
return ActivateItem(selectedObjectId);
return _interactionState.EnterUse();
}
/// <summary>
/// Retail toolbar Examine button: appraise the current selection immediately,
/// or arm one-shot <c>TARGET_MODE_EXAMINE</c> when no object is selected.
/// Retail reference: <c>gmToolbarUI::ListenToElementMessage @ 0x004BEE90</c>.
/// </summary>
public bool ExamineSelectedOrEnterMode(uint selectedObjectId)
{
if (selectedObjectId == 0)
return _interactionState.EnterExamine();
if (_sendExamine is null)
return false;
_sendExamine(selectedObjectId);
return true;
}
public bool ActivateItem(uint itemGuid)
{
if (itemGuid == 0) return false;
@ -264,7 +316,7 @@ public sealed class ItemInteractionController : IDisposable
public void CancelTargetMode()
{
if (!IsTargetModeActive) return;
if (!IsAnyTargetModeActive) return;
ClearTargetMode();
}

View file

@ -45,13 +45,19 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint PanelIdAttribute = 0x10000029u;
private const uint UseButtonId = 0x1000019Du;
private const uint ExamineButtonId = 0x100001A5u;
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? _characterButton;
private readonly UiButton? _inventoryButton;
private readonly UiButton? _useButton;
private readonly UiButton? _examineButton;
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState;
private readonly Func<IReadOnlyList<ShortcutEntry>> _shortcuts;
@ -63,6 +69,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private readonly Func<uint> _selectedObjectId;
private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private bool _disposed;
@ -94,6 +101,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
@ -109,6 +117,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer;
@ -140,7 +149,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
button.OnClick = toggleCombat;
}
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
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)
{
@ -152,6 +167,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_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.
@ -240,42 +262,54 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
playerGuid, sendPutItemInContainer);
selectedObjectId, playerGuid, sendPutItemInContainer);
c.Populate();
return c;
}
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
/// <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)
{
if (_inventoryButton is not null)
ArgumentNullException.ThrowIfNull(isAvailable);
ArgumentNullException.ThrowIfNull(togglePanel);
foreach (var (panelId, button) in _panelButtons)
{
_inventoryButton.OnClick = () =>
bool available = isAvailable(panelId);
button.Enabled = available;
button.OnClick = !available ? null : () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
toggleInventory?.Invoke();
if (ReferenceEquals(button, _inventoryButton)
&& _itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return;
togglePanel(panelId);
};
}
if (_characterButton is not null)
_characterButton.OnClick = toggleCharacter;
}
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
private static void SetButtonOpen(UiButton? button, bool open)
public void SetPanelOpen(uint panelId, bool open)
{
if (button is not null)
button.TrySetRetailState(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>
@ -584,6 +618,21 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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;
_repo.ObjectAdded -= OnRepositoryObjectChanged;

View file

@ -0,0 +1,48 @@
namespace AcDream.App.UI;
/// <summary>
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
/// seven buttons and reads this property from each one.
/// Only panels mounted in the retained runtime are mapped; other authored
/// toolbar buttons remain present but ghosted until their panel ships.
/// </summary>
public static class RetailPanelCatalog
{
public const uint Inventory = 7u;
public const uint Character = 11u;
private static readonly (uint PanelId, string WindowName)[] Mounted =
{
(Inventory, WindowNames.Inventory),
(Character, WindowNames.Character),
};
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
public static bool TryGetWindowName(uint panelId, out string windowName)
{
foreach (var entry in Mounted)
{
if (entry.PanelId != panelId) continue;
windowName = entry.WindowName;
return true;
}
windowName = string.Empty;
return false;
}
public static bool TryGetPanelId(string windowName, out uint panelId)
{
foreach (var entry in Mounted)
{
if (!string.Equals(entry.WindowName, windowName, StringComparison.Ordinal)) continue;
panelId = entry.PanelId;
return true;
}
panelId = 0;
return false;
}
}

View file

@ -128,6 +128,8 @@ public sealed class RetailUiRuntime : IDisposable
MountCharacter();
MountPlugins();
MountInventory();
Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged;
BindToolbarPanelButtons();
SyncToolbarWindowButtons();
if (bindings.Persistence is { } persistence)
@ -197,22 +199,34 @@ public sealed class RetailUiRuntime : IDisposable
public void RestoreLayout() => _persistence?.RestoreAll();
public bool ToggleWindow(string name)
{
bool visible = Host.ToggleWindow(name);
SyncToolbarWindowButtons();
return visible;
}
=> Host.ToggleWindow(name);
public void CloseWindow(string name)
{
Host.HideWindow(name);
SyncToolbarWindowButtons();
}
=> Host.HideWindow(name);
public void SyncToolbarWindowButtons()
{
ToolbarController?.SetInventoryOpen(Host.IsWindowVisible(WindowNames.Inventory));
ToolbarController?.SetCharacterOpen(Host.IsWindowVisible(WindowNames.Character));
if (ToolbarController is null) return;
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
}
private void BindToolbarPanelButtons()
{
ToolbarController?.BindPanelButtons(
panelId => RetailPanelCatalog.TryGetWindowName(panelId, out string name)
&& Host.WindowManager.TryGet(name, out _),
panelId =>
{
if (RetailPanelCatalog.TryGetWindowName(panelId, out string name))
ToggleWindow(name);
});
}
private void OnWindowVisibilityChanged(string windowName, bool visible)
{
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
ToolbarController?.SetPanelOpen(panelId, visible);
}
private ImportedLayout? Import(uint layoutId)
@ -374,6 +388,7 @@ public sealed class RetailUiRuntime : IDisposable
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
@ -420,9 +435,6 @@ public sealed class RetailUiRuntime : IDisposable
SelectedObjectController),
});
ConfigureToolbarCollapse(layout, (UiCollapsibleFrame)handle.OuterFrame, root.Height);
ToolbarController.BindWindowToggles(
() => ToggleWindow(WindowNames.Inventory),
() => ToggleWindow(WindowNames.Character));
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
}
@ -635,6 +647,7 @@ public sealed class RetailUiRuntime : IDisposable
if (_disposed) return;
_disposed = true;
_persistence?.Dispose();
Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged;
Host.Dispose();
}
}

View file

@ -31,6 +31,7 @@ public sealed class RetailWindowManager : IDisposable
public bool IsLocked => _root.UiLocked;
public IReadOnlyCollection<RetailWindowHandle> Windows => _byName.Values;
public event Action<string, bool>? WindowVisibilityChanged;
public RetailWindowHandle Register(
string name,
@ -233,6 +234,7 @@ public sealed class RetailWindowManager : IDisposable
}
handle.NotifyVisibility(visible);
WindowVisibilityChanged?.Invoke(handle.Name, visible);
}
private void OnKeyboardFocusChanged(UiElement? oldFocus, UiElement? newFocus)

View file

@ -123,6 +123,20 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
}
/// <summary>Reads a resolved enum-valued DAT attribute from this button.</summary>
public bool TryGetEnumAttribute(uint propertyId, out uint value)
{
if (_info.TryGetEffectiveProperty(propertyId, out var property)
&& property.Kind == UiPropertyKind.Enum)
{
value = checked((uint)property.UnsignedValue);
return true;
}
value = 0;
return false;
}
public override string ActiveCursorStateName => ActiveState;
public bool TrySetRetailState(uint stateId)

View file

@ -1331,6 +1331,13 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid));
}
/// <summary>Send retail IdentifyObject/Appraise (0x00C8).</summary>
public void SendAppraise(uint targetGuid)
{
uint seq = NextGameActionSequence();
SendGameAction(AppraiseRequest.Build(seq, targetGuid));
}
/// <summary>Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement
/// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail:
/// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer.</summary>