fix(items): complete retail corpse looting feedback

Route corpse Use through the shared ItemHolder policy so Stuck corpses open instead of being sent as pickup requests. Restore the framed, horizontally resizable external-container strip and use a compact initial width. Port retail's target-list pending item projection so loot is marked in the chosen inventory slot without changing canonical ownership before the server confirms.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 16:53:49 +02:00
parent 20ce67b625
commit d51a0fc825
13 changed files with 275 additions and 102 deletions

View file

@ -13232,50 +13232,11 @@ public sealed class GameWindow : IDisposable
return;
}
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
// key. Retail dispatches by TARGET TYPE first; the useability gate
// is enforced by each individual action handler (SendUse checks
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
// a top-level block. Previously the IsUseableTarget gate at the
// entry point rejected USEABLE_NO=1 items (spell components,
// gems) which retail accepts as pickupable — they just aren't
// "useable" in the activate-from-world sense.
//
// Dispatch order:
// 1. Creature → SendUse (talk to NPC, attack monster)
// 2. Pickupable → SendPickUp (small items, corpses)
// 3. Useable → SendUse (doors, portals, lifestones,
// potions / scrolls activated from world)
// 4. Else → toast "X cannot be used" (signs, banners,
// decorative scenery)
//
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used".
bool isCreature = (LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0;
if (isCreature)
{
SendUse(sel);
return;
}
if (IsPickupableTarget(sel))
{
SendPickUp(sel);
return;
}
if (IsUseableTarget(sel))
{
SendUse(sel);
return;
}
string label = DescribeLiveEntity(sel);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
// Retail sends keyboard Use through ItemHolder::UseObject @ 0x00588A80,
// the same policy owner used by the toolbar. Keeping a second classifier here
// made BF_CORPSE containers look pickupable and sent PutItemInContainer,
// which ACE correctly rejected as Stuck (0x0029).
_itemInteractionController?.UseSelectedOrEnterMode(sel);
}
private void SendUse(uint guid)
@ -13896,29 +13857,6 @@ public sealed class GameWindow : IDisposable
return false;
}
/// <summary>
/// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click
/// "Pick Up." Distinct from <see cref="IsUseableTarget"/> because
/// pickup is more restrictive than Use: the entity must be useable
/// FROM THE WORLD (USEABLE_REMOTE bit, 0x20). Signs / banners with
/// USEABLE_NO (0x1) lack the REMOTE bit so pickup is blocked
/// client-side without a wire packet — matches retail's "The X can't
/// be picked up!" client-side toast.
///
/// <para>
/// Useable values that include USEABLE_REMOTE (0x20):
/// USEABLE_REMOTE (0x20), USEABLE_REMOTE_NEVER_WALK (0x60),
/// USEABLE_VIEWED_REMOTE (0x30), and the SOURCE_*_TARGET_REMOTE
/// composites in the 0x200000+ range.
/// </para>
///
/// <para>
/// Null-useability fallback: same as <see cref="IsUseableTarget"/>
/// — permit pickup for entities with BF_CORPSE bit set, and for
/// items with small-item ItemType. This preserves M1 ground-item
/// pickup flow for entities where ACE didn't publish useability.
/// </para>
/// </summary>
/// <summary>
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
///
@ -13935,7 +13873,7 @@ public sealed class GameWindow : IDisposable
/// </para>
///
/// <para>
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
/// Now matches retail: a non-Stuck small-item ItemType class
/// → pickupable. Server validates the request server-side
/// (in-range, target-still-exists, container-has-room).
/// </para>
@ -13961,11 +13899,9 @@ public sealed class GameWindow : IDisposable
if (spawn.ObjectDescriptionFlags is { } odf)
{
const uint BF_STUCK = 0x0004u;
const uint BF_CORPSE = 0x2000u;
// Corpses are pickupable (loot) — BF_CORPSE wins over
// any BF_STUCK that might be coincidentally set.
if ((odf & BF_CORPSE) != 0u) return true;
// Anything else with BF_STUCK is immovable scenery.
// Corpses are BF_STUCK containers: Use opens their contents; the
// corpse object itself is never picked up. ItemHolder::DetermineUseResult
// @ 0x00588460 excludes Stuck objects from PlaceInBackpack.
if ((odf & BF_STUCK) != 0u) return false;
}

View file

@ -21,6 +21,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
private const float ItemCellSize = 32f;
private const float ContainerCellSize = 36f;
// User-directed retained default: the raw 800-pixel LayoutDesc remains
// horizontally reachable by resize/persistence but opens less wide.
internal const float DefaultContentWidth = 700f;
internal const float MinimumContentWidth = 160f;
private readonly ExternalContainerState _state;
private readonly ClientObjectTable _objects;
@ -144,6 +148,32 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine
contentsEmptySprite,
containerEmptySprite);
/// <summary>
/// Retail's root authors only the tiled center surface; the common floating-window
/// bevel surrounds it. Its Left/Right edge rules stretch every horizontal list and
/// pin the close button, so this strip resizes on X only.
/// </summary>
internal static RetailWindowFrame.Options CreateWindowOptions(UiElement root)
=> new()
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.NineSlice,
Left = root.Left,
Top = root.Top,
ContentWidth = Math.Min(root.Width, DefaultContentWidth),
ContentHeight = root.Height,
MinWidth = MinimumContentWidth + 2f * RetailChromeSprites.Border,
Visible = false,
Draggable = true,
Resizable = true,
ResizeX = true,
ResizeY = false,
ResizableEdges = ResizeEdges.Left | ResizeEdges.Right,
ConstrainDragToParent = true,
ConstrainResizeToParent = true,
DrawChromeCenter = false,
};
public void Tick()
{
uint root = _state.CurrentContainerId;

View file

@ -63,8 +63,14 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint, uint>? _notifyMergeAttempt;
private readonly ItemInteractionController? _itemInteraction;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private PendingListPlacement? _pendingListPlacement;
private bool _disposed;
private readonly record struct PendingListPlacement(
uint ItemId,
uint ContainerId,
int Placement);
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation
@ -167,6 +173,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.MoveRequestFailed += OnMoveRequestFailed;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged;
@ -225,9 +232,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectChanged(ClientObject o)
{
if (Concerns(o) || _pendingListPlacement?.ItemId == o.ObjectId)
Populate();
}
private void OnObjectRemoved(ClientObject o)
{
if (_pendingListPlacement?.ItemId == o.ObjectId)
_pendingListPlacement = null;
if (_selection.SelectedObjectId == o.ObjectId)
{
_selection.Clear(
@ -238,8 +251,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
private void OnObjectMoved(ClientObjectMove move)
{
bool resolvedPending = _pendingListPlacement?.ItemId == move.ItemId;
if (resolvedPending)
_pendingListPlacement = null;
uint player = _playerGuid();
if ((move.Item is { } item && Concerns(item))
if (resolvedPending
|| (move.Item is { } item && Concerns(item))
|| move.Previous.ContainerId == player
|| move.Current.ContainerId == player
|| move.Previous.WielderId == player
@ -253,7 +270,18 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
private void OnInteractionStateChanged() => ApplyIndicators();
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnObjectsCleared() => Populate();
private void OnMoveRequestFailed(MoveRequestFailure failure)
{
if (_pendingListPlacement?.ItemId != failure.ItemId)
return;
_pendingListPlacement = null;
Populate();
}
private void OnObjectsCleared()
{
_pendingListPlacement = null;
Populate();
}
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
@ -291,12 +319,32 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has
// no sub-bags, so the isBag skip is a no-op when a bag is open.)
var visibleContents = new List<uint>();
foreach (var guid in _objects.GetContents(open))
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = IsBag(item);
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
if (!isBag) visibleContents.Add(guid);
}
PendingListPlacement? pending = _pendingListPlacement;
if (pending is { } projection
&& projection.ContainerId == open
&& !visibleContents.Contains(projection.ItemId)
&& _objects.Get(projection.ItemId) is { } pendingItem
&& !IsBag(pendingItem))
{
int index = Math.Clamp(projection.Placement, 0, visibleContents.Count);
visibleContents.Insert(index, projection.ItemId);
}
foreach (uint guid in visibleContents)
{
bool waiting = pending is { } waitingProjection
&& waitingProjection.ContainerId == open
&& waitingProjection.ItemId == guid;
AddCell(_contentsGrid, guid, isContainer: false, waiting);
}
// Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24).
@ -365,7 +413,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
/// <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)
private void AddCell(UiItemList? list, uint guid, bool isContainer, bool waiting = false)
{
if (list is null) return;
var item = _objects.Get(guid);
@ -376,6 +424,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects) ?? 0u;
var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve };
cell.SetItem(guid, tex, dragIconTexture: dragTex);
cell.SetWaitingState(waiting);
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
ConfigureDropFeedback(list, cell);
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
@ -456,6 +505,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
return ItemDragAcceptance.None;
if (payload.ObjId == 0)
return ItemDragAcceptance.Reject;
if (payload.SourceKind == ItemDragSource.Ground
&& _pendingListPlacement is not null)
return ItemDragAcceptance.Reject;
if (targetList == _contentsGrid)
return ItemDragAcceptance.Accept;
if (targetList == _containerList || targetList == _topContainer)
@ -525,12 +577,22 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
}
}
// External-container contents are a temporary ViewContents projection.
// Retail UIAttemptPutInContainer leaves them untouched until the server's
// authoritative move response; only rearrangements within owned inventory
// use the existing optimistic projection.
if (payload.SourceKind != ItemDragSource.Ground)
// External-container contents retain canonical ownership while the request
// is in flight, but retail immediately inserts an m_pendingItem copy into
// the chosen destination slot and ghosts it. The server move/failure notice
// resolves that visual projection. UIElement_ItemList::HandleDropRelease
// @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680.
if (payload.SourceKind == ItemDragSource.Ground)
{
if (_pendingListPlacement is not null)
return;
_pendingListPlacement = new PendingListPlacement(item, container, placement);
Populate();
}
else
{
_objects.MoveItemOptimistic(item, container, placement);
}
_sendPutItemInContainer?.Invoke(item, container, placement);
}
@ -716,6 +778,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.MoveRequestFailed -= OnMoveRequestFailed;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged;

View file

@ -1640,20 +1640,7 @@ public sealed class RetailUiRuntime : IDisposable
Host.Root,
root,
_bindings.Assets.ResolveSprite,
new RetailWindowFrame.Options
{
WindowName = WindowNames.ExternalContainer,
Chrome = RetailWindowChrome.Imported,
Left = root.Left,
Top = root.Top,
ContentWidth = root.Width,
ContentHeight = root.Height,
Visible = false,
Draggable = false,
Resizable = false,
ResizeX = false,
ResizeY = false,
});
ExternalContainerController.CreateWindowOptions(root));
uint contentsEmpty;
uint containerEmpty;

View file

@ -144,9 +144,17 @@ public class UiItemSlot : UiElement
// ItemList_BeginDrag ghosts physical lists, but explicitly excludes shortcut lists
// (along with vendor/salvage lists, which acdream does not model as ItemDragSource).
// Keep the source's full cell icon in place and reveal the authored grey mesh over it.
_waiting = active && ItemId != 0 && SourceKind != ItemDragSource.ShortcutBar;
SetWaitingState(active && SourceKind != ItemDragSource.ShortcutBar);
}
/// <summary>
/// Apply retail's <c>m_elem_Icon_Ghosted</c> waiting presentation independently
/// of pointer ownership. Destination ItemLists use this for their temporary
/// <c>m_pendingItem</c> inserted by <c>HandleDropRelease @ 0x004E4790</c>.
/// </summary>
internal void SetWaitingState(bool waiting)
=> _waiting = waiting && ItemId != 0;
/// <summary>An OCCUPIED slot is a drag source — a press-and-move picks up the item
/// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a
/// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar