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

17 KiB

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::HandleSelectionChangeddocs/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)

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:

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)

/// <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):

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 0x01C0GameEventWiringCombatState.OnUpdateHealth → cache → meter Fill poll reads GetHealthPercent → bar fills. Deselect / despawn → SelectionChanged(null) → strip cleared.

Error handling / edge cases

  • Unknown guidGetHealthPercent returns 1.0 (full) until the QueryHealth reply arrives.
  • Selected entity despawns → existing despawn-clear sets SelectedGuid = nullSelectionChanged(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.