fix(items): finish corpse pickup and combat switching

Port retail's first-slot ShowPendingInPlayer path for double-click loot and carry the current owned-container destination through deferred pickup. Retire the previous ground-container view as soon as a replacement is requested so its range close cannot cancel ACE's active MoveTo chain.

Preserve active-combat weapon intent across ACE's authoritative wand-to-missile stance tail while leaving peace-mode switches unchanged.

Release build succeeds and all 5,885 tests pass with five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 18:35:29 +02:00
parent d51a0fc825
commit 0392c6d721
17 changed files with 631 additions and 81 deletions

View file

@ -1136,7 +1136,13 @@ public sealed class GameWindow : IDisposable
// rotating to face the target). Only set for close-range Use/PickUp;
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
// time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
private PendingPostArrivalAction? _pendingPostArrivalAction;
private readonly record struct PendingPostArrivalAction(
uint Guid,
bool IsPickup,
uint DestinationContainerId = 0u,
int Placement = 0);
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, registered in _physicsHosts so remote
@ -2127,7 +2133,13 @@ public sealed class GameWindow : IDisposable
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
sendUse: g => _liveSession?.SendUse(g),
// ItemHolder::UseObject is the common policy owner, but world
// activation still has to pass through the application-level
// use adapter. It owns speculative turn/move, the close-range
// deferred send, and ACE's far-range MoveTo callback. Calling
// WorldSession directly here made keyboard R approach a corpse
// without completing the same open transaction as double-click.
sendUse: SendUse,
sendExamine: g => _liveSession?.SendAppraise(g),
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
@ -2144,8 +2156,19 @@ public sealed class GameWindow : IDisposable
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
combatState: Combat,
sendChangeCombatMode: mode =>
_liveSession?.SendChangeCombatMode(mode),
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
backpackContainerId: () =>
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
?? _playerServerGuid,
// Retail ItemHolder::DetermineUseResult treats children of the
// current ground object as loot. Keep this late-bound because
// ViewContents can replace/close the external container while
// the retained controller remains alive.
groundObjectId: () => _externalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
_liveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
@ -13258,9 +13281,6 @@ public sealed class GameWindow : IDisposable
return;
}
if (IsExternalContainerTarget(guid))
_externalContainers.RequestOpen(guid);
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
// through the player's MoveToManager so close-range Use rotates the
// body to face before the action fires. For FAR targets, ACE's
@ -13278,7 +13298,7 @@ public sealed class GameWindow : IDisposable
{
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
// will fire it after rotation completes.
_pendingPostArrivalAction = (guid, false);
_pendingPostArrivalAction = new PendingPostArrivalAction(guid, IsPickup: false);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
return;
@ -13318,6 +13338,9 @@ public sealed class GameWindow : IDisposable
}
private void SendPickUp(uint itemGuid)
=> SendPickUp(itemGuid, _playerServerGuid, placement: 0);
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@ -13362,7 +13385,11 @@ public sealed class GameWindow : IDisposable
if (closeRange)
{
_pendingPostArrivalAction = (itemGuid, true);
_pendingPostArrivalAction = new PendingPostArrivalAction(
itemGuid,
IsPickup: true,
destinationContainerId,
placement);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
return;
@ -13375,9 +13402,9 @@ public sealed class GameWindow : IDisposable
// ACE's chain alive.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
seq, itemGuid, destinationContainerId, placement);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={seq}");
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
string label = DescribeLiveEntity(itemGuid);
@ -13398,7 +13425,7 @@ public sealed class GameWindow : IDisposable
/// </summary>
private void OnAutoWalkArrivedSendDeferredAction()
{
if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending)
if (_pendingPostArrivalAction is not { } pending)
return;
_pendingPostArrivalAction = null;
@ -13407,18 +13434,21 @@ public sealed class GameWindow : IDisposable
return;
var seq = _liveSession.NextGameActionSequence();
if (isPickup)
if (pending.IsPickup)
{
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, guid, _playerServerGuid, placement: 0);
seq,
pending.Guid,
pending.DestinationContainerId,
pending.Placement);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
Console.WriteLine($"[B.5] pickup-deferred item=0x{pending.Guid:X8} container=0x{pending.DestinationContainerId:X8} placement={pending.Placement} seq={seq}");
}
else
{
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, pending.Guid);
_liveSession.SendGameAction(body);
Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}");
Console.WriteLine($"[B.4b] use-deferred guid=0x{pending.Guid:X8} seq={seq}");
}
}
@ -13497,20 +13527,6 @@ public sealed class GameWindow : IDisposable
return dx * dx + dy * dy <= useRadius * useRadius;
}
private bool IsExternalContainerTarget(uint guid)
{
if (Objects.Get(guid) is { } item
&& (item.ContainerTypeHint != 0u
|| item.Type.HasFlag(AcDream.Core.Items.ItemType.Container)
|| item.ItemsCapacity > 0))
{
return true;
}
return LastSpawns.TryGetValue(guid, out var spawn)
&& (spawn.ObjectDescriptionFlags.GetValueOrDefault() & 0x2000u) != 0u;
}
private void InstallSpeculativeTurnToTarget(uint targetGuid)
{
if (_playerController is not { } pc || pc.MoveTo is null) return;

View file

@ -1,4 +1,5 @@
using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.UI;
@ -63,8 +64,11 @@ internal sealed class AutoWieldController : IDisposable
private readonly Action<uint, uint, int>? _sendPutItemInContainer;
private readonly Action<string>? _toast;
private readonly Action<string>? _systemMessage;
private readonly CombatState? _combatState;
private readonly Action<CombatMode>? _sendChangeCombatMode;
private PendingSwitch? _pendingSwitch;
private PendingCombatSettlement? _pendingCombatSettlement;
private bool _disposed;
public bool IsBusy => _pendingSwitch is not null;
@ -75,7 +79,9 @@ internal sealed class AutoWieldController : IDisposable
Action<uint, uint>? sendWield,
Action<uint, uint, int>? sendPutItemInContainer,
Action<string>? toast,
Action<string>? systemMessage = null)
Action<string>? systemMessage = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -83,12 +89,16 @@ internal sealed class AutoWieldController : IDisposable
_sendPutItemInContainer = sendPutItemInContainer;
_toast = toast;
_systemMessage = systemMessage;
_combatState = combatState;
_sendChangeCombatMode = sendChangeCombatMode;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.MoveRequestFailed += OnMoveRequestFailed;
_objects.WieldConfirmed += OnWieldConfirmed;
_objects.Cleared += OnObjectsCleared;
if (_combatState is not null)
_combatState.CombatModeChanged += OnCombatModeChanged;
}
/// <summary>
@ -97,7 +107,10 @@ internal sealed class AutoWieldController : IDisposable
/// as retail's inventory-ready gate prevents a second concurrent request.
/// </summary>
public bool TryWield(ClientObject item)
=> TryWield(item, EquipMask.None);
{
_pendingCombatSettlement = null;
return TryWield(item, EquipMask.None, combatModeAfterWield: null);
}
/// <summary>
/// Execute AutoWield while retaining the paperdoll's resolved target side.
@ -106,6 +119,15 @@ internal sealed class AutoWieldController : IDisposable
/// bypassing the transaction with a direct wire request.
/// </summary>
public bool TryWield(ClientObject item, EquipMask requestedMask)
{
_pendingCombatSettlement = null;
return TryWield(item, requestedMask, combatModeAfterWield: null);
}
private bool TryWield(
ClientObject item,
EquipMask requestedMask,
CombatMode? combatModeAfterWield)
{
ArgumentNullException.ThrowIfNull(item);
@ -130,9 +152,13 @@ internal sealed class AutoWieldController : IDisposable
if (GetEquippedObjectAtLocation(
requestedMask, priority: 0, item.ObjectId) is { } blocker)
{
return BeginWeaponReplacement(item.ObjectId, blocker, requestedMask);
return BeginWeaponReplacement(
item.ObjectId,
blocker,
requestedMask,
combatModeAfterWield);
}
return SendWield(item, requestedMask);
return SendWield(item, requestedMask, combatModeAfterWield);
}
EquipMask weaponLocation = requestedMask == EquipMask.None
@ -140,10 +166,24 @@ internal sealed class AutoWieldController : IDisposable
: requestedMask & WeaponReadyMask;
if (weaponLocation != EquipMask.None)
{
// Preserve the ready mode only when the transaction starts in
// combat. During a replacement ACE legitimately reports the
// intermediate stances used to put the old weapon away; those
// must not erase the user's original intent before the new wield
// is authoritatively confirmed.
combatModeAfterWield ??= (_combatState?.CurrentMode ?? CombatMode.NonCombat)
== CombatMode.NonCombat
? null
: CombatModeForWeaponLocation(weaponLocation);
ClientObject? blocker = GetEquippedObjectAtLocation(
WeaponReadyMask, priority: 0, item.ObjectId);
if (blocker is not null)
return BeginWeaponReplacement(item.ObjectId, blocker, weaponLocation);
return BeginWeaponReplacement(
item.ObjectId,
blocker,
weaponLocation,
combatModeAfterWield);
// Retail checks secondary blockers only after the primary weapon
// slot is vacant, then re-enters AutoWield after each confirmed
@ -151,15 +191,23 @@ internal sealed class AutoWieldController : IDisposable
if (BlocksUseOfShield(item)
&& GetEquippedObjectAtLocation(
EquipMask.Shield, priority: 0, item.ObjectId) is { } shield)
return BeginWeaponReplacement(item.ObjectId, shield, weaponLocation);
return BeginWeaponReplacement(
item.ObjectId,
shield,
weaponLocation,
combatModeAfterWield);
if (item.AmmoType is > 0
&& GetEquippedObjectAtLocation(
EquipMask.MissileAmmo, priority: 0, item.ObjectId) is { } ammo
&& ammo.AmmoType != item.AmmoType)
return BeginWeaponReplacement(item.ObjectId, ammo, weaponLocation);
return BeginWeaponReplacement(
item.ObjectId,
ammo,
weaponLocation,
combatModeAfterWield);
return SendWield(item, weaponLocation);
return SendWield(item, weaponLocation, combatModeAfterWield);
}
EquipMask mask = requestedMask != EquipMask.None
@ -171,13 +219,14 @@ internal sealed class AutoWieldController : IDisposable
return false;
}
return SendWield(item, mask);
return SendWield(item, mask, combatModeAfterWield: null);
}
private bool BeginWeaponReplacement(
uint requestedItemId,
ClientObject blockingItem,
EquipMask requestedMask)
EquipMask requestedMask,
CombatMode? combatModeAfterWield)
{
if (_sendPutItemInContainer is null)
{
@ -192,7 +241,8 @@ internal sealed class AutoWieldController : IDisposable
_pendingSwitch = new PendingSwitch(
requestedItemId,
blockingItem.ObjectId,
requestedMask);
requestedMask,
combatModeAfterWield);
// Retail AttemptToPlaceInContainer(blockingID, playerID, 0, 1, 0).
// Do not change the local equip projection yet: the server's move event
@ -203,14 +253,18 @@ internal sealed class AutoWieldController : IDisposable
return true;
}
private bool SendWield(ClientObject item, EquipMask mask)
private bool SendWield(
ClientObject item,
EquipMask mask,
CombatMode? combatModeAfterWield)
{
if (_sendWield is null)
return false;
_pendingSwitch = new PendingSwitch(
item.ObjectId,
BlockingItemId: 0,
RequestedMask: mask);
RequestedMask: mask,
CombatModeAfterWield: combatModeAfterWield);
if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask))
{
_pendingSwitch = null;
@ -235,14 +289,75 @@ internal sealed class AutoWieldController : IDisposable
// vacant ready slot and send GetAndWieldItem for the requested weapon.
_pendingSwitch = null;
if (_objects.Get(pending.RequestedItemId) is { } requested)
TryWield(requested, pending.RequestedMask);
TryWield(
requested,
pending.RequestedMask,
pending.CombatModeAfterWield);
}
private void OnWieldConfirmed(uint itemId)
{
if (_pendingSwitch is { BlockingItemId: 0 } pending
&& itemId == pending.RequestedItemId)
{
_pendingSwitch = null;
if (pending.CombatModeAfterWield is { } readyMode)
{
// ACE publishes its queued dequip tail after WieldObject. Wait
// for that first authoritative post-wield mode notice before
// responding; sending inside WieldConfirmed races the tail.
_pendingCombatSettlement = new(
itemId,
readyMode,
CombatSettlementPhase.AwaitingPostWieldMode);
}
}
}
private void OnCombatModeChanged(CombatMode mode)
{
if (_pendingCombatSettlement is not { } settlement)
return;
if (mode == settlement.ReadyMode)
{
if (settlement.Phase == CombatSettlementPhase.SawTransitionalPeace)
{
// ACE selected the correct weapon stance, but its queued
// dequip callback still has one trailing Peace notice.
_pendingCombatSettlement = settlement with
{
Phase = CombatSettlementPhase.SawReadyAfterPeace,
};
}
else
{
// A server that immediately settled on the intended ready
// stance needs no compatibility request.
_pendingCombatSettlement = null;
}
return;
}
if (mode != CombatMode.NonCombat)
return;
if (settlement.Phase == CombatSettlementPhase.SawReadyAfterPeace)
{
// This is ACE's trailing Peace after it already selected the new
// weapon stance. The server's stance shuffle is now settled, so a
// normal request from this notice is accepted and ordered after it.
_pendingCombatSettlement = null;
_sendChangeCombatMode?.Invoke(settlement.ReadyMode);
}
else
{
// First transitional Peace. ACE is still shuffling the old weapon;
// requesting here races the later queued callback.
_pendingCombatSettlement = settlement with
{
Phase = CombatSettlementPhase.SawTransitionalPeace,
};
}
}
private void OnMoveRequestFailed(MoveRequestFailure failure)
@ -259,10 +374,16 @@ internal sealed class AutoWieldController : IDisposable
&& (item.ObjectId == pending.BlockingItemId
|| item.ObjectId == pending.RequestedItemId))
_pendingSwitch = null;
if (_pendingCombatSettlement is { } settlement
&& item.ObjectId == settlement.ItemId)
_pendingCombatSettlement = null;
}
private void OnObjectsCleared()
=> _pendingSwitch = null;
{
_pendingSwitch = null;
_pendingCombatSettlement = null;
}
private EquipMask BestAvailableEquipMask(ClientObject item)
{
@ -357,21 +478,48 @@ internal sealed class AutoWieldController : IDisposable
|| item.Type.HasFlag(ItemType.Caster);
}
private static CombatMode? CombatModeForWeaponLocation(EquipMask location)
{
if ((location & EquipMask.MissileWeapon) != 0)
return CombatMode.Missile;
if ((location & EquipMask.Held) != 0)
return CombatMode.Magic;
if ((location & (EquipMask.MeleeWeapon | EquipMask.TwoHanded)) != 0)
return CombatMode.Melee;
return null;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_pendingSwitch = null;
_pendingCombatSettlement = null;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.MoveRequestFailed -= OnMoveRequestFailed;
_objects.WieldConfirmed -= OnWieldConfirmed;
_objects.Cleared -= OnObjectsCleared;
if (_combatState is not null)
_combatState.CombatModeChanged -= OnCombatModeChanged;
}
private readonly record struct PendingSwitch(
uint RequestedItemId,
uint BlockingItemId,
EquipMask RequestedMask);
EquipMask RequestedMask,
CombatMode? CombatModeAfterWield);
private readonly record struct PendingCombatSettlement(
uint ItemId,
CombatMode ReadyMode,
CombatSettlementPhase Phase);
private enum CombatSettlementPhase
{
AwaitingPostWieldMode,
SawTransitionalPeace,
SawReadyAfterPeace,
}
}

View file

@ -1,4 +1,5 @@
using System;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.UI;
@ -40,7 +41,8 @@ public sealed class ItemInteractionController : IDisposable
private readonly Func<bool> _playerOnGround;
private readonly Func<bool> _inNonCombatMode;
private readonly Func<uint, bool> _isComponentPack;
private readonly Action<uint>? _placeInBackpack;
private readonly Action<uint, uint, int>? _placeInBackpack;
private readonly Func<uint> _backpackContainerId;
private readonly Action<uint>? _requestExternalContainer;
private readonly Action<ItemPolicyAction>? _auxiliaryAction;
private readonly InteractionState _interactionState;
@ -73,7 +75,8 @@ public sealed class ItemInteractionController : IDisposable
Func<bool>? playerOnGround = null,
Func<bool>? inNonCombatMode = null,
Func<uint, bool>? isComponentPack = null,
Action<uint>? placeInBackpack = null,
Action<uint, uint, int>? placeInBackpack = null,
Func<uint>? backpackContainerId = null,
Action<ItemPolicyAction>? auxiliaryAction = null,
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
@ -83,7 +86,9 @@ public sealed class ItemInteractionController : IDisposable
Func<bool>? dragOnPlayerOpensSecureTrade = null,
Action<string>? systemMessage = null,
Action<uint, uint, uint, uint>? sendSplitToContainer = null,
Action<uint>? requestExternalContainer = null)
Action<uint>? requestExternalContainer = null,
CombatState? combatState = null,
Action<CombatMode>? sendChangeCombatMode = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -105,6 +110,7 @@ public sealed class ItemInteractionController : IDisposable
_inNonCombatMode = inNonCombatMode ?? (() => false);
_isComponentPack = isComponentPack ?? (_ => false);
_placeInBackpack = placeInBackpack;
_backpackContainerId = backpackContainerId ?? _playerGuid;
_requestExternalContainer = requestExternalContainer;
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
@ -119,11 +125,20 @@ public sealed class ItemInteractionController : IDisposable
_sendWield,
sendPutItemInContainer,
_toast,
_systemMessage);
_systemMessage,
combatState,
sendChangeCombatMode);
}
public event Action? StateChanged;
/// <summary>
/// Retail <c>CM_Item::SendNotice_ShowPendingInPlayer</c>: the inventory
/// panel inserts a waiting projection before the pickup request is sent.
/// The destination and placement are the same values sent on the wire.
/// </summary>
public event Action<uint, uint, int>? PendingBackpackPlacementRequested;
public uint PlayerGuid => _playerGuid();
public InteractionState InteractionState => _interactionState;
@ -399,8 +414,18 @@ public sealed class ItemInteractionController : IDisposable
switch (action.Kind)
{
case ItemPolicyActionKind.PlaceInBackpack:
_placeInBackpack?.Invoke(action.ObjectId);
acted |= _placeInBackpack is not null;
if (_placeInBackpack is null)
break;
uint containerId = _backpackContainerId();
if (containerId == 0u)
containerId = _playerGuid();
const int placement = 0;
PendingBackpackPlacementRequested?.Invoke(
action.ObjectId,
containerId,
placement);
_placeInBackpack(action.ObjectId, containerId, placement);
acted = true;
break;
case ItemPolicyActionKind.WieldRight:
case ItemPolicyActionKind.WieldLeft:

View file

@ -246,6 +246,18 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private void OnExternalContainerChanged(ExternalContainerTransition transition)
{
if (transition.Kind == ExternalContainerTransitionKind.ReplacementRequested)
{
// Retail SetGroundObject(new) first publishes SetGroundObject(0)
// to the panel and only repopulates it when the new root's
// ViewContents is accepted.
_openContainer = 0u;
_closeRequested = false;
_window.Hide();
ClearLists();
return;
}
if (transition.ContainerId == 0u)
{
_openContainer = 0u;

View file

@ -180,7 +180,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
if (_itemInteraction is not null)
{
_itemInteraction.StateChanged += OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested += OnPendingBackpackPlacementRequested;
}
Populate();
}
@ -269,6 +272,26 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Populate();
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnPendingBackpackPlacementRequested(
uint itemId,
uint containerId,
int placement)
{
if (_pendingListPlacement is not null
|| itemId == 0u
|| containerId != EffectiveOpen()
|| _objects.Get(itemId) is not { } item
|| IsBag(item))
{
return;
}
_pendingListPlacement = new PendingListPlacement(
itemId,
containerId,
placement);
Populate();
}
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnMoveRequestFailed(MoveRequestFailure failure)
{
@ -411,6 +434,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>The owned destination retail PlaceInBackpack currently uses.</summary>
public uint CurrentOpenContainerId => EffectiveOpen();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
/// item cell → select-only.</summary>
private void AddCell(UiItemList? list, uint guid, bool isContainer, bool waiting = false)
@ -785,6 +811,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
if (_itemInteraction is not null)
{
_itemInteraction.StateChanged -= OnInteractionStateChanged;
_itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested;
}
}
}

View file

@ -274,6 +274,7 @@ public sealed class RetailUiRuntime : IDisposable
public SelectedObjectController? SelectedObjectController { get; private set; }
public UiViewport? PaperdollViewportWidget { get; private set; }
public UiNineSlicePanel? InventoryFrame { get; private set; }
public InventoryController? InventoryPanelController { get; private set; }
public RetailDialogFactory? DialogFactory { get; private set; }
public ExternalContainerController? ExternalContainerController { get; private set; }
@ -1603,6 +1604,7 @@ public sealed class RetailUiRuntime : IDisposable
() => CloseWindow(WindowNames.Inventory),
StackSplitQuantity,
b.ResolveDragIcon);
InventoryPanelController = inventory;
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction,
contents, _bindings.Assets.DefaultFont, paperdollClickMap,

View file

@ -33,9 +33,11 @@ public sealed class ExternalContainerLifecycleController : IDisposable
if (transition.PreviousContainerId != 0u)
_objects.StopViewingContentsTree(transition.PreviousContainerId);
// Only replacement emits Event_NoLongerViewingContents. The authored
// close/range path instead sends Use(root) and waits for event 0x0052.
if (transition.Kind == ExternalContainerTransitionKind.Replaced
// SetGroundObject(new, notifyServer:true) emits this while the new
// request is still pending, not after ViewContents arrives. The
// authored close/range path instead sends Use(root) and waits for
// event 0x0052.
if (transition.Kind == ExternalContainerTransitionKind.ReplacementRequested
&& transition.PreviousContainerId != 0u)
{
_sendNoLongerViewingContents(transition.PreviousContainerId);