fix(ui): port exact paperdoll body selection

Resolve and sample retail's authored nine-color paperdoll click map, preserve local click coordinates, and select the stable highest-priority worn item with the player fallback. Keep targeted-use body clicks routed to self and pin both synthetic and live-DAT conformance.

Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-11 11:56:26 +02:00
parent 2644d1d527
commit 0b74d19475
16 changed files with 490 additions and 32 deletions

View file

@ -0,0 +1,92 @@
using AcDream.Core.Items;
using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
namespace AcDream.App.UI.Layout;
/// <summary>
/// CPU-side body-part map used by retail's paperdoll drag mask. Ports
/// <c>gmPaperDollUI::CreateClickMap @ 0x004A4850</c> and
/// <c>gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920</c>.
/// </summary>
public sealed class PaperdollClickMap
{
public const uint ClickMapEnum = 0x1000000Cu;
public const uint InterfaceEnumCategory = 7u;
private readonly byte[] _rgba;
public int Width { get; }
public int Height { get; }
public PaperdollClickMap(byte[] rgba, int width, int height)
{
ArgumentNullException.ThrowIfNull(rgba);
if (width <= 0) throw new ArgumentOutOfRangeException(nameof(width));
if (height <= 0) throw new ArgumentOutOfRangeException(nameof(height));
if (rgba.Length < checked(width * height * 4))
throw new ArgumentException("The RGBA buffer is smaller than the declared map.", nameof(rgba));
_rgba = rgba;
Width = width;
Height = height;
}
/// <summary>
/// Resolves <c>DBObj::GetDIDByEnum(0x1000000C, 7)</c> through the portal master map
/// and decodes the resulting RenderSurface for exact per-pixel sampling.
/// The caller owns synchronization around <paramref name="dats"/>.
/// </summary>
public static PaperdollClickMap? Load(DatCollection dats)
{
ArgumentNullException.ThrowIfNull(dats);
uint masterDid = (uint)dats.Portal.Header.MasterMapId;
if (masterDid == 0
|| !dats.Portal.TryGet<EnumIDMap>(masterDid, out var master)
|| master is null
|| !master.ClientEnumToID.TryGetValue(InterfaceEnumCategory, out uint subMapDid)
|| !dats.Portal.TryGet<EnumIDMap>(subMapDid, out var subMap)
|| subMap is null
|| !subMap.ClientEnumToID.TryGetValue(ClickMapEnum, out uint surfaceDid))
return null;
if (!dats.Portal.TryGet<RenderSurface>(surfaceDid, out var surface)
&& !dats.HighRes.TryGet<RenderSurface>(surfaceDid, out surface))
return null;
Palette? palette = surface.DefaultPaletteId != 0
? dats.Get<Palette>(surface.DefaultPaletteId)
: null;
DecodedTexture decoded = SurfaceDecoder.DecodeRenderSurface(surface, palette);
return decoded.Width > 1 && decoded.Height > 1
? new PaperdollClickMap(decoded.Rgba8, decoded.Width, decoded.Height)
: null;
}
public EquipMask GetBodyLocation(int x, int y)
{
if ((uint)x >= (uint)Width || (uint)y >= (uint)Height)
return EquipMask.None;
int pixel = (y * Width + x) * 4;
byte r = _rgba[pixel];
byte g = _rgba[pixel + 1];
byte b = _rgba[pixel + 2];
return (r, g, b) switch
{
(0x00, 0x00, 0xFF) => EquipMask.HeadWear,
(0x00, 0xFF, 0x00) => EquipMask.ChestWear | EquipMask.ChestArmor,
(0xFF, 0x00, 0x00) => EquipMask.AbdomenWear | EquipMask.AbdomenArmor,
(0x00, 0xFF, 0xFF) => EquipMask.UpperArmWear | EquipMask.UpperArmArmor,
(0xFF, 0x00, 0xFF) => EquipMask.LowerArmWear | EquipMask.LowerArmArmor,
(0xFF, 0xFF, 0x00) => EquipMask.UpperLegWear | EquipMask.UpperLegArmor,
(0x00, 0x00, 0x80) => EquipMask.LowerLegWear | EquipMask.LowerLegArmor,
(0x00, 0x80, 0x00) => EquipMask.HandWear,
(0x80, 0x00, 0x00) => EquipMask.FootWear,
_ => EquipMask.None,
};
}
}

View file

@ -81,6 +81,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly SelectionState _selection;
private readonly PaperdollClickMap? _clickMap;
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -94,11 +95,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield,
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction,
PaperdollClickMap? clickMap)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_clickMap = clickMap;
for (int i = 0; i < SlotMap.Length; i++)
{
@ -142,13 +145,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(DollViewportId); // doll viewport (may be null until Slice 3)
// The doll IS the player: clicking it during target-use self-targets
// like the equip cells do. (The CURSOR over it stays the pending
// four-arrows — retail resolves target cursors off the SmartBox world
// object only; UI hover is always pending. 2026-07-03 visual gate.)
Action offerSelf = () => { _itemInteraction?.OfferSelfPrimaryClick(); };
// The authored map decides whether the pixel is a body hit and which worn
// item is visually upper there. Target-use deliberately substitutes self.
// The cursor remains pending over UI; retail resolves target cursors from
// the SmartBox world object rather than the paperdoll viewport.
Action<int, int> clickDoll = HandleDollClick;
if (_dollViewport is UiViewport doll)
doll.Clicked = offerSelf;
doll.ClickedAt = clickDoll;
// Retail's authored click/drag mask sits above the viewport and owns
// doll hit-testing (gmPaperDollUI::ListenToElementMessage). Binding only
@ -157,12 +160,12 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
{
case UiButton dragMaskButton:
_dollDragMask = dragMaskButton;
dragMaskButton.OnClick = offerSelf;
dragMaskButton.OnClickAt = clickDoll;
break;
case UiDatElement dragMaskElement:
_dollDragMask = dragMaskElement;
dragMaskElement.ClickThrough = false;
dragMaskElement.OnClick = offerSelf;
dragMaskElement.OnClickAt = clickDoll;
break;
}
@ -191,10 +194,31 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null)
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null)
=> new PaperdollController(
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
datFont, itemInteraction);
datFont, itemInteraction, clickMap);
private void HandleDollClick(int x, int y)
{
EquipMask bodyLocation = _clickMap?.GetBodyLocation(x, y) ?? EquipMask.None;
uint hitObject = PaperdollSelectionPolicy.GetUpperInventoryObject(
_objects,
_playerGuid(),
bodyLocation);
if (hitObject == 0)
return;
// Retail targets self for any valid paperdoll body hit while target mode
// is active; ordinary clicks select the upper worn object or player fallback.
// gmPaperDollUI::ListenToElementMessage @ 0x004A5C30.
if (_itemInteraction?.OfferSelfPrimaryClick()
is not null and not ItemPrimaryClickResult.NotActive)
return;
_selection.Select(hitObject, SelectionChangeSource.Paperdoll);
}
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
@ -313,14 +337,14 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_dollViewport is UiViewport doll)
doll.Clicked = null;
doll.ClickedAt = null;
switch (_dollDragMask)
{
case UiButton button:
button.OnClick = null;
button.OnClickAt = null;
break;
case UiDatElement element:
element.OnClick = null;
element.OnClickAt = null;
break;
}
}

View file

@ -129,10 +129,18 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// elements (e.g. the chat Send / max-min buttons). Requires
/// <see cref="UiElement.ClickThrough"/> = false to receive click events.</summary>
public Action? OnClick { get; set; }
public Action<int, int>? OnClickAt { get; set; }
public override bool HandlesClick => OnClick is not null || OnClickAt is not null;
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
if (e.Type == UiEventType.Click && (OnClick is not null || OnClickAt is not null))
{
OnClick?.Invoke();
OnClickAt?.Invoke(e.Data1, e.Data2);
return true;
}
return false;
}

View file

@ -597,11 +597,13 @@ public sealed class RetailUiRuntime : IDisposable
});
uint contents, sideBag, mainPack;
PaperdollClickMap? paperdollClickMap;
lock (_bindings.Assets.DatLock)
{
contents = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000021u, 0x100001C6u);
sideBag = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000022u, 0x100001CAu);
mainPack = ItemListCellTemplate.ResolveEmptySprite(_bindings.Assets.Dats, 0x21000022u, 0x100001C9u);
paperdollClickMap = PaperdollClickMap.Load(_bindings.Assets.Dats);
}
InventoryRuntimeBindings b = _bindings.Inventory;
@ -618,7 +620,7 @@ public sealed class RetailUiRuntime : IDisposable
StackSplitQuantity);
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.SendWield,
contents, _bindings.Assets.DefaultFont, b.ItemInteraction);
contents, _bindings.Assets.DefaultFont, b.ItemInteraction, paperdollClickMap);
Host.WindowManager.AttachController(
WindowNames.Inventory,
new RetainedPanelControllerGroup(inventory, paperdoll));

View file

@ -40,11 +40,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
private bool _selected;
private bool _hotClicking;
private bool _suppressNextClick;
private int _pointerX;
private int _pointerY;
private double _nextHotClickTime = double.NaN;
/// <summary>Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).</summary>
public Action? OnClick { get; set; }
/// <summary>
/// Optional position-aware click handler. Coordinates are local pixels in this button,
/// matching retail's <c>UIElementMessageInfo.ptWindow</c> paperdoll hit-test input.
/// </summary>
public Action<int, int>? OnClickAt { get; set; }
/// <summary>
/// Optional item-drop target callbacks. Retail uses ordinary buttons as drop surfaces
/// in a few panels (notably gmToolbarUI's inventory button), so this belongs on the
@ -265,12 +273,15 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
UpdateVisualState();
return true;
case UiEventType.MouseDown:
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_pressed = true;
UpdateVisualState();
if (HotClickEnabled && Enabled)
{
OnClick?.Invoke();
OnClickAt?.Invoke(_pointerX, _pointerY);
_hotClicking = true;
_nextHotClickTime = double.NaN;
}
@ -278,12 +289,16 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
case UiEventType.MouseMove:
if (_pressed)
{
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
UpdateVisualState();
return true;
}
return false;
case UiEventType.MouseUp:
_pointerX = e.Data1;
_pointerY = e.Data2;
_pointerOver = ContainsLocal(e.Data1, e.Data2);
_suppressNextClick = _hotClicking && _pointerOver;
_hotClicking = false;
@ -301,7 +316,8 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
return true;
}
OnClick?.Invoke();
return OnClick is not null;
OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null;
case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None
@ -348,6 +364,7 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
if (_pointerOver && nowSeconds >= _nextHotClickTime)
{
OnClick?.Invoke();
OnClickAt?.Invoke(_pointerX, _pointerY);
_nextHotClickTime += HotClickRepeatInterval;
}
}

View file

@ -9,20 +9,24 @@ public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
/// <summary>Optional click action (e.g. the paperdoll doll = "target self"
/// during item target-use). Non-null makes the viewport handle the press
/// <summary>Optional click action. Non-null makes the viewport handle the press
/// itself instead of it being captured as a window move.</summary>
public System.Action? Clicked { get; set; }
/// <summary>Position-aware variant with viewport-local pixel coordinates.</summary>
public System.Action<int, int>? ClickedAt { get; set; }
public override bool HandlesClick => Clicked is not null;
public override bool HandlesClick => Clicked is not null || ClickedAt is not null;
public override bool OnEvent(in UiEvent e)
{
if (Clicked is null) return base.OnEvent(e);
if (Clicked is null && ClickedAt is null) return base.OnEvent(e);
switch (e.Type)
{
case UiEventType.MouseDown: return true; // consume the press; act on Click
case UiEventType.Click: Clicked.Invoke(); return true;
case UiEventType.Click:
Clicked?.Invoke();
ClickedAt?.Invoke(e.Data1, e.Data2);
return true;
}
return base.OnEvent(e);
}

View file

@ -0,0 +1,47 @@
namespace AcDream.Core.Items;
/// <summary>
/// Retail paperdoll body-part selection. Ports
/// <c>InventoryPlacement::DetermineHigherPriority @ 0x004A44B0</c> and
/// <c>gmPaperDollUI::GetUpperInvObj @ 0x004A4750</c>.
/// </summary>
public static class PaperdollSelectionPolicy
{
/// <summary>
/// Returns the visually upper equipped object covering <paramref name="bodyLocationMask"/>.
/// When the body part is valid but uncovered, retail returns the player object itself.
/// </summary>
public static uint GetUpperInventoryObject(
ClientObjectTable objects,
uint playerId,
EquipMask bodyLocationMask)
{
if (playerId == 0 || bodyLocationMask == EquipMask.None || objects.Get(playerId) is null)
return 0;
ClientObject? winner = null;
foreach (ClientObject candidate in objects.Objects)
{
if ((candidate.CurrentlyEquippedLocation & bodyLocationMask) == EquipMask.None)
continue;
if (candidate.WielderId != playerId && candidate.ContainerId != playerId)
continue;
if (winner is null
|| EffectivePriority(candidate, bodyLocationMask)
> EffectivePriority(winner, bodyLocationMask))
winner = candidate;
}
return winner?.ObjectId ?? playerId;
}
private static uint EffectivePriority(ClientObject item, EquipMask bodyLocationMask)
{
uint priority = item.Priority;
uint coveredLocation = (uint)(item.CurrentlyEquippedLocation & bodyLocationMask);
if (priority == 0 && coveredLocation is >= 0x200u and <= 0x4000u)
priority = 0x7Fu;
return priority;
}
}