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>
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::HandleSelectionChanged — docs/research/named-retail/acclient_2013_pseudo_c.txt:198635.
Verbatim behavior (the spec follows this exactly):
- 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.
- Selection == 0 → set the use-object button to disabled state and return (strip stays cleared).
- 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 nopet_owner,eax_32 = ObjectIsAttackable(selectedID).- If
IsPlayer || pet_owner != 0 || attackable:m_pSelObjectField->SetState(0x1000000b)(the "ObjectSelected" state) andCM_Combat::Event_QueryHealth(m_iidSelectedObject). (Health meter becomes visible via the subsequentRecvNotice_UpdateObjectHealthpath, whichSetVisible(1)s it and sets the fill — see handoff §2.) - Else:
m_pSelObjectField->SetState(0x1000000b); ifIsOwnedByPlayer,CM_Item::Event_QueryItemMana(selectedID)(mana deferred).
- Name =
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.TargetIndicatorPanelpolls it viaselectedGuidProvider: () => _selectedGuid. CombatState(AcDream.Core.Combat) hasGetHealthPercent(guid)(returns1fif unseen) andHealthChanged.UpdateHealth (0x01C0)→OnUpdateHealthis already wired (GameEventWiring).SocialActions.BuildQueryHealth(uint seq, uint targetGuid)exists (opcode0x01BF, repliesUpdateHealth 0x01C0). NoWorldSession.SendQueryHealthwrapper yet.IsLiveCreatureTarget(uint guid)(GameWindow.cs~:11979): not-self + in-world +ItemType.Creatureflag. Used to gate Tab/Q targeting andUseItemByGuid.VitalsController.Bindis the proven bind pattern: find meter by id, setm.Fill = () => pct()(polled each draw), attach a centeredUiTextchild (dat font,ClickThrough) for text.UiMeter.DrawHBaralready renders a single full-width sprite correctly: withtile/rightids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. NoUiMeterchange is needed for the single-image toolbar meters.DatWidgetFactory.BuildMeterassumes 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 → thecontainers.Count != 2branch mishandles them.UiDatElement.ActiveState(string) drivesActiveMedia();""= blank DirectState. This is the overlay-state switch for0x100001A0.ClientObjectexposesNameandStackSize.ClientObjectTable.Get(guid)returns the object (or null).ToolbarControlleralready binds withObjects(theClientObjectTable).ToolbarController.HiddenIdscurrently hides0x100001A1(health),0x100001A2(mana),0x100001A4(stack slider) at bind.
Decisions (settled in brainstorm)
- Selection signal: event via property setter. Convert
_selectedGuid→ aSelectedGuidproperty whose setter firesevent Action<uint?>? SelectionChangedonly 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 serverUpdateHealthbroadcasts. - 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
SliceIdsmust not be used for it; read the container's ownStateMedia[""]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
UiTextchild to the name element (mirrorVitalsController.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; overlayActiveState = "";_currentName = null. - Set
_current = guid. - If
guid is null→ done (strip cleared). - Else:
_currentName = name(guid)(the nameUiTextreads 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 fromHandleSelectionChangeditself. acdream shows it immediately on select for a health target (the fill pollsGetHealthPercent, which is1.0until theQueryHealthreply 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 0x01C0
→ GameEventWiring → CombatState.OnUpdateHealth → cache → meter Fill poll reads
GetHealthPercent → bar fills. Deselect / despawn → SelectionChanged(null) → strip cleared.
Error handling / edge cases
- Unknown guid →
GetHealthPercentreturns1.0(full) until theQueryHealthreply arrives. - Selected entity despawns → existing despawn-clear sets
SelectedGuid = null→SelectionChanged(null). - Partial / test layout (missing elements) → controller silently skips absent elements
(
VitalsControllerpattern). - No live session →
_liveSession?.SendQueryHealthno-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/.
DatWidgetFactoryTests(extend): feed a synthetic 1-container meterElementInfo(back on the element'sStateMedia[""], fill on the single Type-3 child'sStateMedia[""]) → assertBackLeft == 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.SelectedObjectControllerTests(new — mirrorToolbarControllerTests): build a minimalImportedLayoutcontaining0x1000019F/0x100001A0(asUiDatElement)/0x100001A1(asUiMeter). Use recording delegates. Assert:- bind → health meter
Visible == false, a nameUiTextchild attached. - select health target → meter
Visible == true, overlayActiveState == "ObjectSelected", name provider returns the object name,sendQueryHealthinvoked exactly once with the guid. - select stack (
stackSize > 1) → overlayActiveState == "StackedItemSelected". - select non-health target → meter stays hidden, name set,
sendQueryHealthnot invoked. - deselect (
null) → meter hidden, overlayActiveState == "", 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).
- bind → health meter
SendQueryHealth(net test): driveWorldSession.SendQueryHealth(guid)through the existing send-capture seam (the same harnessSendChangeCombatMode/ chat sends use) and assert the captured GameAction bytes equalSocialActions.BuildQueryHealth(seq, guid).SelectedGuiddedup: the property is onGameWindow(not unit-testable in isolation). Its contract — "fires once on change, never on same value, firesnullon 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 usesIsLiveCreatureTarget(theItemType.Creatureflag). Risk: a friendly (non-attackable) NPC shows a health meter where retail would show name+overlay only. CiteSelectedObjectController+HandleSelectionChanged:198754. - Meter-visible timing. acdream shows the health meter on select; retail shows it from
RecvNotice_UpdateObjectHealthwhen the queried value arrives. Risk: a freshly-selected off-screen-damaged target reads full for one server round-trip. CiteSelectedObjectController.OnSelectionChanged+HandleSelectionChanged:198757.
Acceptance criteria
dotnet buildgreen;dotnet testgreen (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.