feat(ui): complete retail item drop branches

Give retained buttons a reusable item-drop seam, wire the toolbar backpack target, and port retail stack-merge legality, capacity clamping, wire dispatch, destination selection, and immediate shortcut rekey notice. Record the live ACE merge gate and keep split quantity under AP-101.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 09:46:32 +02:00
parent 3281e0acb4
commit dc1649c493
19 changed files with 479 additions and 28 deletions

View file

@ -489,6 +489,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence 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 — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
- **✓ 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.

View file

@ -401,7 +401,7 @@ All rows are `outbound` direction (GameActions are client→server only).
| 0x0048 | outbound | CastUntargetedSpell | `CM_Magic::Event_CastUntargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x004A | outbound | CastTargetedSpell | `CM_Magic::Event_CastTargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x0053 | outbound | ChangeCombatMode | `CM_Combat::Event_ChangeCombatMode` | W | H | W | B+W | Wired in WorldSession.SendChangeCombatMode |
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | W | B+W | Wired through `StackMergePlanner` → inventory drop → `WorldSession.SendStackableMerge` |
| 0x0055 | outbound | StackableSplitToContainer | `CM_Inventory::Event_StackableSplitToContainer` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0056 | outbound | StackableSplitTo3D | `CM_Inventory::Event_StackableSplitTo3D` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0058 | outbound | ModifyCharacterSquelch | `CM_Communication::Event_ModifyCharacterSquelch` | | H | | B | Mute one player |

View file

@ -212,7 +212,7 @@ where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs`
| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) |
| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed**`InteractRequests.BuildUseWithTarget` |
| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed**`InteractRequests.BuildUse` |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableMerge` |
| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **wired** — `StackMergePlanner``InventoryController``WorldSession.SendStackableMerge` |
| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToContainer` |
| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitTo3D` |
| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed**`InventoryActions.BuildStackableSplitToWield` |

View file

@ -422,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.6 Full-stack merge rekey
### 4.6 Stack-merge attempt rekey
```text
RecvNotice_FullMergingItem(oldId, newId): // 0x004BE9B0
@ -435,6 +435,39 @@ This body updates the first matching shortcut returned by `RemoveShortcut`.
Whether duplicate object shortcuts can exist through external player-description
data is unresolved; `CreateShortcutToItem` normally prevents duplicates.
The notice name is misleading: `ItemHolder::AttemptMerge @ 0x005878F0` broadcasts
`SendNotice_FullMergingItem(oldId, newId)` immediately after `UIAttemptMerge`, for
every legal merge request. It is not an ACE confirmation and is not conditional on
the source stack being exhausted. `CreateShortcutToItem` also removes any existing
`newId` shortcut before adding it at the old source shortcut's slot.
```text
IsMergeAttemptLegal(sourceId, targetId, quiet): // 0x00586F30
if player not ready for inventory request: return false
if sourceId == targetId: return false
source = lookup sourceId; target = lookup targetId
if either missing: return false
if source.maxStackSize <= 1 or target.maxStackSize <= 1: return false
if source.tradeState == 1 or target.tradeState == 1: return false
if source.wcid != target.wcid: return false
return target.stackSize < target.maxStackSize
AttemptMerge(sourceId, targetId, quiet): // 0x005878F0
if not IsMergeAttemptLegal(...): return false
requested = sourceId == selectedId ? splitSize : max(1, source.stackSize)
available = target.maxStackSize - max(1, target.stackSize)
amount = min(requested, available)
source.UIAttemptMerge(targetId, amount) // sends 0x0054
SendNotice_FullMergingItem(sourceId, targetId) // immediate local notice
Select(targetId)
return true
```
acdream's selected-object stack entry/slider is still absent under AP-101, so the
current inventory merge path requests the whole source stack and lets the same
retail capacity clamp limit the transfer. Once the shared split-quantity owner
lands, it supplies `requested` without changing legality or wire ordering.
---
## 5. Selected-object health, mana, and stack behavior

View file

@ -471,7 +471,7 @@ same slot after a clean reconnect.
### 4.3 Port every drag/drop branch
- [ ] Add a pure `ShortcutDropPlanner` keyed by source kind: toolbar reorder,
- [x] Add a pure `ShortcutDropPlanner` keyed by source kind: toolbar reorder,
inventory fresh add, occupied target, inventory-button target,
off-bar removal, and full-stack merge replacement.
- [x] For an inventory-to-occupied-slot drop, place the displaced shortcut at
@ -479,7 +479,7 @@ same slot after a clean reconnect.
- [x] Validate the complete mutation before changing state, apply it atomically, and
emit exact Remove/Add wire ordering. Use pending/confirm/rollback only where the
protocol actually exposes rejection; never leave a partially mutated bar.
- [ ] On full-stack merge, rekey every matching object shortcut and preserve the
- [x] On full-stack merge, rekey the 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
@ -499,9 +499,18 @@ records, exact wire order, and raw full-stack rekeying have conformance fixtures
`ToolbarController` applies the validated local transaction atomically, then emits
its ordered Remove/Add events. AP-102 is retired; 4,646 tests pass. The live
inventory-to-occupied displacement plus toolbar-reorder gate passed 2026-07-11.
Inventory-button placement and
the upstream full-stack-merge notice owner remain part of the unchecked umbrella
items above rather than being implied complete by this slice.
Wave 4.3c completes the umbrella: retained buttons can own item-drop callbacks;
the toolbar inventory button uses retail's fresh-item gate and exact green-arrow
overlay, while aliases stay neutral. Inventory now tries the pure named-retail
stack-merge legality/amount planner before ordinary insertion, sends `0x0054`,
broadcasts `FullMergingItem` immediately from the attempt owner, rekeys the toolbar
with existing-destination removal, and selects the target. The notice is not a
server confirmation despite its name. AP-101 still covers the absent shared split
entry/slider; until it lands, merge requests use the whole source stack before the
retail destination-capacity clamp. Release build is warning-free and 4,660 tests
pass. The compatible-stack live gate passed 2026-07-11; it exposed the expected
Wave 4.4/AP-101 gap that the selected strip still displays one instead of the
stack count and has no split slider. The inventory-button live gate remains pending.
### 4.4 Finish toolbar controls

View file

@ -2124,7 +2124,10 @@ public sealed class GameWindow : IDisposable
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => _liveSession?.SendQueryHealth(guid)),
guid => _liveSession?.SendQueryHealth(guid),
() => _playerServerGuid,
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
@ -2137,6 +2140,8 @@ public sealed class GameWindow : IDisposable
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(source, target, amount) =>
_liveSession?.SendStackableMerge(source, target, amount),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController,
_selection),

View file

@ -127,6 +127,8 @@ public sealed class ItemInteractionController : IDisposable
public int BusyCount => _busyCount;
public bool CanMakeInventoryRequest => _readyForInventoryRequest() && _busyCount == 0;
/// <summary>
/// Raised for retail confirmation/auxiliary actions whose retained panel is
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).

View file

@ -58,6 +58,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private bool _disposed;
@ -80,6 +82,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint>? sendStackableMerge,
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
Action? onClose)
{
@ -91,6 +95,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableMerge = sendStackableMerge;
_notifyMergeAttempt = notifyMergeAttempt;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
@ -197,12 +203,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendStackableMerge = null,
Action<uint, uint>? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendStackableMerge, notifyMergeAttempt, itemInteraction,
onClose);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
@ -417,6 +426,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
uint item = payload.ObjId;
if (item == 0) return;
// UIElement_ItemList::AcceptDragObject tries ItemHolder::AttemptMerge first
// when a physical item is released on an occupied inventory cell. A legal
// merge consumes the drop; an illegal merge falls through to insert/move.
if (targetList == _contentsGrid
&& targetCell.ItemId != 0
&& TryMergeStacks(item, targetCell.ItemId))
return;
uint container; int placement;
if (targetList == _contentsGrid)
{
@ -439,6 +456,39 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendPutItemInContainer?.Invoke(item, container, placement);
}
private bool TryMergeStacks(uint sourceId, uint targetId)
{
if (_sendStackableMerge is null
|| _objects.Get(sourceId) is not { } source
|| _objects.Get(targetId) is not { } target)
return false;
StackMergePlan? plan = StackMergePlanner.Plan(
new StackMergeItem(
source.ObjectId,
source.WeenieClassId,
source.StackSize,
source.StackSizeMax,
source.TradeState),
new StackMergeItem(
target.ObjectId,
target.WeenieClassId,
target.StackSize,
target.StackSizeMax,
target.TradeState),
_itemInteraction?.CanMakeInventoryRequest ?? true,
requestedAmount: Math.Max(1, source.StackSize));
if (plan is not { } merge)
return false;
_sendStackableMerge(merge.SourceObjectId, merge.TargetObjectId, merge.Amount);
// ItemHolder::AttemptMerge broadcasts this immediately after UIAttemptMerge;
// it is an attempt notice, not a later server-confirm event.
_notifyMergeAttempt?.Invoke(merge.SourceObjectId, merge.TargetObjectId);
_selection.Select(merge.TargetObjectId, SelectionChangeSource.Inventory);
return true;
}
/// <summary>True only when we KNOW the container is full (capacity known + contents indexed). A
/// closed bag (unknown count) returns false → advisory accept; the server is authoritative.</summary>
private bool IsContainerFull(uint container)

View file

@ -72,6 +72,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
private readonly Action<uint>? _selectItem;
private readonly Func<uint>? _playerGuid;
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private bool _disposed;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
@ -100,7 +102,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
Action<uint>? selectItem = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
_repo = repo;
_combatState = combatState;
@ -114,6 +118,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
_selectItem = selectItem;
_playerGuid = playerGuid;
_sendPutItemInContainer = sendPutItemInContainer;
for (int i = 0; i < SlotIds.Length; i++)
{
@ -145,6 +151,15 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
if (_inventoryButton is not null)
{
// gmToolbarUI::HandleInventoryButtonDragOver @ 0x004BD180 uses the
// authored child overlay's Accept state (0x10000046 = 0x060011F7).
// UiButton owns the retained drop-target seam; the panel owns policy.
_inventoryButton.ItemDragAcceptSprite = 0x060011F7u;
_inventoryButton.OnItemDragOver = InventoryButtonDragOver;
_inventoryButton.OnItemDrop = HandleInventoryButtonDrop;
}
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
@ -237,11 +252,14 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Action<ShortcutEntry>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null,
Action? toggleCombat = null,
Action<uint>? selectItem = null)
Action<uint>? selectItem = null,
Func<uint>? playerGuid = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem);
sendAddShortcut, sendRemoveShortcut, toggleCombat, selectItem,
playerGuid, sendPutItemInContainer);
c.Populate();
return c;
}
@ -392,6 +410,24 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
};
}
private static ItemDragAcceptance InventoryButtonDragOver(ItemDragPayload payload)
=> payload.ObjId != 0 && payload.SourceKind != ItemDragSource.ShortcutBar
? ItemDragAcceptance.Accept
: ItemDragAcceptance.None;
private void HandleInventoryButtonDrop(ItemDragPayload payload)
{
// gmToolbarUI::HandleDropRelease @ 0x004BE7C0 inventory-button branch:
// only fresh physical items are placed into the player's main inventory.
// A shortcut alias remains neutral and its remove-on-lift result stands.
if (payload.ObjId == 0 || payload.SourceKind == ItemDragSource.ShortcutBar)
return;
uint player = _playerGuid?.Invoke() ?? 0u;
if (player == 0 || _repo.Get(payload.ObjId) is null)
return;
_sendPutItemInContainer?.Invoke(payload.ObjId, player, 0);
}
/// <summary>
/// Port of <c>gmToolbarUI::UseShortcut @ 0x004BD350</c>. Target mode
/// is one-shot and precedes both normal use and selection.

View file

@ -53,7 +53,9 @@ public sealed record ToolbarRuntimeBindings(
Func<uint, float> HealthPercent,
Func<uint, bool> HasHealth,
Func<uint, uint> StackSize,
Action<uint> SendQueryHealth);
Action<uint> SendQueryHealth,
Func<uint> PlayerGuid,
Action<uint, uint, int>? SendPutItemInContainer);
public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider);
@ -65,6 +67,7 @@ public sealed record InventoryRuntimeBindings(
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint>? SendStackableMerge,
Action<uint, uint>? SendWield,
ItemInteractionController ItemInteraction,
SelectionState Selection);
@ -365,7 +368,9 @@ public sealed class RetailUiRuntime : IDisposable
layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat,
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut,
toggleCombat: b.ToggleCombat,
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar));
selectItem: guid => b.Selection.Select(guid, SelectionChangeSource.Toolbar),
playerGuid: b.PlayerGuid,
sendPutItemInContainer: b.SendPutItemInContainer);
ToolbarInputController = new ToolbarInputController(ToolbarController, b.Selection);
SelectedObjectController = Layout.SelectedObjectController.Bind(
layout,
@ -583,11 +588,15 @@ public sealed class RetailUiRuntime : IDisposable
}
InventoryRuntimeBindings b = _bindings.Inventory;
Action<uint, uint>? notifyMergeAttempt = ToolbarController is null
? null
: ToolbarController.ReplaceFullyMergedShortcut;
InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
b.SendPutItemInContainer, b.ItemInteraction,
b.SendPutItemInContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory));
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,

View file

@ -45,6 +45,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
/// retained button rather than in UiRoot or a panel-specific hit-test workaround.
/// </summary>
public Func<ItemDragPayload, ItemDragAcceptance>? OnItemDragOver { get; set; }
public Action<ItemDragPayload>? OnItemDrop { get; set; }
public uint ItemDragAcceptSprite { get; set; }
public uint ItemDragRejectSprite { get; set; }
private ItemDragAcceptance _itemDragAcceptance;
internal ItemDragAcceptance ItemDragAcceptanceForTest => _itemDragAcceptance;
/// <summary>The dat element id from <see cref="ElementInfo.Id"/>.</summary>
public uint ElementId => _info.Id;
@ -224,6 +237,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
float ty = (Height - lf.LineHeight) * 0.5f;
ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
}
uint dragSprite = _itemDragAcceptance switch
{
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
ItemDragAcceptance.Reject => ItemDragRejectSprite,
_ => 0u,
};
if (dragSprite != 0)
{
var (tex, _, _) = _resolve(dragSprite);
if (tex != 0)
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
}
public override bool OnEvent(in UiEvent e)
@ -276,6 +302,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
}
OnClick?.Invoke();
return OnClick is not null;
case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
: ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DragOver:
_itemDragAcceptance = ItemDragAcceptance.None;
return OnItemDragOver is not null;
case UiEventType.DropReleased:
_itemDragAcceptance = ItemDragAcceptance.None;
if (e.Payload is ItemDragPayload dropped)
OnItemDrop?.Invoke(dropped);
return OnItemDrop is not null;
default:
return false;
}

View file

@ -1336,6 +1336,13 @@ public sealed class WorldSession : IDisposable
SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement));
}
/// <summary>Send StackableMerge (0x0054) with retail's already-clamped transfer amount.</summary>
public void SendStackableMerge(uint sourceGuid, uint targetGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
}
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
/// <remarks>
/// Retail anchor: <c>CM_Combat::Event_QueryHealth</c> / <c>gmToolbarUI::HandleSelectionChanged:198635</c>

View file

@ -92,8 +92,8 @@ public static class ShortcutDropPlanner
/// <summary>
/// Plan the toolbar side of <c>RecvNotice_FullMergingItem @ 0x004BE9B0</c>.
/// The normal retail invariant is one object shortcut; all matching records are
/// rekeyed here so a malformed duplicate cannot retain a destroyed stack id.
/// <c>CreateShortcutToItem</c> removes an existing destination shortcut before
/// placing it into the source shortcut's former slot.
/// </summary>
public static ShortcutMutation[] PlanFullStackMerge(
IReadOnlyList<ShortcutEntry?> slots,
@ -104,15 +104,21 @@ public static class ShortcutDropPlanner
if (slots.Count != ShortcutStore.SlotCount || oldObjectId == 0 || newObjectId == 0)
return Array.Empty<ShortcutMutation>();
var mutations = new List<ShortcutMutation>();
var state = new ShortcutEntry?[ShortcutStore.SlotCount];
for (int slot = 0; slot < slots.Count; slot++)
{
if (VisibleEntry(slots[slot]) is not { } entry || entry.ObjectId != oldObjectId)
continue;
mutations.Add(ShortcutMutation.Remove(slot));
mutations.Add(ShortcutMutation.Add(entry with { ObjectId = newObjectId }));
state[slot] = slots[slot];
}
var mutations = new List<ShortcutMutation>(3);
int sourceSlot = FindFirstVisibleObject(state, oldObjectId);
if (sourceSlot < 0)
return Array.Empty<ShortcutMutation>();
ShortcutEntry sourceEntry = state[sourceSlot]!.Value;
RemoveVisibleAt(state, sourceSlot, mutations);
RemoveFirstVisibleObject(state, newObjectId, mutations);
Add(state, sourceEntry with { ObjectId = newObjectId }, mutations);
return mutations.ToArray();
}
@ -149,6 +155,14 @@ public static class ShortcutDropPlanner
}
}
private static int FindFirstVisibleObject(ShortcutEntry?[] state, uint objectId)
{
for (int slot = 0; slot < state.Length; slot++)
if (VisibleEntry(state[slot]) is { } entry && entry.ObjectId == objectId)
return slot;
return -1;
}
private static void Add(
ShortcutEntry?[] state,
ShortcutEntry entry,

View file

@ -0,0 +1,49 @@
using System;
namespace AcDream.Core.Items;
public readonly record struct StackMergeItem(
uint ObjectId,
uint WeenieClassId,
int StackSize,
int MaxStackSize,
int TradeState);
public readonly record struct StackMergePlan(uint SourceObjectId, uint TargetObjectId, uint Amount);
/// <summary>
/// Pure port of <c>ItemHolder::IsMergeAttemptLegal @ 0x00586F30</c> and the
/// transfer-size branch in <c>ItemHolder::AttemptMerge @ 0x005878F0</c>.
/// </summary>
public static class StackMergePlanner
{
public static StackMergePlan? Plan(
in StackMergeItem source,
in StackMergeItem target,
bool readyForInventoryRequest,
int requestedAmount)
{
if (!readyForInventoryRequest
|| source.ObjectId == 0
|| target.ObjectId == 0
|| source.ObjectId == target.ObjectId
|| source.MaxStackSize <= 1
|| target.MaxStackSize <= 1
|| source.TradeState == 1
|| target.TradeState == 1
|| source.WeenieClassId != target.WeenieClassId)
return null;
int targetSize = Math.Max(1, target.StackSize);
int available = target.MaxStackSize - targetSize;
if (available <= 0)
return null;
int sourceSize = Math.Max(1, source.StackSize);
int desired = requestedAmount > 0 ? Math.Min(requestedAmount, sourceSize) : sourceSize;
uint transfer = (uint)Math.Min(desired, available);
return transfer == 0
? null
: new StackMergePlan(source.ObjectId, target.ObjectId, transfer);
}
}

View file

@ -56,6 +56,8 @@ public class InventoryControllerTests
private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects,
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null,
List<(uint source, uint target, uint amount)>? merges = null,
List<(uint source, uint target)>? mergeNotices = null,
string? ownerName = null,
Action? onClose = null,
SelectionState? selection = null)
@ -66,6 +68,8 @@ public class InventoryControllerTests
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
sendStackableMerge: merges is null ? null : (s, t, a) => merges.Add((s, t, a)),
notifyMergeAttempt: mergeNotices is null ? null : (s, t) => mergeNotices.Add((s, t)),
onClose: onClose,
selection: selection ?? new SelectionState());
@ -564,6 +568,67 @@ public class InventoryControllerTests
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
}
[Fact]
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xA,
WeenieClassId = 0x1234,
StackSize = 40,
StackSizeMax = 100,
});
objects.MoveItem(0xA, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xB,
WeenieClassId = 0x1234,
StackSize = 95,
StackSizeMax = 100,
});
objects.MoveItem(0xB, Player, 1);
var puts = new List<(uint item, uint container, int placement)>();
var merges = new List<(uint source, uint target, uint amount)>();
var notices = new List<(uint source, uint target)>();
var selection = new SelectionState();
var ctrl = Bind(layout, objects, puts: puts, merges: merges,
mergeNotices: notices, selection: selection);
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
Assert.Equal(new[] { (0xAu, 0xBu, 5u) }, merges);
Assert.Equal(new[] { (0xAu, 0xBu) }, notices);
Assert.Empty(puts);
Assert.Equal(0xBu, selection.SelectedObjectId);
}
[Fact]
public void Drop_differentStackType_fallsThroughToNormalInventoryInsert()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xA, WeenieClassId = 0x1111, StackSize = 10, StackSizeMax = 100,
});
objects.MoveItem(0xA, Player, 0);
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xB, WeenieClassId = 0x2222, StackSize = 10, StackSizeMax = 100,
});
objects.MoveItem(0xB, Player, 1);
var puts = new List<(uint item, uint container, int placement)>();
var merges = new List<(uint source, uint target, uint amount)>();
var ctrl = Bind(layout, objects, puts: puts, merges: merges);
ctrl.HandleDropRelease(grid, grid.GetItem(1)!, Payload(0xAu));
Assert.Empty(merges);
Assert.Contains((0xAu, Player, 1), puts);
}
[Fact]
public void Drop_onSideBagCell_movesIntoThatBag()
{

View file

@ -207,6 +207,59 @@ public class ToolbarControllerTests
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void InventoryButton_freshItemDrop_acceptsAndPlacesInPlayerBackpack()
{
const uint player = 0x5000u;
const uint item = 0x5001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
var payload = new ItemDragPayload(item, ItemDragSource.Inventory, 12, new UiItemSlot());
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload)));
Assert.Equal(ItemDragAcceptance.Accept, button.ItemDragAcceptanceForTest);
Assert.Equal(0x060011F7u, button.ItemDragAcceptSprite);
Assert.True(button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload)));
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
Assert.Equal(new[] { (item, player, 0) }, puts);
}
[Fact]
public void InventoryButton_shortcutAliasDrop_staysNeutralAndDoesNotMovePhysicalItem()
{
const uint player = 0x5000u;
const uint item = 0x5001u;
var (layout, _, _) = FakeToolbar();
var repo = new ClientObjectTable();
repo.AddOrUpdate(new ClientObject { ObjectId = item, Type = ItemType.Misc });
var puts = new List<(uint Item, uint Container, int Placement)>();
ToolbarController.Bind(layout, repo,
() => Array.Empty<ShortcutEntry>(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { },
playerGuid: () => player,
sendPutItemInContainer: (i, c, p) => puts.Add((i, c, p)));
var button = (UiButton)layout.FindElement(InventoryButtonId)!;
var payload = new ItemDragPayload(item, ItemDragSource.ShortcutBar, 3, new UiItemSlot());
button.OnEvent(new UiEvent(0u, button, UiEventType.DragEnter, Payload: payload));
Assert.Equal(ItemDragAcceptance.None, button.ItemDragAcceptanceForTest);
button.OnEvent(new UiEvent(0u, button, UiEventType.DropReleased, Payload: payload));
Assert.Empty(puts);
}
[Fact]
public void WindowButtonState_highlightsWhenOpen()
{

View file

@ -0,0 +1,20 @@
using System.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.Core.Net.Tests;
public sealed class WorldSessionInventoryActionTests
{
[Fact]
public void SendStackableMerge_UsesNextSequenceAndExactBuilderBytes()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendStackableMerge(0xAAu, 0xBBu, 5u);
Assert.Equal(InventoryActions.BuildStackableMerge(1, 0xAAu, 0xBBu, 5u), captured);
}
}

View file

@ -121,11 +121,11 @@ public sealed class ShortcutDropPlannerTests
}
[Fact]
public void FullStackMerge_RekeysAllMatchesAndPreservesRawSpellWord()
public void FullStackMerge_RemovesExistingTargetShortcutThenRekeysSourceSlot()
{
var slots = Empty();
slots[2] = new ShortcutEntry(2, 0xA001u, 0x11223344u);
slots[9] = new ShortcutEntry(9, 0xA001u, 0x55667788u);
slots[9] = new ShortcutEntry(9, 0xB001u, 0x55667788u);
ShortcutMutation[] plan = ShortcutDropPlanner.PlanFullStackMerge(
slots, 0xA001u, 0xB001u);
@ -133,9 +133,8 @@ public sealed class ShortcutDropPlannerTests
Assert.Equal(new[]
{
ShortcutMutation.Remove(2),
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
ShortcutMutation.Remove(9),
ShortcutMutation.Add(new ShortcutEntry(9, 0xB001u, 0x55667788u)),
ShortcutMutation.Add(new ShortcutEntry(2, 0xB001u, 0x11223344u)),
}, plan);
}

View file

@ -0,0 +1,60 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class StackMergePlannerTests
{
[Fact]
public void MatchingStacks_TransfersRequestedAmount()
{
StackMergePlan? plan = StackMergePlanner.Plan(
Item(1, size: 40), Item(2, size: 20),
readyForInventoryRequest: true, requestedAmount: 15);
Assert.Equal(new StackMergePlan(1, 2, 15), plan);
}
[Fact]
public void Transfer_IsLimitedByDestinationCapacity()
{
StackMergePlan? plan = StackMergePlanner.Plan(
Item(1, size: 40), Item(2, size: 95),
readyForInventoryRequest: true, requestedAmount: 40);
Assert.Equal(new StackMergePlan(1, 2, 5), plan);
}
[Fact]
public void ZeroRequestedAmount_UsesWholeSourceStack()
{
StackMergePlan? plan = StackMergePlanner.Plan(
Item(1, size: 7), Item(2, size: 20),
readyForInventoryRequest: true, requestedAmount: 0);
Assert.Equal(new StackMergePlan(1, 2, 7), plan);
}
[Theory]
[InlineData(false, 10u, 10, 0, 100, 100)]
[InlineData(true, 11u, 10, 0, 100, 100)]
[InlineData(true, 10u, 10, 1, 100, 100)]
[InlineData(true, 10u, 10, 0, 1, 100)]
[InlineData(true, 10u, 10, 0, 100, 1)]
[InlineData(true, 10u, 100, 0, 100, 100)]
public void IllegalMerge_ReturnsNoPlan(
bool ready,
uint targetWcid,
int targetSize,
int sourceTradeState,
int sourceMax,
int targetMax)
{
var source = new StackMergeItem(1, 10, 10, sourceMax, sourceTradeState);
var target = new StackMergeItem(2, targetWcid, targetSize, targetMax, 0);
Assert.Null(StackMergePlanner.Plan(source, target, ready, requestedAmount: 10));
}
private static StackMergeItem Item(uint id, int size)
=> new(id, WeenieClassId: 10, StackSize: size, MaxStackSize: 100, TradeState: 0);
}