feat(ui): centralize retail selection state

This commit is contained in:
Erik 2026-07-11 00:51:20 +02:00
parent c7607f019c
commit 7983309d23
30 changed files with 591 additions and 108 deletions

View file

@ -102,7 +102,11 @@ retained gameplay tree, LayoutDesc importer, window runtime, and panel
controllers. Neither presentation stack owns independent game-state truth. controllers. Neither presentation stack owns independent game-state truth.
Plugins register retained gameplay markup through the BCL-only Plugins register retained gameplay markup through the BCL-only
`AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui `AcDream.Plugin.Abstractions.IUiRegistry`; they do not import App or ImGui
assemblies. assemblies. Core `SelectionState` is the sole selected-object owner for world,
radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins;
`IPluginHost.Selection` exposes that same state and retail-style old/new callback.
Temporary pointer modes are separate App orchestration in `InteractionState` and
must never become a competing selection owner.
Every retained gameplay window has a typed `RetailWindowHandle` and at most one Every retained gameplay window has a typed `RetailWindowHandle` and at most one
`IRetainedPanelController` lifecycle owner. Multi-controller windows use `IRetainedPanelController` lifecycle owner. Multi-controller windows use
@ -164,6 +168,7 @@ src/
IPluginHost.cs -> done IPluginHost.cs -> done
IGameState.cs -> done IGameState.cs -> done
IEvents.cs -> done IEvents.cs -> done
ISelectionService.cs -> done
AcDream.App/ Layer 1 + Layer 4 wiring AcDream.App/ Layer 1 + Layer 4 wiring
Rendering/ Rendering/

View file

@ -214,8 +214,9 @@ What `GameWindow` loses:
`LiveSessionController`. `LiveSessionController`.
- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id - `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
translation → moved into `LiveEntityRuntime`. translation → moved into `LiveEntityRuntime`.
- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` → moved - `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved
into `SelectionInteractionController`. into `SelectionInteractionController`; Core `SelectionState` remains the
already-shipped session owner and is injected into that controller.
- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`. - Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
The eventual `GameEntity` aggregation (target state described in The eventual `GameEntity` aggregation (target state described in
@ -289,7 +290,7 @@ All four M1 demo targets must still work.
### Step 4 — `SelectionInteractionController` ### Step 4 — `SelectionInteractionController`
**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`, **Scope:** Extract `WorldPicker`, the Core `SelectionState` consumers, `SendUse`,
`SendPickUp`, and the `InputAction.Select*` / `UseSelected` / `SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
`SelectionPickUp` switch cases into one controller. Depends on Step 3 `SelectionPickUp` switch cases into one controller. Depends on Step 3
(uses `LiveEntityRuntime`). (uses `LiveEntityRuntime`).

View file

@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 99 active rows ## 3. Documented approximation (AP) — 98 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -162,8 +162,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | | AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` |
| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | | AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize |
| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` | | AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` |
| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | | AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by `_openContainer` plus Core `SelectionState.SelectedObjectId`, reproducing the retail keying (`item.itemID == openContainerId / selectedID` per `UpdateOpenContainerIndicator 0x004e3070` / `SetSelectedObject 0x0058c2e0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ACCWeenieObject::SetSelectedObject` 0x0058c2e0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 |
| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` |
| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | | AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` |
| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | | AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt |
| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | | AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` |

View file

@ -481,7 +481,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only.
- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. - **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2``0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 19** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject``0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay``ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout.
- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. - **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`.
- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan. - **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅, and **Wave 3.1 unified selection** (Core `SelectionState`; AP-58 retired) ✅ are shipped. Build order from here: **finish Wave 3 item policy/router/cursors, then D.5.3 selected-object mana + stack entry/slider and the separate favorite-spell bars; ISSUES #146/#147/#197.** Each ☐ below gets its own focused oracle/spec/plan/gate.
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. - **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15``UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B``UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory`**STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. - **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15``UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B``UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory`**STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
- **✓ 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.

View file

@ -979,8 +979,9 @@ implementation patch.
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations, is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
the complete `DetermineUseResult` classification, container post-actions, the complete `DetermineUseResult` classification, container post-actions,
`AttemptPlaceIn3D`, and retail busy semantics. `AttemptPlaceIn3D`, and retail busy semantics.
6. Selection remains owned by `GameWindow._selectedGuid`; the previously documented 6. **Resolved 2026-07-11:** selection is owned by Core `SelectionState` and shared
`Core.SelectionState` file is absent in this worktree. by world/radar/inventory/paperdoll/toolbar/plugins; the former
`GameWindow._selectedGuid` and inventory-local selected item are gone.
7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog 7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog
also includes `0x01..0x06` and `0x0A..0x0F`. also includes `0x01..0x06` and `0x0A..0x0F`.
8. Current cursor resolution uses a modern static priority chain. Retail instead 8. Current cursor resolution uses a modern static priority chain. Retail instead

View file

@ -316,15 +316,24 @@ chrome and content together; full suite and user visual gate.
### 3.1 Centralize selection ### 3.1 Centralize selection
- [ ] Add Core `SelectionState` with selected guid, previous guid, reason/source, - [x] Add Core `SelectionState` with selected guid, previous guid, reason/source,
and one change event. and one change event.
- [ ] Route world pick, radar click, inventory selection, paperdoll selection, - [x] Route world pick, radar click, inventory selection, paperdoll selection,
toolbar selected strip, target indicator, examine/use, and plugins through it. toolbar selected strip, target indicator, examine/use, and plugins through it.
- [ ] Delete panel-local competing selection state except transient hover/row focus. - [x] Delete panel-local competing selection state except transient hover/row focus.
- [ ] Add one App interaction-state owner for `None`, `Use`, `Examine`, and - [x] Add one App interaction-state owner for `None`, `Use`, `Examine`, and
`UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target `UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target
mode remains UI orchestration state. mode remains UI orchestration state.
**Implementation (2026-07-11):** Core `SelectionState` ports
`ACCWeenieObject::SetSelectedObject @ 0x0058C2E0`, including deduplication,
previous/previous-valid tracking, object-removal clear, and plugin callbacks.
World pick, radar, combat targeting, inventory, paperdoll slots, the selected
toolbar strip, target indicator, use/pickup, PreviousSelection, and the new
`IPluginHost.Selection` contract share that owner. `InteractionState` separately
owns `None`/`Use`/`Examine`/`UseItemOnTarget`; `ItemInteractionController` now
projects its pending source from that state. AP-58 retired.
### 3.2 Port item-use/drop legality into Core ### 3.2 Port item-use/drop legality into Core
- [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and - [ ] Translate `ItemHolder::UseObject`, `DetermineUseResult`, and

View file

@ -120,3 +120,19 @@ window mounts, toolbar digit and inventory-cell asset lookup, plugin mounts,
cursor feedback, persistence, automation, and retained tick/draw/restore/dispose cursor feedback, persistence, automation, and retained tick/draw/restore/dispose
paths live behind the runtime. The live Debug gate passed across two graceful paths live behind the runtime. The live Debug gate passed across two graceful
restart cycles. Do not add panel mount bodies back to `GameWindow`. restart cycles. Do not add panel mount bodies back to `GameWindow`.
## 2026-07-11 selection and interaction ownership
`AcDream.Core.Selection.SelectionState` is the only selected-object truth. It
ports retail `ACCWeenieObject::SetSelectedObject`: deduplicate the current guid,
remember previous and previous-valid guids, commit before firing one typed
transition, and clear with the removed object retained as previous. World pick,
radar, combat targeting, inventory, paperdoll slots, toolbar selected-object
presentation, target indicator, use/pickup, PreviousSelection, and plugins all
read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event
failures are isolated from the client and other plugins.
`AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration:
`None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a
projection of this state, not a second selected-object field. Keep selection in
Core and interaction modes in App when completing Wave 3.

View file

@ -4,16 +4,23 @@ namespace AcDream.App.Plugins;
public sealed class AppPluginHost : IPluginHost public sealed class AppPluginHost : IPluginHost
{ {
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) public AppPluginHost(
IPluginLogger log,
IGameState state,
IEvents events,
ISelectionService selection,
IUiRegistry ui)
{ {
Log = log; Log = log;
State = state; State = state;
Events = events; Events = events;
Selection = selection;
Ui = ui; Ui = ui;
} }
public IPluginLogger Log { get; } public IPluginLogger Log { get; }
public IGameState State { get; } public IGameState State { get; }
public IEvents Events { get; } public IEvents Events { get; }
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; } public IUiRegistry Ui { get; }
} }

View file

@ -31,8 +31,10 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents(); var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var selection = new AcDream.Core.Selection.SelectionState();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); var host = new AppPluginHost(
new SerilogAdapter(Log.Logger), worldGameState, worldEvents, selection, uiRegistry);
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
Log.Information("scanning plugins in {PluginsDir}", pluginsDir); Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
@ -65,7 +67,8 @@ try
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
} }
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); using var window = new GameWindow(
runtimeOptions, worldGameState, worldEvents, selection, uiRegistry);
window.Run(); window.Run();
} }
finally finally

View file

@ -16,6 +16,7 @@ public sealed class GameWindow : IDisposable
private readonly string _datDir; private readonly string _datDir;
private readonly WorldGameState _worldGameState; private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents; private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
private IWindow? _window; private IWindow? _window;
private GL? _gl; private GL? _gl;
private IInputContext? _input; private IInputContext? _input;
@ -1032,24 +1033,6 @@ public sealed class GameWindow : IDisposable
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary> /// </summary>
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new(); private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
/// <summary>Fires when the selected world object changes (retail gmToolbarUI selection-change event,
/// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes.</summary>
private event Action<uint?>? SelectionChanged;
/// <summary>Currently-selected world object guid. The setter fires <see cref="SelectionChanged"/> only on
/// an actual change (dedup), so all writes go through here; reads may use the field directly.</summary>
private uint? SelectedGuid
{
get => _selectedGuid;
set
{
if (_selectedGuid == value) return;
_selectedGuid = value;
SelectionChanged?.Invoke(value);
}
}
// B.6/B.7 (2026-05-16): pending close-range action that will be fired // B.6/B.7 (2026-05-16): pending close-range action that will be fired
// once the local auto-walk overlay reports arrival (body has finished // once the local auto-walk overlay reports arrival (body has finished
// rotating to face the target). Only set for close-range Use/PickUp; // rotating to face the target). Only set for close-range Use/PickUp;
@ -1085,13 +1068,18 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame; private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames; private int _liveAnimRejectPartFrames;
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, public GameWindow(
AcDream.App.RuntimeOptions options,
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.Core.Selection.SelectionState selection,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{ {
_options = options ?? throw new System.ArgumentNullException(nameof(options)); _options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir; _datDir = options.DatDir;
_worldGameState = worldGameState; _worldGameState = worldGameState;
_worldEvents = worldEvents; _worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry; _uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
@ -1525,13 +1513,13 @@ public sealed class GameWindow : IDisposable
// B.7 Vivid Target Indicator — corner-triangle highlight // B.7 Vivid Target Indicator — corner-triangle highlight
// around the currently-selected entity. Delegates pull // around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame: // live state from this GameWindow instance every frame:
// - selected guid → _selectedGuid (set by PickAndStoreSelection) // - selected guid → shared SelectionState
// - entity resolver → position from _entitiesByServerGuid + // - entity resolver → position from _entitiesByServerGuid +
// itemType from ClientObjectTable (Objects) + last spawn // itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not // - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0. // yet ready, in which case the panel bails on viewport==0.
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel( _targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
selectedGuidProvider: () => _selectedGuid, selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid => entityResolver: guid =>
{ {
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
@ -2078,7 +2066,7 @@ public sealed class GameWindow : IDisposable
playerGuid: () => _playerServerGuid, playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f, playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u, playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid, selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar, coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI); uiLocked: () => _persistedGameplay.LockUI);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
@ -2103,7 +2091,7 @@ public sealed class GameWindow : IDisposable
AcDream.UI.Abstractions.NullCommandBus.Instance), AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings( Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot, radarSnapshotProvider.BuildSnapshot,
guid => SelectedGuid = guid, _selection,
SetRetailUiLocked), SetRetailUiLocked),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects, Objects,
@ -2114,8 +2102,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController, _itemInteractionController,
(index, guid) => _liveSession?.SendAddShortcut(index, guid), (index, guid) => _liveSession?.SendAddShortcut(index, guid),
index => _liveSession?.SendRemoveShortcut(index), index => _liveSession?.SendRemoveShortcut(index),
handler => SelectionChanged += handler, _selection,
handler => SelectionChanged -= handler,
handler => Combat.HealthChanged += handler, handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler, handler => Combat.HealthChanged -= handler,
IsHealthBarTarget, IsHealthBarTarget,
@ -2137,7 +2124,8 @@ public sealed class GameWindow : IDisposable
(item, container, placement) => (item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement), _liveSession?.SendPutItemInContainer(item, container, placement),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController), _itemInteractionController,
_selection),
Cursor: new AcDream.App.UI.RetailUiCursorBindings( Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController, cursorFeedbackController,
retailCursorManager), retailCursorManager),
@ -4478,8 +4466,12 @@ public sealed class GameWindow : IDisposable
_remoteLastMove.Remove(serverGuid); _remoteLastMove.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid);
_lastSpawnByGuid.Remove(serverGuid); _lastSpawnByGuid.Remove(serverGuid);
if (_selectedGuid == serverGuid) if (_selection.SelectedObjectId == serverGuid)
SelectedGuid = null; {
_selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
}
// A7 indoor lighting: release this entity's owned lights on EVERY // A7 indoor lighting: release this entity's owned lights on EVERY
// removal, including the respawn-dedup path (former logDelete=false). // removal, including the respawn-dedup path (former logDelete=false).
@ -11874,6 +11866,10 @@ public sealed class GameWindow : IDisposable
SelectClosestCombatTarget(showToast: true); SelectClosestCombatTarget(showToast: true);
break; break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection:
_selection.SelectPrevious();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat: case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode(); ToggleLiveCombatMode();
break; break;
@ -11903,7 +11899,7 @@ public sealed class GameWindow : IDisposable
break; break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp: case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget) if (_selection.SelectedObjectId is uint pickupTarget)
SendPickUp(pickupTarget); SendPickUp(pickupTarget);
else else
_debugVm?.AddToast("Nothing selected"); _debugVm?.AddToast("Nothing selected");
@ -11979,7 +11975,7 @@ public sealed class GameWindow : IDisposable
private uint? GetSelectedOrClosestCombatTarget() private uint? GetSelectedOrClosestCombatTarget()
{ {
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected)) if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
return selected; return selected;
return SelectClosestCombatTarget(showToast: false); return SelectClosestCombatTarget(showToast: false);
@ -12066,7 +12062,7 @@ public sealed class GameWindow : IDisposable
return; return;
} }
SelectedGuid = guid; _selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World);
string label = DescribeLiveEntity(guid); string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can // B.7 (2026-05-15): one-shot per-pick diagnostic so we can
@ -12107,7 +12103,7 @@ public sealed class GameWindow : IDisposable
private void UseCurrentSelection() private void UseCurrentSelection()
{ {
if (_selectedGuid is not uint sel) if (_selection.SelectedObjectId is not uint sel)
{ {
_debugVm?.AddToast("Nothing selected"); _debugVm?.AddToast("Nothing selected");
return; return;
@ -12503,7 +12499,10 @@ public sealed class GameWindow : IDisposable
bestGuid = guid; bestGuid = guid;
} }
SelectedGuid = bestGuid; if (bestGuid is { } selection)
_selection.Select(selection, AcDream.Core.Selection.SelectionChangeSource.Keyboard);
else
_selection.Clear(AcDream.Core.Selection.SelectionChangeSource.Keyboard);
if (bestGuid is { } selected) if (bestGuid is { } selected)
{ {
string label = DescribeLiveEntity(selected); string label = DescribeLiveEntity(selected);
@ -13592,6 +13591,8 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime?.Dispose(); _retailUiRuntime?.Dispose();
_retailUiRuntime = null; _retailUiRuntime = null;
_uiHost = null; _uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
// Phase A.1: join the streamer worker thread before tearing down GL // Phase A.1: join the streamer worker thread before tearing down GL
// state. The worker may still be processing a load job that references // state. The worker may still be processing a load job that references

View file

@ -113,6 +113,7 @@ public static class FixtureProvider
uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u); uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u);
var iconIds = MakeIconIds(stack); var iconIds = MakeIconIds(stack);
var selection = new AcDream.Core.Selection.SelectionState();
InventoryController.Bind( InventoryController.Bind(
layout, layout,
@ -120,6 +121,7 @@ public static class FixtureProvider
playerGuid: () => SampleData.PlayerGuid, playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds, iconIds: iconIds,
strength: () => 100, strength: () => 100,
selection: selection,
datFont: stack.VitalsDatFont, datFont: stack.VitalsDatFont,
contentsEmptySprite: contentsEmpty, contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty, sideBagEmptySprite: sideBagEmpty,
@ -133,6 +135,7 @@ public static class FixtureProvider
objects, objects,
playerGuid: () => SampleData.PlayerGuid, playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds, iconIds: iconIds,
selection: selection,
sendWield: null, // no live session in the studio sendWield: null, // no live session in the studio
emptySlotSprite: contentsEmpty, emptySlotSprite: contentsEmpty,
datFont: stack.VitalsDatFont); datFont: stack.VitalsDatFont);

View file

@ -0,0 +1,54 @@
namespace AcDream.App.UI;
public enum InteractionModeKind
{
None,
Use,
Examine,
UseItemOnTarget,
}
public readonly record struct InteractionMode(
InteractionModeKind Kind,
uint SourceObjectId = 0)
{
public static InteractionMode None => new(InteractionModeKind.None);
}
public readonly record struct InteractionModeTransition(
InteractionMode Previous,
InteractionMode Current);
/// <summary>
/// Single App-layer owner for retail's pointer interaction mode. Core selection
/// remains session truth; this state represents temporary UI orchestration.
/// </summary>
public sealed class InteractionState
{
public InteractionMode Current { get; private set; } = InteractionMode.None;
public event Action<InteractionModeTransition>? Changed;
public bool EnterUse() => Set(new InteractionMode(InteractionModeKind.Use));
public bool EnterExamine() => Set(new InteractionMode(InteractionModeKind.Examine));
public bool EnterUseItemOnTarget(uint sourceObjectId)
{
if (sourceObjectId == 0)
throw new ArgumentOutOfRangeException(nameof(sourceObjectId));
return Set(new InteractionMode(InteractionModeKind.UseItemOnTarget, sourceObjectId));
}
public bool Clear() => Set(InteractionMode.None);
private bool Set(InteractionMode mode)
{
if (Current == mode)
return false;
InteractionMode previous = Current;
Current = mode;
Changed?.Invoke(new InteractionModeTransition(previous, mode));
return true;
}
}

View file

@ -8,7 +8,7 @@ namespace AcDream.App.UI;
/// target acquisition, and drag-out drops here instead of duplicating /// target acquisition, and drag-out drops here instead of duplicating
/// ItemHolder::UseObject fragments in each panel. /// ItemHolder::UseObject fragments in each panel.
/// </summary> /// </summary>
public sealed class ItemInteractionController public sealed class ItemInteractionController : IDisposable
{ {
private static readonly EquipMask[] AutoEquipOrder = private static readonly EquipMask[] AutoEquipOrder =
{ {
@ -55,8 +55,10 @@ public sealed class ItemInteractionController
private readonly Action<uint, uint>? _sendWield; private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop; private readonly Action<uint>? _sendDrop;
private readonly Action<string>? _toast; private readonly Action<string>? _toast;
private readonly InteractionState _interactionState;
private long _lastUseMs = long.MinValue / 2; private long _lastUseMs = long.MinValue / 2;
private bool _disposed;
public ItemInteractionController( public ItemInteractionController(
ClientObjectTable objects, ClientObjectTable objects,
@ -66,7 +68,8 @@ public sealed class ItemInteractionController
Action<uint, uint>? sendWield, Action<uint, uint>? sendWield,
Action<uint>? sendDrop, Action<uint>? sendDrop,
Func<long>? nowMs = null, Func<long>? nowMs = null,
Action<string>? toast = null) Action<string>? toast = null,
InteractionState? interactionState = null)
{ {
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -76,15 +79,23 @@ public sealed class ItemInteractionController
_sendDrop = sendDrop; _sendDrop = sendDrop;
_nowMs = nowMs ?? (() => Environment.TickCount64); _nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast; _toast = toast;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
} }
public event Action? StateChanged; public event Action? StateChanged;
public uint PlayerGuid => _playerGuid(); public uint PlayerGuid => _playerGuid();
public uint PendingSourceItem { get; private set; } public InteractionState InteractionState => _interactionState;
public bool IsTargetModeActive => PendingSourceItem != 0; public uint PendingSourceItem
=> _interactionState.Current is { Kind: InteractionModeKind.UseItemOnTarget } mode
? mode.SourceObjectId
: 0u;
public bool IsTargetModeActive
=> _interactionState.Current.Kind == InteractionModeKind.UseItemOnTarget;
public bool IsPendingSource(uint itemGuid) public bool IsPendingSource(uint itemGuid)
=> itemGuid != 0 && itemGuid == PendingSourceItem; => itemGuid != 0 && itemGuid == PendingSourceItem;
@ -188,8 +199,7 @@ public sealed class ItemInteractionController
private void EnterTargetMode(uint sourceGuid) private void EnterTargetMode(uint sourceGuid)
{ {
PendingSourceItem = sourceGuid; _interactionState.EnterUseItemOnTarget(sourceGuid);
StateChanged?.Invoke();
var name = _objects.Get(sourceGuid)?.Name; var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}"); _toast?.Invoke($"Choose a target for the {name}");
@ -197,8 +207,17 @@ public sealed class ItemInteractionController
private void ClearTargetMode() private void ClearTargetMode()
{ {
PendingSourceItem = 0; _interactionState.Clear();
StateChanged?.Invoke(); }
private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
} }
private bool ConsumeUseThrottle() private bool ConsumeUseThrottle()

View file

@ -2,6 +2,7 @@ using System;
using System.Numerics; using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -53,7 +54,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f);
private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid
private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) private readonly SelectionState _selection;
private readonly Action<uint>? _sendUse; private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing; private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement) private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
@ -70,6 +71,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Func<uint> playerGuid, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength, Func<int?> strength,
SelectionState selection,
Func<string>? ownerName, Func<string>? ownerName,
UiDatFont? datFont, UiDatFont? datFont,
uint contentsEmptySprite, uint contentsEmptySprite,
@ -90,6 +92,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendNoLongerViewing = sendNoLongerViewing; _sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer; _sendPutItemInContainer = sendPutItemInContainer;
_itemInteraction = itemInteraction; _itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
WindowChromeController.BindCloseButton(layout, onClose); WindowChromeController.BindCloseButton(layout, onClose);
@ -152,8 +155,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Rebuild on any change to the player's possessions. // Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged; _objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged; _objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
if (_itemInteraction is not null) if (_itemInteraction is not null)
_itemInteraction.StateChanged += OnInteractionStateChanged; _itemInteraction.StateChanged += OnInteractionStateChanged;
@ -184,6 +188,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Func<uint> playerGuid, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength, Func<int?> strength,
SelectionState selection,
UiDatFont? datFont, UiDatFont? datFont,
Func<string>? ownerName = null, Func<string>? ownerName = null,
uint contentsEmptySprite = 0u, uint contentsEmptySprite = 0u,
@ -194,14 +199,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint, int>? sendPutItemInContainer = null, Action<uint, uint, int>? sendPutItemInContainer = null,
ItemInteractionController? itemInteraction = null, ItemInteractionController? itemInteraction = null,
Action? onClose = null) Action? onClose = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont, => new InventoryController(layout, objects, playerGuid, iconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose); sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
onClose);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectRemoved(ClientObject o)
{
if (_selection.SelectedObjectId == o.ObjectId)
{
_selection.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved);
}
if (Concerns(o)) Populate();
}
private void OnObjectMoved(ClientObject o, uint from, uint to) private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
private void OnInteractionStateChanged() => ApplyIndicators(); private void OnInteractionStateChanged() => ApplyIndicators();
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary> /// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o) private bool Concerns(ClientObject o)
@ -402,8 +420,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private void SelectItem(uint guid) private void SelectItem(uint guid)
{ {
if (guid == 0) return; if (guid == 0) return;
_selectedItem = guid; _selection.Select(guid, SelectionChangeSource.Inventory);
ApplyIndicators();
} }
/// <summary>Open a container (side bag or main pack): it becomes both the selected item and the /// <summary>Open a container (side bag or main pack): it becomes both the selected item and the
@ -413,7 +430,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private void OpenContainer(uint guid) private void OpenContainer(uint guid)
{ {
if (guid == 0) return; if (guid == 0) return;
_selectedItem = guid; // the container cell is also the selected item _selection.Select(guid, SelectionChangeSource.Inventory);
uint open = EffectiveOpen(); uint open = EffectiveOpen();
if (guid == open) { ApplyIndicators(); return; } // already open — just move the square if (guid == open) { ApplyIndicators(); return; } // already open — just move the square
@ -444,7 +461,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
var cell = list.GetItem(i); var cell = list.GetItem(i);
if (cell is null) continue; if (cell is null) continue;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource; cell.Selected = cell.ItemId != 0
&& cell.ItemId == _selection.SelectedObjectId
&& !pendingTargetSource;
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
} }
} }
@ -533,8 +552,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_disposed = true; _disposed = true;
_objects.ObjectAdded -= OnObjectChanged; _objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved; _objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged; _objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_itemInteraction is not null) if (_itemInteraction is not null)
_itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged;
} }

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -76,6 +77,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
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 List<(EquipMask Mask, UiItemList List)> _slots = new(); private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ──────────────────────────────────────────────────────────────────────── // ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -86,11 +88,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private PaperdollController( private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid, ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield, Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield,
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction) uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
{ {
_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));
for (int i = 0; i < SlotMap.Length; i++) for (int i = 0; i < SlotMap.Length; i++)
{ {
@ -100,7 +104,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
list.Cell.SourceKind = ItemDragSource.Equipment; list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); }; list.Cell.Clicked = () =>
{
if (_itemInteraction?.IsTargetModeActive == true)
{
_itemInteraction.AcquireSelfTarget();
return;
}
if (list.Cell.ItemId != 0)
_selection.Select(list.Cell.ItemId, SelectionChangeSource.Paperdoll);
};
list.Cell.DoubleClicked = () => list.Cell.DoubleClicked = () =>
{ {
if (list.Cell.ItemId != 0) if (list.Cell.ItemId != 0)
@ -119,6 +132,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged; _objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
// ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── // ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds) foreach (var id in ArmorSlotElementIds)
@ -154,14 +168,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
public static PaperdollController Bind( public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid, ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null, Func<ItemType, uint, uint, uint, uint, uint> iconIds, SelectionState selection,
Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null, uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null) ItemInteractionController? itemInteraction = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction); => new PaperdollController(
layout, objects, playerGuid, iconIds, selection, sendWield, emptySlotSprite,
datFont, itemInteraction);
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)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may /// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
@ -196,6 +214,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects); uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects);
list.Cell.SetItem(worn.ObjectId, tex); list.Cell.SetItem(worn.ObjectId, tex);
} }
ApplySelectionIndicators();
}
private void ApplySelectionIndicators()
{
foreach (var (_, list) in _slots)
{
list.Cell.Selected = list.Cell.ItemId != 0
&& list.Cell.ItemId == _selection.SelectedObjectId;
}
} }
private EquipMask MaskFor(UiItemList list) private EquipMask MaskFor(UiItemList list)
@ -251,5 +279,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectMoved -= OnObjectMoved; _objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged; _objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
} }
} }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Numerics; using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.Core.Selection;
namespace AcDream.App.UI.Layout; namespace AcDream.App.UI.Layout;
@ -82,7 +83,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
private readonly Func<uint, bool> _hasHealth; private readonly Func<uint, bool> _hasHealth;
private readonly Func<uint, uint> _stackSize; private readonly Func<uint, uint> _stackSize;
private readonly Action<uint> _sendQueryHealth; private readonly Action<uint> _sendQueryHealth;
private readonly Action<Action<uint?>> _unsubscribeSelectionChanged; private readonly SelectionState _selection;
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged; private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
// ── Live state (read by closures on the per-frame draw path) ──────────── // ── Live state (read by closures on the per-frame draw path) ────────────
@ -96,8 +97,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
private SelectedObjectController( private SelectedObjectController(
ImportedLayout layout, ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged, SelectionState selection,
Action<Action<uint?>> unsubscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged, Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged, Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget, Func<uint, bool> isHealthTarget,
@ -114,7 +114,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
_hasHealth = hasHealth; _hasHealth = hasHealth;
_stackSize = stackSize; _stackSize = stackSize;
_sendQueryHealth = sendQueryHealth; _sendQueryHealth = sendQueryHealth;
_unsubscribeSelectionChanged = unsubscribeSelectionChanged; _selection = selection ?? throw new ArgumentNullException(nameof(selection));
_unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeHealthChanged = unsubscribeHealthChanged;
// Find elements — silently skip absent ones (partial/test layouts). // Find elements — silently skip absent ones (partial/test layouts).
@ -171,8 +171,10 @@ public sealed class SelectedObjectController : IRetainedPanelController
} }
// Register the handlers LAST so the initial state is fully set up first. // Register the handlers LAST so the initial state is fully set up first.
subscribeSelectionChanged(OnSelectionChanged); _selection.Changed += OnSelectionTransition;
subscribeHealthChanged(OnHealthChanged); subscribeHealthChanged(OnHealthChanged);
if (_selection.SelectedObjectId is { } initial)
ApplySelection(initial);
} }
/// <summary> /// <summary>
@ -180,8 +182,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>. /// Port of retail <c>gmToolbarUI::HandleSelectionChanged</c> + <c>RecvNotice_UpdateObjectHealth</c>.
/// </summary> /// </summary>
/// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param> /// <param name="layout">Imported toolbar layout (LayoutDesc 0x21000016).</param>
/// <param name="subscribeSelectionChanged">Called once with <see cref="OnSelectionChanged"/> /// <param name="selection">The single Core selected-object owner.</param>
/// (typical host: <c>h =&gt; SelectionChanged += h</c>).</param>
/// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/> /// <param name="subscribeHealthChanged">Called once with <see cref="OnHealthChanged"/>
/// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param> /// (typical host: <c>h =&gt; Combat.HealthChanged += h</c>) — drives meter visibility.</param>
/// <param name="isHealthTarget">Returns true for guids that may show a health meter /// <param name="isHealthTarget">Returns true for guids that may show a health meter
@ -195,8 +196,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param> /// <param name="datFont">Dat font for the name label; null = debug bitmap font fallback.</param>
public static SelectedObjectController Bind( public static SelectedObjectController Bind(
ImportedLayout layout, ImportedLayout layout,
Action<Action<uint?>> subscribeSelectionChanged, SelectionState selection,
Action<Action<uint?>> unsubscribeSelectionChanged,
Action<Action<uint, float>> subscribeHealthChanged, Action<Action<uint, float>> subscribeHealthChanged,
Action<Action<uint, float>> unsubscribeHealthChanged, Action<Action<uint, float>> unsubscribeHealthChanged,
Func<uint, bool> isHealthTarget, Func<uint, bool> isHealthTarget,
@ -207,7 +207,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
Action<uint> sendQueryHealth, Action<uint> sendQueryHealth,
UiDatFont? datFont) UiDatFont? datFont)
=> new SelectedObjectController( => new SelectedObjectController(
layout, subscribeSelectionChanged, unsubscribeSelectionChanged, layout, selection,
subscribeHealthChanged, unsubscribeHealthChanged, subscribeHealthChanged, unsubscribeHealthChanged,
isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont); isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont);
@ -215,7 +215,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>): /// Port of <c>gmToolbarUI::HandleSelectionChanged</c> (<c>:198635</c>):
/// clear-then-populate the selected-object strip on any selection change. /// clear-then-populate the selected-object strip on any selection change.
/// </summary> /// </summary>
public void OnSelectionChanged(uint? guid) private void ApplySelection(uint? guid)
{ {
// ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0)
// + SetVisible(0) on the meters). ────────────────────────────────────── // + SetVisible(0) on the meters). ──────────────────────────────────────
@ -279,11 +279,14 @@ public sealed class SelectedObjectController : IRetainedPanelController
_overlay?.TrySetRetailState(state); _overlay?.TrySetRetailState(state);
} }
private void OnSelectionTransition(SelectionTransition transition)
=> ApplySelection(transition.SelectedObjectId);
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
_unsubscribeSelectionChanged(OnSelectionChanged); _selection.Changed -= OnSelectionTransition;
_unsubscribeHealthChanged(OnHealthChanged); _unsubscribeHealthChanged(OnHealthChanged);
} }
} }

View file

@ -6,6 +6,7 @@ using AcDream.App.UI.Testing;
using AcDream.Core.Combat; using AcDream.Core.Combat;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Selection;
using AcDream.UI.Abstractions; using AcDream.UI.Abstractions;
using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Chat;
using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Settings;
@ -31,7 +32,7 @@ public sealed record ChatRuntimeBindings(ChatVM ViewModel, Func<ICommandBus> Com
public sealed record RadarRuntimeBindings( public sealed record RadarRuntimeBindings(
Func<UiRadarSnapshot> Snapshot, Func<UiRadarSnapshot> Snapshot,
Action<uint> SelectObject, SelectionState Selection,
Action<bool> SetUiLocked); Action<bool> SetUiLocked);
public sealed record ToolbarRuntimeBindings( public sealed record ToolbarRuntimeBindings(
@ -43,8 +44,7 @@ public sealed record ToolbarRuntimeBindings(
ItemInteractionController ItemInteraction, ItemInteractionController ItemInteraction,
Action<uint, uint>? SendAddShortcut, Action<uint, uint>? SendAddShortcut,
Action<uint>? SendRemoveShortcut, Action<uint>? SendRemoveShortcut,
Action<Action<uint?>> SubscribeSelectionChanged, SelectionState Selection,
Action<Action<uint?>> UnsubscribeSelectionChanged,
Action<Action<uint, float>> SubscribeHealthChanged, Action<Action<uint, float>> SubscribeHealthChanged,
Action<Action<uint, float>> UnsubscribeHealthChanged, Action<Action<uint, float>> UnsubscribeHealthChanged,
Func<uint, bool> IsHealthTarget, Func<uint, bool> IsHealthTarget,
@ -65,7 +65,8 @@ public sealed record InventoryRuntimeBindings(
Action<uint>? SendNoLongerViewing, Action<uint>? SendNoLongerViewing,
Action<uint, uint, int>? SendPutItemInContainer, Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint>? SendWield, Action<uint, uint>? SendWield,
ItemInteractionController ItemInteraction); ItemInteractionController ItemInteraction,
SelectionState Selection);
public sealed record RetailUiPersistenceBindings( public sealed record RetailUiPersistenceBindings(
SettingsStore Store, SettingsStore Store,
@ -256,7 +257,7 @@ public sealed class RetailUiRuntime : IDisposable
RadarController controller = RadarController.Bind( RadarController controller = RadarController.Bind(
layout, layout,
_bindings.Radar.Snapshot, _bindings.Radar.Snapshot,
_bindings.Radar.SelectObject, guid => _bindings.Radar.Selection.Select(guid, SelectionChangeSource.Radar),
setUiLocked: _bindings.Radar.SetUiLocked, setUiLocked: _bindings.Radar.SetUiLocked,
datFont: _bindings.Assets.DefaultFont); datFont: _bindings.Assets.DefaultFont);
RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite, RetailWindowFrame.Mount(Host.Root, radarRoot, _bindings.Assets.ResolveSprite,
@ -354,8 +355,7 @@ public sealed class RetailUiRuntime : IDisposable
peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut); peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut);
SelectedObjectController = Layout.SelectedObjectController.Bind( SelectedObjectController = Layout.SelectedObjectController.Bind(
layout, layout,
b.SubscribeSelectionChanged, b.Selection,
b.UnsubscribeSelectionChanged,
b.SubscribeHealthChanged, b.SubscribeHealthChanged,
b.UnsubscribeHealthChanged, b.UnsubscribeHealthChanged,
b.IsHealthTarget, b.IsHealthTarget,
@ -570,13 +570,13 @@ public sealed class RetailUiRuntime : IDisposable
InventoryRuntimeBindings b = _bindings.Inventory; InventoryRuntimeBindings b = _bindings.Inventory;
InventoryController inventory = InventoryController.Bind( InventoryController inventory = InventoryController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Strength, b.Selection,
_bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName, _bindings.Assets.DefaultFont, _bindings.Character.Provider.CharacterName,
contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing, contents, sideBag, mainPack, b.SendUse, b.SendNoLongerViewing,
b.SendPutItemInContainer, b.ItemInteraction, b.SendPutItemInContainer, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory)); () => CloseWindow(WindowNames.Inventory));
PaperdollController paperdoll = PaperdollController.Bind( PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, 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);
Host.WindowManager.AttachController( Host.WindowManager.AttachController(
WindowNames.Inventory, WindowNames.Inventory,

View file

@ -24,7 +24,7 @@ namespace AcDream.App.UI;
/// on internal state types: /// on internal state types:
/// </para> /// </para>
/// <list type="bullet"> /// <list type="bullet">
/// <item><c>selectedGuidProvider</c> — host's current <c>_selectedGuid</c>.</item> /// <item><c>selectedGuidProvider</c> — Core <c>SelectionState</c>'s current guid.</item>
/// <item><c>entityResolver</c> — returns /// <item><c>entityResolver</c> — returns
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if /// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
/// the entity is no longer in the world (despawned).</item> /// the entity is no longer in the world (despawned).</item>

View file

@ -0,0 +1,106 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Selection;
public enum SelectionChangeSource
{
System,
World,
Radar,
Inventory,
Paperdoll,
Toolbar,
Keyboard,
Plugin,
}
public enum SelectionChangeReason
{
Selected,
Cleared,
SelectedObjectRemoved,
PreviousSelection,
}
public readonly record struct SelectionTransition(
uint? PreviousObjectId,
uint? SelectedObjectId,
SelectionChangeSource Source,
SelectionChangeReason Reason);
/// <summary>
/// Single owner for the client's selected object. This ports the state behavior
/// of retail <c>ACCWeenieObject::SetSelectedObject @ 0x0058C2E0</c>: changes are
/// deduplicated, the old selection becomes the previous selection, and plugin/UI
/// listeners receive one transition after state is committed.
/// </summary>
public sealed class SelectionState : ISelectionService
{
private event Action<SelectionChangedEvent>? PluginChanged;
public uint? SelectedObjectId { get; private set; }
public uint? PreviousObjectId { get; private set; }
public uint? PreviousValidObjectId { get; private set; }
public event Action<SelectionTransition>? Changed;
event Action<SelectionChangedEvent> ISelectionService.Changed
{
add => PluginChanged += value;
remove => PluginChanged -= value;
}
public bool Select(
uint objectId,
SelectionChangeSource source,
SelectionChangeReason reason = SelectionChangeReason.Selected)
=> objectId == 0
? Clear(source)
: Set(objectId, source, reason);
public bool Clear(
SelectionChangeSource source,
SelectionChangeReason reason = SelectionChangeReason.Cleared)
=> Set(null, source, reason);
public bool SelectPrevious(SelectionChangeSource source = SelectionChangeSource.Keyboard)
=> PreviousObjectId is uint previous
&& Set(previous, source, SelectionChangeReason.PreviousSelection);
bool ISelectionService.Select(uint objectId)
=> Select(objectId, SelectionChangeSource.Plugin);
bool ISelectionService.Clear()
=> Clear(SelectionChangeSource.Plugin);
private bool Set(
uint? selectedObjectId,
SelectionChangeSource source,
SelectionChangeReason reason)
{
if (SelectedObjectId == selectedObjectId)
return false;
uint? old = SelectedObjectId;
SelectedObjectId = selectedObjectId;
PreviousObjectId = old;
if (old is not null)
PreviousValidObjectId = old;
var transition = new SelectionTransition(old, selectedObjectId, source, reason);
Changed?.Invoke(transition);
DispatchPluginChanged(new SelectionChangedEvent(old, selectedObjectId));
return true;
}
private void DispatchPluginChanged(SelectionChangedEvent change)
{
Action<SelectionChangedEvent>? listeners = PluginChanged;
if (listeners is null) return;
foreach (Action<SelectionChangedEvent> listener in listeners.GetInvocationList())
{
try { listener(change); }
catch { /* plugin exceptions never escape into client state changes */ }
}
}
}

View file

@ -10,5 +10,6 @@ public interface IPluginHost
IPluginLogger Log { get; } IPluginLogger Log { get; }
IGameState State { get; } IGameState State { get; }
IEvents Events { get; } IEvents Events { get; }
ISelectionService Selection { get; }
IUiRegistry Ui { get; } IUiRegistry Ui { get; }
} }

View file

@ -0,0 +1,19 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>A plugin-safe selection transition matching retail's selected-target callback.</summary>
public readonly record struct SelectionChangedEvent(
uint? PreviousObjectId,
uint? SelectedObjectId);
/// <summary>
/// Shared selected-object service. Plugins read and change the same selection
/// used by the world, radar, inventory, paperdoll, and toolbar.
/// </summary>
public interface ISelectionService
{
uint? SelectedObjectId { get; }
uint? PreviousObjectId { get; }
event Action<SelectionChangedEvent> Changed;
bool Select(uint objectId);
bool Clear();
}

View file

@ -0,0 +1,32 @@
using AcDream.App.UI;
namespace AcDream.App.Tests.UI;
public sealed class InteractionStateTests
{
[Fact]
public void Modes_HaveOneOwnerAndOneDeduplicatedTransitionStream()
{
var state = new InteractionState();
var changes = new List<InteractionModeTransition>();
state.Changed += changes.Add;
Assert.True(state.EnterUse());
Assert.False(state.EnterUse());
Assert.True(state.EnterExamine());
Assert.True(state.EnterUseItemOnTarget(0x100u));
Assert.True(state.Clear());
Assert.Equal(4, changes.Count);
Assert.Equal(InteractionModeKind.None, state.Current.Kind);
Assert.Equal(0x100u, changes[2].Current.SourceObjectId);
}
[Fact]
public void UseItemOnTarget_RejectsMissingSource()
{
var state = new InteractionState();
Assert.Throws<ArgumentOutOfRangeException>(() => state.EnterUseItemOnTarget(0));
Assert.Equal(InteractionMode.None, state.Current);
}
}

View file

@ -77,6 +77,10 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.IsTargetModeActive); Assert.True(h.Controller.IsTargetModeActive);
Assert.True(h.Controller.IsPendingSource(0x50000A01u)); Assert.True(h.Controller.IsPendingSource(0x50000A01u));
Assert.Equal(InteractionModeKind.UseItemOnTarget,
h.Controller.InteractionState.Current.Kind);
Assert.Equal(0x50000A01u,
h.Controller.InteractionState.Current.SourceObjectId);
Assert.Empty(h.UseWithTarget); Assert.Empty(h.UseWithTarget);
} }

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -56,7 +57,8 @@ public class InventoryControllerTests
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null, int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null, List<(uint item, uint container, int placement)>? puts = null,
string? ownerName = null, string? ownerName = null,
Action? onClose = null) Action? onClose = null,
SelectionState? selection = null)
=> InventoryController.Bind(layout, objects, () => Player, => InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null, strength: () => strength, datFont: null,
@ -64,7 +66,8 @@ public class InventoryControllerTests
sendUse: uses is null ? null : g => uses.Add(g), sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g), sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)), sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
onClose: onClose); onClose: onClose,
selection: selection ?? new SelectionState());
private static UiButton MakeButton(uint id) private static UiButton MakeButton(uint id)
{ {
@ -316,7 +319,8 @@ public class InventoryControllerTests
{ {
var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
InventoryController.Bind(layout, new ClientObjectTable(), () => Player, InventoryController.Bind(layout, new ClientObjectTable(), () => Player,
iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null, iconIds: (_, _, _, _, _) => 0u, strength: () => 100,
selection: new SelectionState(), datFont: null,
contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu); contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu);
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
@ -414,6 +418,27 @@ public class InventoryControllerTests
Assert.Empty(closes); Assert.Empty(closes);
} }
[Fact]
public void ClickGridItem_updatesSharedSelection_andExternalSelectionMovesSquare()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedContained(objects, 0xAu, Player, slot: 0);
SeedContained(objects, 0xBu, Player, slot: 1);
var selection = new SelectionState();
Bind(layout, objects, selection: selection);
grid.GetItem(0)!.Clicked!();
Assert.Equal(0xAu, selection.SelectedObjectId);
Assert.True(grid.GetItem(0)!.Selected);
selection.Select(0xBu, SelectionChangeSource.World);
Assert.False(grid.GetItem(0)!.Selected);
Assert.True(grid.GetItem(1)!.Selected);
}
[Fact] [Fact]
public void TargetMode_suppressesSelectedSquare_onPendingSource() public void TargetMode_suppressesSelectedSquare_onPendingSource()
{ {
@ -439,6 +464,7 @@ public class InventoryControllerTests
InventoryController.Bind(layout, objects, () => Player, InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u, iconIds: (_, _, _, _, _) => 0u,
strength: () => 100, strength: () => 100,
selection: new SelectionState(),
datFont: null, datFont: null,
itemInteraction: interaction); itemInteraction: interaction);
grid.GetItem(0)!.Clicked!(); grid.GetItem(0)!.Clicked!();
@ -469,7 +495,7 @@ public class InventoryControllerTests
(ItemType type, uint icon)? mainPackCall = null; (ItemType type, uint icon)? mainPackCall = null;
InventoryController.Bind(layout, objects, () => Player, InventoryController.Bind(layout, objects, () => Player,
iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; }, iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; },
strength: () => 100, datFont: null); strength: () => 100, selection: new SelectionState(), datFont: null);
// Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons // Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22). // IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).

View file

@ -110,6 +110,7 @@ public class InventoryFrameImportProbe
playerGuid: static () => 0u, playerGuid: static () => 0u,
iconIds: static (_, _, _, _, _) => 0u, iconIds: static (_, _, _, _, _) => 0u,
strength: static () => 100, strength: static () => 100,
selection: new AcDream.Core.Selection.SelectionState(),
datFont: null, datFont: null,
onClose: () => closes++); onClose: () => closes++);

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Items; using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -34,11 +35,13 @@ public class PaperdollControllerTests
} }
private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects,
List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u) List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u,
SelectionState? selection = null)
=> PaperdollController.Bind(layout, objects, () => Player, => PaperdollController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0x1234u, iconIds: (_, _, _, _, _) => 0x1234u,
sendWield: wields is null ? null : (i, m) => wields.Add((i, m)), sendWield: wields is null ? null : (i, m) => wields.Add((i, m)),
emptySlotSprite: emptySlot); emptySlotSprite: emptySlot,
selection: selection ?? new SelectionState());
private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc)
{ {
@ -63,6 +66,21 @@ public class PaperdollControllerTests
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
} }
[Fact]
public void ClickEquippedItem_updatesSharedSelection()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
var selection = new SelectionState();
Bind(layout, objects, selection: selection);
lists[HeadSlot].Cell.Clicked!();
Assert.Equal(0xA01u, selection.SelectedObjectId);
Assert.True(lists[HeadSlot].Cell.Selected);
}
[Fact] [Fact]
public void Populate_matches_a_weapon_into_the_composite_slot() public void Populate_matches_a_weapon_into_the_composite_slot()
{ {

View file

@ -4,6 +4,7 @@ using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.App.UI; using AcDream.App.UI;
using AcDream.App.UI.Layout; using AcDream.App.UI.Layout;
using AcDream.Core.Selection;
using Xunit; using Xunit;
namespace AcDream.App.Tests.UI.Layout; namespace AcDream.App.Tests.UI.Layout;
@ -65,7 +66,7 @@ public class SelectedObjectControllerTests
private sealed class Harness private sealed class Harness
{ {
public Action<uint?>? SelectionHandler; public readonly SelectionState Selection = new();
public Action<uint, float>? HealthHandler; public Action<uint, float>? HealthHandler;
public readonly List<uint> QueryHealthCalls = new(); public readonly List<uint> QueryHealthCalls = new();
@ -75,17 +76,19 @@ public class SelectedObjectControllerTests
public readonly Dictionary<uint, bool> HasHealthMap = new(); public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, uint> StackMap = new(); public readonly Dictionary<uint, uint> StackMap = new();
public void FireSelection(uint? g) => SelectionHandler?.Invoke(g); public void FireSelection(uint? g)
{
if (g is { } guid)
Selection.Select(guid, SelectionChangeSource.System);
else
Selection.Clear(SelectionChangeSource.System);
}
public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct);
public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null)
=> SelectedObjectController.Bind( => SelectedObjectController.Bind(
layout, layout,
subscribeSelectionChanged: h => SelectionHandler = h, selection: Selection,
unsubscribeSelectionChanged: h =>
{
if (SelectionHandler == h) SelectionHandler = null;
},
subscribeHealthChanged: h => HealthHandler = h, subscribeHealthChanged: h => HealthHandler = h,
unsubscribeHealthChanged: h => unsubscribeHealthChanged: h =>
{ {
@ -362,7 +365,6 @@ public class SelectedObjectControllerTests
h.NameMap[0x12345678u] = "Something"; h.NameMap[0x12345678u] = "Something";
var c = h.Bind(layout); var c = h.Bind(layout);
Assert.NotNull(h.SelectionHandler);
Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u))); Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u)));
Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f))); Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f)));
Assert.Null(Record.Exception(() => c.Tick(0.5))); Assert.Null(Record.Exception(() => c.Tick(0.5)));
@ -425,7 +427,6 @@ public class SelectedObjectControllerTests
h.FireSelection(0xAA09u); h.FireSelection(0xAA09u);
h.FireHealth(0xAA09u, 0.5f); h.FireHealth(0xAA09u, 0.5f);
Assert.Null(h.SelectionHandler);
Assert.Null(h.HealthHandler); Assert.Null(h.HealthHandler);
Assert.False(healthMeterEl.Visible); Assert.False(healthMeterEl.Visible);
Assert.Empty(h.QueryHealthCalls); Assert.Empty(h.QueryHealthCalls);

View file

@ -32,6 +32,7 @@ public sealed class RetailUiInteractionFlowTests
public readonly List<uint> Uses = new(); public readonly List<uint> Uses = new();
public readonly List<(uint Source, uint Target)> UseWithTarget = new(); public readonly List<(uint Source, uint Target)> UseWithTarget = new();
public readonly List<(uint Item, uint Mask)> Wields = new(); public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly AcDream.Core.Selection.SelectionState Selection = new();
public readonly List<uint> Drops = new(); public readonly List<uint> Drops = new();
public long Now = 10_000; public long Now = 10_000;
@ -149,6 +150,7 @@ public sealed class RetailUiInteractionFlowTests
playerGuid: () => Player, playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u, iconIds: static (_, _, _, _, _) => 0x1234u,
strength: () => 100, strength: () => 100,
selection: Selection,
datFont: null, datFont: null,
itemInteraction: interaction); itemInteraction: interaction);
@ -167,6 +169,7 @@ public sealed class RetailUiInteractionFlowTests
Objects, Objects,
playerGuid: () => Player, playerGuid: () => Player,
iconIds: static (_, _, _, _, _) => 0x1234u, iconIds: static (_, _, _, _, _) => 0x1234u,
selection: Selection,
sendWield: (item, mask) => Wields.Add((item, mask)), sendWield: (item, mask) => Wields.Add((item, mask)),
emptySlotSprite: 0x06004D20u); emptySlotSprite: 0x06004D20u);

View file

@ -1,4 +1,5 @@
using AcDream.Core.Plugins; using AcDream.Core.Plugins;
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Plugins; namespace AcDream.Core.Tests.Plugins;
@ -30,6 +31,7 @@ public class PluginLoaderTests
public IPluginLogger Log { get; } = new StubLogger(); public IPluginLogger Log { get; } = new StubLogger();
public IGameState State { get; } = new StubState(); public IGameState State { get; } = new StubState();
public IEvents Events { get; } = new StubEvents(); public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new SelectionState();
public IUiRegistry Ui { get; } = new StubUiRegistry(); public IUiRegistry Ui { get; } = new StubUiRegistry();
} }

View file

@ -0,0 +1,100 @@
using AcDream.Core.Selection;
using AcDream.Plugin.Abstractions;
namespace AcDream.Core.Tests.Selection;
public sealed class SelectionStateTests
{
[Fact]
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
{
var state = new SelectionState();
var transitions = new List<SelectionTransition>();
state.Changed += transitions.Add;
Assert.True(state.Select(0x50000001u, SelectionChangeSource.World));
Assert.True(state.Select(0x50000002u, SelectionChangeSource.Radar));
Assert.Equal(0x50000002u, state.SelectedObjectId);
Assert.Equal(0x50000001u, state.PreviousObjectId);
Assert.Equal(0x50000001u, state.PreviousValidObjectId);
Assert.Equal(2, transitions.Count);
Assert.Equal(SelectionChangeSource.Radar, transitions[1].Source);
Assert.Equal(SelectionChangeReason.Selected, transitions[1].Reason);
}
[Fact]
public void Select_SameObject_IsDeduplicated()
{
var state = new SelectionState();
int changed = 0;
state.Changed += _ => changed++;
Assert.True(state.Select(0x50000001u, SelectionChangeSource.World));
Assert.False(state.Select(0x50000001u, SelectionChangeSource.Inventory));
Assert.Equal(1, changed);
Assert.Null(state.PreviousObjectId);
}
[Fact]
public void SelectPrevious_SwapsCurrentAndPrevious()
{
var state = new SelectionState();
state.Select(0x50000001u, SelectionChangeSource.World);
state.Select(0x50000002u, SelectionChangeSource.World);
Assert.True(state.SelectPrevious());
Assert.Equal(0x50000001u, state.SelectedObjectId);
Assert.Equal(0x50000002u, state.PreviousObjectId);
}
[Fact]
public void ClearRemoved_PreservesRemovedObjectAsPreviousValid()
{
var state = new SelectionState();
SelectionTransition transition = default;
state.Changed += value => transition = value;
state.Select(0x50000001u, SelectionChangeSource.World);
Assert.True(state.Clear(
SelectionChangeSource.System,
SelectionChangeReason.SelectedObjectRemoved));
Assert.Null(state.SelectedObjectId);
Assert.Equal(0x50000001u, state.PreviousObjectId);
Assert.Equal(0x50000001u, state.PreviousValidObjectId);
Assert.Equal(SelectionChangeReason.SelectedObjectRemoved, transition.Reason);
}
[Fact]
public void PluginService_UsesSameStateAndEventStream()
{
var state = new SelectionState();
ISelectionService plugin = state;
SelectionChangedEvent change = default;
plugin.Changed += value => change = value;
Assert.True(plugin.Select(0x50000001u));
Assert.Equal(0x50000001u, state.SelectedObjectId);
Assert.Equal(0x50000001u, plugin.SelectedObjectId);
Assert.Null(change.PreviousObjectId);
Assert.Equal(0x50000001u, change.SelectedObjectId);
}
[Fact]
public void PluginListenerFailure_DoesNotEscapeOrBlockOtherPlugins()
{
var state = new SelectionState();
ISelectionService plugin = state;
int delivered = 0;
plugin.Changed += _ => throw new InvalidOperationException("plugin failure");
plugin.Changed += _ => delivered++;
Assert.Null(Record.Exception(() => plugin.Select(0x50000001u)));
Assert.Equal(1, delivered);
Assert.Equal(0x50000001u, state.SelectedObjectId);
}
}