fix(ui): port retail partial stack transfers

Route the selected stack quantity through retail GetObjectSplitSize semantics, send exact split-to-container and split-to-ground actions, and keep the original object in place until the server publishes the newly guided stack. Cover selection scoping, wire bytes, container placement, and world drops with conformance tests.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 10:34:13 +02:00
parent 6005c51c4d
commit ea72c395c9
14 changed files with 280 additions and 19 deletions

View file

@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
## #141 — Toolbar interactivity — selected-object display
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count + entry/slider implemented 2026-07-11, visual gate pending; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count + entry/slider visually confirmed 2026-07-11; partial stack-transfer live gate pending; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140.
**Severity:** MEDIUM
**Filed:** 2026-06-17
**Component:** ui — D.5 toolbar / selection
@ -2060,7 +2060,7 @@ See divergence register **AP-59**.
**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2).
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds both controls and the selected-source merge amount. Warning-free App Release + 4,691 tests; live visual gate pending.
- **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish visually confirmed, partial-transfer live action gate pending.
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).

View file

@ -490,7 +490,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ 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.
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with inventory merge. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, and pointer drag have conformance tests. Warning-free App Release build and 4,691 tests pass; live visual gate pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping now drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests pass; controls/polish are live-confirmed and partial-transfer live gate is pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
- **✓ 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

@ -794,6 +794,44 @@ UseWithTarget (0x0035): u32 sourceObjectId, u32 targetObjectId
## 8. `AttemptPlaceIn3D` pseudocode
### 8.1 Shared selected-stack amount
```text
GetObjectSplitSize(item): // 0x00586F00
if item.id == selectedID:
return GenItemHolder.splitSize
if item.stackSize != 0:
return item.stackSize
return 1
```
The quantity field is therefore global UI state, but it applies only to the
currently selected object. Dragging any other stack uses that object's full
quantity.
### 8.2 Container move versus container split
After the legality, capacity, and auto-merge branches choose a destination,
retail makes this exact decision:
```text
AttemptToPlaceInContainer(itemId, containerId, ..., placement): // 0x00588140
...legality and destination selection...
if splitSize >= maxSplitSize:
item.UIAttemptPutInContainer(containerId, placement)
return true
item.UIAttemptSplitToContainer(containerId, placement, splitSize)
return true
UIAttemptSplitToContainer(item, containerId, placement, amount): // 0x0058D7D0
Event_StackableSplitToContainer(item.id, containerId, placement, amount)
```
The partial path sends GameAction `0x0055` with
`(stackGuid, containerGuid, placement, amount)`. It does not move the original
client object into the destination: the server reduces that source stack and
creates a distinct destination object with a new guid.
```text
AttemptPlaceIn3D(itemId, targetId, allowGroundFallback): // 0x00588600
if player not ready for inventory request: return false
@ -873,6 +911,11 @@ The vendor-drop test is now pinned. Matching-executable disassembly at
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
`ClientUISystem.vendorID`.
`UIAttemptSplitTo3D @ 0x0058D850` dispatches
`CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0`, GameAction `0x0056`
with `(stackGuid, amount)`. Like the container split, the source object remains
in place until authoritative server updates arrive.
---
## 9. Target-mode click ordering

View file

@ -509,8 +509,9 @@ server confirmation despite its name. Release build is warning-free and 4,660 te
pass. The compatible-stack live gate passed 2026-07-11 and exposed the expected
Wave 4.4/AP-101 selected-stack gap. Wave 4.4a now implements that gap: exact count-first
name formatting, authored entry/slider media, one Core split-quantity owner, live
stack-size refresh, and selected-source merge amount. The inventory-button and
selected-stack visual gates remain pending.
stack-size refresh, and selected-source item-holder amounts. Controls/polish passed
the live gate. Partial container/ground transfers now send retail `0x0055/0x0056`;
their live action gate remains pending. The inventory-button gate remains pending.
### 4.4 Finish toolbar controls
@ -526,7 +527,7 @@ selected-stack visual gates remain pending.
- [ ] Replace the PWD-only health gate with the exact attackable/player/pet policy.
- [ ] Add item-mana query/cancel state and health query cancellation.
- [x] Add one shared split-quantity owner for the entry, slider, and selected-source
item-holder merge payload.
item-holder merge, container-split, and ground-split payloads.
**Wave 4.4a implementation (2026-07-11):** static cdb against the matching retail
binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exact
@ -535,11 +536,16 @@ binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exac
`InventoryController` consumes the value only when the dragged source is selected.
The horizontal scrollbar remains the generic retained `UiScrollbar` in scalar mode,
using the DAT track/thumb rather than panel-local drawing. The warning-free App Release
build and 4,691-test suite are green. Gate polish preserves CreateObject
build and 4,697-test suite are green. Gate polish preserves CreateObject
`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
entry's authored right alignment, and lets the pointer-owned thumb remain at exact
minimum/maximum endpoints instead of snapping back from the quantized amount. The
live visual gate follows.
controls/polish live gate passed. `ItemHolder::GetObjectSplitSize @ 0x00586F00`
now scopes that quantity to the selected object for all transfers. Partial inventory
moves send `StackableSplitToContainer 0x0055`; partial drag-out drops send
`StackableSplitTo3D 0x0056`; neither path moves the original object optimistically
because the authoritative destination stack receives a server-assigned guid. The
partial-transfer live action gate follows.
**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

View file

@ -759,6 +759,7 @@ public sealed class GameWindow : IDisposable
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
@ -2003,7 +2004,11 @@ public sealed class GameWindow : IDisposable
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
placeInBackpack: SendPickUp);
placeInBackpack: SendPickUp,
sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity);
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
@ -2140,6 +2145,9 @@ public sealed class GameWindow : IDisposable
guid => _liveSession?.SendNoLongerViewingContents(guid),
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
_liveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
(source, target, amount) =>
_liveSession?.SendStackableMerge(source, target, amount),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
@ -2148,6 +2156,7 @@ public sealed class GameWindow : IDisposable
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
Probe: new AcDream.App.UI.RetailUiProbeBindings(

View file

@ -63,6 +63,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendUseWithTarget;
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
private readonly Func<uint> _activeVendorId;
@ -73,6 +74,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint>? _placeInBackpack;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private long _lastUseMs = long.MinValue / 2;
private uint _consumedPrimaryClickTarget;
@ -97,7 +100,10 @@ public sealed class ItemInteractionController : IDisposable
Func<bool>? inNonCombatMode = null,
Func<uint, bool>? isComponentPack = null,
Action<uint>? placeInBackpack = null,
Action<ItemPolicyAction>? auxiliaryAction = null)
Action<ItemPolicyAction>? auxiliaryAction = null,
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
StackSplitQuantityState? stackSplitQuantity = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -105,6 +111,7 @@ public sealed class ItemInteractionController : IDisposable
_sendUseWithTarget = sendUseWithTarget;
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
@ -115,6 +122,8 @@ public sealed class ItemInteractionController : IDisposable
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
}
@ -268,7 +277,9 @@ public sealed class ItemInteractionController : IDisposable
if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item)
return false;
int splitSize = Math.Max(1, item.StackSize);
uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
Snapshot(item),
_playerGuid(),
@ -353,6 +364,11 @@ public sealed class ItemInteractionController : IDisposable
_objects.MoveItemOptimistic(action.ObjectId, newContainerId: 0u, newSlot: -1);
_sendDrop?.Invoke(action.ObjectId);
break;
case ItemPolicyActionKind.SplitToWorld:
// UIAttemptSplitTo3D leaves the original object in its container;
// the server assigns the split-off ground stack a new guid.
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);

View file

@ -58,6 +58,7 @@ 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, uint>? _sendStackableSplitToContainer;
private readonly Action<uint, uint, uint>? _sendStackableMerge;
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
@ -83,6 +84,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer,
Action<uint, uint, uint>? sendStackableMerge,
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
@ -97,6 +99,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_sendStackableSplitToContainer = sendStackableSplitToContainer;
_sendStackableMerge = sendStackableMerge;
_notifyMergeAttempt = notifyMergeAttempt;
_itemInteraction = itemInteraction;
@ -206,6 +209,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint, uint>? sendStackableSplitToContainer = null,
Action<uint, uint, uint>? sendStackableMerge = null,
Action<uint, uint>? notifyMergeAttempt = null,
ItemInteractionController? itemInteraction = null,
@ -215,7 +219,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer,
sendStackableMerge, notifyMergeAttempt, itemInteraction,
sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
@ -417,8 +422,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.Reject;
}
/// <summary>Perform the move: resolve (container, placement) from the target, optimistically move
/// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem.</summary>
/// <summary>Resolve the destination and either split or move the stack. A partial split waits
/// for the server-created object's guid; a whole move remains optimistic. Retail:
/// <c>ItemHolder::AttemptToPlaceInContainer @ 0x00588140</c>.</summary>
public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload)
{
// UIElement_ItemList::HandleDropRelease @ 0x004E4790 applies the same
@ -456,6 +462,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
else return;
if (container == item) return; // never into itself
if (_objects.Get(item) is { } source)
{
uint fullStack = (uint)Math.Max(source.StackSize, 1);
uint splitSize = _stackSplitQuantity?.GetObjectSplitSize(
item, _selection.SelectedObjectId ?? 0u, fullStack) ?? fullStack;
if (splitSize < fullStack)
{
// UIAttemptSplitToContainer leaves the source stack where it is. ACE will
// publish the reduced source plus a newly-guided destination stack.
_sendStackableSplitToContainer?.Invoke(
item, container, (uint)placement, splitSize);
return;
}
}
_objects.MoveItemOptimistic(item, container, placement); // instant local move
_sendPutItemInContainer?.Invoke(item, container, placement);
}

View file

@ -67,6 +67,7 @@ public sealed record InventoryRuntimeBindings(
Action<uint>? SendUse,
Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
Action<uint, uint, uint>? SendStackableMerge,
Action<uint, uint>? SendWield,
ItemInteractionController ItemInteraction,
@ -97,6 +98,7 @@ public sealed record RetailUiRuntimeBindings(
CharacterRuntimeBindings Character,
InventoryRuntimeBindings Inventory,
RetailUiCursorBindings Cursor,
StackSplitQuantityState StackSplitQuantity,
BufferedUiRegistry? Plugins,
RetailUiPersistenceBindings? Persistence,
RetailUiProbeBindings Probe);
@ -109,7 +111,7 @@ public sealed record RetailUiRuntimeBindings(
public sealed class RetailUiRuntime : IDisposable
{
private readonly RetailUiRuntimeBindings _bindings;
private readonly StackSplitQuantityState _stackSplitQuantity = new();
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
private readonly RetailWindowLayoutPersistence? _persistence;
private readonly RetailUiAutomationScriptRunner? _automation;
private bool _disposed;
@ -385,7 +387,7 @@ public sealed class RetailUiRuntime : IDisposable
b.StackSize,
b.SendQueryHealth,
_bindings.Assets.DefaultFont,
_stackSplitQuantity,
StackSplitQuantity,
handler => b.Objects.ObjectUpdated += handler,
handler => b.Objects.ObjectUpdated -= handler);
@ -599,10 +601,10 @@ public sealed class RetailUiRuntime : IDisposable
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.SendStackableMerge,
b.SendPutItemInContainer, b.SendStackableSplitToContainer, b.SendStackableMerge,
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
_stackSplitQuantity);
StackSplitQuantity);
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);

View file

@ -1345,6 +1345,28 @@ public sealed class WorldSession : IDisposable
SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount));
}
/// <summary>
/// Send retail StackableSplitToContainer (0x0055).
/// <c>CM_Inventory::Event_StackableSplitToContainer @ 0x006AC0D0</c>.
/// </summary>
public void SendStackableSplitToContainer(
uint stackGuid, uint containerGuid, uint placement, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableSplitToContainer(
seq, stackGuid, containerGuid, placement, amount));
}
/// <summary>
/// Send retail StackableSplitTo3D (0x0056).
/// <c>CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0</c>.
/// </summary>
public void SendStackableSplitTo3D(uint stackGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, 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

@ -48,6 +48,18 @@ public sealed class StackSplitQuantityState
public void SetValue(uint value) => Set(value, Maximum);
/// <summary>
/// Ports <c>ItemHolder::GetObjectSplitSize @ 0x00586F00</c>: only the
/// currently selected object consumes <c>GenItemHolder::splitSize</c>.
/// Every other object operation uses that object's full stack size.
/// </summary>
public uint GetObjectSplitSize(uint objectId, uint selectedObjectId, uint objectStackSize)
{
if (objectId == selectedObjectId)
return Value;
return Math.Max(objectStackSize, 1u);
}
private void Set(uint value, uint maximum)
{
maximum = Math.Max(maximum, 1u);

View file

@ -18,7 +18,10 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<string> Toasts = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public uint SelectedObject;
public long Now = 1_000;
public Harness()
@ -46,7 +49,10 @@ public sealed class ItemInteractionControllerTests
sendWield: (item, mask) => Wields.Add((item, mask)),
sendDrop: Drops.Add,
nowMs: () => Now,
toast: Toasts.Add);
toast: Toasts.Add,
sendSplitToWorld: (item, amount) => SplitDrops.Add((item, amount)),
selectedObjectId: () => SelectedObject,
stackSplitQuantity: SplitQuantity);
}
public ItemInteractionController Controller { get; }
@ -389,6 +395,28 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
{
var h = new Harness();
h.AddContained(0x50000A70u, item => item.StackSize = 10);
h.SelectedObject = 0x50000A70u;
h.SplitQuantity.Reset(10u);
h.SplitQuantity.SetValue(1u);
var payload = new ItemDragPayload(
0x50000A70u,
ItemDragSource.Inventory,
SourceSlot: 0,
SourceCell: new UiItemSlot());
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { (0x50000A70u, 1u) }, h.SplitDrops);
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId);
Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize);
}
[Fact]
public void ToolbarShortcutDragOutsideUi_doesNotDropRealItem()
{

View file

@ -56,6 +56,7 @@ 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 item, uint container, uint placement, uint amount)>? splits = null,
List<(uint source, uint target, uint amount)>? merges = null,
List<(uint source, uint target)>? mergeNotices = null,
string? ownerName = null,
@ -69,6 +70,9 @@ 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)),
sendStackableSplitToContainer: splits is null
? null
: (i, c, p, a) => splits.Add((i, c, p, a)),
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,
@ -570,6 +574,62 @@ public class InventoryControllerTests
Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty
}
[Fact]
public void Drop_selectedPartialStack_splitsIntoContainer_withoutMovingOriginal()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 0xAu,
StackSize = 10,
StackSizeMax = 100,
});
objects.MoveItem(0xAu, Player, 0);
var selection = new SelectionState();
selection.Select(0xAu, SelectionChangeSource.Inventory);
var splitQuantity = new StackSplitQuantityState();
splitQuantity.Reset(10u);
splitQuantity.SetValue(1u);
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
var puts = new List<(uint item, uint container, int placement)>();
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
selection: selection, stackSplitQuantity: splitQuantity);
ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu));
Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits);
Assert.Empty(puts);
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot);
Assert.Equal(10, objects.Get(0xAu)!.StackSize);
}
[Fact]
public void Drop_unselectedStack_ignoresOtherSelectionSplitQuantity_andMovesWholeStack()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 0xAu, StackSize = 10 });
objects.MoveItem(0xAu, Player, 0);
objects.AddOrUpdate(new ClientObject { ObjectId = 0xBu, StackSize = 20 });
objects.MoveItem(0xBu, Player, 1);
var selection = new SelectionState();
selection.Select(0xBu, SelectionChangeSource.Inventory);
var splitQuantity = new StackSplitQuantityState();
splitQuantity.Reset(20u);
splitQuantity.SetValue(1u);
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
var puts = new List<(uint item, uint container, int placement)>();
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
selection: selection, stackSplitQuantity: splitQuantity);
ctrl.HandleDropRelease(grid, grid.GetItem(4)!, Payload(0xAu));
Assert.Empty(splits);
Assert.Equal(new[] { (0xAu, Player, 2) }, puts);
}
[Fact]
public void Drop_matchingStackOnOccupiedStack_mergesBeforeMoveAndBroadcastsToolbarNotice()
{

View file

@ -17,4 +17,32 @@ public sealed class WorldSessionInventoryActionTests
Assert.Equal(InventoryActions.BuildStackableMerge(1, 0xAAu, 0xBBu, 5u), captured);
}
[Fact]
public void SendStackableSplitToContainer_UsesNextSequenceAndExactBuilderBytes()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendStackableSplitToContainer(0xAAu, 0xBBu, 4u, 2u);
Assert.Equal(
InventoryActions.BuildStackableSplitToContainer(1, 0xAAu, 0xBBu, 4u, 2u),
captured);
}
[Fact]
public void SendStackableSplitTo3D_UsesNextSequenceAndExactBuilderBytes()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendStackableSplitTo3D(0xAAu, 2u);
Assert.Equal(InventoryActions.BuildStackableSplitTo3D(1, 0xAAu, 2u), captured);
}
}

View file

@ -55,4 +55,17 @@ public sealed class StackSplitQuantityStateTests
Assert.Equal(expected, state.Value);
}
[Fact]
public void GetObjectSplitSize_usesSliderOnlyForSelectedObject()
{
var state = new StackSplitQuantityState();
state.Reset(10u);
state.SetValue(3u);
Assert.Equal(3u, state.GetObjectSplitSize(0xAu, 0xAu, 10u));
Assert.Equal(7u, state.GetObjectSplitSize(0xBu, 0xAu, 7u));
Assert.Equal(3u, state.GetObjectSplitSize(0xCu, 0xCu, 0u));
Assert.Equal(1u, state.GetObjectSplitSize(0xCu, 0xDu, 0u));
}
}