acdream/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md
Erik e8562fc4e2 docs(D.5.3a): spec + plan — selected-object meter (Stream A)
Brainstormed design for the action bar's bottom strip: name + Health meter
on selection (mana deferred #140). Decisions: SelectionChanged via property
setter; send QueryHealth(0x01BF) on select. Grounded in retail
gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198635) —
clear-then-populate, overlay state 0x1000000b, health gate
IsPlayer||pet||attackable. Render-bug fix is BuildMeter-only (single-image
back+fill meter; UiMeter already renders it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:19:14 +02:00

289 lines
17 KiB
Markdown

# 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<uint?>? 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<uint?>? 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<Action<uint?>> subscribeSelectionChanged, // hands the controller its handler to register
Func<uint, bool> isHealthTarget, // IsLiveCreatureTarget proxy
Func<uint, string?> name, // ClientObjectTable.Get(g)?.Name
Func<uint, float> healthPercent, // CombatState.GetHealthPercent
Func<uint, uint> stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state)
Action<uint> 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
/// <summary>Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0).</summary>
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 `0x100001A1` and `0x100001A2` from `ToolbarController.HiddenIds` (single-owner: the
selected-object meters are now owned by `SelectedObjectController`); `0x100001A4` (stack slider) stays
in `HiddenIds` (deferred). 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.