feat(ui): drive toolbar use state from selection

Port the retail selected-object availability predicate into Core and project it through the shared interaction owner. The imported hand now follows canonical selection/object notices, keeps weapon and targeted-tool activation on the existing wield/use cursor paths, and ghosts empty or explicitly unusable selections per the connected UX requirement.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-23 08:07:47 +02:00
parent 0134122c28
commit 60387668d0
11 changed files with 269 additions and 16 deletions

View file

@ -107,7 +107,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 88 active rows ## 3. Documented approximation (AP) — 89 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -218,6 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
| AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` | | AP-120 | `ObjectTableWiring.ApplyEntitySpawn` publishes the CreateObject's WeenieDesc/item state before the same-generation physics update tail. Retail applies WeenieDesc after ObjDesc, Parent-or-Position/Pickup, Movement, State, and Vector. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (`ApplyEntitySpawn`); `src/AcDream.App/World/LiveEntitySameGenerationUpdateRouter.cs` | Core.Net owns item-table ingestion before App callbacks and the current single-thread FIFO prevents a second network packet from interleaving; changing publication order crosses the Core.Net/App ownership boundary and requires a separately tested event transaction. | A synchronous item-table observer can see the refreshed WeenieDesc while the same object's physics/parent/state still reflects the prior snapshot; retail observers see the completed physics tail first. | `SmartBox::HandleCreateObject @ 0x00454C80`; `ACCObjectMaint::CreateObject @ 0x00558870` |
| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | | AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` |
| AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` |
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) ## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)

View file

@ -12,14 +12,14 @@ The active work order is
It closes six remaining retail interaction surfaces before larger M4 subsystem It closes six remaining retail interaction surfaces before larger M4 subsystem
bodies begin: spell-bar overflow controls, status-bar Use/Assess commands, bodies begin: spell-bar overflow controls, status-bar Use/Assess commands,
assessment information, equipped-child picking, vendor browsing, and assessment information, equipped-child picking, vendor browsing, and
server-authoritative vendor transactions. Slice 1 is active and ports the server-authoritative vendor transactions. Slice 1's DAT-authored arrow-only
DAT-authored arrow-only favorite-spell scrollbar through the shared retained favorite-spell scrollbar, fixed 18-cell capacity, authored arrow sides/media,
scrollbar/list model. The corrected import preserves authored arrow sides and and persistent manual offset passed the connected gate. Slice 2 is active: the
rollover/pressed media, passive refresh preserves manual offsets, and the status hand now follows canonical selection/object notices, ghosts empty and
mixed-parent DAT anchor chain produces the fixed retail capacity of 18 visible unusable selections, and routes enabled weapons, direct-use items, and
favorite cells (nine numbered plus nine unnumbered). Its use-on-target tools through the existing interaction transaction and cursor
implementation and complete Release suite pass; connected overflow-arrow owners. Its connected hand-state gate is pending; the magnifying-glass Assess
visual acceptance is pending. command follows in the same slice.
--- ---

View file

@ -89,6 +89,11 @@ transactions. This is deliberately using the extracted interaction owners and
canonical shared main-panel host before quest/emote/character-creation bodies canonical shared main-panel host before quest/emote/character-creation bodies
broaden the feature surface. broaden the feature surface.
Slice 1's 18-cell favorite-spell overflow bar is user-accepted. Slice 2 is
active: the status hand's selected-object availability and existing
Use/AutoWield/use-on-target command routing are code-complete, with the
connected state/cursor gate pending before the Assess half continues.
Carried: Carried:
#153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus #153 far-teleport residual, #116 slide-response, the remaining R6 ownership cleanup plus
registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset registered TS-50/TS-51/TS-53 timing residuals (the complete-root-Frame/object-workset

View file

@ -1,6 +1,7 @@
# World interaction completion — pre-M4 program # World interaction completion — pre-M4 program
**Status:** Slice 1 code-complete 2026-07-23; connected visual gate pending. **Status:** Slice 1 user-accepted 2026-07-23. Slice 2 Use-hand state and command
path code-complete; connected visual gate pending before Assess continues.
**Milestone:** M4 prerequisite/preamble. **Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and **Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell. interaction state. `GameWindow` remains a composition/callback shell.
@ -52,9 +53,21 @@ spells. Passive object/endowment refresh preserves a manual offset. The mixed
producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine producing exactly 18 visible 32-pixel favorite cells (nine numbered plus nine
unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed; unnumbered) without consuming the overflow. The initial 57-test/full-suite gate passed;
the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution the corrective 104-test focus set, 3,472 App tests / 3 skips, Release solution
build, and 7,845 complete-solution tests / 5 skips pass. The connected gate is build, and 7,845 complete-solution tests / 5 skips pass. The corrected
pending after user feedback on arrow sides, rollover, width, and offset 18-cell bar and both overflow directions passed the connected gate.
persistence.
Slice 2 now drives the toolbar hand from canonical `SelectionState` changes and
live `ClientObjectTable` updates. The pure Core predicate ports
`gmToolbarUI::HandleSelectionChanged`: combat-use items, armor/clothing/jewelry,
and `ItemUses` values without `USEABLE_NO` remain active. Clicking still enters
the existing `ItemInteractionController` command path, so weapons use the
server-confirmed AutoWield transaction and targeted tools enter the existing
use-on-target cursor. Empty selection and explicitly unusable spell components
are ghosted and ignore clicks. Empty-selection ghosting is the user's explicit
choice over retail's generic `TARGET_MODE_USE` entry and is registered as
AP-122. The 14-test Core interaction focus, 49-test toolbar focus, Release
solution build, 3,474 App tests / 3 skips, and 7,848 complete-solution tests /
5 skips pass.
## Slice 1 — spell-bar overflow arrows ## Slice 1 — spell-bar overflow arrows
@ -115,3 +128,27 @@ Named retail references and executable pseudocode are recorded in
- Existing stack, combat-power, chat, inventory, spellbook, and external - Existing stack, combat-power, chat, inventory, spellbook, and external
container scrollbars retain their current behavior. container scrollbars retain their current behavior.
- No substantial feature body enters `GameWindow`. - No substantial feature body enters `GameWindow`.
## Slice 2 — status Use/Assess commands
### Use-hand implementation
1. `ItemInteractionPolicy.IsToolbarUseEnabled` is the pure named-retail
selection predicate.
2. `ItemInteractionController.IsToolbarUseEnabled` adapts the selected live
object without triggering a request or consuming the use throttle.
3. `ToolbarController` subscribes to canonical `SelectionState.Changed` and
selected-object add/update/remove notices, then sets the imported button's
normal or ghosted state through `UiButton.Enabled`.
4. Enabled clicks retain the one existing activation path:
equipment enters `AutoWieldController`, ordinary use enters the normal Use
request owner, and targeted items enter `UseItemOnTarget`, which already
owns the retail target cursor.
### Remaining Slice 2 work
- Port the magnifying-glass Assess command state and activation.
- Run the connected Use-hand visual gate: no selection, spell component,
weapon, and health kit.
- Continue directly into the Assess information slice after the combined
status-control gate.

View file

@ -579,8 +579,15 @@ HandleSelectionChanged(): // 0x004BF380
``` ```
The exact item-type mask in the use-button ghost test is visible as separate The exact item-type mask in the use-button ghost test is visible as separate
`InqType() & 4`, `& 2`, and `& 8` checks. Preserve those numeric tests until the `InqType() & 4`, `& 2`, and `& 8` checks. The verbatim `ITEM_TYPE` enum now pins
retail `ITEM_TYPE` names are pinned. those values as Clothing, Armor, and Jewelry respectively.
**2026-07-23 connected requirement:** acdream intentionally ghosts the hand
when the selection is empty. Retail instead sets state 1 and lets a click enter
generic `TARGET_MODE_USE` (see `ListenToElementMessage @ 0x004BEE90`). Selected
objects still use the exact predicate above and still activate through the
ordinary `ItemHolder::UseObject` path. The empty-selection difference is
registered as AP-122.
### 5.3 Meter notices ### 5.3 Meter notices

View file

@ -496,6 +496,23 @@ public sealed class ItemInteractionController : IDisposable
return true; return true;
} }
/// <summary>
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
/// from the live selected object's public description. This is deliberately
/// a read-only query; activation still flows through <see cref="ActivateItem"/>.
/// </summary>
public bool IsToolbarUseEnabled(uint selectedObjectId)
{
if (selectedObjectId == 0
|| _objects.Get(selectedObjectId) is not { } item)
return false;
return ItemInteractionPolicy.IsToolbarUseEnabled(
item.Type,
item.CombatUse ?? 0,
item.Useability ?? ItemUseability.Undef);
}
public bool ActivateItem(uint itemGuid) public bool ActivateItem(uint itemGuid)
{ {
if (itemGuid == 0) return false; if (itemGuid == 0) return false;

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -73,6 +74,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly ItemInteractionController? _itemInteraction; private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem; private readonly Action<uint>? _selectItem;
private readonly Func<uint> _selectedObjectId; private readonly Func<uint> _selectedObjectId;
private readonly SelectionState? _selection;
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 uint _ammoObjectId;
@ -106,6 +108,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action? toggleCombat = null, Action? toggleCombat = null,
Action<uint>? selectItem = null, Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null, Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null, Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null, Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null, UiDatFont? ammoFont = null,
@ -125,6 +128,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_sendRemoveShortcut = sendRemoveShortcut; _sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem; _selectItem = selectItem;
_selectedObjectId = selectedObjectId ?? (() => 0u); _selectedObjectId = selectedObjectId ?? (() => 0u);
_selection = selection;
_playerGuid = playerGuid; _playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
@ -196,6 +200,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
// Wire live combat-mode changes if a CombatState was provided. // Wire live combat-mode changes if a CombatState was provided.
if (_combatState is not null) if (_combatState is not null)
_combatState.CombatModeChanged += SetCombatMode; _combatState.CombatModeChanged += SetCombatMode;
if (_selection is not null)
_selection.Changed += OnSelectionChanged;
// D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter // D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter
// to our 18 shortcut guids — else every creature spawn in a busy zone // to our 18 shortcut guids — else every creature spawn in a busy zone
@ -206,6 +212,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
repo.ObjectMoved += OnRepositoryObjectMoved; repo.ObjectMoved += OnRepositoryObjectMoved;
repo.Cleared += OnRepositoryCleared; repo.Cleared += OnRepositoryCleared;
RefreshAmmo(); RefreshAmmo();
RefreshUseButton();
} }
private void OnRepositoryObjectChanged(ClientObject obj) private void OnRepositoryObjectChanged(ClientObject obj)
@ -214,6 +221,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
RefreshAmmo(); RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId)) if (IsShortcutGuid(obj.ObjectId))
Populate(); Populate();
if (obj.ObjectId == _selectedObjectId())
RefreshUseButton();
} }
private void OnRepositoryObjectMoved(ClientObjectMove move) private void OnRepositoryObjectMoved(ClientObjectMove move)
@ -234,6 +243,23 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
{ {
RefreshAmmo(); RefreshAmmo();
Populate(); Populate();
RefreshUseButton();
}
private void OnSelectionChanged(SelectionTransition _)
=> RefreshUseButton();
private void RefreshUseButton()
{
if (_useButton is null)
return;
// Retail normally leaves state 1 active when selectedID == 0 so the
// click can enter generic TARGET_MODE_USE. acdream intentionally
// ghosts that empty state per the connected UX requirement; every
// selected-object classification below is the exact retail predicate.
_useButton.Enabled =
_itemInteraction?.IsToolbarUseEnabled(_selectedObjectId()) == true;
} }
private bool IsAmmoRelated(ClientObject obj) private bool IsAmmoRelated(ClientObject obj)
@ -330,6 +356,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action? toggleCombat = null, Action? toggleCombat = null,
Action<uint>? selectItem = null, Action<uint>? selectItem = null,
Func<uint>? selectedObjectId = null, Func<uint>? selectedObjectId = null,
SelectionState? selection = null,
Func<uint>? playerGuid = null, Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null, Action<uint, uint, int>? sendPutItemInContainer = null,
UiDatFont? ammoFont = null, UiDatFont? ammoFont = null,
@ -338,7 +365,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
regularDigits, ghostedDigits, emptyDigits, itemInteraction, regularDigits, ghostedDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem, sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
selectedObjectId, playerGuid, sendPutItemInContainer, ammoFont, selectedObjectId, selection, playerGuid, sendPutItemInContainer, ammoFont,
dragIconIds); dragIconIds);
c.Populate(); c.Populate();
return c; return c;
@ -736,6 +763,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
if (_combatState is not null) if (_combatState is not null)
_combatState.CombatModeChanged -= SetCombatMode; _combatState.CombatModeChanged -= SetCombatMode;
if (_selection is not null)
_selection.Changed -= OnSelectionChanged;
_repo.ObjectAdded -= OnRepositoryObjectChanged; _repo.ObjectAdded -= OnRepositoryObjectChanged;
_repo.ObjectUpdated -= OnRepositoryObjectChanged; _repo.ObjectUpdated -= OnRepositoryObjectChanged;
_repo.ObjectRemoved -= OnRepositoryObjectChanged; _repo.ObjectRemoved -= OnRepositoryObjectChanged;

View file

@ -689,6 +689,7 @@ public sealed class RetailUiRuntime : IDisposable
toggleCombat: b.ToggleCombat, toggleCombat: b.ToggleCombat,
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,
selection: b.Selection,
playerGuid: b.PlayerGuid, playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer, sendPutItemInContainer: b.SendPutItemInContainer,
ammoFont: _bindings.Assets.DefaultFont, ammoFont: _bindings.Assets.DefaultFont,

View file

@ -151,6 +151,22 @@ public static class ItemInteractionPolicy
private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u; private static readonly EquipMask BodyLocations = (EquipMask)0x00007E00u;
private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu; private static readonly EquipMask ClothingLocations = (EquipMask)0x080001FFu;
private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u; private static readonly EquipMask HeldLocations = (EquipMask)0x7C0F8000u;
private const ItemType ToolbarEquipmentTypes =
ItemType.Armor | ItemType.Clothing | ItemType.Jewelry;
/// <summary>
/// Exact selected-object predicate used by retail's toolbar hand.
/// <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c> leaves the
/// button active for combat-use objects, armor/clothing/jewelry, or an
/// <c>ItemUses</c> value whose <c>USEABLE_NO</c> bit is clear.
/// </summary>
public static bool IsToolbarUseEnabled(
ItemType type,
byte combatUse,
uint useability)
=> combatUse != 0
|| (type & ToolbarEquipmentTypes) != 0
|| (useability & ItemUseability.No) == 0;
public static ItemPrimaryUseResult DetermineUseResult( public static ItemPrimaryUseResult DetermineUseResult(
in ItemPolicyObject item, in ItemPolicyObject item,

View file

@ -5,6 +5,7 @@ using AcDream.App.UI.Layout;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -515,6 +516,123 @@ public class ToolbarControllerTests
Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind); Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind);
} }
[Fact]
public void UseButton_tracksEmptyUnusableAndTargetedSelection()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint component = 0x50000003u;
const uint healthKit = 0x50000004u;
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.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = component,
Type = ItemType.SpellComponents,
Useability = ItemUseability.No,
});
repo.MoveItem(component, pack, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = healthKit,
Type = ItemType.Misc,
Useability = 0x00220008u,
TargetType = (uint)ItemType.Creature,
});
repo.MoveItem(healthKit, pack, 1);
var selection = new SelectionState();
using var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null);
using var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
selection: selection);
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(InteractionModeKind.None, interaction.InteractionState.Current.Kind);
selection.Select(component, SelectionChangeSource.Inventory);
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
selection.Select(healthKit, SelectionChangeSource.Inventory);
Assert.True(use.Enabled);
Assert.Equal("Normal", use.ActiveState);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(
InteractionModeKind.UseItemOnTarget,
interaction.InteractionState.Current.Kind);
selection.Clear(SelectionChangeSource.Inventory);
Assert.False(use.Enabled);
Assert.Equal("Ghosted", use.ActiveState);
}
[Fact]
public void UseButton_selectedCombatItem_routesExistingWieldPath()
{
const uint player = 0x50000001u;
const uint pack = 0x50000002u;
const uint sword = 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.MoveItem(pack, player, 0);
repo.AddOrUpdate(new ClientObject
{
ObjectId = sword,
Type = ItemType.MeleeWeapon,
CombatUse = 1,
Useability = ItemUseability.No,
ValidLocations = EquipMask.MeleeWeapon,
});
repo.MoveItem(sword, pack, 0);
var selection = new SelectionState();
selection.Select(sword, SelectionChangeSource.Inventory);
var wields = new List<(uint Item, uint Location)>();
using var interaction = new ItemInteractionController(
repo,
() => player,
sendUse: null,
sendUseWithTarget: null,
sendWield: (item, location) => wields.Add((item, location)),
sendDrop: null);
using var controller = ToolbarController.Bind(
layout,
repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
itemInteraction: interaction,
selectedObjectId: () => selection.SelectedObjectId ?? 0u,
selection: selection);
var use = Assert.IsType<UiButton>(layout.FindElement(UseButtonId));
Assert.True(use.Enabled);
use.OnEvent(new UiEvent(0u, use, UiEventType.Click));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields);
}
// ── C1: combat-mode indicator tests ───────────────────────────────────── // ── C1: combat-mode indicator tests ─────────────────────────────────────
[Fact] [Fact]

View file

@ -7,6 +7,28 @@ public sealed class ItemInteractionPolicyTests
private const uint Player = 0x50000001u; private const uint Player = 0x50000001u;
private const uint GroundContainer = 0x70000001u; private const uint GroundContainer = 0x70000001u;
[Fact]
public void ToolbarUseEnabled_matchesRetailSelectionPredicate()
{
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.MeleeWeapon,
combatUse: 1,
useability: ItemUseability.No));
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.Armor | ItemType.Clothing,
combatUse: 0,
useability: ItemUseability.No));
Assert.True(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.Misc,
combatUse: 0,
useability: 0x00220008u));
Assert.False(ItemInteractionPolicy.IsToolbarUseEnabled(
ItemType.SpellComponents,
combatUse: 0,
useability: ItemUseability.No));
}
[Fact] [Fact]
public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch() public void DetermineUseResult_looseItemAndComponentPack_matchRetailPickupBranch()
{ {