fix(ui): port exact paperdoll body selection

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

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

View file

@ -2050,7 +2050,7 @@ See divergence register **AP-59**.
## #141 — Toolbar interactivity — selected-object display ## #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 **Severity:** MEDIUM
**Filed:** 2026-06-17 **Filed:** 2026-06-17
**Component:** ui — D.5 toolbar / selection **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.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.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 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`). **Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).

View file

@ -208,7 +208,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-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-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-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-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 | | AP-110 | Remaining retained gameplay panels and world HUD are absent: combat/powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Existing panels cover the current M2 loop; Wave 10 lands each missing surface against authoritative state | Large portions of retail gameplay have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |

View file

@ -492,11 +492,11 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending. - **✓ SHIPPED — Wave 4.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.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. - **✓ 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 — 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. - **✓ 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. - **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.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. - **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. - **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command.

View file

@ -571,6 +571,12 @@ target mode against the **player ID**, not the visually upper item. Examine
and drag use the upper item. Selection lighting pulses matching body parts at and drag use the upper item. Selection lighting pulses matching body parts at
0.2-second intervals; player-ID selection uses mask `0x7FFFFFFF`. 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 ### Aetheria visibility
Retail anchors: Retail anchors:

View file

@ -680,12 +680,14 @@ full suite and user visual gate.
including Aetheria `0x10000595/596/597` and exact equip masks. including Aetheria `0x10000595/596/597` and exact equip masks.
- [ ] Drive Aetheria visibility from authoritative unlock/player state, not an - [ ] Drive Aetheria visibility from authoritative unlock/player state, not an
unconditional layout toggle. 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. equipment is deterministic and retail-correct.
- [ ] Port `AutoWieldIsLegal` and the dual-wield shield-slot special before accepting - [ ] Port `AutoWieldIsLegal` and the dual-wield shield-slot special before accepting
a drop; share legality with double-click auto-wield. a drop; share legality with double-click auto-wield.
- [ ] Implement doll body-part hit testing, worn-item lookup, select/examine, - [x] Implement exact DAT doll body-part hit testing, worn-item lookup, normal
drag-icon preparation, and part-selection lighting. 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; - [ ] 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. 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 - [ ] 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 | | Chat scrollbar arrows reversed | 2 / 5 |
| External-container close lifecycle missing; owned-side-bag `0x0195` premise disproved | 2 / 6 | | External-container close lifecycle missing; owned-side-bag `0x0195` premise disproved | 2 / 6 |
| Inventory capacity/burden/drag residuals | 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 | | Paperdoll empty art and per-race behavior | 7 |
| Character Titles inert, live title/luminance missing | 8 | | Character Titles inert, live title/luminance missing | 8 |
| Character raises mutate optimistically instead of using proven server-authoritative flow | 8 | | Character raises mutate optimistically instead of using proven server-authoritative flow | 8 |

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -186,7 +186,9 @@ public sealed class RetailUiInteractionFlowTests
return interaction; return interaction;
} }
public void BindPaperdoll(ItemInteractionController? itemInteraction = null) public void BindPaperdoll(
ItemInteractionController? itemInteraction = null,
PaperdollClickMap? clickMap = null)
=> PaperdollController.Bind( => PaperdollController.Bind(
Layout, Layout,
Objects, Objects,
@ -195,7 +197,23 @@ public sealed class RetailUiInteractionFlowTests
selection: Selection, selection: Selection,
sendWield: (item, mask) => Wields.Add((item, mask)), sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u, 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() public RetailUiAutomationProbe Probe()
=> new(Root, Objects); => new(Root, Objects);
@ -300,6 +318,33 @@ public sealed class RetailUiInteractionFlowTests
Assert.False(interaction.IsTargetModeActive); 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] [Fact]
public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback() public void InventoryTargetUse_invalidInventoryItem_isConsumedWithoutSelectionFallback()
{ {

View file

@ -16,6 +16,20 @@ public class UiButtonTests
Assert.True(_clicked); 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] [Fact]
public void NotClickThrough_SoItReceivesClicks() public void NotClickThrough_SoItReceivesClicks()
{ {

View file

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