feat(ui): port retail toolbar ammo count

Resolve stacked thrown weapons versus separately equipped missile ammo exactly like gmToolbarUI, normalize a zero stack to one, and refresh the authored missile indicator from equipment and stack events. Keep the decision in pure Core with retained and real-DAT conformance coverage.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 12:29:51 +02:00
parent 400f01eef1
commit 564d39dfea
9 changed files with 330 additions and 7 deletions

View file

@ -48,6 +48,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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 };
@ -57,6 +58,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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;
@ -72,6 +74,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Func<uint> _selectedObjectId;
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).
@ -103,7 +106,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null)
{
_repo = repo;
_combatState = combatState;
@ -149,6 +153,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
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
@ -189,14 +200,60 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectAdded += OnRepositoryObjectChanged;
repo.ObjectUpdated += OnRepositoryObjectChanged;
repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved;
RefreshAmmo();
}
private void OnRepositoryObjectChanged(ClientObject obj)
{
if (IsAmmoRelated(obj))
RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId))
Populate();
}
private void OnRepositoryObjectMoved(ClientObject obj, uint oldContainer, uint newContainer)
{
uint player = _playerGuid?.Invoke() ?? 0u;
if (obj.ObjectId == _ammoObjectId
|| (player != 0 && (oldContainer == player || newContainer == player)))
RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId))
Populate();
}
private bool IsAmmoRelated(ClientObject obj)
{
uint player = _playerGuid?.Invoke() ?? 0u;
return obj.ObjectId == _ammoObjectId
|| (player != 0 && obj.ObjectId == player)
|| (player != 0
&& obj.ContainerId == player
&& (obj.CurrentlyEquippedLocation
& (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0);
}
private void RefreshAmmo()
{
uint player = _playerGuid?.Invoke() ?? 0u;
var placements = new List<ClientObject>();
if (player != 0)
{
foreach (uint objectId in _repo.GetContents(player))
if (_repo.Get(objectId) is { } item)
placements.Add(item);
}
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"/>).
@ -264,12 +321,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, playerGuid, sendPutItemInContainer);
selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont);
c.Populate();
return c;
}
@ -638,5 +696,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
_repo.ObjectMoved -= OnRepositoryObjectMoved;
}
}

View file

@ -390,7 +390,8 @@ public sealed class RetailUiRuntime : IDisposable
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer);
sendPutItemInContainer: b.SendPutItemInContainer,
ammoFont: _bindings.Assets.DefaultFont);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind(
layout,

View file

@ -0,0 +1,47 @@
namespace AcDream.Core.Items;
/// <summary>
/// Pure retail toolbar ammunition selection.
/// Retail references: <c>gmToolbarUI::UpdateAmmoID @ 0x004BF210</c> and
/// <c>gmToolbarUI::UpdateAmmoNumber @ 0x004BE9E0</c>.
/// </summary>
public static class ToolbarAmmoPolicy
{
public readonly record struct Result(uint ObjectId, int DisplayCount)
{
public bool IsVisible => ObjectId != 0;
}
/// <summary>
/// Resolve the first equipped missile weapon. If it is stackable, retail
/// treats that stack as its own ammunition (thrown weapons). Otherwise it
/// resolves the first separately equipped missile-ammo object.
/// </summary>
public static Result Resolve(IReadOnlyList<ClientObject> inventoryPlacements)
{
ArgumentNullException.ThrowIfNull(inventoryPlacements);
ClientObject? missileWeapon = FindAtLocation(
inventoryPlacements,
EquipMask.MissileWeapon);
ClientObject? ammo = missileWeapon is { StackSizeMax: > 1 }
? missileWeapon
: FindAtLocation(inventoryPlacements, EquipMask.MissileAmmo);
return ammo is null
? default
: new Result(ammo.ObjectId, ammo.StackSize == 0 ? 1 : ammo.StackSize);
}
private static ClientObject? FindAtLocation(
IReadOnlyList<ClientObject> inventoryPlacements,
EquipMask location)
{
// ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00 returns the first
// inventory placement whose location mask intersects the requested mask.
foreach (ClientObject item in inventoryPlacements)
if ((item.CurrentlyEquippedLocation & location) != 0)
return item;
return null;
}
}