feat(ui): port retail selected-item mana

Parse and route the complete 0x0264 guid/fraction/valid response, send exact 0x0263 item-mana queries, and reproduce retail meter visibility plus guid-zero cancellation for mana and health selection changes. Keep the behavior in Core state and the retained selected-object controller with wire/state/UI conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:16:21 +02:00
parent df460f94c3
commit fa1da02783
21 changed files with 402 additions and 38 deletions

View file

@ -728,6 +728,7 @@ public sealed class GameWindow : IDisposable
// SetTurbineChatChannels (0x0295) shortly after EnterWorld.
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
// Issue #11 — load static spell metadata from data/spells.csv at startup.
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
@ -2117,6 +2118,7 @@ public sealed class GameWindow : IDisposable
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
ItemMana,
ToggleLiveCombatMode,
_itemInteractionController,
entry => _liveSession?.SendAddShortcut(entry),
@ -2130,6 +2132,7 @@ public sealed class GameWindow : IDisposable
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => _liveSession?.SendQueryHealth(guid),
guid => _liveSession?.SendQueryItemMana(guid),
() => _playerServerGuid,
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement)),
@ -2590,7 +2593,8 @@ public sealed class GameWindow : IDisposable
},
onShortcuts: list => Shortcuts = list,
playerGuid: () => _playerServerGuid,
onUseDone: error => _itemInteractionController?.CompleteUse(error));
onUseDone: error => _itemInteractionController?.CompleteUse(error),
itemMana: ItemMana);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into

View file

@ -7,15 +7,17 @@ using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A1).
/// Controller for the action bar's selected-object strip (ids 0x1000019E0x100001A4).
/// Analogue of retail <c>gmToolbarUI::HandleSelectionChanged</c>
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>) +
/// <c>RecvNotice_UpdateItemMana</c> (<c>:196188</c>).
///
/// <para>
/// On selection change: clears the strip (name, overlay flash, health meter), then if a
/// guid is provided it sets the name, flashes the selection overlay briefly, and (for
/// health-bearing targets) sends a <c>QueryHealth (0x01BF)</c> request. The Health meter
/// guid is provided it sets the name, flashes the selection overlay briefly, and sends
/// either <c>QueryHealth (0x01BF)</c> for health-bearing targets or
/// <c>QueryItemMana (0x0263)</c> for owned non-stack items. The Health meter
/// becomes visible only when the server actually reports health for the selected guid —
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
@ -54,6 +56,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
public const uint OverlayId = 0x100001A0;
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
public const uint HealthMeterId = 0x100001A1;
/// <summary>Selected-object item-mana meter element id (retail m_pSelObjectManaMeter).</summary>
public const uint ManaMeterId = 0x100001A2;
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
public const uint StackSizeEntryId = 0x100001A3;
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
@ -80,19 +84,24 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly UiElement? _name;
private readonly UiDatElement? _overlay;
private readonly UiMeter? _healthMeter;
private readonly UiMeter? _manaMeter;
private readonly UiField? _stackSizeEntry;
private readonly UiScrollbar? _stackSizeSlider;
// ── Captured delegates ───────────────────────────────────────────────────
private readonly Func<uint, bool> _isHealthTarget;
private readonly Func<uint, bool> _isOwnedByPlayer;
private readonly Func<uint, string?> _resolveName;
private readonly Func<uint, float> _healthPercent;
private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth;
private readonly Func<uint, float> _manaPercent;
private readonly Action<uint> _sendQueryItemMana;
private readonly StackSplitQuantityState _splitQuantity;
private readonly SelectionState _selection;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
// ── Live state (read by closures on the per-frame draw path) ────────────
@ -110,32 +119,42 @@ public sealed class SelectedObjectController : IRetainedPanelController
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
Action<Action<ClientObject>> unsubscribeObjectUpdated)
{
_isHealthTarget = isHealthTarget;
_isOwnedByPlayer = isOwnedByPlayer;
_resolveName = name;
_healthPercent = healthPercent;
_hasHealth = hasHealth;
_stackSize = stackSize;
_sendQueryHealth = sendQueryHealth;
_manaPercent = manaPercent;
_sendQueryItemMana = sendQueryItemMana;
_splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity));
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_unsubscribeHealthChanged = unsubscribeHealthChanged;
_unsubscribeItemManaChanged = unsubscribeItemManaChanged;
_unsubscribeObjectUpdated = unsubscribeObjectUpdated;
// Find elements — silently skip absent ones (partial/test layouts).
_name = layout.FindElement(NameId);
_overlay = layout.FindElement(OverlayId) as UiDatElement;
_healthMeter = layout.FindElement(HealthMeterId) as UiMeter;
_manaMeter = layout.FindElement(ManaMeterId) as UiMeter;
_stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField;
_stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar;
@ -151,6 +170,11 @@ public sealed class SelectedObjectController : IRetainedPanelController
// Fill polls live: _current holds the currently-selected guid (or null).
_healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f;
}
if (_manaMeter is not null)
{
_manaMeter.Visible = false;
_manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f;
}
if (_stackSizeEntry is not null)
{
@ -210,6 +234,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection.Changed += OnSelectionTransition;
_splitQuantity.Changed += OnSplitQuantityChanged;
subscribeHealthChanged(OnHealthChanged);
subscribeItemManaChanged(OnItemManaChanged);
subscribeObjectUpdated(OnObjectUpdated);
if (_selection.SelectedObjectId is { } initial)
ApplySelection(initial);
@ -237,12 +262,17 @@ public sealed class SelectedObjectController : IRetainedPanelController
SelectionState selection,
Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged,
Action<Action<uint, float, bool>> subscribeItemManaChanged,
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
Func<uint, bool> isHealthTarget,
Func<uint, bool> isOwnedByPlayer,
Func<uint, string?> name,
Func<uint, float> healthPercent,
Func<uint, bool> hasHealth,
Func<uint, uint> stackSize,
Action<uint> sendQueryHealth,
Func<uint, float> manaPercent,
Action<uint> sendQueryItemMana,
UiDatFont? datFont,
StackSplitQuantityState splitQuantity,
Action<Action<ClientObject>> subscribeObjectUpdated,
@ -250,7 +280,9 @@ public sealed class SelectedObjectController : IRetainedPanelController
=> new SelectedObjectController(
layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont,
subscribeItemManaChanged, unsubscribeItemManaChanged,
isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize,
sendQueryHealth, manaPercent, sendQueryItemMana, datFont,
splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated);
/// <summary>
@ -259,9 +291,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// </summary>
private void ApplySelection(uint? guid)
{
bool selectionChanged = _current != guid;
// gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from
// a visible meter cancels its server query with object id zero.
if (selectionChanged)
{
if (_healthMeter?.Visible == true)
_sendQueryHealth(0);
if (_manaMeter?.Visible == true)
_sendQueryItemMana(0);
}
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ──────────────────────────────────────
if (_healthMeter is not null) _healthMeter.Visible = false;
if (selectionChanged)
{
if (_healthMeter is not null) _healthMeter.Visible = false;
if (_manaMeter is not null) _manaMeter.Visible = false;
}
if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false;
if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false;
_splitQuantity.Reset(1u);
@ -306,12 +354,18 @@ public sealed class SelectedObjectController : IRetainedPanelController
// ── 4. Health: query, and show the meter only if real health is already known.
// Otherwise the meter appears when OnHealthChanged fires for this guid
// (retail RecvNotice_UpdateObjectHealth :196213). ──────────────────────────
if (_isHealthTarget(g))
if (stackSize <= 1u && _isHealthTarget(g))
{
_sendQueryHealth(g);
if (selectionChanged)
_sendQueryHealth(g);
if (_hasHealth(g) && _healthMeter is not null)
_healthMeter.Visible = true;
}
else if (stackSize <= 1u && _isOwnedByPlayer(g))
{
if (selectionChanged)
_sendQueryItemMana(g);
}
}
/// <summary>
@ -339,6 +393,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
_overlay?.TrySetRetailState(state);
}
/// <summary>
/// Port of <c>gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0</c>.
/// Invalid results cancel with object id zero; valid results reveal the meter.
/// </summary>
public void OnItemManaChanged(uint guid, float percent, bool valid)
{
if (_current != guid)
return;
if (!valid)
{
_sendQueryItemMana(0);
return;
}
if (_manaMeter is not null)
_manaMeter.Visible = true;
}
private void CommitStackEntry(string text)
=> _splitQuantity.SetFromText(text);
@ -380,6 +453,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
_selection.Changed -= OnSelectionTransition;
_splitQuantity.Changed -= OnSplitQuantityChanged;
_unsubscribeHealthChanged(OnHealthChanged);
_unsubscribeItemManaChanged(OnItemManaChanged);
_unsubscribeObjectUpdated(OnObjectUpdated);
}
}

View file

@ -35,17 +35,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF,
};
// Elements hidden by default in retail gmToolbarUI::PostInit.
// Ids confirmed from the toolbar LayoutDesc dump.
// 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) —
// it hides A1 at bind and shows it on a health-target selection, so A1 is removed
// from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size
// entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split
// UI) with no controller yet, so they stay hidden here — otherwise their dat sprites
// render as stray bars / a black box on the toolbar. Retail hides A3/A4 in
// gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742),
// showing them only for a stacked-item selection.
private static readonly uint[] HiddenIds = { 0x100001A2 };
// 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.
@ -161,10 +152,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
}
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
// 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.

View file

@ -41,6 +41,7 @@ public sealed record ToolbarRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Action<uint> UseItem,
CombatState Combat,
ItemManaState ItemMana,
Action ToggleCombat,
ItemInteractionController ItemInteraction,
Action<ShortcutEntry>? SendAddShortcut,
@ -54,6 +55,7 @@ public sealed record ToolbarRuntimeBindings(
Func<uint, bool> HasHealth,
Func<uint, uint> StackSize,
Action<uint> SendQueryHealth,
Action<uint> SendQueryItemMana,
Func<uint> PlayerGuid,
Action<uint, uint, int>? SendPutItemInContainer);
@ -380,12 +382,17 @@ public sealed class RetailUiRuntime : IDisposable
b.Selection,
b.SubscribeHealthChanged,
b.UnsubscribeHealthChanged,
handler => b.ItemMana.ItemManaChanged += handler,
handler => b.ItemMana.ItemManaChanged -= handler,
b.IsHealthTarget,
b.ItemInteraction.IsOwnedByPlayer,
b.ResolveName,
b.HealthPercent,
b.HasHealth,
b.StackSize,
b.SendQueryHealth,
b.ItemMana.GetManaPercent,
b.SendQueryItemMana,
_bindings.Assets.DefaultFont,
StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler,

View file

@ -69,7 +69,8 @@ public static class GameEventWiring
// B-Wire: the local player's server guid. When provided, the PD handler upserts
// the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject.
Func<uint>? playerGuid = null,
Action<uint /*weenieError*/>? onUseDone = null)
Action<uint /*weenieError*/>? onUseDone = null,
ItemManaState? itemMana = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -161,6 +162,16 @@ public static class GameEventWiring
var p = GameEvents.ParseUpdateHealth(e.Payload.Span);
if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent);
});
if (itemMana is not null)
{
dispatcher.Register(GameEventType.QueryItemManaResponse, e =>
{
var p = GameEvents.ParseQueryItemManaResponse(e.Payload.Span);
if (p is not null)
itemMana.OnQueryItemManaResponse(
p.Value.ItemGuid, p.Value.ManaPercent, p.Value.Valid);
});
}
dispatcher.Register(GameEventType.VictimNotification, e =>
{
var p = GameEvents.ParseVictimNotification(e.Payload.Span);

View file

@ -450,15 +450,20 @@ public static class GameEvents
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
}
/// <summary>0x0264 QueryItemManaResponse: (itemGuid, manaPercent).</summary>
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent);
/// <summary>
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
/// reads the trailing 32-bit validity flag at message offset 0x0C.
/// </summary>
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid);
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan<byte> payload)
{
if (payload.Length < 8) return null;
if (payload.Length < 12) return null;
return new QueryItemManaResponse(
BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)));
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0);
}
/// <summary>0x0274 CharacterConfirmationRequest — server-driven modal confirm.</summary>

View file

@ -29,6 +29,7 @@ public static class SocialActions
// Queries
public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid
public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels
public const uint PingRequestOpcode = 0x01E9u; // u32 clientId
// Fellowship
@ -56,6 +57,20 @@ public static class SocialActions
return body;
}
/// <summary>
/// Query an owned item's mana fraction, or cancel the active query with item guid zero.
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
/// </summary>
public static byte[] BuildQueryItemMana(uint seq, uint itemGuid)
{
byte[] body = new byte[16];
BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryItemManaOpcode);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid);
return body;
}
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary>
public static byte[] BuildPingRequest(uint seq, uint clientId)
{

View file

@ -1379,6 +1379,17 @@ public sealed class WorldSession : IDisposable
SendGameAction(body);
}
/// <summary>
/// Send retail QueryItemMana (0x0263), using item guid zero to cancel.
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
/// </summary>
public void SendQueryItemMana(uint itemGuid)
{
uint seq = NextGameActionSequence();
byte[] body = SocialActions.BuildQueryItemMana(seq, itemGuid);
SendGameAction(body);
}
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
{

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Concurrent;
namespace AcDream.Core.Items;
/// <summary>
/// Last server-reported mana fraction for queried inventory items.
/// Retail routes <c>QueryItemManaResponse (0x0264)</c> through
/// <c>ClientUISystem::Handle_Item__QueryItemManaResponse @ 0x00563FE0</c>, including
/// the response's validity flag, before notifying the selected-object toolbar.
/// </summary>
public sealed class ItemManaState
{
private readonly ConcurrentDictionary<uint, float> _manaByGuid = new();
/// <summary>Fires for every valid or invalid query response.</summary>
public event Action<uint /*guid*/, float /*fraction*/, bool /*valid*/>? ItemManaChanged;
public float GetManaPercent(uint guid) =>
_manaByGuid.TryGetValue(guid, out float percent) ? percent : 0f;
public bool HasMana(uint guid) => _manaByGuid.ContainsKey(guid);
public void OnQueryItemManaResponse(uint itemGuid, float manaPercent, bool valid)
{
if (valid)
_manaByGuid[itemGuid] = manaPercent;
else
_manaByGuid.TryRemove(itemGuid, out _);
ItemManaChanged?.Invoke(itemGuid, manaPercent, valid);
}
public void Clear() => _manaByGuid.Clear();
}