diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9182352f..4ed37884 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1607,9 +1607,9 @@ R5/R6 touches the action list. **Where:** **Description:** `UiRoot`'s window registry flips `Visible` with no lifecycle event, toolbar-button sync is manually pushed at each mutation site, and acdream has no retained external/ground-container window. The original issue incorrectly assigned `NoLongerViewingContents (0x0195)` to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to `ClientUISystem.groundObject`: replacing or closing an external container sends `0x0195` exactly once. The owned `gmInventoryUI` side-bag path has no corresponding static send. -**Root cause / status:** Review finding (theme T2), re-framed by the 2026-07-10 named-retail trace. Add typed retained-window lifecycle events and subscriber-driven toolbar state. Port a separate external-container owner whose ground-object replacement/close path emits one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace. +**Root cause / status:** PARTIAL 2026-07-11. Typed retained-window visibility events and subscriber-driven toolbar state shipped in Wave 4.4d, replacing manual sync at mutation sites. The issue remains open for the separate external-container owner whose ground-object replacement/close path must emit one `0x0195`. Do not add that packet to owned side-bag navigation without a live packet trace. -**Files:** `src/AcDream.App/UI/UiRoot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`, `src/AcDream.App/Rendering/GameWindow.cs` (`CloseRetailWindow`/`SyncToolbarWindowButtons`); future external-container controller. +**Files:** `src/AcDream.App/UI/RetailWindowManager.cs`, `src/AcDream.App/UI/RetailUiRuntime.cs`; future external-container controller. **Research:** `docs/research/2026-07-02-ui-architecture-review.md`; correction oracle `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md`. diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 191d2f09..8bb08ee1 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 | Most toolbar panel/Use/Examine controls are not fully dispatched | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, selected-item mana with exact query/cancel validity handling, exact stack name/entry/slider, shared split quantity, quick-slot actions, some panel toggles, and all four combat indicators work | Remaining bound retail buttons do nothing | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | +| 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-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 79a739da..0332146a 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -493,6 +493,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11. - **✓ 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. +- **Wave 4.4d implemented — toolbar launchers + Use/Examine (live gate pending).** 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 now 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; AP-101 is narrowed to the remaining ammo-number display. - **✓ 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-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md index c163e19c..5c357be2 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -1125,8 +1125,10 @@ implementation patch. 3. `ToolbarController.HandleDropRelease` uses the shortcut-source restoration branch for every source. Fresh inventory drops need the cyclic-right empty-slot branch. -4. Toolbar input actions, seven panel buttons, Use, Examine, mana, and stack - controls are not fully wired. +4. **Resolved 2026-07-11:** toolbar input actions, all seven DAT-discovered panel + buttons, Use, Examine, selected-item mana, and stack controls are wired. Mounted + panel state follows typed retained-window visibility events; unavailable panels + are ghosted. The separate ammo-number display remains tracked as AP-101. 5. `ItemInteractionController.ActivateItem` is a useful orchestration seam, but it is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations, the complete `DetermineUseResult` classification, container post-actions, 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 785ae9b7..81971aaa 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 @@ -515,10 +515,10 @@ their live action gate remains pending. The inventory-button gate remains pendin ### 4.4 Finish toolbar controls -- [ ] Bind all seven retail panel buttons by their DAT panel-id attribute plus combat, +- [x] Bind all seven retail panel buttons by their DAT panel-id attribute plus combat, Use, and Examine actions; dispatch panel buttons through the window registry, ghosting unavailable panels rather than constructing placeholders. -- [ ] Make open/closed states event-driven from `RetailWindowManager`. +- [x] Make open/closed states event-driven from `RetailWindowManager`. - [x] Complete selected-object item mana and port `RecvNotice_UpdateItemMana`. - [x] Complete formatted stack count, stack entry and split slider, the applicable `RecvNotice_SplitStack` activation behavior, and `HandleSelectionChanged` stack @@ -569,6 +569,17 @@ friendly NPCs and rejecting attackable non-creatures. AP-46 is retired; automate parser/model/policy conformance is green. The world/radar matrix passed live, and the paperdoll re-gate passed after its separate exact DAT body-map/upper-item route landed. +**Wave 4.4d implementation (2026-07-11; live gate pending):** `ToolbarController` +discovers all seven authored launcher buttons and reads their panel IDs from DAT enum +attribute `0x10000029`, matching `gmToolbarUI::PostInit @ 0x004BEA80`. Inventory and +Character dispatch through the retained window registry; the five panels not yet mounted +are ghosted rather than represented by placeholders. `RetailWindowManager` visibility +events now drive state 6/1 (Highlight/Normal), including close-button and persistence +transitions, replacing mutation-site synchronization. Use and Examine dispatch the current +selection immediately or enter their one-shot retail target modes; Examine sends the exact +Appraise request and both modes use the retail global cursor catalog. The warning-free +App build and full 4,747-pass / 5-skip Release 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 @@ -581,8 +592,8 @@ 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`. Use, Examine, remaining panel buttons, raw shortcut preservation, -drag/drop edge cases, and selected-object residuals remain in this wave. +`CombatState`. Raw shortcut preservation, drag/drop edge cases, and the toolbar +ammo-number display remain in this wave. **Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`, `UIElement_UIItem::SetShortcutNum`, shortcut wire builders. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 19ba0daf..a6984a9e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1995,6 +1995,7 @@ public sealed class GameWindow : IDisposable Objects, playerGuid: () => _playerServerGuid, sendUse: g => _liveSession?.SendUse(g), + sendExamine: g => _liveSession?.SendAppraise(g), sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target), sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), sendDrop: item => _liveSession?.SendDropItem(item), @@ -11946,7 +11947,7 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.EscapeKey: - if (_itemInteractionController?.IsTargetModeActive == true) + if (_itemInteractionController?.IsAnyTargetModeActive == true) _itemInteractionController.CancelTargetMode(); else if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor @@ -12092,7 +12093,7 @@ public sealed class GameWindow : IDisposable // Target-use mode picks WITH self (retail: kit-heal yourself by // clicking your own toon); plain selection keeps the self-exclusion. var picked = PickWorldGuidAtCursor( - includeSelf: _itemInteractionController?.IsTargetModeActive == true); + includeSelf: _itemInteractionController?.IsAnyTargetModeActive == true); if (picked is uint guid) { @@ -12133,7 +12134,7 @@ public sealed class GameWindow : IDisposable } else { - if (_itemInteractionController?.IsTargetModeActive == true) + if (_itemInteractionController?.IsAnyTargetModeActive == true) return; _debugVm?.AddToast("Nothing to select"); } diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index 31685f4d..bd10f0d0 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -113,9 +113,7 @@ public sealed class CursorFeedbackController // window occludes the world (no found object → pending). The one // UI-side source retail-style cells contribute is an occupied item // slot's own item. - RetailCursorTargetMode targetMode = _itemInteraction?.IsTargetModeActive == true - ? RetailCursorTargetMode.UseTarget - : RetailCursorTargetMode.None; + RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction); uint hoverTarget = hover is null ? _worldTargetProvider?.Invoke() ?? 0u : targetMode == RetailCursorTargetMode.UseTarget @@ -265,9 +263,18 @@ public sealed class CursorFeedbackController private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot) => snapshot.TargetMode == RetailCursorTargetMode.None - && _itemInteraction?.IsTargetModeActive == true - ? RetailCursorTargetMode.UseTarget - : snapshot.TargetMode; + ? ModeFromInteraction(_itemInteraction) + : snapshot.TargetMode; + + private static RetailCursorTargetMode ModeFromInteraction( + ItemInteractionController? interaction) + => interaction?.InteractionState.Current.Kind switch + { + InteractionModeKind.Use => RetailCursorTargetMode.Use, + InteractionModeKind.Examine => RetailCursorTargetMode.Examine, + InteractionModeKind.UseItemOnTarget => RetailCursorTargetMode.UseTarget, + _ => RetailCursorTargetMode.None, + }; private static CursorFeedbackKind KindForResize(ResizeEdges edges) { diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 04ff7f50..fdd9e4a4 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -60,6 +60,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Func _playerGuid; private readonly Func _nowMs; private readonly Action? _sendUse; + private readonly Action? _sendExamine; private readonly Action? _sendUseWithTarget; private readonly Action? _sendWield; private readonly Action? _sendDrop; @@ -90,6 +91,7 @@ public sealed class ItemInteractionController : IDisposable Action? sendUseWithTarget, Action? sendWield, Action? sendDrop, + Action? sendExamine = null, Func? nowMs = null, Action? toast = null, InteractionState? interactionState = null, @@ -108,6 +110,7 @@ public sealed class ItemInteractionController : IDisposable _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendUse = sendUse; + _sendExamine = sendExamine; _sendUseWithTarget = sendUseWithTarget; _sendWield = sendWield; _sendDrop = sendDrop; @@ -152,6 +155,9 @@ public sealed class ItemInteractionController : IDisposable public bool IsTargetModeActive => _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget; + public bool IsAnyTargetModeActive + => _interactionState.Current.Kind != InteractionModeKind.None; + public bool IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; @@ -182,7 +188,8 @@ public sealed class ItemInteractionController : IDisposable /// public ItemPrimaryClickResult OfferPrimaryClick(uint targetGuid) { - if (!IsTargetModeActive) + InteractionModeKind mode = _interactionState.Current.Kind; + if (mode == InteractionModeKind.None) { long now = _nowMs(); if (targetGuid != 0 @@ -193,7 +200,25 @@ public sealed class ItemInteractionController : IDisposable return ItemPrimaryClickResult.NotActive; } - bool accepted = AcquireTarget(targetGuid); + bool accepted; + switch (mode) + { + case InteractionModeKind.Use: + ClearTargetMode(); + accepted = ActivateItem(targetGuid); + break; + case InteractionModeKind.Examine: + ClearTargetMode(); + accepted = targetGuid != 0 && _sendExamine is not null; + if (accepted) + _sendExamine!(targetGuid); + break; + case InteractionModeKind.UseItemOnTarget: + accepted = AcquireTarget(targetGuid); + break; + default: + throw new InvalidOperationException($"Unknown interaction mode {mode}."); + } _consumedPrimaryClickTarget = targetGuid; _consumedPrimaryClickMs = _nowMs(); return accepted @@ -204,6 +229,33 @@ public sealed class ItemInteractionController : IDisposable public ItemPrimaryClickResult OfferSelfPrimaryClick() => OfferPrimaryClick(_playerGuid()); + /// + /// Retail toolbar Use button: use the current selection immediately, or arm + /// one-shot TARGET_MODE_USE when no object is selected. + /// Retail reference: gmToolbarUI::ListenToElementMessage @ 0x004BEE90. + /// + public bool UseSelectedOrEnterMode(uint selectedObjectId) + { + if (selectedObjectId != 0) + return ActivateItem(selectedObjectId); + return _interactionState.EnterUse(); + } + + /// + /// Retail toolbar Examine button: appraise the current selection immediately, + /// or arm one-shot TARGET_MODE_EXAMINE when no object is selected. + /// Retail reference: gmToolbarUI::ListenToElementMessage @ 0x004BEE90. + /// + public bool ExamineSelectedOrEnterMode(uint selectedObjectId) + { + if (selectedObjectId == 0) + return _interactionState.EnterExamine(); + if (_sendExamine is null) + return false; + _sendExamine(selectedObjectId); + return true; + } + public bool ActivateItem(uint itemGuid) { if (itemGuid == 0) return false; @@ -264,7 +316,7 @@ public sealed class ItemInteractionController : IDisposable public void CancelTargetMode() { - if (!IsTargetModeActive) return; + if (!IsAnyTargetModeActive) return; ClearTargetMode(); } diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index ff65721a..4b60b4ab 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -45,13 +45,19 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private static readonly uint[] CombatIndicatorIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; - private const uint CharacterButtonId = 0x10000199u; + private const uint PanelIdAttribute = 0x10000029u; + private const uint UseButtonId = 0x1000019Du; + private const uint ExamineButtonId = 0x100001A5u; private const uint InventoryButtonId = 0x100001B1u; + private static readonly uint[] PanelButtonIds = + { 0x10000197u, 0x10000198u, 0x10000199u, 0x1000055Au, 0x1000019Au, 0x1000019Bu, InventoryButtonId }; private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; - private readonly UiButton? _characterButton; private readonly UiButton? _inventoryButton; + private readonly UiButton? _useButton; + private readonly UiButton? _examineButton; + private readonly List<(uint PanelId, UiButton Button)> _panelButtons = new(); private readonly ClientObjectTable _repo; private readonly CombatState? _combatState; private readonly Func> _shortcuts; @@ -63,6 +69,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private readonly Action? _sendRemoveShortcut; // (index) private readonly ItemInteractionController? _itemInteraction; private readonly Action? _selectItem; + private readonly Func _selectedObjectId; private readonly Func? _playerGuid; private readonly Action? _sendPutItemInContainer; private bool _disposed; @@ -94,6 +101,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Action? sendRemoveShortcut = null, Action? toggleCombat = null, Action? selectItem = null, + Func? selectedObjectId = null, Func? playerGuid = null, Action? sendPutItemInContainer = null) { @@ -109,6 +117,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _sendAddShortcut = sendAddShortcut; _sendRemoveShortcut = sendRemoveShortcut; _selectItem = selectItem; + _selectedObjectId = selectedObjectId ?? (() => 0u); _playerGuid = playerGuid; _sendPutItemInContainer = sendPutItemInContainer; @@ -140,7 +149,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont button.OnClick = toggleCombat; } - _characterButton = layout.FindElement(CharacterButtonId) as UiButton; + foreach (uint elementId in PanelButtonIds) + { + if (layout.FindElement(elementId) is UiButton panelButton + && panelButton.TryGetEnumAttribute(PanelIdAttribute, out uint panelId)) + _panelButtons.Add((panelId, panelButton)); + } + _inventoryButton = layout.FindElement(InventoryButtonId) as UiButton; if (_inventoryButton is not null) { @@ -152,6 +167,13 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _inventoryButton.OnItemDrop = HandleInventoryButtonDrop; } + _useButton = layout.FindElement(UseButtonId) as UiButton; + _examineButton = layout.FindElement(ExamineButtonId) as UiButton; + if (_useButton is not null) + _useButton.OnClick = () => _itemInteraction?.UseSelectedOrEnterMode(_selectedObjectId()); + if (_examineButton is not null) + _examineButton.OnClick = () => _itemInteraction?.ExamineSelectedOrEnterMode(_selectedObjectId()); + // Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669): // exactly one indicator visible at a time. Default to NonCombat (peace) — the player // always spawns in peace mode; retail has not yet called SetVisible when PostInit runs. @@ -240,42 +262,54 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Action? sendRemoveShortcut = null, Action? toggleCombat = null, Action? selectItem = null, + Func? selectedObjectId = null, Func? playerGuid = null, Action? sendPutItemInContainer = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, peaceDigits, warDigits, emptyDigits, itemInteraction, sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem, - playerGuid, sendPutItemInContainer); + selectedObjectId, playerGuid, sendPutItemInContainer); c.Populate(); return c; } - /// Wire the retail status-bar launch buttons to top-level window toggles. - public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter) + /// + /// Binds all seven authored panel buttons by DAT panel id. Buttons whose panel + /// is not registered are ghosted, matching retail unavailable-panel behavior. + /// + public void BindPanelButtons(Func isAvailable, Action togglePanel) { - if (_inventoryButton is not null) + ArgumentNullException.ThrowIfNull(isAvailable); + ArgumentNullException.ThrowIfNull(togglePanel); + + foreach (var (panelId, button) in _panelButtons) { - _inventoryButton.OnClick = () => + bool available = isAvailable(panelId); + button.Enabled = available; + button.OnClick = !available ? null : () => { - if (_itemInteraction?.AcquireSelfTarget() == true) return; - toggleInventory?.Invoke(); + if (ReferenceEquals(button, _inventoryButton) + && _itemInteraction?.OfferSelfPrimaryClick() + is not null and not ItemPrimaryClickResult.NotActive) + return; + togglePanel(panelId); }; } - if (_characterButton is not null) - _characterButton.OnClick = toggleCharacter; } - public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open); - - public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open); - - private static void SetButtonOpen(UiButton? button, bool open) + public void SetPanelOpen(uint panelId, bool open) { - if (button is not null) - button.TrySetRetailState(open + // gmToolbarUI::RecvNotice_SetPanelVisibility @ 0x004BD300 maps visible + // to state 6 (Highlight) and hidden to state 1 (Normal). + foreach (var entry in _panelButtons) + { + if (entry.PanelId != panelId || !entry.Button.Enabled) continue; + entry.Button.TrySetRetailState(open ? UiButtonStateMachine.Highlight : UiButtonStateMachine.Normal); + return; + } } /// @@ -584,6 +618,21 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont if (_disposed) return; _disposed = true; + foreach (var (_, button) in _panelButtons) + button.OnClick = null; + foreach (var indicator in _combatIndicators) + if (indicator is UiButton button) + button.OnClick = null; + if (_useButton is not null) + _useButton.OnClick = null; + if (_examineButton is not null) + _examineButton.OnClick = null; + if (_inventoryButton is not null) + { + _inventoryButton.OnItemDragOver = null; + _inventoryButton.OnItemDrop = null; + } + if (_combatState is not null) _combatState.CombatModeChanged -= SetCombatMode; _repo.ObjectAdded -= OnRepositoryObjectChanged; diff --git a/src/AcDream.App/UI/RetailPanelCatalog.cs b/src/AcDream.App/UI/RetailPanelCatalog.cs new file mode 100644 index 00000000..87292087 --- /dev/null +++ b/src/AcDream.App/UI/RetailPanelCatalog.cs @@ -0,0 +1,48 @@ +namespace AcDream.App.UI; + +/// +/// Retail toolbar panel ids carried by DAT property 0x10000029. +/// Retail reference: gmToolbarUI::PostInit @ 0x004BEA80 discovers the +/// seven buttons and reads this property from each one. +/// Only panels mounted in the retained runtime are mapped; other authored +/// toolbar buttons remain present but ghosted until their panel ships. +/// +public static class RetailPanelCatalog +{ + public const uint Inventory = 7u; + public const uint Character = 11u; + + private static readonly (uint PanelId, string WindowName)[] Mounted = + { + (Inventory, WindowNames.Inventory), + (Character, WindowNames.Character), + }; + + public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted; + + public static bool TryGetWindowName(uint panelId, out string windowName) + { + foreach (var entry in Mounted) + { + if (entry.PanelId != panelId) continue; + windowName = entry.WindowName; + return true; + } + + windowName = string.Empty; + return false; + } + + public static bool TryGetPanelId(string windowName, out uint panelId) + { + foreach (var entry in Mounted) + { + if (!string.Equals(entry.WindowName, windowName, StringComparison.Ordinal)) continue; + panelId = entry.PanelId; + return true; + } + + panelId = 0; + return false; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index f845ca4f..ba5ab2e9 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -128,6 +128,8 @@ public sealed class RetailUiRuntime : IDisposable MountCharacter(); MountPlugins(); MountInventory(); + Host.WindowManager.WindowVisibilityChanged += OnWindowVisibilityChanged; + BindToolbarPanelButtons(); SyncToolbarWindowButtons(); if (bindings.Persistence is { } persistence) @@ -197,22 +199,34 @@ public sealed class RetailUiRuntime : IDisposable public void RestoreLayout() => _persistence?.RestoreAll(); public bool ToggleWindow(string name) - { - bool visible = Host.ToggleWindow(name); - SyncToolbarWindowButtons(); - return visible; - } + => Host.ToggleWindow(name); public void CloseWindow(string name) - { - Host.HideWindow(name); - SyncToolbarWindowButtons(); - } + => Host.HideWindow(name); public void SyncToolbarWindowButtons() { - ToolbarController?.SetInventoryOpen(Host.IsWindowVisible(WindowNames.Inventory)); - ToolbarController?.SetCharacterOpen(Host.IsWindowVisible(WindowNames.Character)); + if (ToolbarController is null) return; + foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels) + ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName)); + } + + private void BindToolbarPanelButtons() + { + ToolbarController?.BindPanelButtons( + panelId => RetailPanelCatalog.TryGetWindowName(panelId, out string name) + && Host.WindowManager.TryGet(name, out _), + panelId => + { + if (RetailPanelCatalog.TryGetWindowName(panelId, out string name)) + ToggleWindow(name); + }); + } + + private void OnWindowVisibilityChanged(string windowName, bool visible) + { + if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId)) + ToolbarController?.SetPanelOpen(panelId, visible); } private ImportedLayout? Import(uint layoutId) @@ -374,6 +388,7 @@ public sealed class RetailUiRuntime : IDisposable peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut, toggleCombat: b.ToggleCombat, selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar), + selectedObjectId: () => b.Selection.SelectedObjectId ?? 0u, playerGuid: b.PlayerGuid, sendPutItemInContainer: b.SendPutItemInContainer); ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection); @@ -420,9 +435,6 @@ public sealed class RetailUiRuntime : IDisposable SelectedObjectController), }); ConfigureToolbarCollapse(layout, (UiCollapsibleFrame)handle.OuterFrame, root.Height); - ToolbarController.BindWindowToggles( - () => ToggleWindow(WindowNames.Inventory), - () => ToggleWindow(WindowNames.Character)); Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); } @@ -635,6 +647,7 @@ public sealed class RetailUiRuntime : IDisposable if (_disposed) return; _disposed = true; _persistence?.Dispose(); + Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; Host.Dispose(); } } diff --git a/src/AcDream.App/UI/RetailWindowManager.cs b/src/AcDream.App/UI/RetailWindowManager.cs index 15b6e650..ae3399e2 100644 --- a/src/AcDream.App/UI/RetailWindowManager.cs +++ b/src/AcDream.App/UI/RetailWindowManager.cs @@ -31,6 +31,7 @@ public sealed class RetailWindowManager : IDisposable public bool IsLocked => _root.UiLocked; public IReadOnlyCollection Windows => _byName.Values; + public event Action? WindowVisibilityChanged; public RetailWindowHandle Register( string name, @@ -233,6 +234,7 @@ public sealed class RetailWindowManager : IDisposable } handle.NotifyVisibility(visible); + WindowVisibilityChanged?.Invoke(handle.Name, visible); } private void OnKeyboardFocusChanged(UiElement? oldFocus, UiElement? newFocus) diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index 668d7631..0b5f7936 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -123,6 +123,20 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful } } + /// Reads a resolved enum-valued DAT attribute from this button. + public bool TryGetEnumAttribute(uint propertyId, out uint value) + { + if (_info.TryGetEffectiveProperty(propertyId, out var property) + && property.Kind == UiPropertyKind.Enum) + { + value = checked((uint)property.UnsignedValue); + return true; + } + + value = 0; + return false; + } + public override string ActiveCursorStateName => ActiveState; public bool TrySetRetailState(uint stateId) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 5fc0c4a9..6524bb89 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1331,6 +1331,13 @@ public sealed class WorldSession : IDisposable SendGameAction(InteractRequests.BuildUseWithTarget(seq, sourceGuid, targetGuid)); } + /// Send retail IdentifyObject/Appraise (0x00C8). + public void SendAppraise(uint targetGuid) + { + uint seq = NextGameActionSequence(); + SendGameAction(AppraiseRequest.Build(seq, targetGuid)); + } + /// Send PutItemInContainer (0x0019) - move an item into a container at a slot. placement /// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail: /// CM_Inventory::Event_PutItemInContainer -> ACE Player.HandleActionPutItemInContainer. diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 5993c96f..e01231cc 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -157,6 +157,33 @@ public sealed class CursorFeedbackControllerTests Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind); } + [Fact] + public void GenericUseAndExamineModes_driveRetailGlobalCursors() + { + var objects = SeedTargetObjects(); + var interaction = new ItemInteractionController( + objects, + playerGuid: () => Player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); + var c = new CursorFeedbackController(interaction); + + Assert.True(interaction.UseSelectedOrEnterMode(0)); + Assert.Equal(RetailGlobalCursorKind.Use, + c.Resolve(new CursorFeedbackSnapshot()).GlobalKind); + Assert.Equal(RetailGlobalCursorKind.UseFound, + c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind); + + interaction.CancelTargetMode(); + Assert.True(interaction.ExamineSelectedOrEnterMode(0)); + Assert.Equal(RetailGlobalCursorKind.Examine, + c.Resolve(new CursorFeedbackSnapshot()).GlobalKind); + Assert.Equal(RetailGlobalCursorKind.ExamineFound, + c.Resolve(new CursorFeedbackSnapshot(HoverTargetGuid: Target)).GlobalKind); + } + public static TheoryData GlobalCursorCases => new() { { new(), RetailGlobalCursorKind.Default }, diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 5c903923..a08c19b4 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -15,6 +15,7 @@ public sealed class ItemInteractionControllerTests { public readonly ClientObjectTable Objects = new(); public readonly List Uses = new(); + public readonly List Examines = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Item, uint Mask)> Wields = new(); public readonly List Drops = new(); @@ -45,6 +46,7 @@ public sealed class ItemInteractionControllerTests Objects, playerGuid: () => Player, sendUse: Uses.Add, + sendExamine: Examines.Add, sendUseWithTarget: (source, target) => UseWithTarget.Add((source, target)), sendWield: (item, mask) => Wields.Add((item, mask)), sendDrop: Drops.Add, @@ -116,6 +118,38 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (0x50000A01u, Player) }, h.UseWithTarget); } + [Fact] + public void ToolbarUse_withoutSelection_armsOneShotUseMode() + { + var h = new Harness(); + h.AddContained(0x50000A10u, item => item.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.UseSelectedOrEnterMode(0u)); + Assert.True(h.Controller.IsAnyTargetModeActive); + Assert.Equal(InteractionModeKind.Use, h.Controller.InteractionState.Current.Kind); + + Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess, + h.Controller.OfferPrimaryClick(0x50000A10u)); + Assert.Equal(new[] { 0x50000A10u }, h.Uses); + Assert.False(h.Controller.IsAnyTargetModeActive); + } + + [Fact] + public void ToolbarExamine_usesSelectionOrArmsOneShotExamineMode() + { + var h = new Harness(); + + Assert.True(h.Controller.ExamineSelectedOrEnterMode(Player)); + Assert.Equal(new[] { Player }, h.Examines); + + Assert.True(h.Controller.ExamineSelectedOrEnterMode(0u)); + Assert.Equal(InteractionModeKind.Examine, h.Controller.InteractionState.Current.Kind); + Assert.Equal(ItemPrimaryClickResult.ConsumedSuccess, + h.Controller.OfferPrimaryClick(Pack)); + Assert.Equal(new[] { Player, Pack }, h.Examines); + Assert.False(h.Controller.IsAnyTargetModeActive); + } + [Fact] public void SelfTarget_sendsUseWithTargetAndClearsTargetMode() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 1b1cab74..f428da2a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -20,6 +20,18 @@ public class ToolbarControllerTests private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; private const uint CharacterButtonId = 0x10000199u; private const uint InventoryButtonId = 0x100001B1u; + private const uint UseButtonId = 0x1000019Du; + private const uint ExamineButtonId = 0x100001A5u; + private static readonly (uint ElementId, uint PanelId)[] PanelButtons = + { + (0x10000197u, 12u), + (0x10000198u, 13u), + (CharacterButtonId, RetailPanelCatalog.Character), + (0x1000055Au, 25u), + (0x1000019Au, 16u), + (0x1000019Bu, 10u), + (InventoryButtonId, RetailPanelCatalog.Inventory), + }; private static (ImportedLayout layout, Dictionary slots, Dictionary indicators) FakeToolbar() @@ -37,8 +49,10 @@ public class ToolbarControllerTests AddButton(id); indicators[id] = dict[id]; } - AddButton(CharacterButtonId); - AddButton(InventoryButtonId); + foreach (var (elementId, panelId) in PanelButtons) + AddButton(elementId, panelId); + AddButton(UseButtonId); + AddButton(ExamineButtonId); return (new ImportedLayout(root, dict), slots, indicators); void AddSlot(uint id) @@ -47,7 +61,7 @@ public class ToolbarControllerTests dict[id] = list; slots[id] = list; root.AddChild(list); } - void AddButton(uint id) + void AddButton(uint id, uint? panelId = null) { var info = new ElementInfo { @@ -59,6 +73,25 @@ public class ToolbarControllerTests }; info.StateMedia["Normal"] = (0x1u, 1); info.StateMedia["Highlight"] = (0x2u, 1); + info.StateMedia["Ghosted"] = (0x3u, 1); + if (panelId is { } value) + { + info.States[UiStateInfo.DirectStateId] = new UiStateInfo + { + Id = UiStateInfo.DirectStateId, + Properties = new UiPropertyBag + { + Values = + { + [0x10000029u] = new UiPropertyValue + { + Kind = UiPropertyKind.Enum, + UnsignedValue = value, + }, + }, + }, + }; + } var button = new UiButton(info, _ => (0u, 0, 0)) { Width = 32, Height = 32 }; dict[id] = button; root.AddChild(button); @@ -140,25 +173,30 @@ public class ToolbarControllerTests } [Fact] - public void WindowToggleButtons_clickCallbacks_fire() + public void PanelButtons_bindByDatPanelId_andGhostUnavailablePanels() { var (layout, _, _) = FakeToolbar(); var repo = new ClientObjectTable(); - int inventoryClicks = 0; - int characterClicks = 0; + var toggles = new List(); var ctrl = ToolbarController.Bind(layout, repo, () => Array.Empty(), iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - ctrl.BindWindowToggles( - toggleInventory: () => inventoryClicks++, - toggleCharacter: () => characterClicks++); + ctrl.BindPanelButtons( + panelId => panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character, + toggles.Add); ((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); ((UiButton)layout.FindElement(CharacterButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); - Assert.Equal(1, inventoryClicks); - Assert.Equal(1, characterClicks); + Assert.Equal(new[] { RetailPanelCatalog.Inventory, RetailPanelCatalog.Character }, toggles); + foreach (var (elementId, panelId) in PanelButtons) + { + var button = (UiButton)layout.FindElement(elementId)!; + Assert.Equal( + panelId is RetailPanelCatalog.Inventory or RetailPanelCatalog.Character, + button.Enabled); + } } [Fact] @@ -195,9 +233,9 @@ public class ToolbarControllerTests iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, itemInteraction: interaction); - ctrl.BindWindowToggles( - toggleInventory: () => inventoryClicks++, - toggleCharacter: () => { }); + ctrl.BindPanelButtons( + panelId => panelId == RetailPanelCatalog.Inventory, + _ => inventoryClicks++); interaction.ActivateItem(kit); ((UiButton)layout.FindElement(InventoryButtonId)!).OnEvent(new UiEvent(0u, null, UiEventType.Click)); @@ -271,19 +309,82 @@ public class ToolbarControllerTests var inventoryButton = (UiButton)layout.FindElement(InventoryButtonId)!; var characterButton = (UiButton)layout.FindElement(CharacterButtonId)!; - ctrl.SetInventoryOpen(true); - ctrl.SetCharacterOpen(true); + ctrl.BindPanelButtons(_ => true, _ => { }); + ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, true); + ctrl.SetPanelOpen(RetailPanelCatalog.Character, true); Assert.Equal("Highlight", inventoryButton.ActiveState); Assert.Equal("Highlight", characterButton.ActiveState); - ctrl.SetInventoryOpen(false); - ctrl.SetCharacterOpen(false); + ctrl.SetPanelOpen(RetailPanelCatalog.Inventory, false); + ctrl.SetPanelOpen(RetailPanelCatalog.Character, false); Assert.Equal("Normal", inventoryButton.ActiveState); Assert.Equal("Normal", characterButton.ActiveState); } + [Fact] + public void RetailFixture_panelButtonsExposeExactDatPanelIds() + { + ImportedLayout layout = FixtureLoader.LoadToolbar(); + + foreach (var (elementId, panelId) in PanelButtons) + { + var button = Assert.IsType(layout.FindElement(elementId)); + Assert.True(button.TryGetEnumAttribute(0x10000029u, out uint actual)); + Assert.Equal(panelId, actual); + } + } + + [Fact] + public void UseAndExamineButtons_dispatchSelectedObjectPolicy() + { + const uint player = 0x50000001u; + const uint pack = 0x50000002u; + const uint item = 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 = item, + Type = ItemType.Misc, + Useability = ItemUseability.Contained, + }); + repo.MoveItem(item, pack, 0); + var uses = new List(); + var examines = new List(); + uint selected = item; + var interaction = new ItemInteractionController( + repo, + () => player, + sendUse: uses.Add, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null, + sendExamine: examines.Add); + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: (_, _, _, _, _) => 0u, + useItem: _ => { }, + itemInteraction: interaction, + selectedObjectId: () => selected); + + ((UiButton)layout.FindElement(UseButtonId)!).OnClick!.Invoke(); + ((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke(); + + Assert.Equal(new[] { item }, uses); + Assert.Equal(new[] { item }, examines); + + selected = 0; + ((UiButton)layout.FindElement(ExamineButtonId)!).OnClick!.Invoke(); + Assert.Equal(InteractionModeKind.Examine, interaction.InteractionState.Current.Kind); + } + // ── C1: combat-mode indicator tests ───────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs index 96e96fb3..a2d93a05 100644 --- a/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailWindowManagerTests.cs @@ -201,6 +201,31 @@ public sealed class RetailWindowManagerTests Assert.Equal(1, controller.DisposeCount); } + [Fact] + public void VisibilityEvent_tracksEveryRegisteredWindowTransition() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var inventory = new UiPanel { Width = 100, Height = 100 }; + root.AddChild(inventory); + root.RegisterWindow("inventory", inventory); + var transitions = new List<(string Name, bool Visible)>(); + root.WindowManager.WindowVisibilityChanged += + (name, visible) => transitions.Add((name, visible)); + + Assert.True(root.HideWindow("inventory")); + Assert.True(root.ShowWindow("inventory")); + Assert.False(root.ToggleWindow("inventory")); + + Assert.Equal( + new[] + { + ("inventory", false), + ("inventory", true), + ("inventory", false), + }, + transitions); + } + private sealed class RecordingController : IRetainedPanelController { public int ShownCount { get; private set; } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs index c5326ab5..12cfd138 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -168,4 +168,16 @@ public sealed class WorldSessionCombatTests InteractRequests.BuildUseWithTarget(1, 0x50000A01u, 0x50000001u), captured); } + + [Fact] + public void SendAppraise_UsesRetailBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendAppraise(0x5000000Au); + + Assert.Equal(AppraiseRequest.Build(1u, 0x5000000Au), captured); + } }