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

@ -203,7 +203,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | | ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` |
| ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior | | ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior |
| ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | | ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` |
| AP-101 | Toolbar ammo-number updates are not wired | `src/AcDream.App/UI/Layout/ToolbarController.cs` | All seven DAT-discovered panel launchers, combat, Use, Examine, selected health/mana/name/stack controls, and shortcut actions work; unavailable panels are ghosted and mounted-panel highlights follow window lifecycle events | Missile combat does not display the equipped ammunition count | `gmToolbarUI::UpdateAmmoNumber`; `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | ~~AP-101~~ | **RETIRED 2026-07-11 (Wave 4.4e)** — toolbar control coverage is complete: DAT panel launchers, combat, Use/Examine, selected-object strip, shortcuts, and exact thrown-weapon/separate-ammo count resolution on authored element `0x10000194`. | `src/AcDream.Core/Items/ToolbarAmmoPolicy.cs`; `src/AcDream.App/UI/Layout/ToolbarController.cs` | — | — | `gmToolbarUI::UpdateAmmoID @ 0x004BF210`; `UpdateAmmoNumber @ 0x004BE9E0` |
| AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` |
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |

View file

@ -494,6 +494,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — Wave 4.4b selected-item mana.** Owned non-stack selections send exact `QueryItemMana 0x0263`; `0x0264` parses guid/fraction/trailing-valid into Core `ItemManaState`; valid matching responses reveal authored meter `0x100001A2`, invalid and visible selection-change paths cancel with guid zero. Health uses the same retail visible-meter cancellation rule. Wire/state/controller conformance is green in the 4,712-test Release suite. Live Mana Stone source-destruction → armor-mana-transfer → bar-update gate passed 2026-07-11. AP-101 now covers remaining toolbar controls only. - **✓ SHIPPED — Wave 4.4b selected-item mana.** Owned non-stack selections send exact `QueryItemMana 0x0263`; `0x0264` parses guid/fraction/trailing-valid into Core `ItemManaState`; valid matching responses reveal authored meter `0x100001A2`, invalid and visible selection-change paths cancel with guid zero. Health uses the same retail visible-meter cancellation rule. Wire/state/controller conformance is green in the 4,712-test Release suite. Live Mana Stone source-destruction → armor-mana-transfer → bar-update gate passed 2026-07-11. AP-101 now covers remaining toolbar controls only.
- **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11. - **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11.
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display. - **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
- **✓ 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. - **✓ 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.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository``ClientObjectTable` / `ItemInstance``ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.

View file

@ -0,0 +1,60 @@
# Retail toolbar ammunition pseudocode
## Named-retail oracle
- `gmToolbarUI::UpdateAmmoID @ 0x004BF210`
- `gmToolbarUI::UpdateAmmoNumber @ 0x004BE9E0`
- `gmToolbarUI::RecvNotice_ServerSaysMoveItem @ 0x004BF2B0`
- `gmToolbarUI::RecvNotice_ItemAttributesChanged @ 0x004BF8F0`
- `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00`
The Binary Ninja text corrupts the second `GetObjectAtLocation` constant into a
string literal. The surrounding branch, retail `INVENTORY_LOC` header, ACE
`EquipMask`, and holtburger equipment model agree that the two locations are
`MISSILE_WEAPON_LOC = 0x00400000` and `MISSILE_AMMO_LOC = 0x00800000`.
## Literal pseudocode
```text
UpdateAmmoID:
oldAmmoID = ammoID
ammoID = 0
player = GetWeenieObject(playerID)
if player exists:
weaponID = player.GetObjectAtLocation(MISSILE_WEAPON_LOC, priority=0)
if weaponID != 0:
weapon = GetWeenieObject(weaponID)
if weapon exists and weapon.maxStackSize > 1:
ammoID = weaponID
if ammoID == 0:
ammoID = player.GetObjectAtLocation(MISSILE_AMMO_LOC, priority=0)
if ammoID != oldAmmoID:
UpdateAmmoNumber()
UpdateAmmoNumber:
text = ""
ammo = GetWeenieObject(ammoID)
if ammo exists:
count = ammo.stackSize
if count == 0:
count = 1
text = decimal(count)
SetText(toolbarElement 0x10000194, text)
```
`GetObjectAtLocation` walks the player's ordered inventory-placement list and
returns the first entry whose location mask intersects the requested mask.
Stackable missile weapons therefore display their own count (thrown weapons);
ordinary launchers display the separately equipped ammunition stack.
Move notices touching player equipment locations `0x00C00000` recompute the
ammo object ID. Attribute/stack changes for the current ammo object refresh its
number. Player-description arrival also performs an initial resolution.
## Port boundary
`ToolbarAmmoPolicy` is pure Core logic over the ordered player placement list.
`ToolbarController` owns repository event subscriptions and writes the resulting
decimal label into authored element `0x10000194`. Missing ammo is an empty label.

View file

@ -582,6 +582,16 @@ App build and full 4,747-pass / 5-skip Release suite are green. The user confirm
launcher toggles, close-driven highlight clearing, unavailable-button ghosting, and launcher toggles, close-driven highlight clearing, unavailable-button ghosting, and
selected/targeted Use and Examine behavior in the connected Release client. selected/targeted Use and Examine behavior in the connected Release client.
**Wave 4.4e implementation (2026-07-11; live gate pending):** pure Core
`ToolbarAmmoPolicy` ports `gmToolbarUI::UpdateAmmoID @ 0x004BF210` and
`UpdateAmmoNumber @ 0x004BE9E0`. It walks the ordered player placements exactly
like `GetObjectAtLocation`: a stackable missile weapon supplies its own count,
otherwise the separate missile-ammo slot supplies the count; wire stack zero displays
as one and no ammo displays blank. `ToolbarController` refreshes authored missile
indicator `0x10000194` on relevant equipment and stack events and uses the DAT font.
Focused pure-policy, retained-event, and real-toolbar-fixture conformance is green.
The warning-free Release build and full 4,754-pass / 5-skip suite are green.
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8, **Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
shortcut to the selected owned/eligible object in the first empty slot. A focused shortcut to the selected owned/eligible object in the first empty slot. A focused
@ -594,8 +604,7 @@ indicator buttons `0x10000192..0x10000195` now all dispatch one injected combat
toggle command. The key binding and retained toolbar therefore converge on toggle command. The key binding and retained toolbar therefore converge on
`GameWindow.ToggleLiveCombatMode`, matching `gmToolbarUI::ListenToElementMessage `GameWindow.ToggleLiveCombatMode`, matching `gmToolbarUI::ListenToElementMessage
@ 0x004BEE90`; the visible indicator continues to update from authoritative @ 0x004BEE90`; the visible indicator continues to update from authoritative
`CombatState`. Raw shortcut preservation, drag/drop edge cases, and the toolbar `CombatState`. Raw shortcut preservation and drag/drop edge cases remain in this wave.
ammo-number display remain in this wave.
**Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`, **Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`,
`UIElement_UIItem::SetShortcutNum`, shortcut wire builders. `UIElement_UIItem::SetShortcutNum`, shortcut wire builders.

View file

@ -48,6 +48,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private const uint PanelIdAttribute = 0x10000029u; private const uint PanelIdAttribute = 0x10000029u;
private const uint UseButtonId = 0x1000019Du; private const uint UseButtonId = 0x1000019Du;
private const uint ExamineButtonId = 0x100001A5u; private const uint ExamineButtonId = 0x100001A5u;
private const uint AmmoIndicatorId = 0x10000194u;
private const uint InventoryButtonId = 0x100001B1u; private const uint InventoryButtonId = 0x100001B1u;
private static readonly uint[] PanelButtonIds = private static readonly uint[] PanelButtonIds =
{ 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId }; { 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId };
@ -57,6 +58,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly UiButton? _inventoryButton; private readonly UiButton? _inventoryButton;
private readonly UiButton? _useButton; private readonly UiButton? _useButton;
private readonly UiButton? _examineButton; private readonly UiButton? _examineButton;
private readonly UiButton? _ammoIndicator;
private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new(); private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new();
private readonly ClientObjectTable _repo; private readonly ClientObjectTable _repo;
private readonly CombatState? _combatState; private readonly CombatState? _combatState;
@ -72,6 +74,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Func<uint> _selectedObjectId; private readonly Func<uint> _selectedObjectId;
private readonly Func<uint>? _playerGuid; private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private uint _ammoObjectId;
private bool _disposed; private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9). // 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, Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null, Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = null, Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null) Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null)
{ {
_repo = repo; _repo = repo;
_combatState = combatState; _combatState = combatState;
@ -149,6 +153,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
button.OnClick = toggleCombat; 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) foreach (uint elementId in PanelButtonIds)
{ {
if (layout.FindElement(elementId) is UiButton panelButton if (layout.FindElement(elementId) is UiButton panelButton
@ -189,14 +200,60 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectAdded += OnRepositoryObjectChanged; repo.ObjectAdded += OnRepositoryObjectChanged;
repo.ObjectUpdated += OnRepositoryObjectChanged; repo.ObjectUpdated += OnRepositoryObjectChanged;
repo.ObjectRemoved += OnRepositoryObjectChanged; repo.ObjectRemoved += OnRepositoryObjectChanged;
repo.ObjectMoved += OnRepositoryObjectMoved;
RefreshAmmo();
} }
private void OnRepositoryObjectChanged(ClientObject obj) private void OnRepositoryObjectChanged(ClientObject obj)
{ {
if (IsAmmoRelated(obj))
RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId)) if (IsShortcutGuid(obj.ObjectId))
Populate(); 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> /// <summary>
/// Returns true if <paramref name="guid"/> is one of the currently-active shortcut guids /// 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"/>). /// (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, Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null, Func<uint>? selectedObjectId = null,
Func<uint>? playerGuid = 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, var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction, peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem, sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, playerGuid, sendPutItemInContainer); selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont);
c.Populate(); c.Populate();
return c; return c;
} }
@ -638,5 +696,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_repo.ObjectAdded -= OnRepositoryObjectChanged; _repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged; _repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= 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), selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u, selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u,
playerGuid: b.PlayerGuid, playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer); sendPutItemInContainer: b.SendPutItemInContainer,
ammoFont: _bindings.Assets.DefaultFont);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection); ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind( SelectedObjectController = Layout.SelectedObjectController.Bind(
layout, 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;
}
}

View file

@ -336,6 +336,17 @@ public class ToolbarControllerTests
} }
} }
[Fact]
public void RetailFixture_ammoNumberUsesAuthoredMissileIndicatorElement()
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
var ammoIndicator = Assert.IsType<UiButton>(layout.FindElement(0x10000194u));
Assert.Equal(55f, ammoIndicator.Width);
Assert.Equal(58f, ammoIndicator.Height);
}
[Fact] [Fact]
public void UseAndExamineButtons_dispatchSelectedObjectPolicy() public void UseAndExamineButtons_dispatchSelectedObjectPolicy()
{ {
@ -387,6 +398,76 @@ public class ToolbarControllerTests
// ── C1: combat-mode indicator tests ───────────────────────────────────── // ── C1: combat-mode indicator tests ─────────────────────────────────────
[Fact]
public void AmmoNumber_usesStackableMissileWeapon_andTracksStackChanges()
{
const uint player = 0x50000001u;
const uint thrownWeapon = 0x50000002u;
const uint separateAmmo = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject
{
ObjectId = thrownWeapon,
StackSize = 7,
StackSizeMax = 25,
});
repo.MoveItem(thrownWeapon, player, newEquipLocation: EquipMask.MissileWeapon);
repo.AddOrUpdate(new ClientObject
{
ObjectId = separateAmmo,
StackSize = 80,
StackSizeMax = 100,
});
repo.MoveItem(separateAmmo, player, newEquipLocation: EquipMask.MissileAmmo);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player);
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
Assert.Equal("7", ammoButton.Label);
Assert.True(repo.UpdateStackSize(thrownWeapon, 6, value: 0));
Assert.Equal("6", ammoButton.Label);
}
[Fact]
public void AmmoNumber_forLauncher_usesAmmoSlot_andClearsOnUnwield()
{
const uint player = 0x50000001u;
const uint pack = 0x50000010u;
const uint bow = 0x50000002u;
const uint arrows = 0x50000003u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 });
repo.MoveItem(bow, player, newEquipLocation: EquipMask.MissileWeapon);
repo.AddOrUpdate(new ClientObject { ObjectId = arrows, StackSize = 42, StackSizeMax = 100 });
repo.MoveItem(arrows, player, newEquipLocation: EquipMask.MissileAmmo);
ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player);
var ammoButton = (UiButton)layout.FindElement(0x10000194u)!;
Assert.Equal("42", ammoButton.Label);
Assert.True(repo.MoveItem(arrows, pack));
Assert.Null(ammoButton.Label);
}
[Fact] [Fact]
public void CombatIndicators_allDispatchSharedRetailToggleCommand() public void CombatIndicators_allDispatchSharedRetailToggleCommand()
{ {

View file

@ -0,0 +1,65 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class ToolbarAmmoPolicyTests
{
[Fact]
public void StackableMissileWeapon_isItsOwnAmmo()
{
var thrown = Item(1u, EquipMask.MissileWeapon, stack: 7, maxStack: 25);
var separateAmmo = Item(2u, EquipMask.MissileAmmo, stack: 80, maxStack: 100);
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(
new[] { thrown, separateAmmo });
Assert.Equal(new ToolbarAmmoPolicy.Result(1u, 7), result);
}
[Fact]
public void NonStackableLauncher_usesSeparateAmmoSlot()
{
var bow = Item(1u, EquipMask.MissileWeapon, stack: 1, maxStack: 1);
var arrows = Item(2u, EquipMask.MissileAmmo, stack: 42, maxStack: 100);
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(
new[] { bow, arrows });
Assert.Equal(new ToolbarAmmoPolicy.Result(2u, 42), result);
}
[Fact]
public void MissingAmmo_isHidden_andWireZeroStackDisplaysOne()
{
Assert.Equal(default, ToolbarAmmoPolicy.Resolve(Array.Empty<ClientObject>()));
var arrows = Item(3u, EquipMask.MissileAmmo, stack: 0, maxStack: 100);
Assert.Equal(
new ToolbarAmmoPolicy.Result(3u, 1),
ToolbarAmmoPolicy.Resolve(new[] { arrows }));
}
[Fact]
public void FirstIntersectingInventoryPlacementWins()
{
var first = Item(1u, EquipMask.MissileAmmo | EquipMask.Held, stack: 10, maxStack: 100);
var second = Item(2u, EquipMask.MissileAmmo, stack: 20, maxStack: 100);
Assert.Equal(
new ToolbarAmmoPolicy.Result(1u, 10),
ToolbarAmmoPolicy.Resolve(new[] { first, second }));
}
private static ClientObject Item(
uint id,
EquipMask location,
int stack,
int maxStack)
=> new()
{
ObjectId = id,
CurrentlyEquippedLocation = location,
StackSize = stack,
StackSizeMax = maxStack,
};
}