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:
parent
6005c51c4d
commit
ea72c395c9
14 changed files with 280 additions and 19 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue