diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d1dfe7c2..bb9fa789 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -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`).
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 92e1f4e5..191d2f09 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -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 |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 20bd200a..8e805360 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -492,11 +492,11 @@ behavior. Estimated 17–26 days focused work, 3–5 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.
diff --git a/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md b/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
index 8b3ee1c1..a72f09da 100644
--- a/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
+++ b/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
@@ -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:
diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
index a7d5301b..cdf53103 100644
--- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
+++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
@@ -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 |
diff --git a/src/AcDream.App/UI/Layout/PaperdollClickMap.cs b/src/AcDream.App/UI/Layout/PaperdollClickMap.cs
new file mode 100644
index 00000000..87d9cbca
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/PaperdollClickMap.cs
@@ -0,0 +1,92 @@
+using AcDream.Core.Items;
+using AcDream.Core.Textures;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// CPU-side body-part map used by retail's paperdoll drag mask. Ports
+/// gmPaperDollUI::CreateClickMap @ 0x004A4850 and
+/// gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920.
+///
+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;
+ }
+
+ ///
+ /// Resolves DBObj::GetDIDByEnum(0x1000000C, 7) through the portal master map
+ /// and decodes the resulting RenderSurface for exact per-pixel sampling.
+ /// The caller owns synchronization around .
+ ///
+ public static PaperdollClickMap? Load(DatCollection dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+
+ uint masterDid = (uint)dats.Portal.Header.MasterMapId;
+ if (masterDid == 0
+ || !dats.Portal.TryGet(masterDid, out var master)
+ || master is null
+ || !master.ClientEnumToID.TryGetValue(InterfaceEnumCategory, out uint subMapDid)
+ || !dats.Portal.TryGet(subMapDid, out var subMap)
+ || subMap is null
+ || !subMap.ClientEnumToID.TryGetValue(ClickMapEnum, out uint surfaceDid))
+ return null;
+
+ if (!dats.Portal.TryGet(surfaceDid, out var surface)
+ && !dats.HighRes.TryGet(surfaceDid, out surface))
+ return null;
+
+ Palette? palette = surface.DefaultPaletteId != 0
+ ? dats.Get(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,
+ };
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs
index 82ee2203..d825ae0f 100644
--- a/src/AcDream.App/UI/Layout/PaperdollController.cs
+++ b/src/AcDream.App/UI/Layout/PaperdollController.cs
@@ -81,6 +81,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private readonly Action? _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 playerGuid,
Func iconIds, SelectionState selection,
Action? 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 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 iconIds, SelectionState selection,
Action? 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;
}
}
diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs
index 94cf792a..5fcb7b3a 100644
--- a/src/AcDream.App/UI/Layout/UiDatElement.cs
+++ b/src/AcDream.App/UI/Layout/UiDatElement.cs
@@ -129,10 +129,18 @@ public sealed class UiDatElement : UiElement, IUiDatStateful
/// elements (e.g. the chat Send / max-min buttons). Requires
/// = false to receive click events.
public Action? OnClick { get; set; }
+ public Action? 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;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index abe99524..f845ca4f 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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));
diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs
index 68541c2e..668d7631 100644
--- a/src/AcDream.App/UI/UiButton.cs
+++ b/src/AcDream.App/UI/UiButton.cs
@@ -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;
/// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize).
public Action? OnClick { get; set; }
+ ///
+ /// Optional position-aware click handler. Coordinates are local pixels in this button,
+ /// matching retail's UIElementMessageInfo.ptWindow paperdoll hit-test input.
+ ///
+ public Action? OnClickAt { get; set; }
+
///
/// 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;
}
}
diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs
index d6636edc..f5a50323 100644
--- a/src/AcDream.App/UI/UiViewport.cs
+++ b/src/AcDream.App/UI/UiViewport.cs
@@ -9,20 +9,24 @@ public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
- /// Optional click action (e.g. the paperdoll doll = "target self"
- /// during item target-use). Non-null makes the viewport handle the press
+ /// Optional click action. Non-null makes the viewport handle the press
/// itself instead of it being captured as a window move.
public System.Action? Clicked { get; set; }
+ /// Position-aware variant with viewport-local pixel coordinates.
+ public System.Action? 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);
}
diff --git a/src/AcDream.Core/Items/PaperdollSelectionPolicy.cs b/src/AcDream.Core/Items/PaperdollSelectionPolicy.cs
new file mode 100644
index 00000000..894b842b
--- /dev/null
+++ b/src/AcDream.Core/Items/PaperdollSelectionPolicy.cs
@@ -0,0 +1,47 @@
+namespace AcDream.Core.Items;
+
+///
+/// Retail paperdoll body-part selection. Ports
+/// InventoryPlacement::DetermineHigherPriority @ 0x004A44B0 and
+/// gmPaperDollUI::GetUpperInvObj @ 0x004A4750.
+///
+public static class PaperdollSelectionPolicy
+{
+ ///
+ /// Returns the visually upper equipped object covering .
+ /// When the body part is valid but uncovered, retail returns the player object itself.
+ ///
+ 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;
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollClickMapTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollClickMapTests.cs
new file mode 100644
index 00000000..44fc107c
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollClickMapTests.cs
@@ -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 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.Load(dats));
+ ImportedLayout layout = Assert.IsType(LayoutImporter.Import(
+ dats,
+ 0x21000023u,
+ static _ => (0u, 0, 0),
+ null));
+ UiElement dragMask = Assert.IsAssignableFrom(
+ layout.FindElement(PaperdollController.DollDragMaskId));
+ Assert.Equal((int)dragMask.Width, map.Width);
+ Assert.Equal((int)dragMask.Height, map.Height);
+
+ var regions = new HashSet();
+ 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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
index ace738a9..29d7f5db 100644
--- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
@@ -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()
{
diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
index 88fadd5a..aab9d8ac 100644
--- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiButtonTests.cs
@@ -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()
{
diff --git a/tests/AcDream.Core.Tests/Items/PaperdollSelectionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/PaperdollSelectionPolicyTests.cs
new file mode 100644
index 00000000..c391a432
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/PaperdollSelectionPolicyTests.cs
@@ -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,
+ });
+ }
+}