diff --git a/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md b/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md new file mode 100644 index 00000000..61af1469 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md @@ -0,0 +1,46 @@ +# D.5.3a — Selected-object meter — implementation plan + +Spec: `docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md`. +Pre-approved by user 2026-06-18; subagent-driven, sequential (build-safe in one worktree). + +Mandatory per task: cite named-retail anchors in comments; `dotnet build` + the relevant +`dotnet test` green; match surrounding code style. No commits by subagents — the lead commits the +coherent set after the full build+test passes. + +## Task order (each builds on the accumulated working tree) + +### T1 — `WorldSession.SendQueryHealth` (+ net test) · project: `AcDream.Core.Net` +- Add `SendQueryHealth(uint targetGuid)` mirroring `SendChangeCombatMode` (`WorldSession.cs:1134`): + `NextGameActionSequence()` → `SocialActions.BuildQueryHealth(seq, guid)` → `SendGameAction(body)`. +- Test in `tests/AcDream.Core.Net.Tests/`: drive it through the existing send-capture seam used by the + other `WorldSession.Send*` tests; assert captured bytes == `BuildQueryHealth(seq, guid)`. +- Accept: `dotnet test` for `AcDream.Core.Net.Tests` green. + +### T2 — `DatWidgetFactory.BuildMeter` single-image shape (+ test) · project: `AcDream.App` +- Handle `containers.Count == 1`: `BackLeft = info.StateMedia[""].File`, + `FrontLeft = containers[0].StateMedia[""].File`, tile/right = 0. Keep `>= 2` (vitals) path unchanged. + Warn only on `Count == 0` / `Count > 2`. +- Extend `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`: 1-container synthetic meter + asserts Back/Front populated + others 0; 2-container case asserts vitals path unchanged. +- Accept: `dotnet test` for `AcDream.App.Tests` green. + +### T3 — `SelectedObjectController` (+ test) · project: `AcDream.App` +- New `src/AcDream.App/UI/Layout/SelectedObjectController.cs` per spec §3 (Bind signature, bind-time + setup, `OnSelectionChanged` clear-then-populate). Cite `HandleSelectionChanged:198635`. +- New `tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs` per spec §Testing item 2 + (mirror `ToolbarControllerTests` for building a minimal `ImportedLayout` + recording delegates). +- Accept: `dotnet test` for `AcDream.App.Tests` green. + +### T4 — GameWindow integration + register rows · project: `AcDream.App` (depends on T1, T3) +- Convert `_selectedGuid` field → `SelectedGuid` property + `SelectionChanged` event (spec §1); replace + the 3 write sites; leave read sites on the field. +- Remove `0x100001A1` + `0x100001A2` from `ToolbarController.HiddenIds` (keep `0x100001A4`). +- Wire `SelectedObjectController.Bind(...)` after `ToolbarController.Bind` (spec §5). +- Add the 2 divergence rows (spec §Divergence) to + `docs/architecture/retail-divergence-register.md`. +- Accept: full `dotnet build` + `dotnet test` green. + +## Then (lead) +- Adversarial Opus review of the full diff vs spec + decomp. +- Commit the coherent set to the branch; update roadmap/ISSUES if applicable; memory if a durable lesson. +- Stop for the user's visual gate (the acceptance test for this stream). diff --git a/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md b/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md new file mode 100644 index 00000000..1c22d391 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md @@ -0,0 +1,289 @@ +# 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 `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.