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

@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
## #141 — Toolbar interactivity — selected-object display
**Status:** IN PROGRESS (all implementation complete; health/name/flash confirmed 2026-06-20; stack controls/transfers and selected-item mana confirmed 2026-07-11; exact self/pet/Free-PK health-policy live gate pending). 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 (all implementation complete; health/name/flash confirmed 2026-06-20; stack controls/transfers, selected-item mana, and the non-paperdoll health-policy matrix confirmed 2026-07-11; exact paperdoll body-selection re-gate pending). 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
@ -2063,6 +2063,7 @@ See divergence register **AP-59**.
- **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, partial inventory split, and partial ground drop visually confirmed.
- **D.5.3c implementation + live gate (2026-07-11):** owned non-stack selections send retail `QueryItemMana (0x0263)`; `QueryItemManaResponse (0x0264)` now parses the trailing validity flag and flows through Core `ItemManaState`; valid matching responses reveal/fill authored meter `0x100001A2`, invalid responses cancel with guid zero, and changing selection cancels any visible mana or health query before hiding its meter. Stacked items stay exclusively on the split-control path. Live confirmation used an empty Mana Stone on a source magic item: the source was destroyed, its mana transferred into armor, and the selected armor's bar updated immediately from the server response. Named anchors: `CM_Item::Event_QueryItemMana @ 0x006A8610`, `DispatchUI_QueryItemManaResponse @ 0x006A84D0`, `gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0`.
- **D.5.3d implementation (2026-07-11; live gate pending):** pure Core `SelectedObjectHealthPolicy` ports `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` and the toolbar's outer player/pet short-circuit. CreateObject now parses second-header `PetOwner (0x8)` after MaterialType/Cooldown fields and carries it through `WorldSession`/`WeenieData` into `ClientObject`. This restores self, pet, and Free-PK creature queries while friendly NPCs and attackable doors remain name-only. AP-46 retired. Research: `docs/research/2026-07-11-retail-selected-health-policy-pseudocode.md`.
- **D.5.3d live gate/fix (2026-07-11):** monster/player/friendly-NPC/door policy branches passed. The paperdoll exposed a separate deferred gap: its authored drag-mask handler offered target-use self but ordinary clicks had no fallback. The exact retail route now resolves enum DID `(0x1000000C, 7)`, samples all nine body colors, selects the highest-priority worn object on that part, and falls back to the player when uncovered; target mode still substitutes self. Real-DAT conformance verifies the map and mask dimensions match. Paperdoll re-gate pending.
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).

View file

@ -208,7 +208,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods |
| AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` |
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll omits Aetheria, authoritative upper-item priority, full `AutoWieldIsLegal`/dual-wield rules, and body-part interaction/highlighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, and optimistic wield work | Layered clothing, special slots, invalid drops, and doll-body clicks differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: combat/powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Existing panels cover the current M2 loop; Wave 10 lands each missing surface against authoritative state | Large portions of retail gameplay have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |

View file

@ -492,11 +492,11 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ 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.
- **✓ SHIPPED — 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 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 passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11.
- **✓ SHIPPED — Wave 4.4b selected-item mana.** Owned non-stack selections send exact `QueryItemMana 0x0263`; `0x0264` parses guid/fraction/trailing-valid into Core `ItemManaState`; valid matching responses reveal authored meter `0x100001A2`, invalid and visible selection-change paths cancel with guid zero. Health uses the same retail visible-meter cancellation rule. Wire/state/controller conformance is green in the 4,712-test Release suite. Live Mana Stone source-destruction → armor-mana-transfer → bar-update gate passed 2026-07-11. AP-101 now covers remaining toolbar controls only.
- **✓ IMPLEMENTED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired; live gate pending.
- **✓ IMPLEMENTED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix passed live; the paperdoll's separate normal-click gap is now an exact DAT body-map + upper-item-priority port and awaits re-gate.
- **✓ 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.
- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Implementation complete: exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, and health/name. Remaining: live gate for the newly restored health-policy edge cases, then close #141. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Implementation complete: exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, and health/name. The non-paperdoll health-policy edge matrix passed live; remaining acceptance is the paperdoll body-selection re-gate, then close #141. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase.
- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire.
- **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp.
- **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.

View file

@ -571,6 +571,12 @@ target mode against the **player ID**, not the visually upper item. Examine
and drag use the upper item. Selection lighting pulses matching body parts at
0.2-second intervals; player-ID selection uses mask `0x7FFFFFFF`.
Implementation note (2026-07-11): `PaperdollClickMap` now resolves the exact enum
DID, decodes and samples the nine-color map, and `PaperdollSelectionPolicy` ports
the stable upper-item priority plus the uncovered player fallback. Normal selection
and target-self dispatch are wired through the authored drag mask. Examine, doll-body
drag, and part-selection lighting remain under AP-108.
### Aetheria visibility
Retail anchors:

View file

@ -680,12 +680,14 @@ full suite and user visual gate.
including Aetheria `0x10000595/596/597` and exact equip masks.
- [ ] Drive Aetheria visibility from authoritative unlock/player state, not an
unconditional layout toggle.
- [ ] Port `GetUpperInvObj` priority via Core `InventoryPlacement` rules so layered
- [x] Port `GetUpperInvObj` priority via Core `InventoryPlacement` rules so layered
equipment is deterministic and retail-correct.
- [ ] Port `AutoWieldIsLegal` and the dual-wield shield-slot special before accepting
a drop; share legality with double-click auto-wield.
- [ ] Implement doll body-part hit testing, worn-item lookup, select/examine,
drag-icon preparation, and part-selection lighting.
- [x] Implement exact DAT doll body-part hit testing, worn-item lookup, normal
selection/player fallback, and target-mode self dispatch.
- [ ] Complete double-click examine, drag-icon preparation, and part-selection
lighting on the doll body.
- [ ] Resolve empty-slot art/visibility separately for doll and slot views from DAT;
remove inventory-grid art from equip slots unless retail uses it in that state.
- [ ] Port `UpdateForRace` and retain the cdb-confirmed Horan default; keep the RTT/FBO
@ -811,7 +813,7 @@ scenario, UI Studio fixture, live user visual gate, divergence cleanup.
| Chat scrollbar arrows reversed | 2 / 5 |
| External-container close lifecycle missing; owned-side-bag `0x0195` premise disproved | 2 / 6 |
| Inventory capacity/burden/drag residuals | 6 |
| Paperdoll Aetheria, priority, legality, dual wield, body interactions | 7 |
| Paperdoll Aetheria, legality, dual wield, examine/drag/selection lighting | 7 |
| Paperdoll empty art and per-race behavior | 7 |
| Character Titles inert, live title/luminance missing | 8 |
| Character raises mutate optimistically instead of using proven server-authoritative flow | 8 |

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;
}
}

View file

@ -0,0 +1,83 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
public sealed class PaperdollClickMapTests
{
public static TheoryData<byte, byte, byte, EquipMask> RetailColors => new()
{
{ 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 },
};
[Theory]
[MemberData(nameof(RetailColors))]
public void GetBodyLocation_mapsRetailHitColors(
byte r,
byte g,
byte b,
EquipMask expected)
{
var map = new PaperdollClickMap([r, g, b, 0xFF], 1, 1);
Assert.Equal(expected, map.GetBodyLocation(0, 0));
}
[Fact]
public void GetBodyLocation_rejectsBackgroundAndOutOfBounds()
{
var map = new PaperdollClickMap([0xFF, 0xFF, 0xFF, 0xFF], 1, 1);
Assert.Equal(EquipMask.None, map.GetBodyLocation(0, 0));
Assert.Equal(EquipMask.None, map.GetBodyLocation(-1, 0));
Assert.Equal(EquipMask.None, map.GetBodyLocation(1, 0));
}
[Fact]
public void LiveDat_resolvesRetailClickMapAndAllNineBodyRegions()
{
string datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
if (!Directory.Exists(datDir)) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
PaperdollClickMap map = Assert.IsType<PaperdollClickMap>(PaperdollClickMap.Load(dats));
ImportedLayout layout = Assert.IsType<ImportedLayout>(LayoutImporter.Import(
dats,
0x21000023u,
static _ => (0u, 0, 0),
null));
UiElement dragMask = Assert.IsAssignableFrom<UiElement>(
layout.FindElement(PaperdollController.DollDragMaskId));
Assert.Equal((int)dragMask.Width, map.Width);
Assert.Equal((int)dragMask.Height, map.Height);
var regions = new HashSet<EquipMask>();
for (int y = 0; y < map.Height; y++)
for (int x = 0; x < map.Width; x++)
{
EquipMask region = map.GetBodyLocation(x, y);
if (region != EquipMask.None)
regions.Add(region);
}
Assert.Equal(9, regions.Count);
Assert.Contains(EquipMask.HeadWear, regions);
Assert.Contains(EquipMask.ChestWear | EquipMask.ChestArmor, regions);
Assert.Contains(EquipMask.FootWear, regions);
}
}

View file

@ -186,7 +186,9 @@ public sealed class RetailUiInteractionFlowTests
return interaction;
}
public void BindPaperdoll(ItemInteractionController? itemInteraction = null)
public void BindPaperdoll(
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null)
=> PaperdollController.Bind(
Layout,
Objects,
@ -195,7 +197,23 @@ public sealed class RetailUiInteractionFlowTests
selection: Selection,
sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u,
itemInteraction: itemInteraction);
itemInteraction: itemInteraction,
clickMap: clickMap ?? SolidClickMap(0x00, 0x00, 0xFF));
public static PaperdollClickMap SolidClickMap(byte r, byte g, byte b)
{
const int width = 70;
const int height = 90;
var pixels = new byte[width * height * 4];
for (int i = 0; i < pixels.Length; i += 4)
{
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
pixels[i + 3] = 0xFF;
}
return new PaperdollClickMap(pixels, width, height);
}
public RetailUiAutomationProbe Probe()
=> new(Root, Objects);
@ -300,6 +318,33 @@ public sealed class RetailUiInteractionFlowTests
Assert.False(interaction.IsTargetModeActive);
}
[Fact]
public void PaperdollBodyClick_uncoveredPart_selectsPlayerThroughUiRoot()
{
var h = new Harness();
h.BindPaperdoll();
var probe = h.Probe();
Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId));
Assert.Equal(Player, h.Selection.SelectedObjectId);
}
[Fact]
public void PaperdollBodyClick_coveredPart_selectsUpperEquippedItemThroughUiRoot()
{
var h = new Harness();
h.SeedHauberk();
h.Objects.MoveItem(Hauberk, Player, -1, HauberkMask);
h.BindPaperdoll(
clickMap: Harness.SolidClickMap(0x00, 0xFF, 0x00));
var probe = h.Probe();
Assert.True(probe.ClickElement(PaperdollController.DollDragMaskId));
Assert.Equal(Hauberk, h.Selection.SelectedObjectId);
}
[Fact]
public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback()
{

View file

@ -16,6 +16,20 @@ public class UiButtonTests
Assert.True(_clicked);
}
[Fact]
public void Click_ProvidesLocalCoordinatesToPositionAwareHandler()
{
(int X, int Y) clicked = default;
var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex)
{
OnClickAt = (x, y) => clicked = (x, y),
};
b.OnEvent(new UiEvent(0, b, UiEventType.Click, Data1: 17, Data2: 9));
Assert.Equal((17, 9), clicked);
}
[Fact]
public void NotClickThrough_SoItReceivesClicks()
{

View file

@ -0,0 +1,113 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class PaperdollSelectionPolicyTests
{
private const uint Player = 0x50000001u;
[Fact]
public void UncoveredValidBodyPart_returnsPlayer()
{
var objects = TableWithPlayer();
uint selected = PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.HeadWear);
Assert.Equal(Player, selected);
}
[Fact]
public void DefaultPriorityArmor_beatsClothingOnSameBodyPart()
{
var objects = TableWithPlayer();
AddEquipped(objects, 0x50001001u, EquipMask.ChestWear, priority: 20u);
AddEquipped(objects, 0x50001002u, EquipMask.ChestArmor, priority: 0u);
uint selected = PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.ChestWear | EquipMask.ChestArmor);
Assert.Equal(0x50001002u, selected);
}
[Fact]
public void HigherExplicitPriorityWins_andTiesPreserveListOrder()
{
var objects = TableWithPlayer();
AddEquipped(objects, 0x50001001u, EquipMask.HeadWear, priority: 12u);
AddEquipped(objects, 0x50001002u, EquipMask.HeadWear, priority: 12u);
AddEquipped(objects, 0x50001003u, EquipMask.HeadWear, priority: 13u);
uint selected = PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.HeadWear);
Assert.Equal(0x50001003u, selected);
objects.Remove(0x50001003u);
selected = PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.HeadWear);
Assert.Equal(0x50001001u, selected);
}
[Fact]
public void DefaultArmorPriority_usesCoveredIntersectionRangeExactly()
{
var objects = TableWithPlayer();
AddEquipped(objects, 0x50001001u, EquipMask.LowerLegWear, priority: 1u);
AddEquipped(
objects,
0x50001002u,
EquipMask.LowerLegWear | EquipMask.LowerLegArmor,
priority: 0u);
uint selected = PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.LowerLegWear | EquipMask.LowerLegArmor);
// Retail compares the covered value 0x4080 to the inclusive
// 0x0200..0x4000 normalization range, so this combined placement stays 0.
Assert.Equal(0x50001001u, selected);
}
[Fact]
public void InvalidBodyMask_returnsZero()
{
var objects = TableWithPlayer();
Assert.Equal(0u, PaperdollSelectionPolicy.GetUpperInventoryObject(
objects,
Player,
EquipMask.None));
}
private static ClientObjectTable TableWithPlayer()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = Player, Type = ItemType.Creature });
return objects;
}
private static void AddEquipped(
ClientObjectTable objects,
uint objectId,
EquipMask location,
uint priority)
{
objects.AddOrUpdate(new ClientObject
{
ObjectId = objectId,
WielderId = Player,
CurrentlyEquippedLocation = location,
Priority = priority,
});
}
}