From fa1da02783f7912297f5cda61fdfd82c3135574d Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Jul 2026 11:16:21 +0200 Subject: [PATCH] feat(ui): port retail selected-item mana Parse and route the complete 0x0264 guid/fraction/valid response, send exact 0x0263 item-mana queries, and reproduce retail meter visibility plus guid-zero cancellation for mana and health selection changes. Keep the behavior in Core state and the retained selected-object controller with wire/state/UI conformance coverage. Co-Authored-By: Codex --- docs/ISSUES.md | 5 +- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 5 +- .../2026-05-10-phase-m-opcode-matrix.md | 2 +- ...18-d53-bar-finish-and-inventory-handoff.md | 5 + ...026-07-10-retail-ui-fidelity-completion.md | 14 ++- src/AcDream.App/Rendering/GameWindow.cs | 6 +- .../UI/Layout/SelectedObjectController.cs | 90 +++++++++++++++-- .../UI/Layout/ToolbarController.cs | 17 +--- src/AcDream.App/UI/RetailUiRuntime.cs | 7 ++ src/AcDream.Core.Net/GameEventWiring.cs | 13 ++- src/AcDream.Core.Net/Messages/GameEvents.cs | 13 ++- .../Messages/SocialActions.cs | 15 +++ src/AcDream.Core.Net/WorldSession.cs | 11 +++ src/AcDream.Core/Items/ItemManaState.cs | 35 +++++++ .../Layout/SelectedObjectControllerTests.cs | 96 ++++++++++++++++++- .../GameEventWiringTests.cs | 24 +++++ .../Messages/GameEventDispatcherTests.cs | 18 ++++ .../Messages/SocialActionsTests.cs | 16 ++++ .../WorldSessionCombatTests.cs | 13 +++ .../Items/ItemManaStateTests.cs | 33 +++++++ 21 files changed, 402 insertions(+), 38 deletions(-) create mode 100644 src/AcDream.Core/Items/ItemManaState.cs create mode 100644 tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c098e5de..a473c2e1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -2050,7 +2050,7 @@ See divergence register **AP-59**. ## #141 — Toolbar interactivity — selected-object display -**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count, entry/slider, and partial stack transfers visually confirmed 2026-07-11; mana still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140. +**Status:** IN PROGRESS (health/name/flash DONE and visually confirmed 2026-06-20; formatted stack count, entry/slider, and partial stack transfers visually confirmed 2026-07-11; mana implementation complete, live gate pending; exact PK/pet health policy remains). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140. **Severity:** MEDIUM **Filed:** 2026-06-17 **Component:** ui — D.5 toolbar / selection @@ -2058,9 +2058,10 @@ See divergence register **AP-59**. **Description:** The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there). **Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port. -- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46. +- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. Mana was deferred from this slice and lands in D.5.3c below. Divergence row AP-46 remains for exact health-target policy. - **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2). - **D.5.3b implementation (2026-07-11):** stacked selection formats the retail `"%d %hs"` count/name, now preserving the wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish, partial inventory split, and partial ground drop visually confirmed. +- **D.5.3c implementation (2026-07-11; live gate pending):** owned non-stack selections send retail `QueryItemMana (0x0263)`; `QueryItemManaResponse (0x0264)` now parses the trailing validity flag and flows through Core `ItemManaState`; valid matching responses reveal/fill authored meter `0x100001A2`, invalid responses cancel with guid zero, and changing selection cancels any visible mana or health query before hiding its meter. Stacked items stay exclusively on the split-control path. Named anchors: `CM_Item::Event_QueryItemMana @ 0x006A8610`, `DispatchUI_QueryItemManaResponse @ 0x006A84D0`, `gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0`. **Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`). diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 142c8941..1a47c155 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -204,7 +204,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps. | ~~AP-97~~ | **RETIRED 2026-07-10 (Wave 1 retained-widget foundation)** — the prior generic Device-timer premise was a decomp misread. Named retail polls the hovered element's tooltip deadline, then broadcasts global UI time message `3`; it does not expose the assumed arbitrary timer queue. `UiRoot.Tick` now preserves that order and subtree removal clears input/time ownership before another pulse. | `src/AcDream.App/UI/UiRoot.cs`; `IUiGlobalTimeListener.cs` | — | — | `UIElementManager::UseTime` and hover/focus paths, pinned in `docs/research/2026-07-10-retained-widget-foundations-pseudocode.md` | | ~~AP-98~~ | **RETIRED 2026-07-10 (Wave 2.3)** — typed handles/manager own lifecycle; every production/Studio window uses `RetailWindowFrame`; schema-v2 `UiWindowLayout` persists outer bounds, visibility, toolbar collapse, and chat maximize by character/resolution with legacy-radar migration, nearest-resolution fallback, clamping, and a pre-login `default` write guard. Chat maximize uses DAT 100–360 limits and preserves its lower edge when growing upward. | `src/AcDream.App/UI/RetailWindowManager.cs`; `RetailWindowLayoutPersistence.cs`; `RetailWindowHandle.cs`; `Layout/RetailWindowFrame.cs`; `ChatWindowController.cs`; `SettingsStore.cs` | — | — | `gmMainChatUI::HandleMaximizeButton @ 0x004CCE50`; retail `saveui/loadui` behavior | | ~~AP-99~~ | **RETIRED 2026-07-11 (Wave 3.2)** — Core `ItemInteractionPolicy` ports the complete ordered `DetermineUseResult`/`UseObject`/`AttemptPlaceIn3D` matrix; PWD flags and `CombatUse` survive CreateObject; component-pack membership comes from portal.dat; App owns throttle, UseDone-balanced busy state, target mode, wire/optimistic dispatch, and typed confirmation/auxiliary seams. The two corrupt decompiler operands were pinned from matching x86 as `BF_REQUIRES_PACKSLOT` and `BF_VENDOR`. | `src/AcDream.Core/Items/ItemInteractionPolicy.cs`; `src/AcDream.App/UI/ItemInteractionController.cs` | — | — | `ItemHolder::DetermineUseResult @ 0x00588460`; `UseObject @ 0x00588A80`; `AttemptPlaceIn3D @ 0x00588600` | -| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched and selected-object mana is absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, exact stack name/entry/slider, shared split quantity, quick-slot actions, some panel toggles, and all four combat indicators work | Remaining bound retail buttons do nothing and mana-bearing item selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | +| AP-101 | Most toolbar panel/Use/Examine controls are not fully dispatched | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, selected-item mana with exact query/cancel validity handling, exact stack name/entry/slider, shared split quantity, quick-slot actions, some panel toggles, and all four combat indicators work | Remaining bound retail buttons do nothing | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-104 | Vitals detail element `0x100004A9` and root `HideDetail`/`ShowDetail` transitions are not wired | `src/AcDream.App/UI/Layout/VitalsController.cs` | Compact vitals values/bars are correct | Detail click does nothing and expanded retail state is unreachable | `gmVitalsUI::ListenToElementMessage @ 0x004BFC00`; `PostInit @ 0x004BFCE0` | | AP-105 | **PARTIAL 2026-07-10 (Wave 2.2)** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`). Retained chat still lacks complete tab/filter/unread, social availability, squelch, and focus-opacity behavior; a second ChatVM still loses FPS/position providers. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, and outer maximize geometry work; Wave 5 consolidates remaining chat state | Commands degrade, tabs are no-ops, moderation/channel state is wrong, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | AP-106 | The retained UI has no external/ground-container window lifecycle, while the original owned-side-bag `NoLongerViewingContents` premise was incorrect (#196) | `src/AcDream.App/UI`; window runtime; absent external-container controller | Owned inventory navigation remains usable and must not gain an unproven `0x0195` send; Wave 2/6 adds lifecycle ownership and the separate external surface | External container replacement/close cannot notify the server exactly once; adding the packet to owned bag close would itself diverge from the named retail call graph | `ClientUISystem.groundObject`; `CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50`; `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 4fc54c2c..3c3a9f92 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -490,11 +490,12 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut. - **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11. - **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending. -- **✓ SHIPPED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101. +- **✓ SHIPPED — Wave 4.4a selected-stack controls and transfers.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with every item-holder transfer path. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact `ItemHolder::GetObjectSplitSize` selection scoping drives merge, split-to-container `0x0055`, and split-to-ground `0x0056`; partial splits wait for the server-created object instead of moving the source optimistically. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, pointer drag, and wire bytes have conformance tests. Warning-free App Release build and 4,697 tests passed; controls, partial inventory split, and partial ground drop were live-confirmed 2026-07-11. +- **✓ IMPLEMENTED — Wave 4.4b selected-item mana.** Owned non-stack selections send exact `QueryItemMana 0x0263`; `0x0264` parses guid/fraction/trailing-valid into Core `ItemManaState`; valid matching responses reveal authored meter `0x100001A2`, invalid and visible selection-change paths cancel with guid zero. Health uses the same retail visible-meter cancellation rule. Wire/state/controller conformance is green in the 4,712-test Release suite; live gate pending. AP-101 now covers remaining toolbar controls only; AP-46 remains for exact PK/faction/pet health policy. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. -- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Wire unified selection to the hidden mana meter (`0x100001A2`) and complete residual health policy. Stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, and health/name are implemented. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. +- **☐ D.5.3 — Toolbar selected-object display (issue #141).** Selected-item mana (`0x100001A2`) now has the exact `0x0263/0x0264` query, validity, visibility, and cancel path; live gate pending. Stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, and health/name are implemented. Remaining: exact PK/faction/pet health policy (AP-46). Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. - **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. - **D.6 — HUD.** **Radar/compass IMPLEMENTED 2026-07-10; live-world visual gate pending.** It is the retained retail `gmRadarUI` (`LayoutDesc 0x21000074`): a 120×140 circular radar (`0x06004CC1`) with N/E/S/W token sprites orbiting the face, a fixed bright-green player marker, and the coordinate footer (`0x06004CC0`). Projection is `radarPixelRadius / radarRange` with 75 m outdoor / 25 m indoor ranges — there is no 1.18 factor or scrolling compass strip. It lives in `AcDream.App/UI` beside the other imported retail panels. Remaining D.6: target name plate, damage floaters, and other world-space HUD elements through the raw `TextRenderer` path. See slice 06 §A.2, `docs/research/2026-07-10-retail-radar-pseudocode.md`, and the named `gmRadarUI` decomp. - **✓ SHIPPED — D.7 — Cursor manager (user-confirmed 2026-07-11).** All reachable `ClientUISystem::UpdateCursorState @ 0x00564630` branches resolve through production DAT EnumIDMap table 6, with event-ordered global-default versus captured/hovered `MediaDescCursor` layering from `UIElementManager::CheckCursor @ 0x0045ABF0`. OS fallback remains only AP-72 and emits a visible diagnostic. The live gate covered world/object, combat, targeted-use, text, move/resize, and drag contexts; combat-indicator mouse clicks and the quick key share the same toggle command. diff --git a/docs/research/2026-05-10-phase-m-opcode-matrix.md b/docs/research/2026-05-10-phase-m-opcode-matrix.md index 886a67c0..a375c230 100644 --- a/docs/research/2026-05-10-phase-m-opcode-matrix.md +++ b/docs/research/2026-05-10-phase-m-opcode-matrix.md @@ -305,7 +305,7 @@ All rows are `inbound` direction (GameEvents are server→client only). | 0x0248 | inbound | HouseUpdateRestrictions | `ClientHousingSystem::Handle_House__Recv_UpdateRestrictions` | – | W | – | –defer:Housing | RestrictionDB blob | | 0x0257 | inbound | UpdateHAR | `ClientHousingSystem::Handle_House__Recv_UpdateHAR` | – | W | – | –defer:Housing | HAR blob | | 0x0259 | inbound | HouseTransaction | `ClientHousingSystem::Handle_House__Recv_HouseTransaction` | – | W | – | –defer:Housing | scalar txn code | -| 0x0264 | inbound | QueryItemManaResponse | `ClientUISystem::Handle_Item__QueryItemManaResponse` | W | W | P | P+W | parser exists; needs ItemRepository wiring | +| 0x0264 | inbound | QueryItemManaResponse | `ClientUISystem::Handle_Item__QueryItemManaResponse` | W | W | P | P+W | parser + trailing valid flag wired to `ItemManaState` and selected-object meter (2026-07-11) | | 0x0271 | inbound | AvailableHouses | `ClientHousingSystem::Handle_House__Recv_AvailableHouses` | – | W | – | –defer:Housing | PackableList + flag | | 0x0274 | inbound | CharacterConfirmationRequest | `ClientUISystem::Handle_Character__ConfirmationRequest` | W | W | P | P+W | parser exists; needs modal-confirm wiring | | 0x0276 | inbound | CharacterConfirmationDone | `ClientUISystem::Handle_Character__ConfirmationDone` | W | W | – | P+W | (type, contextId); confirms client ACK | diff --git a/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md b/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md index 7695a1b0..2545e996 100644 --- a/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md +++ b/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md @@ -49,6 +49,11 @@ The named-decomp anchors for each stream are inline below. **Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items. +**Landed 2026-07-11:** the selected-item mana path is implemented end to end. +`CM_Item::Event_QueryItemMana @ 0x006A8610` pins outbound `0x0263`; inbound `0x0264` +is guid + float fraction + trailing 32-bit valid flag; Core `ItemManaState` feeds +`SelectedObjectController`, which ports valid/invalid visibility and guid-zero cancellation. + **Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged` (`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires `CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)` diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md index 9c774227..64d1f53c 100644 --- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md +++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md @@ -519,13 +519,13 @@ their live action gate remains pending. The inventory-button gate remains pendin Use, and Examine actions; dispatch panel buttons through the window registry, ghosting unavailable panels rather than constructing placeholders. - [ ] Make open/closed states event-driven from `RetailWindowManager`. -- [ ] Complete selected-object item mana and port `RecvNotice_UpdateItemMana`. +- [x] Complete selected-object item mana and port `RecvNotice_UpdateItemMana`. - [x] Complete formatted stack count, stack entry and split slider, the applicable `RecvNotice_SplitStack` activation behavior, and `HandleSelectionChanged` stack precedence. Vendor-specific initial quantity remains naturally dormant until a vendor panel owns an active vendor id. - [ ] Replace the PWD-only health gate with the exact attackable/player/pet policy. -- [ ] Add item-mana query/cancel state and health query cancellation. +- [x] Add item-mana query/cancel state and health query cancellation. - [x] Add one shared split-quantity owner for the entry, slider, and selected-source item-holder merge, container-split, and ground-split payloads. @@ -547,6 +547,16 @@ moves send `StackableSplitToContainer 0x0055`; partial drag-out drops send because the authoritative destination stack receives a server-assigned guid. The partial inventory and ground transfers were live-confirmed 2026-07-11. +**Wave 4.4b implementation (2026-07-11):** named retail pins the complete selected-item +mana exchange: `CM_Item::Event_QueryItemMana @ 0x006A8610` sends `0x0263 + guid`, +`DispatchUI_QueryItemManaResponse @ 0x006A84D0` reads guid/fraction/valid, and +`gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0` reveals the meter only for a +valid response. `ItemManaState` owns the parsed response; `SelectedObjectController` +queries only owned non-stack items, ignores stale-guid notices, cancels invalid/visible +queries with guid zero, and applies the same visible-meter cancellation to health. +Automated wire, state, and retained-controller conformance is green; live visual/action +confirmation remains pending. + **Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in the first empty slot. A focused diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index d46dacae..70d27ed0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -728,6 +728,7 @@ public sealed class GameWindow : IDisposable // SetTurbineChatChannels (0x0295) shortly after EnterWorld. public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new(); public readonly AcDream.Core.Combat.CombatState Combat = new(); + public readonly AcDream.Core.Items.ItemManaState ItemMana = new(); // Issue #11 — load static spell metadata from data/spells.csv at startup. // Provides Family for buff stacking (issue #6) + names + icons + tooltips // for the future Spellbook panel. The CSV is copied to bin//net10.0/data/ @@ -2117,6 +2118,7 @@ public sealed class GameWindow : IDisposable (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), UseItemByGuid, Combat, + ItemMana, ToggleLiveCombatMode, _itemInteractionController, entry => _liveSession?.SendAddShortcut(entry), @@ -2130,6 +2132,7 @@ public sealed class GameWindow : IDisposable Combat.HasHealth, guid => (uint)(Objects.Get(guid)?.StackSize ?? 0), guid => _liveSession?.SendQueryHealth(guid), + guid => _liveSession?.SendQueryItemMana(guid), () => _playerServerGuid, (item, container, placement) => _liveSession?.SendPutItemInContainer(item, container, placement)), @@ -2590,7 +2593,8 @@ public sealed class GameWindow : IDisposable }, onShortcuts: list => Shortcuts = list, playerGuid: () => _playerServerGuid, - onUseDone: error => _itemInteractionController?.CompleteUse(error)); + onUseDone: error => _itemInteractionController?.CompleteUse(error), + itemMana: ItemMana); // Phase I.7: subscribe to CombatState events and emit // retail-faithful "You hit X for Y damage" chat lines into diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 271abef5..0970fb03 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -7,15 +7,17 @@ using AcDream.Core.Selection; namespace AcDream.App.UI.Layout; /// -/// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A1). +/// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A4). /// Analogue of retail gmToolbarUI::HandleSelectionChanged /// (docs/research/named-retail/acclient_2013_pseudo_c.txt:198635) + -/// RecvNotice_UpdateObjectHealth (:196213). +/// RecvNotice_UpdateObjectHealth (:196213) + +/// RecvNotice_UpdateItemMana (:196188). /// /// /// On selection change: clears the strip (name, overlay flash, health meter), then if a -/// guid is provided it sets the name, flashes the selection overlay briefly, and (for -/// health-bearing targets) sends a QueryHealth (0x01BF) request. The Health meter +/// guid is provided it sets the name, flashes the selection overlay briefly, and sends +/// either QueryHealth (0x01BF) for health-bearing targets or +/// QueryItemMana (0x0263) for owned non-stack items. The Health meter /// becomes visible only when the server actually reports health for the selected guid — /// either an UpdateHealth (0x01C0) arrives (retail /// RecvNotice_UpdateObjectHealthSetVisible(1)) or the value is already @@ -54,6 +56,8 @@ public sealed class SelectedObjectController : IRetainedPanelController public const uint OverlayId = 0x100001A0; /// Selected-object health meter element id (retail m_pSelObjectHealthMeter). public const uint HealthMeterId = 0x100001A1; + /// Selected-object item-mana meter element id (retail m_pSelObjectManaMeter). + public const uint ManaMeterId = 0x100001A2; /// Editable stack quantity (retail m_pStackSizeEntryBox). public const uint StackSizeEntryId = 0x100001A3; /// Horizontal stack quantity slider (retail m_pStackSizeSlider). @@ -80,19 +84,24 @@ public sealed class SelectedObjectController : IRetainedPanelController private readonly UiElement? _name; private readonly UiDatElement? _overlay; private readonly UiMeter? _healthMeter; + private readonly UiMeter? _manaMeter; private readonly UiField? _stackSizeEntry; private readonly UiScrollbar? _stackSizeSlider; // ── Captured delegates ─────────────────────────────────────────────────── private readonly Func _isHealthTarget; + private readonly Func _isOwnedByPlayer; private readonly Func _resolveName; private readonly Func _healthPercent; private readonly Func _hasHealth; private readonly Func _stackSize; private readonly Action _sendQueryHealth; + private readonly Func _manaPercent; + private readonly Action _sendQueryItemMana; private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; private readonly Action> _unsubscribeHealthChanged; + private readonly Action> _unsubscribeItemManaChanged; private readonly Action> _unsubscribeObjectUpdated; // ── Live state (read by closures on the per-frame draw path) ──────────── @@ -110,32 +119,42 @@ public sealed class SelectedObjectController : IRetainedPanelController SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, + Action> subscribeItemManaChanged, + Action> unsubscribeItemManaChanged, Func isHealthTarget, + Func isOwnedByPlayer, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, + Func manaPercent, + Action sendQueryItemMana, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated) { _isHealthTarget = isHealthTarget; + _isOwnedByPlayer = isOwnedByPlayer; _resolveName = name; _healthPercent = healthPercent; _hasHealth = hasHealth; _stackSize = stackSize; _sendQueryHealth = sendQueryHealth; + _manaPercent = manaPercent; + _sendQueryItemMana = sendQueryItemMana; _splitQuantity = splitQuantity ?? throw new ArgumentNullException(nameof(splitQuantity)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _unsubscribeHealthChanged = unsubscribeHealthChanged; + _unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeObjectUpdated = unsubscribeObjectUpdated; // Find elements — silently skip absent ones (partial/test layouts). _name = layout.FindElement(NameId); _overlay = layout.FindElement(OverlayId) as UiDatElement; _healthMeter = layout.FindElement(HealthMeterId) as UiMeter; + _manaMeter = layout.FindElement(ManaMeterId) as UiMeter; _stackSizeEntry = layout.FindElement(StackSizeEntryId) as UiField; _stackSizeSlider = layout.FindElement(StackSizeSliderId) as UiScrollbar; @@ -151,6 +170,11 @@ public sealed class SelectedObjectController : IRetainedPanelController // Fill polls live: _current holds the currently-selected guid (or null). _healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f; } + if (_manaMeter is not null) + { + _manaMeter.Visible = false; + _manaMeter.Fill = () => _current is uint g ? _manaPercent(g) : (float?)0f; + } if (_stackSizeEntry is not null) { @@ -210,6 +234,7 @@ public sealed class SelectedObjectController : IRetainedPanelController _selection.Changed += OnSelectionTransition; _splitQuantity.Changed += OnSplitQuantityChanged; subscribeHealthChanged(OnHealthChanged); + subscribeItemManaChanged(OnItemManaChanged); subscribeObjectUpdated(OnObjectUpdated); if (_selection.SelectedObjectId is { } initial) ApplySelection(initial); @@ -237,12 +262,17 @@ public sealed class SelectedObjectController : IRetainedPanelController SelectionState selection, Action> subscribeHealthChanged, Action> unsubscribeHealthChanged, + Action> subscribeItemManaChanged, + Action> unsubscribeItemManaChanged, Func isHealthTarget, + Func isOwnedByPlayer, Func name, Func healthPercent, Func hasHealth, Func stackSize, Action sendQueryHealth, + Func manaPercent, + Action sendQueryItemMana, UiDatFont? datFont, StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, @@ -250,7 +280,9 @@ public sealed class SelectedObjectController : IRetainedPanelController => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, - isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont, + subscribeItemManaChanged, unsubscribeItemManaChanged, + isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, + sendQueryHealth, manaPercent, sendQueryItemMana, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated); /// @@ -259,9 +291,25 @@ public sealed class SelectedObjectController : IRetainedPanelController /// private void ApplySelection(uint? guid) { + bool selectionChanged = _current != guid; + + // gmToolbarUI::HandleSelectionChanged @ 0x004BF3D1: changing away from + // a visible meter cancels its server query with object id zero. + if (selectionChanged) + { + if (_healthMeter?.Visible == true) + _sendQueryHealth(0); + if (_manaMeter?.Visible == true) + _sendQueryItemMana(0); + } + // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) // + SetVisible(0) on the meters). ────────────────────────────────────── - if (_healthMeter is not null) _healthMeter.Visible = false; + if (selectionChanged) + { + if (_healthMeter is not null) _healthMeter.Visible = false; + if (_manaMeter is not null) _manaMeter.Visible = false; + } if (_stackSizeEntry is not null) _stackSizeEntry.Visible = false; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = false; _splitQuantity.Reset(1u); @@ -306,12 +354,18 @@ public sealed class SelectedObjectController : IRetainedPanelController // ── 4. Health: query, and show the meter only if real health is already known. // Otherwise the meter appears when OnHealthChanged fires for this guid // (retail RecvNotice_UpdateObjectHealth :196213). ────────────────────────── - if (_isHealthTarget(g)) + if (stackSize <= 1u && _isHealthTarget(g)) { - _sendQueryHealth(g); + if (selectionChanged) + _sendQueryHealth(g); if (_hasHealth(g) && _healthMeter is not null) _healthMeter.Visible = true; } + else if (stackSize <= 1u && _isOwnedByPlayer(g)) + { + if (selectionChanged) + _sendQueryItemMana(g); + } } /// @@ -339,6 +393,25 @@ public sealed class SelectedObjectController : IRetainedPanelController _overlay?.TrySetRetailState(state); } + /// + /// Port of gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0. + /// Invalid results cancel with object id zero; valid results reveal the meter. + /// + public void OnItemManaChanged(uint guid, float percent, bool valid) + { + if (_current != guid) + return; + + if (!valid) + { + _sendQueryItemMana(0); + return; + } + + if (_manaMeter is not null) + _manaMeter.Visible = true; + } + private void CommitStackEntry(string text) => _splitQuantity.SetFromText(text); @@ -380,6 +453,7 @@ public sealed class SelectedObjectController : IRetainedPanelController _selection.Changed -= OnSelectionTransition; _splitQuantity.Changed -= OnSplitQuantityChanged; _unsubscribeHealthChanged(OnHealthChanged); + _unsubscribeItemManaChanged(OnItemManaChanged); _unsubscribeObjectUpdated(OnObjectUpdated); } } diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 05bef60c..ff65721a 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -35,17 +35,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont 0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF, }; - // Elements hidden by default in retail gmToolbarUI::PostInit. - // Ids confirmed from the toolbar LayoutDesc dump. - // 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) — - // it hides A1 at bind and shows it on a health-target selection, so A1 is removed - // from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size - // entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split - // UI) with no controller yet, so they stay hidden here — otherwise their dat sprites - // render as stray bars / a black box on the toolbar. Retail hides A3/A4 in - // gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742), - // showing them only for a stacked-item selection. - private static readonly uint[] HiddenIds = { 0x100001A2 }; + // SelectedObjectController owns the health/mana meters and both stack controls, + // including retail's initial-hidden state and selection-driven visibility. // Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time. // Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic. @@ -161,10 +152,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont _inventoryButton.OnItemDrop = HandleInventoryButtonDrop; } - // Hide target-object meters + stack slider (gmToolbarUI::PostInit). - foreach (var id in HiddenIds) - if (layout.FindElement(id) is { } e) e.Visible = false; - // Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669): // exactly one indicator visible at a time. Default to NonCombat (peace) — the player // always spawns in peace mode; retail has not yet called SetVisible when PostInit runs. diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8b90c526..abe99524 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -41,6 +41,7 @@ public sealed record ToolbarRuntimeBindings( Func ResolveIcon, Action UseItem, CombatState Combat, + ItemManaState ItemMana, Action ToggleCombat, ItemInteractionController ItemInteraction, Action? SendAddShortcut, @@ -54,6 +55,7 @@ public sealed record ToolbarRuntimeBindings( Func HasHealth, Func StackSize, Action SendQueryHealth, + Action SendQueryItemMana, Func PlayerGuid, Action? SendPutItemInContainer); @@ -380,12 +382,17 @@ public sealed class RetailUiRuntime : IDisposable b.Selection, b.SubscribeHealthChanged, b.UnsubscribeHealthChanged, + handler => b.ItemMana.ItemManaChanged += handler, + handler => b.ItemMana.ItemManaChanged -= handler, b.IsHealthTarget, + b.ItemInteraction.IsOwnedByPlayer, b.ResolveName, b.HealthPercent, b.HasHealth, b.StackSize, b.SendQueryHealth, + b.ItemMana.GetManaPercent, + b.SendQueryItemMana, _bindings.Assets.DefaultFont, StackSplitQuantity, handler => b.Objects.ObjectUpdated += handler, diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index c1910d33..d89e41d8 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -69,7 +69,8 @@ public static class GameEventWiring // B-Wire: the local player's server guid. When provided, the PD handler upserts // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. Func? playerGuid = null, - Action? onUseDone = null) + Action? onUseDone = null, + ItemManaState? itemMana = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -161,6 +162,16 @@ public static class GameEventWiring var p = GameEvents.ParseUpdateHealth(e.Payload.Span); if (p is not null) combat.OnUpdateHealth(p.Value.TargetGuid, p.Value.HealthPercent); }); + if (itemMana is not null) + { + dispatcher.Register(GameEventType.QueryItemManaResponse, e => + { + var p = GameEvents.ParseQueryItemManaResponse(e.Payload.Span); + if (p is not null) + itemMana.OnQueryItemManaResponse( + p.Value.ItemGuid, p.Value.ManaPercent, p.Value.Valid); + }); + } dispatcher.Register(GameEventType.VictimNotification, e => { var p = GameEvents.ParseVictimNotification(e.Payload.Span); diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 94f30592..fa3a73ec 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -450,15 +450,20 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } - /// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent). - public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent); + /// + /// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid). + /// Retail anchor: CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0 + /// reads the trailing 32-bit validity flag at message offset 0x0C. + /// + public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid); public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan payload) { - if (payload.Length < 8) return null; + if (payload.Length < 12) return null; return new QueryItemManaResponse( BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4))); + BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(4)), + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) != 0); } /// 0x0274 CharacterConfirmationRequest — server-driven modal confirm. diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 4fb1505b..01c0a854 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -29,6 +29,7 @@ public static class SocialActions // Queries public const uint QueryHealthOpcode = 0x01BFu; // u32 targetGuid + public const uint QueryItemManaOpcode = 0x0263u; // u32 itemGuid; zero cancels public const uint PingRequestOpcode = 0x01E9u; // u32 clientId // Fellowship @@ -56,6 +57,20 @@ public static class SocialActions return body; } + /// + /// Query an owned item's mana fraction, or cancel the active query with item guid zero. + /// Retail anchor: CM_Item::Event_QueryItemMana @ 0x006A8610. + /// + public static byte[] BuildQueryItemMana(uint seq, uint itemGuid) + { + byte[] body = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), QueryItemManaOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); + return body; + } + /// Keepalive ping — server echoes with PingResponse (0x01EA). public static byte[] BuildPingRequest(uint seq, uint clientId) { diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index f27ea8d3..b33e7378 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1379,6 +1379,17 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } + /// + /// Send retail QueryItemMana (0x0263), using item guid zero to cancel. + /// Retail anchor: CM_Item::Event_QueryItemMana @ 0x006A8610. + /// + public void SendQueryItemMana(uint itemGuid) + { + uint seq = NextGameActionSequence(); + byte[] body = SocialActions.BuildQueryItemMana(seq, itemGuid); + SendGameAction(body); + } + /// Send retail TargetedMeleeAttack (0x0008). public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) { diff --git a/src/AcDream.Core/Items/ItemManaState.cs b/src/AcDream.Core/Items/ItemManaState.cs new file mode 100644 index 00000000..6d9f1c4e --- /dev/null +++ b/src/AcDream.Core/Items/ItemManaState.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Concurrent; + +namespace AcDream.Core.Items; + +/// +/// Last server-reported mana fraction for queried inventory items. +/// Retail routes QueryItemManaResponse (0x0264) through +/// ClientUISystem::Handle_Item__QueryItemManaResponse @ 0x00563FE0, including +/// the response's validity flag, before notifying the selected-object toolbar. +/// +public sealed class ItemManaState +{ + private readonly ConcurrentDictionary _manaByGuid = new(); + + /// Fires for every valid or invalid query response. + public event Action? ItemManaChanged; + + public float GetManaPercent(uint guid) => + _manaByGuid.TryGetValue(guid, out float percent) ? percent : 0f; + + public bool HasMana(uint guid) => _manaByGuid.ContainsKey(guid); + + public void OnQueryItemManaResponse(uint itemGuid, float manaPercent, bool valid) + { + if (valid) + _manaByGuid[itemGuid] = manaPercent; + else + _manaByGuid.TryRemove(itemGuid, out _); + + ItemManaChanged?.Invoke(itemGuid, manaPercent, valid); + } + + public void Clear() => _manaByGuid.Clear(); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index fc2ef0e1..f21940ca 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -60,6 +60,10 @@ public class SelectedObjectControllerTests dict[SelectedObjectController.HealthMeterId] = healthMeterEl; root.AddChild(healthMeterEl); + var manaMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true }; + dict[SelectedObjectController.ManaMeterId] = manaMeterEl; + root.AddChild(manaMeterEl); + var stackEntry = new UiField { Width = 50, Height = 14, Visible = true }; dict[SelectedObjectController.StackSizeEntryId] = stackEntry; root.AddChild(stackEntry); @@ -78,13 +82,17 @@ public class SelectedObjectControllerTests public readonly SelectionState Selection = new(); public readonly StackSplitQuantityState SplitQuantity = new(); public Action? HealthHandler; + public Action? ItemManaHandler; public Action? ObjectUpdatedHandler; public readonly List QueryHealthCalls = new(); + public readonly List QueryItemManaCalls = new(); public readonly Dictionary HealthTargetMap = new(); + public readonly Dictionary OwnedMap = new(); public readonly Dictionary NameMap = new(); public readonly Dictionary HealthMap = new(); public readonly Dictionary HasHealthMap = new(); + public readonly Dictionary ManaMap = new(); public readonly Dictionary StackMap = new(); public void FireSelection(uint? g) @@ -95,6 +103,8 @@ public class SelectedObjectControllerTests Selection.Clear(SelectionChangeSource.System); } public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); + public void FireItemMana(uint g, float pct, bool valid) + => ItemManaHandler?.Invoke(g, pct, valid); public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) => SelectedObjectController.Bind( @@ -105,12 +115,20 @@ public class SelectedObjectControllerTests { if (HealthHandler == h) HealthHandler = null; }, + subscribeItemManaChanged: h => ItemManaHandler = h, + unsubscribeItemManaChanged: h => + { + if (ItemManaHandler == h) ItemManaHandler = null; + }, isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v, + isOwnedByPlayer: g => OwnedMap.TryGetValue(g, out var v) && v, name: g => NameMap.TryGetValue(g, out var v) ? v : null, healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f, hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v, stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u, sendQueryHealth: g => QueryHealthCalls.Add(g), + manaPercent: g => ManaMap.TryGetValue(g, out var v) ? v : 0f, + sendQueryItemMana: g => QueryItemManaCalls.Add(g), datFont: datFont, splitQuantity: SplitQuantity, subscribeObjectUpdated: h => ObjectUpdatedHandler = h, @@ -379,6 +397,7 @@ public class SelectedObjectControllerTests Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect"); Assert.Equal("", overlayEl.ActiveState); Assert.Empty(nameEl.Children.OfType().Single().LinesProvider()); + Assert.Equal(new[] { Guid, 0u }, h.QueryHealthCalls); } // ── H5: Re-select a different guid ─────────────────────────────────────── @@ -404,7 +423,7 @@ public class SelectedObjectControllerTests Assert.False(healthMeterEl.Visible, "meter must clear when switching to a non-health target"); Assert.Equal("ObjectSelected", overlayEl.ActiveState); - Assert.Single(h.QueryHealthCalls); // B is not a health target → no extra query + Assert.Equal(new[] { GuidA, 0u }, h.QueryHealthCalls); var lines = nameEl.Children.OfType().Single().LinesProvider(); Assert.Single(lines); @@ -498,6 +517,80 @@ public class SelectedObjectControllerTests Assert.Equal(0f, healthMeterEl.Fill() ?? 0f); } + [Fact] + public void OwnedNonStackItem_QueriesMana_AndValidResponseShowsMeter() + { + const uint Guid = 0x50000A01u; + var (layout, _, _, _) = FakeLayout(); + var manaMeter = Assert.IsType(layout.FindElement(SelectedObjectController.ManaMeterId)); + var h = new Harness(); + h.OwnedMap[Guid] = true; + h.NameMap[Guid] = "Wand"; + h.StackMap[Guid] = 1u; + h.ManaMap[Guid] = 0.625f; + h.Bind(layout); + + h.FireSelection(Guid); + + Assert.Equal(new[] { Guid }, h.QueryItemManaCalls); + Assert.False(manaMeter.Visible); + + h.FireItemMana(Guid, 0.625f, valid: true); + + Assert.True(manaMeter.Visible); + Assert.Equal(0.625f, manaMeter.Fill()); + } + + [Fact] + public void InvalidManaResponse_CancelsQueryWithoutShowingMeter() + { + const uint Guid = 0x50000A02u; + var (layout, _, _, _) = FakeLayout(); + var manaMeter = Assert.IsType(layout.FindElement(SelectedObjectController.ManaMeterId)); + var h = new Harness(); + h.OwnedMap[Guid] = true; + h.StackMap[Guid] = 1u; + h.Bind(layout); + h.FireSelection(Guid); + + h.FireItemMana(Guid, 0f, valid: false); + + Assert.Equal(new[] { Guid, 0u }, h.QueryItemManaCalls); + Assert.False(manaMeter.Visible); + } + + [Fact] + public void ChangingAwayFromVisibleMana_CancelsWithZero() + { + const uint Guid = 0x50000A03u; + var (layout, _, _, _) = FakeLayout(); + var h = new Harness(); + h.OwnedMap[Guid] = true; + h.StackMap[Guid] = 1u; + h.Bind(layout); + h.FireSelection(Guid); + h.FireItemMana(Guid, 0.5f, valid: true); + + h.FireSelection(0x60000001u); + + Assert.Equal(new[] { Guid, 0u }, h.QueryItemManaCalls); + } + + [Fact] + public void StackedOwnedItem_DoesNotQueryMana() + { + const uint Guid = 0x50000A04u; + var (layout, _, _, _) = FakeLayout(); + var h = new Harness(); + h.OwnedMap[Guid] = true; + h.StackMap[Guid] = 2u; + h.Bind(layout); + + h.FireSelection(Guid); + + Assert.Empty(h.QueryItemManaCalls); + } + [Fact] public void Dispose_UnsubscribesBothHandlers_AndIsIdempotent() { @@ -512,6 +605,7 @@ public class SelectedObjectControllerTests h.FireHealth(0xAA09u, 0.5f); Assert.Null(h.HealthHandler); + Assert.Null(h.ItemManaHandler); Assert.False(healthMeterEl.Visible); Assert.Empty(h.QueryHealthCalls); } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 658cd99b..c8b70b37 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -449,6 +449,30 @@ public sealed class GameEventWiringTests Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); } + [Fact] + public void WireAll_QueryItemManaResponse_RoutesValidityToItemManaState() + { + var dispatcher = new GameEventDispatcher(); + var itemMana = new ItemManaState(); + (uint Guid, float Percent, bool Valid)? observed = null; + itemMana.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid); + GameEventWiring.WireAll( + dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), new ChatLog(), + itemMana: itemMana); + + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); + BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.375f); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u); + var envelope = GameEventEnvelope.TryParse( + WrapEnvelope(GameEventType.QueryItemManaResponse, payload)); + + dispatcher.Dispatch(envelope!.Value); + + Assert.Equal((0x50000A01u, 0.375f, true), observed); + Assert.True(itemMana.HasMana(0x50000A01u)); + } + [Fact] public void UseDone_releasesAppBusyOwnerThroughCallback() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs index 0f041065..63001be7 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs @@ -177,6 +177,24 @@ public sealed class GameEventDispatcherTests Assert.Equal(0.42f, parsed.Value.HealthPercent, 4); } + [Fact] + public void ParseQueryItemManaResponse_RoundTripIncludingValidity() + { + // CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0 reads all three fields. + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); + BinaryPrimitives.WriteSingleLittleEndian(payload.AsSpan(4), 0.625f); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 1u); + + var parsed = GameEvents.ParseQueryItemManaResponse(payload); + + Assert.NotNull(parsed); + Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid); + Assert.Equal(0.625f, parsed.Value.ManaPercent); + Assert.True(parsed.Value.Valid); + Assert.Null(GameEvents.ParseQueryItemManaResponse(payload.AsSpan(0, 8))); + } + [Fact] public void ParseWieldObject_acceptsAceEightBytePayload() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index 3018ae1b..ae8fe607 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -18,6 +18,22 @@ public sealed class SocialActionsTests BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } + [Fact] + public void BuildQueryItemMana_HasOpcode0x0263AndGuid() + { + // CM_Item::Event_QueryItemMana @ 0x006A8610: F7B1, seq, 0x0263, guid. + byte[] body = SocialActions.BuildQueryItemMana(seq: 4, itemGuid: 0x50000A01u); + + Assert.Equal(16, body.Length); + Assert.Equal(SocialActions.GameActionEnvelope, + BinaryPrimitives.ReadUInt32LittleEndian(body)); + Assert.Equal(4u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4))); + Assert.Equal(SocialActions.QueryItemManaOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0x50000A01u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } + [Fact] public void BuildPingRequest_HasOpcode0x01E9() { diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs index b22f2732..c5326ab5 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -141,6 +141,19 @@ public sealed class WorldSessionCombatTests Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured); } + [Fact] + public void SendQueryItemMana_UsesRetailQueryItemManaBuilder() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendQueryItemMana(0x50000A01u); + + Assert.NotNull(captured); + Assert.Equal(SocialActions.BuildQueryItemMana(1, 0x50000A01u), captured); + } + [Fact] public void SendUseWithTarget_UsesRetailBuilder() { diff --git a/tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs b/tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs new file mode 100644 index 00000000..23ecedd1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs @@ -0,0 +1,33 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ItemManaStateTests +{ + [Fact] + public void ValidResponseCachesAndRaisesEvent() + { + var state = new ItemManaState(); + (uint Guid, float Percent, bool Valid)? observed = null; + state.ItemManaChanged += (guid, percent, valid) => observed = (guid, percent, valid); + + state.OnQueryItemManaResponse(0x50000A01u, 0.75f, valid: true); + + Assert.Equal((0x50000A01u, 0.75f, true), observed); + Assert.True(state.HasMana(0x50000A01u)); + Assert.Equal(0.75f, state.GetManaPercent(0x50000A01u)); + } + + [Fact] + public void InvalidResponseClearsCachedValueAndRaisesEvent() + { + var state = new ItemManaState(); + state.OnQueryItemManaResponse(0x50000A01u, 0.75f, valid: true); + + state.OnQueryItemManaResponse(0x50000A01u, 0f, valid: false); + + Assert.False(state.HasMana(0x50000A01u)); + Assert.Equal(0f, state.GetManaPercent(0x50000A01u)); + } +}