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.
Plugins register retained gameplay markup through the BCL-only
`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
`IRetainedPanelController` lifecycle owner. Multi-controller windows use
@ -164,6 +168,7 @@ src/
IPluginHost.cs -> done
IGameState.cs -> done
IEvents.cs -> done
ISelectionService.cs -> done
AcDream.App/ Layer 1 + Layer 4 wiring
Rendering/

View file

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

View file

@ -979,8 +979,9 @@ implementation patch.
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
the complete `DetermineUseResult` classification, container post-actions,
`AttemptPlaceIn3D`, and retail busy semantics.
6. Selection remains owned by `GameWindow._selectedGuid`; the previously documented
`Core.SelectionState` file is absent in this worktree.
6. **Resolved 2026-07-11:** selection is owned by Core `SelectionState` and shared
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
also includes `0x01..0x06` and `0x0A..0x0F`.
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
- [ ] 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.
- [ ] 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.
- [ ] Delete panel-local competing selection state except transient hover/row focus.
- [ ] Add one App interaction-state owner for `None`, `Use`, `Examine`, and
- [x] Delete panel-local competing selection state except transient hover/row focus.
- [x] Add one App interaction-state owner for `None`, `Use`, `Examine`, and
`UseItemOnTarget(sourceGuid)`; Core selection remains session truth while target
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
- [ ] 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
paths live behind the runtime. The live Debug gate passed across two graceful
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 AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
public AppPluginHost(
IPluginLogger log,
IGameState state,
IEvents events,
ISelectionService selection,
IUiRegistry ui)
{
Log = log;
State = state;
Events = events;
Selection = selection;
Ui = ui;
}
public IPluginLogger Log { get; }
public IGameState State { get; }
public IEvents Events { get; }
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; }
}

View file

@ -31,8 +31,10 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var selection = new AcDream.Core.Selection.SelectionState();
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");
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); }
}
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
using var window = new GameWindow(
runtimeOptions, worldGameState, worldEvents, selection, uiRegistry);
window.Run();
}
finally

View file

@ -16,6 +16,7 @@ public sealed class GameWindow : IDisposable
private readonly string _datDir;
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
@ -1032,24 +1033,6 @@ public sealed class GameWindow : IDisposable
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
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
// once the local auto-walk overlay reports arrival (body has finished
// 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 _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)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_uiRegistry = uiRegistry;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
@ -1525,13 +1513,13 @@ public sealed class GameWindow : IDisposable
// B.7 Vivid Target Indicator — corner-triangle highlight
// around the currently-selected entity. Delegates pull
// live state from this GameWindow instance every frame:
// - selected guid → _selectedGuid (set by PickAndStoreSelection)
// - selected guid → shared SelectionState
// - entity resolver → position from _entitiesByServerGuid +
// itemType from ClientObjectTable (Objects) + last spawn
// - camera → _cameraController.Active or (zero) when not
// yet ready, in which case the panel bails on viewport==0.
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
selectedGuidProvider: () => _selectedGuid,
selectedGuidProvider: () => _selection.SelectedObjectId,
entityResolver: guid =>
{
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
@ -2078,7 +2066,7 @@ public sealed class GameWindow : IDisposable
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selectedGuid,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI);
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),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
guid => SelectedGuid = guid,
_selection,
SetRetailUiLocked),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
@ -2114,8 +2102,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController,
(index, guid) => _liveSession?.SendAddShortcut(index, guid),
index => _liveSession?.SendRemoveShortcut(index),
handler => SelectionChanged += handler,
handler => SelectionChanged -= handler,
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
@ -2137,7 +2124,8 @@ public sealed class GameWindow : IDisposable
(item, container, placement) =>
_liveSession?.SendPutItemInContainer(item, container, placement),
(item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
_itemInteractionController),
_itemInteractionController,
_selection),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
@ -4478,8 +4466,12 @@ public sealed class GameWindow : IDisposable
_remoteLastMove.Remove(serverGuid);
_entitiesByServerGuid.Remove(serverGuid);
_lastSpawnByGuid.Remove(serverGuid);
if (_selectedGuid == serverGuid)
SelectedGuid = null;
if (_selection.SelectedObjectId == serverGuid)
{
_selection.Clear(
AcDream.Core.Selection.SelectionChangeSource.System,
AcDream.Core.Selection.SelectionChangeReason.SelectedObjectRemoved);
}
// A7 indoor lighting: release this entity's owned lights on EVERY
// removal, including the respawn-dedup path (former logDelete=false).
@ -11874,6 +11866,10 @@ public sealed class GameWindow : IDisposable
SelectClosestCombatTarget(showToast: true);
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPreviousSelection:
_selection.SelectPrevious();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
@ -11903,7 +11899,7 @@ public sealed class GameWindow : IDisposable
break;
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
if (_selectedGuid is uint pickupTarget)
if (_selection.SelectedObjectId is uint pickupTarget)
SendPickUp(pickupTarget);
else
_debugVm?.AddToast("Nothing selected");
@ -11979,7 +11975,7 @@ public sealed class GameWindow : IDisposable
private uint? GetSelectedOrClosestCombatTarget()
{
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected))
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
return selected;
return SelectClosestCombatTarget(showToast: false);
@ -12066,7 +12062,7 @@ public sealed class GameWindow : IDisposable
return;
}
SelectedGuid = guid;
_selection.Select(guid, AcDream.Core.Selection.SelectionChangeSource.World);
string label = DescribeLiveEntity(guid);
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
// 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()
{
if (_selectedGuid is not uint sel)
if (_selection.SelectedObjectId is not uint sel)
{
_debugVm?.AddToast("Nothing selected");
return;
@ -12503,7 +12499,10 @@ public sealed class GameWindow : IDisposable
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)
{
string label = DescribeLiveEntity(selected);
@ -13592,6 +13591,8 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
_uiHost = null;
_itemInteractionController?.Dispose();
_itemInteractionController = null;
// Phase A.1: join the streamer worker thread before tearing down GL
// 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);
var iconIds = MakeIconIds(stack);
var selection = new AcDream.Core.Selection.SelectionState();
InventoryController.Bind(
layout,
@ -120,6 +121,7 @@ public static class FixtureProvider
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
strength: () => 100,
selection: selection,
datFont: stack.VitalsDatFont,
contentsEmptySprite: contentsEmpty,
sideBagEmptySprite: sideBagEmpty,
@ -133,6 +135,7 @@ public static class FixtureProvider
objects,
playerGuid: () => SampleData.PlayerGuid,
iconIds: iconIds,
selection: selection,
sendWield: null, // no live session in the studio
emptySlotSprite: contentsEmpty,
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
/// ItemHolder::UseObject fragments in each panel.
/// </summary>
public sealed class ItemInteractionController
public sealed class ItemInteractionController : IDisposable
{
private static readonly EquipMask[] AutoEquipOrder =
{
@ -55,8 +55,10 @@ public sealed class ItemInteractionController
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<string>? _toast;
private readonly InteractionState _interactionState;
private long _lastUseMs = long.MinValue / 2;
private bool _disposed;
public ItemInteractionController(
ClientObjectTable objects,
@ -66,7 +68,8 @@ public sealed class ItemInteractionController
Action<uint, uint>? sendWield,
Action<uint>? sendDrop,
Func<long>? nowMs = null,
Action<string>? toast = null)
Action<string>? toast = null,
InteractionState? interactionState = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -76,15 +79,23 @@ public sealed class ItemInteractionController
_sendDrop = sendDrop;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
}
public event Action? StateChanged;
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)
=> itemGuid != 0 && itemGuid == PendingSourceItem;
@ -188,8 +199,7 @@ public sealed class ItemInteractionController
private void EnterTargetMode(uint sourceGuid)
{
PendingSourceItem = sourceGuid;
StateChanged?.Invoke();
_interactionState.EnterUseItemOnTarget(sourceGuid);
var name = _objects.Get(sourceGuid)?.Name;
if (!string.IsNullOrWhiteSpace(name))
_toast?.Invoke($"Choose a target for the {name}");
@ -197,8 +207,17 @@ public sealed class ItemInteractionController
private void ClearTargetMode()
{
PendingSourceItem = 0;
StateChanged?.Invoke();
_interactionState.Clear();
}
private void OnInteractionModeChanged(InteractionModeTransition _)
=> StateChanged?.Invoke();
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_interactionState.Changed -= OnInteractionModeChanged;
}
private bool ConsumeUseThrottle()

View file

@ -2,6 +2,7 @@ using System;
using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
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 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>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
@ -70,6 +71,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
SelectionState selection,
Func<string>? ownerName,
UiDatFont? datFont,
uint contentsEmptySprite,
@ -90,6 +92,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
WindowChromeController.BindCloseButton(layout, onClose);
@ -152,8 +155,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged += OnInteractionStateChanged;
@ -184,6 +188,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
SelectionState selection,
UiDatFont? datFont,
Func<string>? ownerName = null,
uint contentsEmptySprite = 0u,
@ -194,14 +199,27 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint, int>? sendPutItemInContainer = null,
ItemInteractionController? itemInteraction = 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,
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction,
onClose);
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)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
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>
private bool Concerns(ClientObject o)
@ -402,8 +420,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private void SelectItem(uint guid)
{
if (guid == 0) return;
_selectedItem = guid;
ApplyIndicators();
_selection.Select(guid, SelectionChangeSource.Inventory);
}
/// <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)
{
if (guid == 0) return;
_selectedItem = guid; // the container cell is also the selected item
_selection.Select(guid, SelectionChangeSource.Inventory);
uint open = EffectiveOpen();
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);
if (cell is null) continue;
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;
}
}
@ -533,8 +552,9 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_disposed = true;
_objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
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 Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly SelectionState _selection;
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -86,11 +88,13 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
private PaperdollController(
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)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_itemInteraction = itemInteraction;
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
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.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.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 = () =>
{
if (list.Cell.ItemId != 0)
@ -119,6 +132,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
_selection.Changed += OnSelectionChanged;
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
foreach (var id in ArmorSlotElementIds)
@ -154,14 +168,18 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
public static PaperdollController Bind(
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,
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 OnObjectMoved(ClientObject o, uint from, uint to)
{ 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
/// 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);
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)
@ -251,5 +279,6 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
_selection.Changed -= OnSelectionChanged;
}
}

View file

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

View file

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

View file

@ -24,7 +24,7 @@ namespace AcDream.App.UI;
/// on internal state types:
/// </para>
/// <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
/// <see cref="TargetInfo"/> for a given guid, or <c>null</c> if
/// 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; }
IGameState State { get; }
IEvents Events { get; }
ISelectionService Selection { 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.IsPendingSource(0x50000A01u));
Assert.Equal(InteractionModeKind.UseItemOnTarget,
h.Controller.InteractionState.Current.Kind);
Assert.Equal(0x50000A01u,
h.Controller.InteractionState.Current.SourceObjectId);
Assert.Empty(h.UseWithTarget);
}

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -56,7 +57,8 @@ public class InventoryControllerTests
int? strength = 100, List<uint>? uses = null, List<uint>? closes = null,
List<(uint item, uint container, int placement)>? puts = null,
string? ownerName = null,
Action? onClose = null)
Action? onClose = null,
SelectionState? selection = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
@ -64,7 +66,8 @@ public class InventoryControllerTests
sendUse: uses is null ? null : g => uses.Add(g),
sendNoLongerViewing: closes is null ? null : g => closes.Add(g),
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)
{
@ -316,7 +319,8 @@ public class InventoryControllerTests
{
var (layout, grid, containers, top, _, _, _, _) = BuildLayout();
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);
Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell
@ -414,6 +418,27 @@ public class InventoryControllerTests
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]
public void TargetMode_suppressesSelectedSquare_onPendingSource()
{
@ -439,6 +464,7 @@ public class InventoryControllerTests
InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
itemInteraction: interaction);
grid.GetItem(0)!.Clicked!();
@ -469,7 +495,7 @@ public class InventoryControllerTests
(ItemType type, uint icon)? mainPackCall = null;
InventoryController.Bind(layout, objects, () => Player,
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
// IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22).

View file

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

View file

@ -2,6 +2,7 @@ using System.Collections.Generic;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -34,11 +35,13 @@ public class PaperdollControllerTests
}
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,
iconIds: (_, _, _, _, _) => 0x1234u,
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)
{
@ -63,6 +66,21 @@ public class PaperdollControllerTests
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]
public void Populate_matches_a_weapon_into_the_composite_slot()
{

View file

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

View file

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

View file

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