From 564d39dfea36359742c57ddd71e287ee93a2c83c Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 12:29:51 +0200 Subject: [PATCH] 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 --- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 1 + ...26-07-11-retail-toolbar-ammo-pseudocode.md | 60 ++++++++++++++ ...026-07-10-retail-ui-fidelity-completion.md | 13 ++- .../UI/Layout/ToolbarController.cs | 65 ++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 3 +- src/AcDream.Core/Items/ToolbarAmmoPolicy.cs | 47 +++++++++++ .../UI/Layout/ToolbarControllerTests.cs | 81 +++++++++++++++++++ .../Items/ToolbarAmmoPolicyTests.cs | 65 +++++++++++++++ 9 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md create mode 100644 src/AcDream.Core/Items/ToolbarAmmoPolicy.cs create mode 100644 tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8bb08ee1..0b6de775 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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-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 100–360 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-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-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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4653855e..123e2a64 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -494,6 +494,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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.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. +- **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 — 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. diff --git a/docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md b/docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md new file mode 100644 index 00000000..cddb2f49 --- /dev/null +++ b/docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md @@ -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. diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index 472806bd..ad633baf 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -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 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, 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 @@ -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 `GameWindow.ToggleLiveCombatMode`, matching `gmToolbarUI::ListenToElementMessage @ 0x004BEE90`; the visible indicator continues to update from authoritative -`CombatState`. Raw shortcut preservation, drag/drop edge cases, and the toolbar -ammo-number display remain in this wave. +`CombatState`. Raw shortcut preservation and drag/drop edge cases remain in this wave. **Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`, `UIElement_UIItem::SetShortcutNum`, shortcut wire builders. diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 4b60b4ab..dc3bb419 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -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 _selectedObjectId; private readonly Func? _playerGuid; private readonly Action? _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? selectItem = null, Func? selectedObjectId = null, Func? playerGuid = null, - Action? sendPutItemInContainer = null) + Action? 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(); + 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; + } + } + /// /// Returns true if is one of the currently-active shortcut guids /// (i.e., present in the live ). @@ -264,12 +321,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Action? selectItem = null, Func? selectedObjectId = null, Func? playerGuid = null, - Action? sendPutItemInContainer = null) + Action? 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; } } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index ba5ab2e9..e88f9195 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -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, diff --git a/src/AcDream.Core/Items/ToolbarAmmoPolicy.cs b/src/AcDream.Core/Items/ToolbarAmmoPolicy.cs new file mode 100644 index 00000000..a4fded9f --- /dev/null +++ b/src/AcDream.Core/Items/ToolbarAmmoPolicy.cs @@ -0,0 +1,47 @@ +namespace AcDream.Core.Items; + +/// +/// Pure retail toolbar ammunition selection. +/// Retail references: gmToolbarUI::UpdateAmmoID @ 0x004BF210 and +/// gmToolbarUI::UpdateAmmoNumber @ 0x004BE9E0. +/// +public static class ToolbarAmmoPolicy +{ + public readonly record struct Result(uint ObjectId, int DisplayCount) + { + public bool IsVisible => ObjectId != 0; + } + + /// + /// 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. + /// + public static Result Resolve(IReadOnlyList 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 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; + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index f428da2a..30669dfc 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -336,6 +336,17 @@ public class ToolbarControllerTests } } + [Fact] + public void RetailFixture_ammoNumberUsesAuthoredMissileIndicatorElement() + { + ImportedLayout layout = FixtureLoader.LoadToolbar(); + + var ammoIndicator = Assert.IsType(layout.FindElement(0x10000194u)); + + Assert.Equal(55f, ammoIndicator.Width); + Assert.Equal(58f, ammoIndicator.Height); + } + [Fact] public void UseAndExamineButtons_dispatchSelectedObjectPolicy() { @@ -387,6 +398,76 @@ public class ToolbarControllerTests // ── 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(), + 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(), + 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] public void CombatIndicators_allDispatchSharedRetailToggleCommand() { diff --git a/tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs new file mode 100644 index 00000000..0cf9e493 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs @@ -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())); + + 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, + }; +}