# D.5.3a — Selected-object meter (Stream A) — design **Date:** 2026-06-18 **Phase:** D.5.3a (the action bar's bottom strip). Roadmap: D.2b retail-UI track, issue #140. **Branch:** `claude/hopeful-maxwell-214a12`. **Handoff parent:** `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §2. ## Goal When the player selects a world object (LMB pick → `PickAndStoreSelection`, or Tab/Q combat-target → `SelectClosestCombatTarget`), the action bar's bottom strip shows: - the selected object's **name** (always, for any selection), and - a live **Health** meter — only for targets that are a player, a pet, or attackable (retail's `IsPlayer() || pet_owner || ObjectIsAttackable()` gate). On deselect (or despawn of the selected object) the strip clears. **Out of scope (deferred):** the **Mana** meter (`0x100001A2`, issue #140 — owned-item-only), the stack-size entry box + slider (`0x100001A3`/`0x100001A4`), and the formatted stack-count name suffix. Mana is a tracked feature gap, not a runtime deviation. ## Retail oracle `gmToolbarUI::HandleSelectionChanged` — `docs/research/named-retail/acclient_2013_pseudo_c.txt:198635`. Verbatim behavior (the spec follows this exactly): 1. **Clear-then-populate.** On any selection change (`m_iidSelectedObject != selectedID`): - `UIElement_Text::SetText(m_pSelObjectName, "")` — clear the name. - `m_pSelObjectField->SetState(0)` — reset the overlay field (`0x100001A0`) to its blank DirectState. - If the health meter was visible: `Event_QueryHealth(0)` (cancel) + `SetVisible(0)`. - If the mana meter was visible: `Event_QueryItemMana(0)` + `SetVisible(0)`. - Hide stack entry box + slider. 2. **Selection == 0** → set the use-object button to disabled state and return (strip stays cleared). 3. **Selection != 0** (weenie object resolved): - Name = `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)`. `_stackSize <= 1` → plain name; `_stackSize > 1` → formatted with count (**deferred**). - For a non-stack (`_stackSize == 0 || _stackSize <= 1`): - `eax_29 = IsPlayer()`; if not player and no `pet_owner`, `eax_32 = ObjectIsAttackable(selectedID)`. - **If `IsPlayer || pet_owner != 0 || attackable`:** `m_pSelObjectField->SetState(0x1000000b)` (the "ObjectSelected" state) **and** `CM_Combat::Event_QueryHealth(m_iidSelectedObject)`. (Health meter becomes visible via the subsequent `RecvNotice_UpdateObjectHealth` path, which `SetVisible(1)`s it and sets the fill — see handoff §2.) - **Else:** `m_pSelObjectField->SetState(0x1000000b)`; if `IsOwnedByPlayer`, `CM_Item::Event_QueryItemMana(selectedID)` (**mana deferred**). Supporting anchors: `RecvNotice_UpdateObjectHealth` (`:196213`) → `SetAttribute_Float(meter, 0x69, pct)` (property `0x69` = fill ratio); `UIElement_Meter::Initialize` (`:123328`), `OnSetAttribute` (`:123712`). State/sprite ids (from `.layout-dumps/toolbar-0x21000016.txt`): the overlay field `0x100001A0` carries states **ObjectSelected** (id `0x1000000b`, sprite `0x06001937`) and **StackedItemSelected** (sprite `0x06004CF4`); health meter `0x100001A1` back-track DirectState `0x0600193E`, fill child `0x00000002` DirectState `0x0600193F`; mana meter `0x100001A2` back `0x060022D5` / fill `0x060022D6`. ## Current-code facts (verified at HEAD) - **Selection state** is a private field `_selectedGuid` (`GameWindow.cs` ~`:848`), assigned at 3 sites: `PickAndStoreSelection` (~`:11571`), `SelectClosestCombatTarget` (~`:11961`), and the despawn-clear (`if (_selectedGuid == serverGuid) _selectedGuid = null;` ~`:3710`). No change event exists. `TargetIndicatorPanel` polls it via `selectedGuidProvider: () => _selectedGuid`. - **`CombatState`** (`AcDream.Core.Combat`) has `GetHealthPercent(guid)` (returns `1f` if unseen) and `HealthChanged`. `UpdateHealth (0x01C0)` → `OnUpdateHealth` is already wired (`GameEventWiring`). - **`SocialActions.BuildQueryHealth(uint seq, uint targetGuid)`** exists (opcode `0x01BF`, replies `UpdateHealth 0x01C0`). No `WorldSession.SendQueryHealth` wrapper yet. - **`IsLiveCreatureTarget(uint guid)`** (`GameWindow.cs` ~`:11979`): not-self + in-world + `ItemType.Creature` flag. Used to gate Tab/Q targeting and `UseItemByGuid`. - **`VitalsController.Bind`** is the proven bind pattern: find meter by id, set `m.Fill = () => pct()` (polled each draw), attach a centered `UiText` child (dat font, `ClickThrough`) for text. - **`UiMeter.DrawHBar`** already renders a *single full-width sprite* correctly: with `tile`/`right` ids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. **No `UiMeter` change is needed** for the single-image toolbar meters. - **`DatWidgetFactory.BuildMeter`** assumes **2** Type-3 slice containers (vitals 3-slice). The toolbar selected-object meters have **1** Type-3 child (the fill, on its own DirectState) with the back-track on the *meter element's own* DirectState → the `containers.Count != 2` branch mishandles them. - **`UiDatElement.ActiveState`** (string) drives `ActiveMedia()`; `""` = blank DirectState. This is the overlay-state switch for `0x100001A0`. - **`ClientObject`** exposes `Name` and `StackSize`. `ClientObjectTable.Get(guid)` returns the object (or null). `ToolbarController` already binds with `Objects` (the `ClientObjectTable`). - **`ToolbarController.HiddenIds`** currently hides `0x100001A1` (health), `0x100001A2` (mana), `0x100001A4` (stack slider) at bind. ## Decisions (settled in brainstorm) - **Selection signal: event via property setter.** Convert `_selectedGuid` → a `SelectedGuid` property whose setter fires `event Action? SelectionChanged` only when the value actually changes. Replace the 3 assignment sites with the property; reads unchanged. (Retail-faithful — selection is event-driven; the setter centralizes the fire and auto-dedups.) - **Send `QueryHealth (0x01BF)` on select** for health-bearing targets (retail-faithful; builder exists). Continuous updates still come from server `UpdateHealth` broadcasts. - **Mana deferred** (issue #140). ## Architecture Three new units + one refactor + one wiring change. Each unit is independently testable. ### 1. `GameWindow.SelectedGuid` property + `SelectionChanged` event (refactor) ```csharp public event Action? SelectionChanged; private uint? _selectedGuid; private uint? SelectedGuid { get => _selectedGuid; set { if (_selectedGuid == value) return; // dedup: fire only on real change _selectedGuid = value; SelectionChanged?.Invoke(value); } } ``` Replace the 3 *write* sites (`_selectedGuid = …`) with `SelectedGuid = …`. Leave all *read* sites (`_selectedGuid is uint`, `() => _selectedGuid`, the despawn comparison's read half) on the field — they observe the same backing store. The despawn-clear becomes `if (_selectedGuid == serverGuid) SelectedGuid = null;`. ### 2. `DatWidgetFactory.BuildMeter` — handle the single-image meter shape After ordering the Type-3 child containers by `ReadOrder`: - **`containers.Count >= 2`** (vitals): unchanged — `SliceIds(containers[0])` → Back\*, `SliceIds(containers[1])` → Front\*. - **`containers.Count == 1`** (toolbar selected-object meter): single-image back+fill. - `m.BackLeft = info.StateMedia[""].File` (the meter element's own DirectState back-track), `BackTile = BackRight = 0`. - `m.FrontLeft = containers[0].StateMedia[""].File` (the fill child's own DirectState), `FrontTile = FrontRight = 0`. - The fill child has **no** image grandchildren, so `SliceIds` must **not** be used for it; read the container's own `StateMedia[""]` directly. - **`containers.Count == 0`**: leave the warning (genuinely malformed). Keep a `Console.WriteLine` only for the genuinely-unexpected `Count == 0` (or `> 2`) case; the `Count == 1` case is now a handled shape, not a warning. `UiMeter` is unchanged — `DrawHBar(BackLeft=fullSprite,0,0,clipW=Width)` draws the back once, `DrawHBar(FrontLeft=fullSprite,0,0,clipW=Width*p)` UV-crops the fill to the health fraction. ### 3. `SelectedObjectController` (new — `src/AcDream.App/UI/Layout/SelectedObjectController.cs`) The `HandleSelectionChanged` analogue. A sealed class (like `ToolbarController`) bound once. **Element ids** (constants): name `0x1000019F`, overlay field `0x100001A0`, health meter `0x100001A1`. (`0x100001A2` mana / `0x100001A3`/`0x100001A4` stack are not touched here — deferred.) **`Bind` signature:** ```csharp public static SelectedObjectController Bind( ImportedLayout layout, Action> subscribeSelectionChanged, // hands the controller its handler to register Func isHealthTarget, // IsLiveCreatureTarget proxy Func name, // ClientObjectTable.Get(g)?.Name Func healthPercent, // CombatState.GetHealthPercent Func stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state) Action sendQueryHealth, // WorldSession.SendQueryHealth (no-op if offline) UiDatFont? datFont) ``` `subscribeSelectionChanged` is invoked once with the controller's `OnSelectionChanged` handler so the host can do `c => SelectionChanged += c` without the controller referencing `GameWindow`. (Keeps the Core-clean delegate-seam style of `TargetIndicatorPanel`.) **Bind-time setup:** - Find the three elements (silently skip any that are absent — partial/test layouts). - `_healthMeter.Visible = false` (this controller now **owns** the meter's initial-hidden state). - Attach a centered `UiText` child to the name element (mirror `VitalsController.BindMeter`'s number attach): `Centered`, `DatFont = datFont`, `ClickThrough`, `AcceptsFocus=false`, `IsEditControl=false`, `CapturesPointerDrag=false`, anchored to fill the parent, `LinesProvider = () =>` the current name as a single white line (empty → no lines). Color: white for D.5.3a (`new Vector4(1,1,1,1)`). - `_healthMeter.Fill = () => _current is uint g ? healthPercent(g) : 0f` (polled each draw). - Register the handler via `subscribeSelectionChanged(OnSelectionChanged)`. **`OnSelectionChanged(uint? guid)`** (mirrors the decomp's clear-then-populate): - **Clear first:** `_healthMeter.Visible = false`; overlay `ActiveState = ""`; `_currentName = null`. - Set `_current = guid`. - If `guid is null` → done (strip cleared). - Else: - `_currentName = name(guid)` (the name `UiText` reads this). - overlay `ActiveState = stackSize(guid) > 1 ? "StackedItemSelected" : "ObjectSelected"`. - If `isHealthTarget(guid)`: `_healthMeter.Visible = true`; `sendQueryHealth(guid)`. - (else: name + overlay only — friendly NPC / non-owned item / scenery.) State held: `_current` (uint?), `_currentName` (string?). The meter `Fill` + name `LinesProvider` read these closures, so the per-frame draw reflects live data without a tick. > **Note on the meter-visible timing.** Retail makes the health meter visible from > `RecvNotice_UpdateObjectHealth` (when the queried value arrives), not from > `HandleSelectionChanged` itself. acdream shows it immediately on select for a health target (the > fill polls `GetHealthPercent`, which is `1.0` until the `QueryHealth` reply lands a beat later). > This avoids a one-round-trip blank-then-pop and is visually indistinguishable for a full-HP target; > for a damaged target the bar corrects within one server round-trip. Recorded as a divergence row. ### 4. `WorldSession.SendQueryHealth(uint targetGuid)` (new) ```csharp /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). public void SendQueryHealth(uint targetGuid) { uint seq = NextGameActionSequence(); byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid); SendGameAction(body); } ``` (Pattern = `SendChangeCombatMode`, `WorldSession.cs:1134`.) ### 5. GameWindow wiring (minimal) After `ToolbarController.Bind` (the toolbar layout is in scope as `toolbarLayout`, dat font as `vitalsDatFont`): ```csharp AcDream.App.UI.Layout.SelectedObjectController.Bind( toolbarLayout, subscribeSelectionChanged: h => SelectionChanged += h, isHealthTarget: IsLiveCreatureTarget, name: g => Objects.Get(g)?.Name, healthPercent: g => Combat.GetHealthPercent(g), stackSize: g => Objects.Get(g)?.StackSize ?? 0u, sendQueryHealth: g => _liveSession?.SendQueryHealth(g), datFont: vitalsDatFont); ``` Also: remove **only** `0x100001A1` from `ToolbarController.HiddenIds` — the health meter is now owned by `SelectedObjectController` (it hides A1 at bind, shows on a health-target select). `0x100001A2` (mana, deferred #140) and `0x100001A4` (stack slider, deferred) **stay** in `HiddenIds`: they have no controller yet, so they must stay hidden or their dat back-track sprites render as stray empty bars. (`HiddenIds = { 0x100001A2, 0x100001A4 }`.) Convert the `_selectedGuid` field → the `SelectedGuid` property (unit 1). ## Data flow select → `SelectedGuid` setter → `SelectionChanged(guid)` → `SelectedObjectController.OnSelectionChanged` → name + overlay set, meter shown (health target), `SendQueryHealth(guid)` → server `UpdateHealth 0x01C0` → `GameEventWiring` → `CombatState.OnUpdateHealth` → cache → meter `Fill` poll reads `GetHealthPercent` → bar fills. Deselect / despawn → `SelectionChanged(null)` → strip cleared. ## Error handling / edge cases - **Unknown guid** → `GetHealthPercent` returns `1.0` (full) until the `QueryHealth` reply arrives. - **Selected entity despawns** → existing despawn-clear sets `SelectedGuid = null` → `SelectionChanged(null)`. - **Partial / test layout** (missing elements) → controller silently skips absent elements (`VitalsController` pattern). - **No live session** → `_liveSession?.SendQueryHealth` no-ops. - **Re-select the same guid** → property setter dedups; no redundant query / re-show. ## Testing (conformance) All App-layer tests in `tests/AcDream.App.Tests/`; net test in `tests/AcDream.Core.Net.Tests/`. 1. **`DatWidgetFactoryTests`** (extend): feed a synthetic 1-container meter `ElementInfo` (back on the element's `StateMedia[""]`, fill on the single Type-3 child's `StateMedia[""]`) → assert `BackLeft == backFile`, `FrontLeft == fillFile`, `BackTile/BackRight/FrontTile/FrontRight == 0`, and no warning path taken. Add/keep a 2-container case asserting the vitals 3-slice path is unchanged. 2. **`SelectedObjectControllerTests`** (new — mirror `ToolbarControllerTests`): build a minimal `ImportedLayout` containing `0x1000019F`/`0x100001A0` (as `UiDatElement`)/`0x100001A1` (as `UiMeter`). Use recording delegates. Assert: - bind → health meter `Visible == false`, a name `UiText` child attached. - select health target → meter `Visible == true`, overlay `ActiveState == "ObjectSelected"`, name provider returns the object name, `sendQueryHealth` invoked exactly once with the guid. - select stack (`stackSize > 1`) → overlay `ActiveState == "StackedItemSelected"`. - select non-health target → meter stays hidden, name set, `sendQueryHealth` **not** invoked. - deselect (`null`) → meter hidden, overlay `ActiveState == ""`, name provider returns empty. - re-fire same guid path is driven by the event, so the dedup is the property's job (covered in 3). 3. **`SendQueryHealth`** (net test): drive `WorldSession.SendQueryHealth(guid)` through the existing send-capture seam (the same harness `SendChangeCombatMode` / chat sends use) and assert the captured GameAction bytes equal `SocialActions.BuildQueryHealth(seq, guid)`. 4. **`SelectedGuid` dedup**: the property is on `GameWindow` (not unit-testable in isolation). Its contract — "fires once on change, never on same value, fires `null` on clear" — is asserted indirectly by test 2's reliance on single-fire and confirmed at the visual gate. No standalone test. ## Divergence register rows (add in the implementation commit) - **Health-meter gate approximation.** Retail shows the health meter for `IsPlayer() || pet_owner || ObjectIsAttackable()`; acdream uses `IsLiveCreatureTarget` (the `ItemType.Creature` flag). Risk: a friendly (non-attackable) NPC shows a health meter where retail would show name+overlay only. Cite `SelectedObjectController` + `HandleSelectionChanged:198754`. - **Meter-visible timing.** acdream shows the health meter on select; retail shows it from `RecvNotice_UpdateObjectHealth` when the queried value arrives. Risk: a freshly-selected off-screen-damaged target reads full for one server round-trip. Cite `SelectedObjectController.OnSelectionChanged` + `HandleSelectionChanged:198757`. ## Acceptance criteria - `dotnet build` green; `dotnet test` green (new + existing). - Every AC-specific behavior cites its named-retail anchor in comments. - Divergence rows added. - Visual gate (user): selecting a creature shows its name + a correct HP bar; deselecting clears the strip; selecting a non-creature object shows the name only.