diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 84eb83c0..e4559047 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -204,7 +204,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 quick-slot actions and most panel/Use/Examine controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | +| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, quick-slot use/select/create actions, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | | AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | 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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index a19ab730..86435775 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -485,6 +485,8 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state. +- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar 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 retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11. +- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut. - **✓ 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 7caa7e72..94b9ee93 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -312,9 +312,44 @@ before introducing a public enum. it first attempts `PlaceInBackpack`; with the flag clear it reports the appropriate not-owned/ineligible/full/duplicate message. For a specified slot it removes any existing occurrence of the same object and adds it at that slot. For auto-slot it -finds an empty slot; a duplicate does not get added again. +passes `-1` to `AddShortcut`; `AddShortcut @ 0x004BD9A0` scans from slot zero and +stops at the first empty slot. A duplicate does not get added again. -### 4.2 Remove-on-lift +### 4.2 Shortcut aliases are not physical items + +Retail preserves the dragged element's source identity in `DropItemFlags`. The +shared inventory-list path and paperdoll path both require +`(flags & 0xE) == 0` before they show an accept/reject state or perform a physical +item move. A shortcut alias therefore remains visually neutral over inventory or +paperdoll, and releasing it there performs no item operation. The shortcut was +already removed by `RecvNotice_ItemListBeginDrag`, so the net result is only +removing the alias from the toolbar. + +```text +UIElement_ItemList.ItemList_DragOver(source, target): // 0x004E3400 + (objectId, _, flags) = InqDropIconInfo(source) + if no custom handler and (flags & 0xE) == 0: + set the target's physical-item accept/reject state + // alias source: leave the target neutral + +UIElement_ItemList.HandleDropRelease(message): // 0x004E4790 + (objectId, _, flags) = InqDropIconInfo(source) + if objectId != 0 and (flags & 0xE) == 0: + AcceptDragObject(objectId, flags & 1) + // alias source: no physical item operation + +gmPaperDollUI.OnItemListDragOver / HandleDropRelease: // 0x004A4270 / 0x004A4D80 + apply the same (flags & 0xE) == 0 gate before wield policy + +gmToolbarUI.OnItemListDragOver: // 0x004BD150 + accept when (flags & 0xE) == 0 or (flags & 4) != 0 +``` + +In acdream's typed payload, `ItemDragSource.ShortcutBar` is the `flags & 4` +alias case. Inventory, equipment, and world placement controllers must never +reinterpret that payload as the underlying physical object. + +### 4.3 Remove-on-lift ```text RecvNotice_ItemListBeginDrag(sourceList): // 0x004BD930 @@ -329,7 +364,7 @@ RecvNotice_ItemListBeginDrag(sourceList): // 0x004BD930 then, when `send=true`, sends `Event_RemoveShortCut(index)` and removes that slot from `PlayerModule`. Releasing a lifted shortcut off the toolbar leaves it removed. -### 4.3 Empty-slot search is cyclic to the right +### 4.4 Empty-slot search is cyclic to the right ```text GetFirstEmptyShortcutToTheRightOf(slot): // 0x004BD560 @@ -341,7 +376,7 @@ GetFirstEmptyShortcutToTheRightOf(slot): // 0x004BD560 It is not an inventory grid index, the original source index, or a global first-empty search unless wraparound reaches slot zero. -### 4.4 Drop release branches +### 4.5 Drop release branches `InqDropIconInfo` supplies the dragged object id and `DropItemFlags`. The exact source-flag enum names are not present in the named body, but the masks and branch @@ -387,7 +422,7 @@ an occupied slot sends the displaced item to the first empty slot cyclically to the right of the target. Only a dragged shortcut alias attempts to restore the displaced item to `m_lastShortcutNumDragged`. -### 4.5 Full-stack merge rekey +### 4.6 Full-stack merge rekey ```text RecvNotice_FullMergingItem(oldId, newId): // 0x004BE9B0 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 9612b204..eb16dd09 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 @@ -442,11 +442,11 @@ skipped. ### 4.1 Wire retail quick-slot input -- [ ] Add a focused retained-UI input action controller which maps +- [x] Add a focused retained-UI input action controller which maps `UseQuickSlot_1..9`, `UseQuickSlot_14..18`, and `CreateShortcut` to `ToolbarController` methods. -- [ ] Port use-vs-select semantics from `gmToolbarUI::ListenToGlobalMessage`. -- [ ] Keep `GameWindow.OnInputAction` to one delegation seam. +- [x] Port use-vs-select semantics from `gmToolbarUI::ListenToGlobalMessage`. +- [x] Keep `GameWindow.OnInputAction` to one delegation seam. ### 4.2 Preserve the exact shortcut model; keep the toolbar object-only @@ -473,6 +473,15 @@ skipped. - [ ] On full-stack merge, rekey every matching object shortcut and preserve the non-object fields of its raw `ShortCutData` entry. +**Wave 4.3 partial implementation (2026-07-11):** drag-over feedback is now an +explicit neutral/accept/reject result instead of a Boolean. Inventory and +paperdoll enforce retail's `(DropItemFlags & 0xE) == 0` physical-item gate, so a +toolbar alias released on either surface only leaves its remove-on-lift shortcut +removal in place; it cannot move, unwield, or wield the underlying object. The +remaining source-aware occupied-toolbar transaction and raw-field preservation +stay in Wave 4.2/4.3. Live gate passed with an equipped helmet: dropping its +toolbar alias onto inventory removed only the shortcut and left the helmet worn. + ### 4.4 Finish toolbar controls - [ ] Bind all seven retail panel buttons by their DAT panel-id attribute plus combat, @@ -486,13 +495,20 @@ skipped. - [ ] Add item-mana query/cancel state, health query cancellation, and one shared split-quantity owner for the entry, slider, and item drag payload. -**Partial implementation (2026-07-11):** the four stance-specific combat +**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 +`ToolbarInputController` maps semantic actions; `ToolbarController` owns the +retail target-before-use/select ordering and auto-slot eligibility; and +`GameWindow.OnInputAction` has one retained-runtime delegation seam. Keybinding +schema v2 migrates only the exact old default Ctrl+number bindings, preserving +custom user bindings. The four stance-specific combat 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, quick slots, and selected -object residuals remain in this wave. +`CombatState`. Use, Examine, remaining panel buttons, raw shortcut preservation, +drag/drop edge cases, and selected-object residuals 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 bf3fb79f..7c4d28b2 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -11781,6 +11781,13 @@ public sealed class GameWindow : IDisposable if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press && activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return; + // Wave 4.1: one retained-UI delegation seam. The runtime routes only + // toolbar-owned semantic actions; every other action continues through + // the game switch below. Keyboard capture is already enforced at the + // InputDispatcher source, matching retail's stack-entry focus gate. + if (_retailUiRuntime?.HandleInputAction(action) == true) + return; + // K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press // Q to start, press Q again to stop. Pressing Backup / Stop / // StrafeLeft / StrafeRight while autorun is active also cancels it diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs index 2c8f1f96..3c149142 100644 --- a/src/AcDream.App/UI/IItemListDragHandler.cs +++ b/src/AcDream.App/UI/IItemListDragHandler.cs @@ -1,11 +1,23 @@ namespace AcDream.App.UI; +/// +/// Visual result of a drag-over query. Retail handlers can consume a drag-over +/// message without setting either accept or reject art; shortcut aliases over a +/// physical item list use that neutral path. +/// +public enum ItemDragAcceptance +{ + None, + Accept, + Reject, +} + /// /// A panel controller implements this and registers itself on each of its /// s. Port of retail's m_dragHandler vtable /// (RegisterItemListDragHandler, decomp 230461; confirmed acclient /// 0x004a539e + the gmToolbarUI block 0x004bdd89). -/// decides the accept/reject OVERLAY only (advisory). +/// decides the neutral/accept/reject overlay only (advisory). /// is authoritative — it performs the action, or /// no-ops to reject. /// @@ -17,8 +29,8 @@ public interface IItemListDragHandler /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); - /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). - bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); + /// Returns the advisory target-cell overlay state. + ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); /// Perform the drop (issue the per-panel wire action), or no-op to reject. void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index b5e52583..039defdd 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -144,6 +144,17 @@ public sealed class ItemInteractionController : IDisposable public bool IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; + /// + /// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with + /// toolbar shortcut creation. Ownership includes self, equipped items, and + /// arbitrarily nested carried containers. + /// + public bool IsOwnedByPlayer(uint itemGuid) + => _objects.Get(itemGuid) is { } item + && (item.ObjectId == _playerGuid() + || IsCarriedByPlayer(item) + || IsEquippedByPlayer(item)); + public bool IsCurrentTargetCompatible(uint targetGuid) { if (!IsTargetModeActive || targetGuid == 0) return false; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index b49c1ba2..a56c140c 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -379,24 +379,41 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost. public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } - /// Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always - /// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full. - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + /// Advisory neutral/accept/reject overlay. Shortcut aliases stay neutral; physical grid + /// drops accept; a side-bag/main-pack drop rejects only when that container is known full. + public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { - if (payload.ObjId == 0) return false; - if (targetList == _contentsGrid) return true; + // UIElement_ItemList::ItemList_DragOver @ 0x004E3400 only evaluates + // physical targets when (DropItemFlags & 0xE) == 0. A toolbar alias + // carries bit 4: leave the inventory target neutral while the alias's + // remove-on-lift stands. + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return ItemDragAcceptance.None; + if (payload.ObjId == 0) + return ItemDragAcceptance.Reject; + if (targetList == _contentsGrid) + return ItemDragAcceptance.Accept; if (targetList == _containerList || targetList == _topContainer) { - if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false; - return !IsContainerFull(targetCell.ItemId); + if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) + return ItemDragAcceptance.Reject; + return IsContainerFull(targetCell.ItemId) + ? ItemDragAcceptance.Reject + : ItemDragAcceptance.Accept; } - return false; + return ItemDragAcceptance.Reject; } /// Perform the move: resolve (container, placement) from the target, optimistically move /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { + // UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same + // (flags & 0xE) == 0 gate before AcceptDragObject. A shortcut alias is + // not the equipped/contained object it points at. + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return; + uint item = payload.ObjId; if (item == 0) return; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 9e97291c..82ee2203 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -259,11 +259,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { + // gmPaperDollUI::OnItemListDragOver @ 0x004A4270 ignores shortcut + // aliases through its (DropItemFlags & 0xE) == 0 gate. + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return ItemDragAcceptance.None; var item = _objects.Get(payload.ObjId); - if (item is null) return false; - return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None; + if (item is null) + return ItemDragAcceptance.Reject; + return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None + ? ItemDragAcceptance.Accept + : ItemDragAcceptance.Reject; } /// Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A. @@ -271,6 +278,10 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { + // gmPaperDollUI::HandleDropRelease @ 0x004A4D80 applies the same flag + // gate before accepting/wielding the physical object. + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return; var item = _objects.Get(payload.ObjId); if (item is null) return; EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList)); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 71350880..f22e697d 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -71,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private readonly Action? _sendAddShortcut; // (index, objectGuid) private readonly Action? _sendRemoveShortcut; // (index) private readonly ItemInteractionController? _itemInteraction; + private readonly Action? _selectItem; private bool _disposed; // Digit sprite DID arrays for slot labels (top row, numbers 1-9). @@ -98,7 +99,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, Action? sendRemoveShortcut = null, - Action? toggleCombat = null) + Action? toggleCombat = null, + Action? selectItem = null) { _repo = repo; _combatState = combatState; @@ -111,6 +113,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _itemInteraction = itemInteraction; _sendAddShortcut = sendAddShortcut; _sendRemoveShortcut = sendRemoveShortcut; + _selectItem = selectItem; for (int i = 0; i < SlotIds.Length; i++) { @@ -233,11 +236,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, Action? sendRemoveShortcut = null, - Action? toggleCombat = null) + Action? toggleCombat = null, + Action? selectItem = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, peaceDigits, warDigits, emptyDigits, itemInteraction, - sendAddShortcut, sendRemoveShortcut, toggleCombat); + sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem); c.Populate(); return c; } @@ -387,6 +391,95 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont }; } + /// + /// Port of gmToolbarUI::UseShortcut @ 0x004BD350. Target mode + /// is one-shot and precedes both normal use and selection. + /// + public bool UseShortcut(int slot, bool use) + { + if ((uint)slot >= _slots.Length || _slots[slot]?.Cell is not { } cell) + return false; + + uint itemId = cell.ItemId; + if (_itemInteraction?.IsTargetModeActive == true) + { + if (itemId != 0) + _itemInteraction.OfferPrimaryClick(itemId); + else + _itemInteraction.CancelTargetMode(); + return true; + } + + if (itemId == 0) + return false; + + if (use) + { + if (_itemInteraction is not null) + _itemInteraction.ActivateItem(itemId); + else + _useItem(itemId); + } + else + { + _selectItem?.Invoke(itemId); + } + + return true; + } + + /// + /// Auto-slot form of retail gmToolbarUI::CreateShortcutToItem @ + /// 0x004BDAC0, used by the global CreateShortcut action. It rejects + /// stuck/non-player objects, non-player creatures, unowned objects, + /// duplicates, and a full bar before emitting AddShortcut. + /// + public bool CreateShortcutToItem(uint itemId) + { + if (itemId == 0 || _repo.Get(itemId) is not { } item) + return false; + + var flags = (PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u); + bool isPlayer = itemId == _itemInteraction?.PlayerGuid + || (flags & PublicWeenieFlags.Player) != 0; + if ((flags & PublicWeenieFlags.Stuck) != 0 && !isPlayer) + return false; + if ((item.Type & ItemType.Creature) != 0 && !isPlayer) + return false; + if (_itemInteraction?.IsOwnedByPlayer(itemId) != true) + return false; + + EnsureStoreLoadedForMutation(); + for (int slot = 0; slot < _slots.Length; slot++) + if (_store.Get(slot) == itemId) + return false; + + int empty = -1; + for (int slot = 0; slot < _slots.Length; slot++) + { + if (_store.IsEmpty(slot)) + { + empty = slot; + break; + } + } + if (empty < 0) + return false; + + _store.Set(empty, itemId); + _sendAddShortcut?.Invoke((uint)empty, itemId); + Populate(); + return true; + } + + private void EnsureStoreLoadedForMutation() + { + if (_storeLoaded) + return; + _store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid))); + _storeLoaded = true; + } + // ── IItemListDragHandler (B.2 live handler) ────────────────────────────── // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. // Retail model (remove-on-lift / place-on-drop / no-restore): @@ -407,8 +500,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont } /// - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - => payload.ObjId != 0; + public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => payload.ObjId != 0 ? ItemDragAcceptance.Accept : ItemDragAcceptance.None; /// public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) diff --git a/src/AcDream.App/UI/Layout/ToolbarInputController.cs b/src/AcDream.App/UI/Layout/ToolbarInputController.cs new file mode 100644 index 00000000..d298da41 --- /dev/null +++ b/src/AcDream.App/UI/Layout/ToolbarInputController.cs @@ -0,0 +1,71 @@ +using AcDream.Core.Selection; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.UI.Layout; + +/// +/// Focused retained-toolbar adapter for retail global input actions. The input +/// dispatcher owns chords/scopes; this class only maps semantic actions to the +/// exact gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 slot intent. +/// +public sealed class ToolbarInputController +{ + private readonly ToolbarController _toolbar; + private readonly SelectionState _selection; + + public ToolbarInputController(ToolbarController toolbar, SelectionState selection) + { + _toolbar = toolbar ?? throw new ArgumentNullException(nameof(toolbar)); + _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + } + + /// Returns true when belongs to the toolbar. + public bool Handle(InputAction action) + { + if (TryMapShortcut(action, out int slot, out bool use)) + { + _toolbar.UseShortcut(slot, use); + return true; + } + + if (action == InputAction.CreateShortcut) + { + _toolbar.CreateShortcutToItem(_selection.SelectedObjectId ?? 0u); + return true; + } + + return false; + } + + internal static bool TryMapShortcut(InputAction action, out int slot, out bool use) + { + int value = (int)action; + if (value >= (int)InputAction.UseQuickSlot_1 + && value <= (int)InputAction.UseQuickSlot_9) + { + slot = value - (int)InputAction.UseQuickSlot_1; + use = true; + return true; + } + + if (value >= (int)InputAction.SelectQuickSlot_1 + && value <= (int)InputAction.SelectQuickSlot_9) + { + slot = value - (int)InputAction.SelectQuickSlot_1; + use = false; + return true; + } + + if (value >= (int)InputAction.UseQuickSlot_14 + && value <= (int)InputAction.UseQuickSlot_18) + { + slot = 13 + value - (int)InputAction.UseQuickSlot_14; + use = true; + return true; + } + + slot = -1; + use = false; + return false; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index ada712bb..b8ebbf52 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -149,6 +149,7 @@ public sealed class RetailUiRuntime : IDisposable public ItemInteractionController ItemInteraction => _bindings.Inventory.ItemInteraction; public CharacterSheetProvider CharacterSheetProvider => _bindings.Character.Provider; public ToolbarController? ToolbarController { get; private set; } + public ToolbarInputController? ToolbarInputController { get; private set; } public SelectedObjectController? SelectedObjectController { get; private set; } public UiViewport? PaperdollViewportWidget { get; private set; } public UiNineSlicePanel? InventoryFrame { get; private set; } @@ -176,6 +177,9 @@ public sealed class RetailUiRuntime : IDisposable public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize); + public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action) + => ToolbarInputController?.Handle(action) == true; + public void UpdateCursor(IEnumerable mice) { CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); @@ -360,7 +364,9 @@ public sealed class RetailUiRuntime : IDisposable ToolbarController = Layout.ToolbarController.Bind( layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat, peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut, - toggleCombat: b.ToggleCombat); + toggleCombat: b.ToggleCombat, + selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar)); + ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection); SelectedObjectController = Layout.SelectedObjectController.Bind( layout, b.Selection, diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs index 2642fe3e..e655b0c6 100644 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ b/src/AcDream.App/UI/UiItemSlot.cs @@ -199,9 +199,15 @@ public sealed class UiItemSlot : UiElement return true; case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler - _dragAccept = (FindList() is { DragHandler: { } h } list - && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p)) - ? DragAcceptState.Accept : DragAcceptState.Reject; + _dragAccept = FindList() is { DragHandler: { } h } list + && e.Payload is ItemDragPayload p + ? h.OnDragOver(list, this, p) switch + { + ItemDragAcceptance.Accept => DragAcceptState.Accept, + ItemDragAcceptance.Reject => DragAcceptState.Reject, + _ => DragAcceptState.None, + } + : DragAcceptState.Reject; return true; case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index 9beb39fb..2f852e07 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -8,12 +8,10 @@ namespace AcDream.UI.Abstractions.Input; /// debug bindings that have no retail equivalent. /// /// -/// K.1a defines the enum but only the existing acdream-current chords -/// resolve to actions; K.1c flips the bindings table to the full retail -/// preset. Several actions in this enum (UseQuickSlot_*, -/// Combat*, UseSpellSlot_*) have NO subscribers in K.1 — -/// the chord fires the event but nothing acts on it. Phase L panels -/// will subscribe. +/// K.1a defined the enum and K.1c flipped the bindings table to the full +/// retail preset. Runtime controllers subscribe by subsystem; actions whose +/// owning panel has not landed yet (for example UseSpellSlot_*) may +/// intentionally remain undispatched. /// /// public enum InputAction @@ -142,12 +140,21 @@ public enum InputAction UseQuickSlot_7, UseQuickSlot_8, UseQuickSlot_9, + SelectQuickSlot_1, + SelectQuickSlot_2, + SelectQuickSlot_3, + SelectQuickSlot_4, + SelectQuickSlot_5, + SelectQuickSlot_6, + SelectQuickSlot_7, + SelectQuickSlot_8, + SelectQuickSlot_9, UseQuickSlot_14, UseQuickSlot_15, UseQuickSlot_16, UseQuickSlot_17, UseQuickSlot_18, - /// Drop-target shortcut creation (retail 0 / drag-drop). + /// Create a toolbar shortcut to the selected object (retail 0). CreateShortcut, // ── Chat ────────────────────────────────────────────── diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index f5c3c4f8..069fb3b6 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input; /// public sealed class KeyBindings { - private const int CurrentSchemaVersion = 1; + private const int CurrentSchemaVersion = 2; private readonly List _bindings = new(); @@ -212,13 +212,18 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT)); // ── QuickslotCommands ────────────────────────────────── - // Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords. + // Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives + // distinct action-id ranges: bare 1..9 USE slots 0..8, while + // Ctrl+1..9 SELECT those slots. The keymap repeats the display name + // UseQuickSlot_N for both bindings, so our semantic action layer must + // preserve the differing intent explicitly. for (int i = 1; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); // Number1..Number9 - var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); - b.Add(new(new KeyChord(k, ModifierMask.None), action)); - b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action)); + var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); + var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1); + b.Add(new(new KeyChord(k, ModifierMask.None), useAction)); + b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction)); } // Alt+5..9 → UseQuickSlot_14..18. for (int i = 5; i <= 9; i++) @@ -377,9 +382,9 @@ public sealed class KeyBindings using var stream = File.OpenRead(path); var doc = JsonDocument.Parse(stream); var root = doc.RootElement; - // version is currently advisory — read so future migrations - // can branch on it; the field's presence is non-fatal. - _ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0; + // Missing/older versions are valid and drive narrow migrations; + // the field's presence remains non-fatal. + int version = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0; var defaults = RetailDefaults(); var loaded = new KeyBindings(); @@ -413,7 +418,9 @@ public sealed class KeyBindings { device = (byte)dEl.GetInt32(); } - loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation)); + var chord = new KeyChord(silkKey, mods, device); + action = MigrateLegacyQuickSlotIntent(version, action, chord, activation); + loaded.Add(new(chord, action, activation)); } } } @@ -481,6 +488,34 @@ public sealed class KeyBindings File.WriteAllText(path, json); } + /// + /// Schema v1 repeated UseQuickSlot_N for bare and Ctrl chords, + /// losing retail's use-vs-select distinction. Migrate only the exact old + /// default Ctrl+matching-number shape; arbitrary user rebindings remain + /// attached to the action the user chose. + /// + private static InputAction MigrateLegacyQuickSlotIntent( + int version, + InputAction action, + KeyChord chord, + ActivationType activation) + { + if (version >= 2 + || activation != ActivationType.Press + || chord.Device != 0 + || chord.Modifiers != ModifierMask.Ctrl) + return action; + + int offset = (int)action - (int)InputAction.UseQuickSlot_1; + if ((uint)offset >= 9u) + return action; + + var expectedKey = (Key)((int)Key.Number1 + offset); + return chord.Key == expectedKey + ? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset) + : action; + } + private static ModifierMask ParseModifiers(JsonElement bindingEl) { if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None; diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs index 698eee14..f2a2570f 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs @@ -172,6 +172,11 @@ public sealed class SettingsPanel : IPanel InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_3, InputAction.UseQuickSlot_4, InputAction.UseQuickSlot_5, InputAction.UseQuickSlot_6, InputAction.UseQuickSlot_7, InputAction.UseQuickSlot_8, InputAction.UseQuickSlot_9, + InputAction.SelectQuickSlot_1, InputAction.SelectQuickSlot_2, InputAction.SelectQuickSlot_3, + InputAction.SelectQuickSlot_4, InputAction.SelectQuickSlot_5, InputAction.SelectQuickSlot_6, + InputAction.SelectQuickSlot_7, InputAction.SelectQuickSlot_8, InputAction.SelectQuickSlot_9, + InputAction.UseQuickSlot_14, InputAction.UseQuickSlot_15, InputAction.UseQuickSlot_16, + InputAction.UseQuickSlot_17, InputAction.UseQuickSlot_18, InputAction.CreateShortcut, }); RenderSection(renderer, "Emotes", new[] diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 6448256e..c9741e45 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -8,15 +8,15 @@ public class DragDropSpineTests // A spy handler used across the spine tests. private sealed class SpyHandler : IItemListDragHandler { - public bool AcceptResult = true; + public ItemDragAcceptance Acceptance = ItemDragAcceptance.Accept; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastLift = (list, cell, p); } - public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastOver = (list, cell, p); return AcceptResult; } + public ItemDragAcceptance OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) + { LastOver = (list, cell, p); return Acceptance; } public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) { LastDrop = (list, cell, p); } @@ -92,7 +92,7 @@ public class DragDropSpineTests public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() { var (_, cell, h) = ListWithHandler(); - h.AcceptResult = true; + h.Acceptance = ItemDragAcceptance.Accept; cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); } @@ -101,11 +101,20 @@ public class DragDropSpineTests public void DragEnter_setsRejectOverlay_whenHandlerRejects() { var (_, cell, h) = ListWithHandler(); - h.AcceptResult = false; + h.Acceptance = ItemDragAcceptance.Reject; cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); } + [Fact] + public void DragEnter_keepsNeutralOverlay_whenHandlerIgnoresAlias() + { + var (_, cell, h) = ListWithHandler(); + h.Acceptance = ItemDragAcceptance.None; + cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); + Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); + } + [Fact] public void DragOver_resetsOverlayToNeutral() { diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 5daa54f2..25cea493 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -591,8 +591,10 @@ public class InventoryControllerTests objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); var ctrl = (IItemListDragHandler)Bind(layout, objects); - Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red - Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green + Assert.Equal(ItemDragAcceptance.Reject, + ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red + Assert.Equal(ItemDragAcceptance.Accept, + ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green } [Fact] @@ -616,7 +618,38 @@ public class InventoryControllerTests var ctrl = (IItemListDragHandler)Bind(layout, objects); // GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept. - Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); + Assert.Equal(ItemDragAcceptance.Accept, + ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); + } + + [Fact] + public void ShortcutAliasDropOnInventory_removesOnlyAlias_andNeverMovesEquippedItem() + { + var (layout, grid, _, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + const uint helmet = 0xD00Du; + objects.AddOrUpdate(new ClientObject + { + ObjectId = helmet, + Type = ItemType.Armor, + ValidLocations = EquipMask.HeadWear, + }); + objects.MoveItem(helmet, Player, newSlot: 0, newEquipLocation: EquipMask.HeadWear); + var puts = new List<(uint item, uint container, int placement)>(); + var ctrl = (IItemListDragHandler)Bind(layout, objects, puts: puts); + var payload = new ItemDragPayload( + helmet, + ItemDragSource.ShortcutBar, + SourceSlot: 3, + SourceCell: new UiItemSlot()); + + Assert.Equal(ItemDragAcceptance.None, + ctrl.OnDragOver(grid, grid.GetItem(0)!, payload)); + ctrl.HandleDropRelease(grid, grid.GetItem(0)!, payload); + + Assert.Empty(puts); + Assert.Equal(Player, objects.Get(helmet)!.ContainerId); + Assert.Equal(EquipMask.HeadWear, objects.Get(helmet)!.CurrentlyEquippedLocation); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index d14c1eb1..1c16e2d5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -99,8 +99,29 @@ public class PaperdollControllerTests SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack var ctrl = Bind(layout, objects); var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); - Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK - Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO + Assert.Equal(ItemDragAcceptance.Accept, + ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK + Assert.Equal(ItemDragAcceptance.Reject, + ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO + } + + [Fact] + public void ShortcutAlias_isNeutral_andNeverWieldsThroughPaperdoll() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedPackItem(objects, 0xC02u, EquipMask.HeadWear); + var wields = new List<(uint item, uint mask)>(); + var ctrl = Bind(layout, objects, wields); + var payload = new ItemDragPayload( + 0xC02u, ItemDragSource.ShortcutBar, 2, lists[HeadSlot].Cell); + + Assert.Equal(ItemDragAcceptance.None, + ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); + ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload); + + Assert.Empty(wields); + Assert.Equal(EquipMask.None, objects.Get(0xC02u)!.CurrentlyEquippedLocation); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index f9fbcd3a..2abd14c4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -254,6 +254,124 @@ public class ToolbarControllerTests Assert.Equal(CombatIds.Length, toggles); } + [Fact] + public void UseShortcut_usesOrSelectsAccordingToRetailIntent() + { + const uint itemId = 0x50001001u; + var (layout, _, _) = FakeToolbar(); + var repo = new ClientObjectTable(); + repo.AddOrUpdate(new ClientObject { ObjectId = itemId, Type = ItemType.Misc }); + var shortcuts = new[] + { + new PlayerDescriptionParser.ShortcutEntry(0, itemId, 0, 0), + }; + uint used = 0; + uint selected = 0; + var controller = ToolbarController.Bind( + layout, + repo, + () => shortcuts, + iconIds: (_, _, _, _, _) => 1u, + useItem: id => used = id, + selectItem: id => selected = id); + + Assert.True(controller.UseShortcut(0, use: true)); + Assert.Equal(itemId, used); + Assert.Equal(0u, selected); + + Assert.True(controller.UseShortcut(0, use: false)); + Assert.Equal(itemId, selected); + } + + [Fact] + public void UseShortcut_targetModePrecedesUseAndClearsOneShotMode() + { + const uint player = 0x50000001u; + const uint pack = 0x50000002u; + const uint source = 0x50000003u; + const uint target = 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 = source, + Type = ItemType.Misc, + Useability = 0x00220008u, + TargetType = (uint)ItemType.Creature, + }); + repo.MoveItem(source, pack, 0); + repo.AddOrUpdate(new ClientObject { ObjectId = target, Type = ItemType.Creature }); + uint sentSource = 0; + uint sentTarget = 0; + var interaction = new ItemInteractionController( + repo, + () => player, + sendUse: null, + sendUseWithTarget: (s, t) => (sentSource, sentTarget) = (s, t), + sendWield: null, + sendDrop: null, + nowMs: () => 1_000); + var shortcuts = new[] + { + new PlayerDescriptionParser.ShortcutEntry(0, target, 0, 0), + }; + var controller = ToolbarController.Bind( + layout, + repo, + () => shortcuts, + iconIds: (_, _, _, _, _) => 1u, + useItem: _ => { }, + itemInteraction: interaction); + + Assert.True(interaction.ActivateItem(source)); + Assert.True(controller.UseShortcut(0, use: false)); + + Assert.Equal(source, sentSource); + Assert.Equal(target, sentTarget); + Assert.False(interaction.IsTargetModeActive); + } + + [Fact] + public void CreateShortcutToItem_addsFirstEmptyOwnedEligibleItemOnce() + { + const uint player = 0x50000001u; + const uint pack = 0x50000002u; + const uint item = 0x50000003u; + var (layout, slots, _) = 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 }); + repo.MoveItem(item, pack, 0); + var interaction = new ItemInteractionController( + repo, + () => player, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); + var sends = new List<(uint Slot, uint Item)>(); + var controller = ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: (_, _, _, _, _) => 1u, + useItem: _ => { }, + itemInteraction: interaction, + sendAddShortcut: (slot, id) => sends.Add((slot, id))); + + Assert.True(controller.CreateShortcutToItem(item)); + Assert.Equal(item, slots[Row1[0]].Cell.ItemId); + Assert.Equal(new[] { (0u, item) }, sends); + + Assert.False(controller.CreateShortcutToItem(item)); + Assert.Single(sends); + } + /// /// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible; /// the melee/missile/magic indicators (0x10000193/4/5) are hidden. @@ -620,7 +738,7 @@ public class ToolbarControllerTests var list = slots[Row1[0]]; var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); - Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); + Assert.Equal(ItemDragAcceptance.Accept, ctrl.OnDragOver(list, list.Cell, payload)); } /// OnDragOver rejects a ghost payload with ObjId 0 (the guard against an @@ -634,7 +752,7 @@ public class ToolbarControllerTests iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); var list = slots[Row1[0]]; var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); - Assert.False(ctrl.OnDragOver(list, list.Cell, payload)); + Assert.Equal(ItemDragAcceptance.None, ctrl.OnDragOver(list, list.Cell, payload)); } // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs new file mode 100644 index 00000000..17ad3a13 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs @@ -0,0 +1,32 @@ +using AcDream.App.UI.Layout; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class ToolbarInputControllerTests +{ + [Theory] + [InlineData(InputAction.UseQuickSlot_1, 0, true)] + [InlineData(InputAction.UseQuickSlot_9, 8, true)] + [InlineData(InputAction.SelectQuickSlot_1, 0, false)] + [InlineData(InputAction.SelectQuickSlot_9, 8, false)] + [InlineData(InputAction.UseQuickSlot_14, 13, true)] + [InlineData(InputAction.UseQuickSlot_18, 17, true)] + public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use) + { + Assert.True(ToolbarInputController.TryMapShortcut(action, out int actualSlot, out bool actualUse)); + Assert.Equal(slot, actualSlot); + Assert.Equal(use, actualUse); + } + + [Theory] + [InlineData(InputAction.CreateShortcut)] + [InlineData(InputAction.CombatToggleCombat)] + [InlineData(InputAction.ToggleChatEntry)] + public void NonSlotActions_doNotMapAsShortcut(InputAction action) + { + Assert.False(ToolbarInputController.TryMapShortcut(action, out int slot, out bool use)); + Assert.Equal(-1, slot); + Assert.False(use); + } +} diff --git a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs index dc01b9dc..b9bcefd6 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiAutomationProbeTests.cs @@ -16,8 +16,8 @@ public sealed class RetailUiAutomationProbeTests public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) => LastLift = (sourceList, sourceCell, payload); - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - => true; + public ItemDragAcceptance OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) + => ItemDragAcceptance.Accept; public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) => LastDrop = (targetList, targetCell, payload); diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs index 1b51c37a..c7f522d3 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -191,6 +191,38 @@ public class KeyBindingsJsonTests } } + [Fact] + public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect() + { + var path = TempFile(); + try + { + const string json = """ + { + "version": 1, + "actions": { + "UseQuickSlot_5": [ + { "key": "Number5" }, + { "key": "Number5", "mod": "Ctrl" } + ] + } + } + """; + File.WriteAllText(path, json); + + var loaded = KeyBindings.LoadOrDefault(path); + + Assert.Equal(InputAction.UseQuickSlot_5, + loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action); + Assert.Equal(InputAction.SelectQuickSlot_5, + loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public void LoadOrDefault_unknown_action_name_skipped_silently() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs index 432f52c5..cfa708da 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -109,12 +109,14 @@ public class KeyBindingsRetailTests } [Fact] - public void UseQuickSlot_5_bound_to_Number5_with_and_without_Ctrl() + public void QuickSlot_5_bareUsesAndCtrlSelects() { var b = KeyBindings.RetailDefaults(); - var binds = b.ForAction(InputAction.UseQuickSlot_5).ToList(); - Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.None)); - Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.Ctrl)); + var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press); + var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press); + + Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action); + Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action); } [Fact]