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 <codex@openai.com>
This commit is contained in:
parent
df460f94c3
commit
fa1da02783
21 changed files with 402 additions and 38 deletions
|
|
@ -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`).
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<u32> + 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 |
|
||||
|
|
|
|||
|
|
@ -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)`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<config>/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
|
||||
|
|
|
|||
|
|
@ -7,15 +7,17 @@ using AcDream.Core.Selection;
|
|||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>gmToolbarUI::HandleSelectionChanged</c>
|
||||
/// (<c>docs/research/named-retail/acclient_2013_pseudo_c.txt:198635</c>) +
|
||||
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>).
|
||||
/// <c>RecvNotice_UpdateObjectHealth</c> (<c>:196213</c>) +
|
||||
/// <c>RecvNotice_UpdateItemMana</c> (<c>:196188</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// 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 <c>QueryHealth (0x01BF)</c> request. The Health meter
|
||||
/// guid is provided it sets the name, flashes the selection overlay briefly, and sends
|
||||
/// either <c>QueryHealth (0x01BF)</c> for health-bearing targets or
|
||||
/// <c>QueryItemMana (0x0263)</c> for owned non-stack items. The Health meter
|
||||
/// becomes visible only when the server actually reports health for the selected guid —
|
||||
/// either an <c>UpdateHealth (0x01C0)</c> arrives (retail
|
||||
/// <c>RecvNotice_UpdateObjectHealth</c> → <c>SetVisible(1)</c>) or the value is already
|
||||
|
|
@ -54,6 +56,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
public const uint OverlayId = 0x100001A0;
|
||||
/// <summary>Selected-object health meter element id (retail m_pSelObjectHealthMeter).</summary>
|
||||
public const uint HealthMeterId = 0x100001A1;
|
||||
/// <summary>Selected-object item-mana meter element id (retail m_pSelObjectManaMeter).</summary>
|
||||
public const uint ManaMeterId = 0x100001A2;
|
||||
/// <summary>Editable stack quantity (retail m_pStackSizeEntryBox).</summary>
|
||||
public const uint StackSizeEntryId = 0x100001A3;
|
||||
/// <summary>Horizontal stack quantity slider (retail m_pStackSizeSlider).</summary>
|
||||
|
|
@ -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<uint, bool> _isHealthTarget;
|
||||
private readonly Func<uint, bool> _isOwnedByPlayer;
|
||||
private readonly Func<uint, string?> _resolveName;
|
||||
private readonly Func<uint, float> _healthPercent;
|
||||
private readonly Func<uint, bool> _hasHealth;
|
||||
private readonly Func<uint, uint> _stackSize;
|
||||
private readonly Action<uint> _sendQueryHealth;
|
||||
private readonly Func<uint, float> _manaPercent;
|
||||
private readonly Action<uint> _sendQueryItemMana;
|
||||
private readonly StackSplitQuantityState _splitQuantity;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly Action<Action<uint, float>> _unsubscribeHealthChanged;
|
||||
private readonly Action<Action<uint, float, bool>> _unsubscribeItemManaChanged;
|
||||
private readonly Action<Action<ClientObject>> _unsubscribeObjectUpdated;
|
||||
|
||||
// ── Live state (read by closures on the per-frame draw path) ────────────
|
||||
|
|
@ -110,32 +119,42 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
SelectionState selection,
|
||||
Action<Action<uint, float>> subscribeHealthChanged,
|
||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||
Action<Action<uint, float, bool>> subscribeItemManaChanged,
|
||||
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, bool> isOwnedByPlayer,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
Func<uint, float> manaPercent,
|
||||
Action<uint> sendQueryItemMana,
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> subscribeObjectUpdated,
|
||||
Action<Action<ClientObject>> 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<Action<uint, float>> subscribeHealthChanged,
|
||||
Action<Action<uint, float>> unsubscribeHealthChanged,
|
||||
Action<Action<uint, float, bool>> subscribeItemManaChanged,
|
||||
Action<Action<uint, float, bool>> unsubscribeItemManaChanged,
|
||||
Func<uint, bool> isHealthTarget,
|
||||
Func<uint, bool> isOwnedByPlayer,
|
||||
Func<uint, string?> name,
|
||||
Func<uint, float> healthPercent,
|
||||
Func<uint, bool> hasHealth,
|
||||
Func<uint, uint> stackSize,
|
||||
Action<uint> sendQueryHealth,
|
||||
Func<uint, float> manaPercent,
|
||||
Action<uint> sendQueryItemMana,
|
||||
UiDatFont? datFont,
|
||||
StackSplitQuantityState splitQuantity,
|
||||
Action<Action<ClientObject>> 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);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -259,9 +291,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -339,6 +393,25 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
|||
_overlay?.TrySetRetailState(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0</c>.
|
||||
/// Invalid results cancel with object id zero; valid results reveal the meter.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public sealed record ToolbarRuntimeBindings(
|
|||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Action<uint> UseItem,
|
||||
CombatState Combat,
|
||||
ItemManaState ItemMana,
|
||||
Action ToggleCombat,
|
||||
ItemInteractionController ItemInteraction,
|
||||
Action<ShortcutEntry>? SendAddShortcut,
|
||||
|
|
@ -54,6 +55,7 @@ public sealed record ToolbarRuntimeBindings(
|
|||
Func<uint, bool> HasHealth,
|
||||
Func<uint, uint> StackSize,
|
||||
Action<uint> SendQueryHealth,
|
||||
Action<uint> SendQueryItemMana,
|
||||
Func<uint> PlayerGuid,
|
||||
Action<uint, uint, int>? 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,
|
||||
|
|
|
|||
|
|
@ -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<uint>? playerGuid = null,
|
||||
Action<uint /*weenieError*/>? onUseDone = null)
|
||||
Action<uint /*weenieError*/>? 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);
|
||||
|
|
|
|||
|
|
@ -450,15 +450,20 @@ public static class GameEvents
|
|||
return BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
}
|
||||
|
||||
/// <summary>0x0264 QueryItemManaResponse: (itemGuid, manaPercent).</summary>
|
||||
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent);
|
||||
/// <summary>
|
||||
/// 0x0264 QueryItemManaResponse: (itemGuid, manaPercent, valid).
|
||||
/// Retail anchor: <c>CM_Item::DispatchUI_QueryItemManaResponse @ 0x006A84D0</c>
|
||||
/// reads the trailing 32-bit validity flag at message offset 0x0C.
|
||||
/// </summary>
|
||||
public readonly record struct QueryItemManaResponse(uint ItemGuid, float ManaPercent, bool Valid);
|
||||
|
||||
public static QueryItemManaResponse? ParseQueryItemManaResponse(ReadOnlySpan<byte> 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);
|
||||
}
|
||||
|
||||
/// <summary>0x0274 CharacterConfirmationRequest — server-driven modal confirm.</summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query an owned item's mana fraction, or cancel the active query with item guid zero.
|
||||
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Keepalive ping — server echoes with PingResponse (0x01EA).</summary>
|
||||
public static byte[] BuildPingRequest(uint seq, uint clientId)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1379,6 +1379,17 @@ public sealed class WorldSession : IDisposable
|
|||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send retail QueryItemMana (0x0263), using item guid zero to cancel.
|
||||
/// Retail anchor: <c>CM_Item::Event_QueryItemMana @ 0x006A8610</c>.
|
||||
/// </summary>
|
||||
public void SendQueryItemMana(uint itemGuid)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
byte[] body = SocialActions.BuildQueryItemMana(seq, itemGuid);
|
||||
SendGameAction(body);
|
||||
}
|
||||
|
||||
/// <summary>Send retail TargetedMeleeAttack (0x0008).</summary>
|
||||
public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel)
|
||||
{
|
||||
|
|
|
|||
35
src/AcDream.Core/Items/ItemManaState.cs
Normal file
35
src/AcDream.Core/Items/ItemManaState.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AcDream.Core.Items;
|
||||
|
||||
/// <summary>
|
||||
/// Last server-reported mana fraction for queried inventory items.
|
||||
/// Retail routes <c>QueryItemManaResponse (0x0264)</c> through
|
||||
/// <c>ClientUISystem::Handle_Item__QueryItemManaResponse @ 0x00563FE0</c>, including
|
||||
/// the response's validity flag, before notifying the selected-object toolbar.
|
||||
/// </summary>
|
||||
public sealed class ItemManaState
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, float> _manaByGuid = new();
|
||||
|
||||
/// <summary>Fires for every valid or invalid query response.</summary>
|
||||
public event Action<uint /*guid*/, float /*fraction*/, bool /*valid*/>? 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();
|
||||
}
|
||||
|
|
@ -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<uint, float>? HealthHandler;
|
||||
public Action<uint, float, bool>? ItemManaHandler;
|
||||
public Action<ClientObject>? ObjectUpdatedHandler;
|
||||
public readonly List<uint> QueryHealthCalls = new();
|
||||
public readonly List<uint> QueryItemManaCalls = new();
|
||||
|
||||
public readonly Dictionary<uint, bool> HealthTargetMap = new();
|
||||
public readonly Dictionary<uint, bool> OwnedMap = new();
|
||||
public readonly Dictionary<uint, string> NameMap = new();
|
||||
public readonly Dictionary<uint, float> HealthMap = new();
|
||||
public readonly Dictionary<uint, bool> HasHealthMap = new();
|
||||
public readonly Dictionary<uint, float> ManaMap = new();
|
||||
public readonly Dictionary<uint, uint> 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<UiText>().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<UiText>().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<UiMeter>(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<UiMeter>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
33
tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs
Normal file
33
tests/AcDream.Core.Tests/Items/ItemManaStateTests.cs
Normal file
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue