From ea72c395c9c54be8e20825260d791e4f1e7c228a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 10:34:13 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 4 +- docs/plans/2026-04-11-roadmap.md | 2 +- ...0-retail-toolbar-interaction-pseudocode.md | 43 +++++++++++++ ...026-07-10-retail-ui-fidelity-completion.md | 16 +++-- src/AcDream.App/Rendering/GameWindow.cs | 11 +++- .../UI/ItemInteractionController.cs | 20 ++++++- .../UI/Layout/InventoryController.cs | 28 ++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 10 ++-- src/AcDream.Core.Net/WorldSession.cs | 22 +++++++ .../Items/StackSplitQuantityState.cs | 12 ++++ .../UI/ItemInteractionControllerTests.cs | 30 +++++++++- .../UI/Layout/InventoryControllerTests.cs | 60 +++++++++++++++++++ .../WorldSessionInventoryActionTests.cs | 28 +++++++++ .../Items/StackSplitQuantityStateTests.cs | 13 ++++ 14 files changed, 280 insertions(+), 19 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 4eda3e76..b515d873 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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`). diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 44f1674a..96807dae 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -490,7 +490,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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. 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 f55903e3..c163e19c 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -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 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 ae6aff9e..8125405e 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 @@ -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 diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 176218a2..d46dacae 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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( diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index a6568726..04ff7f50 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -63,6 +63,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendUseWithTarget; private readonly Action? _sendWield; private readonly Action? _sendDrop; + private readonly Action? _sendSplitToWorld; private readonly Action? _toast; private readonly Func _readyForInventoryRequest; private readonly Func _activeVendorId; @@ -73,6 +74,8 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _placeInBackpack; private readonly Action? _auxiliaryAction; private readonly InteractionState _interactionState; + private readonly Func _selectedObjectId; + private readonly StackSplitQuantityState? _stackSplitQuantity; private long _lastUseMs = long.MinValue / 2; private uint _consumedPrimaryClickTarget; @@ -97,7 +100,10 @@ public sealed class ItemInteractionController : IDisposable Func? inNonCombatMode = null, Func? isComponentPack = null, Action? placeInBackpack = null, - Action? auxiliaryAction = null) + Action? auxiliaryAction = null, + Action? sendSplitToWorld = null, + Func? 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); diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index bc0c4b5c..179c3f03 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -58,6 +58,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo private readonly Action? _sendUse; private readonly Action? _sendNoLongerViewing; private readonly Action? _sendPutItemInContainer; // (item, container, placement) + private readonly Action? _sendStackableSplitToContainer; private readonly Action? _sendStackableMerge; private readonly Action? _notifyMergeAttempt; private readonly ItemInteractionController? _itemInteraction; @@ -83,6 +84,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo Action? sendUse, Action? sendNoLongerViewing, Action? sendPutItemInContainer, + Action? sendStackableSplitToContainer, Action? sendStackableMerge, Action? 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? sendUse = null, Action? sendNoLongerViewing = null, Action? sendPutItemInContainer = null, + Action? sendStackableSplitToContainer = null, Action? sendStackableMerge = null, Action? 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; } - /// Perform the move: resolve (container, placement) from the target, optimistically move - /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. + /// 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: + /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. 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); } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 09daeb65..18ac4206 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -67,6 +67,7 @@ public sealed record InventoryRuntimeBindings( Action? SendUse, Action? SendNoLongerViewing, Action? SendPutItemInContainer, + Action? SendStackableSplitToContainer, Action? SendStackableMerge, Action? 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); diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 05e016ec..f27ea8d3 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1345,6 +1345,28 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildStackableMerge(seq, sourceGuid, targetGuid, amount)); } + /// + /// Send retail StackableSplitToContainer (0x0055). + /// CM_Inventory::Event_StackableSplitToContainer @ 0x006AC0D0. + /// + public void SendStackableSplitToContainer( + uint stackGuid, uint containerGuid, uint placement, uint amount) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildStackableSplitToContainer( + seq, stackGuid, containerGuid, placement, amount)); + } + + /// + /// Send retail StackableSplitTo3D (0x0056). + /// CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0. + /// + public void SendStackableSplitTo3D(uint stackGuid, uint amount) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/src/AcDream.Core/Items/StackSplitQuantityState.cs b/src/AcDream.Core/Items/StackSplitQuantityState.cs index b81d5aac..f582d63b 100644 --- a/src/AcDream.Core/Items/StackSplitQuantityState.cs +++ b/src/AcDream.Core/Items/StackSplitQuantityState.cs @@ -48,6 +48,18 @@ public sealed class StackSplitQuantityState public void SetValue(uint value) => Set(value, Maximum); + /// + /// Ports ItemHolder::GetObjectSplitSize @ 0x00586F00: only the + /// currently selected object consumes GenItemHolder::splitSize. + /// Every other object operation uses that object's full stack size. + /// + 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); diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index b2a25e72..5c903923 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -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 Drops = new(); + public readonly List<(uint Item, uint Amount)> SplitDrops = new(); public readonly List 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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 67521b60..75be6314 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -56,6 +56,7 @@ public class InventoryControllerTests private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, int? strength = 100, List? uses = null, List? 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() { diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs index 7b481ab7..c962d937 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs @@ -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); + } } diff --git a/tests/AcDream.Core.Tests/Items/StackSplitQuantityStateTests.cs b/tests/AcDream.Core.Tests/Items/StackSplitQuantityStateTests.cs index eb984e3e..bb278591 100644 --- a/tests/AcDream.Core.Tests/Items/StackSplitQuantityStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/StackSplitQuantityStateTests.cs @@ -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)); + } }