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:
parent
400f01eef1
commit
564d39dfea
9 changed files with 330 additions and 7 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
60
docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md
Normal file
60
docs/research/2026-07-11-retail-toolbar-ammo-pseudocode.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
47
src/AcDream.Core/Items/ToolbarAmmoPolicy.cs
Normal file
47
src/AcDream.Core/Items/ToolbarAmmoPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
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<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]
|
||||
public void CombatIndicators_allDispatchSharedRetailToggleCommand()
|
||||
{
|
||||
|
|
|
|||
65
tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs
Normal file
65
tests/AcDream.Core.Tests/Items/ToolbarAmmoPolicyTests.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue