From 0b74d19475ba0096124a060d78049f972d291fbe Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 11:56:26 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 3 +- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 4 +- ...-07-10-retail-panel-behavior-pseudocode.md | 6 + ...026-07-10-retail-ui-fidelity-completion.md | 10 +- .../UI/Layout/PaperdollClickMap.cs | 92 ++++++++++++++ .../UI/Layout/PaperdollController.cs | 52 +++++--- src/AcDream.App/UI/Layout/UiDatElement.cs | 10 +- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- src/AcDream.App/UI/UiButton.cs | 19 ++- src/AcDream.App/UI/UiViewport.cs | 14 ++- .../Items/PaperdollSelectionPolicy.cs | 47 ++++++++ .../UI/Layout/PaperdollClickMapTests.cs | 83 +++++++++++++ .../UI/RetailUiInteractionFlowTests.cs | 49 +++++++- tests/AcDream.App.Tests/UI/UiButtonTests.cs | 14 +++ .../Items/PaperdollSelectionPolicyTests.cs | 113 ++++++++++++++++++ 16 files changed, 490 insertions(+), 32 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/PaperdollClickMap.cs create mode 100644 src/AcDream.Core/Items/PaperdollSelectionPolicy.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/PaperdollClickMapTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/PaperdollSelectionPolicyTests.cs 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, + }); + } +}