diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 2bebd47e..84eb83c0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -204,8 +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-100 | Cursor semantics cover only a reachable subset of retail states, and widget cursor media can outrank authoritative global target mode | `src/AcDream.App/UI/CursorFeedbackController.cs`; `src/AcDream.App/Rendering/RetailCursorManager.cs` | Target-use art works for current flows; Wave 3 ports the full table and precedence | Hovering UI can replace the use/examine/busy/combat cursor; unsupported branches fall back to OS art | `ClientUISystem::UpdateCursorState @ 0x00564630` | -| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine/combat controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, stance indicators, health/name, and some toggles are present | Bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | +| AP-101 | Toolbar quick-slot actions and most panel/Use/Examine controls are not fully dispatched; selected-object mana and stack entry/slider are absent | `src/AcDream.App/UI/Layout/ToolbarController.cs`; `GameWindow.OnInputAction` | Item click/use, health/name, some panel toggles, and all four combat indicator buttons work; combat clicks and the bound key share `ToggleLiveCombatMode` | Remaining bound retail keys/buttons do nothing and item/stack selections show incomplete state | `gmToolbarUI @ 0x004BD0C0..0x004BF380`; LayoutDesc `0x21000016` | | AP-102 | Inventory-to-occupied toolbar drop treats the inventory source index as though it were a vacated toolbar slot for the displaced shortcut | `src/AcDream.App/UI/Layout/ToolbarController.cs` drop planner | Normal empty-slot and toolbar-reorder paths work; Wave 4 makes the transaction source-aware | The displaced shortcut is lost or moved to an unrelated slot | `gmToolbarUI::HandleDropRelease @ 0x004BE7C0` | | AP-103 | Shortcut session storage keeps only object guid and discards raw non-object fields; parser/builder names the final uint as `u16 spellId/u16 layer` instead of preserving retail `spellID_` as raw data | `src/AcDream.Core/Items/ShortcutStore.cs`; `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`; `InventoryActions.cs` | `gmToolbarUI` itself displays only nonzero object ids, so current item shortcuts work | Round-trip mutation corrupts unknown/raw fields and encourages invented spell support in the item toolbar | `ShortCutData` in `acclient.h:36484`; `gmToolbarUI::UpdateFromPlayerDesc @ 0x004BF810` | | 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` | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 916c55b7..a19ab730 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -491,7 +491,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **☐ D.5.3 — Toolbar selected-object display (issue #141) + exact shortcut preservation.** Wire unified selection to the hidden mana meter (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), and complete health/name behavior. Preserve all raw `ShortCutData` fields while keeping `gmToolbarUI` object-only, matching `UpdateFromPlayerDesc @ 0x004BF810`. The eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. (Click-to-use + peace/war + health/name already landed.) - **☐ 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. -- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)** +- **✓ 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. - ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index c66f0efa..a5ee2769 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -534,7 +534,8 @@ Open the inventory panel — retail-skinned with the right font, icons, and - **C.4** — Double-sided translucent polys. - **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Current campaign: shared retained-widget/window architecture, then selected-object mana/stack completion. **Retail correction:** the main toolbar is object-only; the eight favorite-spell lists drive separate spell bars. Execution plan: `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. - **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs + - cursor manager. **D.6 radar/compass shipped 2026-07-10** through the retained + cursor manager. **D.6 radar/compass shipped 2026-07-10; D.7 complete retail + cursor catalog/precedence shipped and user-confirmed 2026-07-11.** The radar uses the retained UI architecture using the retail DAT layout and recovered runtime behavior; live-game visual confirmation remains the user gate. - **L.1f** — NPC/monster + item-use animation coverage. diff --git a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md index 96829647..7caa7e72 100644 --- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md +++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md @@ -907,16 +907,29 @@ UpdateCursorState(): // 0x00564630 UIElementManager.SetCursor(did, hotspot, setAsDefault=true) ``` -Production DAT golden values already pinned in tests: +Production DAT golden values pinned by `RetailCursorCatalogTests`: | Enum | RenderSurface DID | |---:|---:| +| `0x01` | `0x06004D68` | +| `0x02` | `0x06004D69` | +| `0x03` | `0x06004D6A` | +| `0x04` | `0x06004D6B` | +| `0x05` | `0x06004D6C` | +| `0x06` | `0x06004D6D` | +| `0x0A` | `0x06004D71` | +| `0x0B` | `0x06004D71` | +| `0x0C` | `0x06004D72` | +| `0x0D` | `0x06004D72` | +| `0x0E` | `0x06004D74` | +| `0x0F` | `0x06004D75` | | `0x27` | `0x06004D73` | | `0x28` | `0x06005E6B` | | `0x29` | `0x06005E6A` | -The DIDs for `0x01..0x0F` should be committed as DAT conformance fixtures before -implementation. The enum behavior above is exact even without those media IDs. +DAT enums `0x07..0x09` resolve to `0x06004D6E..0x06004D70`, but no reachable +`UpdateCursorState` branch selects them. They remain intentionally unnamed and +outside the global cursor catalog until a retail call site supplies semantics. ### 10.1 Widget cursor precedence is separate and temporal 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 7eee8585..9612b204 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 @@ -403,18 +403,39 @@ slot view. Full suite green at the final gate: 4,583 passed, 5 skipped. ### 3.4 Complete retail cursor precedence and catalog -- [ ] Port every reachable branch of `ClientUISystem::UpdateCursorState`, not only +- [x] Port every reachable branch of `ClientUISystem::UpdateCursorState`, not only target enums `0x27/28/29`. -- [ ] Separate global world-interaction cursors from widget-local cursor media; - apply retail precedence so a slot hover cannot replace the global target bullseye. -- [ ] Cover default/combat/use/examine/busy, drag accept/reject, resize/move/text, - and target states with golden EnumIDMap tests. -- [ ] Keep OS cursor fallback only as an explicit registered adaptation with a +- [x] Separate global world-interaction cursors from widget-local cursor media; + preserve retail's event order: a global state change reasserts the default, + while a later captured/hovered widget cursor transition may override it. +- [x] Cover default/combat/use/examine/busy/target states with golden EnumIDMap + tests, and cover drag accept/reject, resize/move/text as widget-local semantic + states without inventing global enum meanings for unreachable `0x07..0x09`. +- [x] Keep OS cursor fallback only as an explicit registered adaptation with a visible diagnostic. +**Implementation (2026-07-11):** `RetailGlobalCursorKind` now models all 15 +reachable enum branches from `ClientUISystem::UpdateCursorState @ 0x00564630`. +Production DAT fixtures pin `0x01..0x06`, `0x0A..0x0F`, and `0x27..0x29` to +their exact RenderSurface DIDs. `CursorFeedbackController` owns the pure global +decision tree and reports only the active captured/hovered widget cursor; +`RetailCursorManager` applies global-change then widget-change transitions in +the same default/local layering used by `UIElementManager::CheckCursor @ +0x0045ABF0`. The old semantic-state alias search (`TargetValid` borrowing +`Drag_rollover_accept`, etc.) is removed. AP-100 is retired; AP-72 remains the +single explicit DAT-failure adaptation and now emits one visible diagnostic per +failed global kind. + **Gate:** live use/drop/target matrix with retail cursor art and selection updates; no click path bypasses the router; full suite and user visual gate. +**Acceptance (2026-07-11):** User-confirmed the complete non-debug Release +cursor matrix. A follow-up gate exposed that the four stance indicators were +display-only; wiring their exact retail click branch passed immediately. Mouse +click and quick key now both toggle peace/combat, update the visible indicator, +and drive the corresponding DAT cursor state. Final suite: 4,618 passed, 5 +skipped. + --- ## 7. Wave 4 -- toolbar and selected-object completion @@ -465,6 +486,14 @@ no click path bypasses the router; full suite and user visual gate. - [ ] Add item-mana query/cancel state, health query cancellation, and one shared split-quantity owner for the entry, slider, and item drag payload. +**Partial implementation (2026-07-11):** the four stance-specific combat +indicator buttons `0x10000192..0x10000195` now all dispatch one injected combat +toggle command. The key binding and retained toolbar therefore converge on +`GameWindow.ToggleLiveCombatMode`, matching `gmToolbarUI::ListenToElementMessage +@ 0x004BEE90`; the visible indicator continues to update from authoritative +`CombatState`. Use, Examine, remaining panel buttons, quick slots, and selected +object residuals remain in this wave. + **Oracles:** `gmToolbarUI @ 0x004BD0C0..0x004BF380`, `UIElement_UIItem::SetShortcutNum`, shortcut wire builders. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3ff381fd..bf3fb79f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2009,7 +2009,8 @@ public sealed class GameWindow : IDisposable // Retail UpdateCursorState (0x00564630) keys target-mode // valid/invalid off the SmartBox found object — the world // entity under the cursor, self included. - worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u); + worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u, + combatModeProvider: () => Combat.CurrentMode); var retailCursorManager = new RetailCursorManager(_dats!, _datLock); _characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider( Objects, LocalPlayer, @@ -2111,6 +2112,7 @@ public sealed class GameWindow : IDisposable (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), UseItemByGuid, Combat, + ToggleLiveCombatMode, _itemInteractionController, (index, guid) => _liveSession?.SendAddShortcut(index, guid), index => _liveSession?.SendRemoveShortcut(index), diff --git a/src/AcDream.App/Rendering/RetailCursorManager.cs b/src/AcDream.App/Rendering/RetailCursorManager.cs index 099aa4a0..bc2de0a6 100644 --- a/src/AcDream.App/Rendering/RetailCursorManager.cs +++ b/src/AcDream.App/Rendering/RetailCursorManager.cs @@ -15,8 +15,12 @@ public sealed class RetailCursorManager private readonly RetailCursorResolver _globalCursors; private readonly Dictionary _imagesBySurface = new(); private readonly HashSet _missingSurfaces = new(); - private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1); - private UiCursorMedia _lastCursor; + private readonly HashSet _reportedFallbacks = new(); + private bool _hasLayerState; + private RetailGlobalCursorKind _lastGlobalKind; + private UiCursorMedia _lastWidgetCursor; + private UiCursorMedia _lastAppliedCursor; + private StandardCursor? _lastStandardCursor; public RetailCursorManager(DatCollection dats, object datLock) { @@ -27,31 +31,51 @@ public sealed class RetailCursorManager public void Apply(IEnumerable mice, CursorFeedback feedback) { - if (feedback.Cursor.IsValid && TryGetImage(feedback.Cursor.File, out var image)) + foreach (RetailCursorLayer layer in PlanApplication( + _hasLayerState, + _lastGlobalKind, + _lastWidgetCursor, + feedback.GlobalKind, + feedback.Cursor)) { - ApplyCustom(mice, feedback.Cursor, image); - _lastKind = feedback.Kind; - _lastCursor = feedback.Cursor; - return; + if (layer == RetailCursorLayer.Widget + && feedback.Cursor.IsValid + && TryGetImage(feedback.Cursor.File, out var image)) + { + ApplyCustom(mice, feedback.Cursor, image); + } + else + { + ApplyGlobal(mice, feedback.GlobalKind); + } } - if (_globalCursors.TryResolve(feedback.Kind, out var globalCursor) + _hasLayerState = true; + _lastGlobalKind = feedback.GlobalKind; + _lastWidgetCursor = feedback.Cursor; + } + + private void ApplyGlobal(IEnumerable mice, RetailGlobalCursorKind kind) + { + if (_globalCursors.TryResolve(kind, out var globalCursor) && TryGetImage(globalCursor.File, out var globalImage)) { ApplyCustom(mice, globalCursor, globalImage); - _lastKind = feedback.Kind; - _lastCursor = globalCursor; return; } - ApplyStandard(mice, StandardCursorFor(feedback.Kind)); - _lastKind = feedback.Kind; - _lastCursor = default; + if (_reportedFallbacks.Add(kind)) + { + Console.Error.WriteLine( + $"[D.2b] retail cursor {kind} could not be resolved from DAT; " + + "using the registered OS cursor adaptation (AP-72)."); + } + ApplyStandard(mice, StandardCursorFor(kind)); } private void ApplyCustom(IEnumerable mice, UiCursorMedia cursorMedia, RawImage image) { - if (_lastCursor.Equals(cursorMedia)) + if (_lastStandardCursor is null && _lastAppliedCursor.Equals(cursorMedia)) return; foreach (var mouse in mice) @@ -63,12 +87,14 @@ public sealed class RetailCursorManager if (cursor.Type != CursorType.Custom) cursor.Type = CursorType.Custom; } + + _lastAppliedCursor = cursorMedia; + _lastStandardCursor = null; } private void ApplyStandard(IEnumerable mice, StandardCursor desired) { - if (_lastCursor.Equals(default(UiCursorMedia)) && _lastKind != (CursorFeedbackKind)(-1) - && StandardCursorFor(_lastKind) == desired) + if (_lastStandardCursor == desired) return; foreach (var mouse in mice) @@ -85,6 +111,9 @@ public sealed class RetailCursorManager if (cursor.StandardCursor != standard) cursor.StandardCursor = standard; } + + _lastAppliedCursor = default; + _lastStandardCursor = desired; } private bool TryGetImage(uint renderSurfaceId, out RawImage image) @@ -122,21 +151,47 @@ public sealed class RetailCursorManager } } - private static StandardCursor StandardCursorFor(CursorFeedbackKind kind) + internal static IReadOnlyList PlanApplication( + bool hasLayerState, + RetailGlobalCursorKind previousGlobal, + UiCursorMedia previousWidget, + RetailGlobalCursorKind currentGlobal, + UiCursorMedia currentWidget) + { + bool globalChanged = !hasLayerState || previousGlobal != currentGlobal; + bool widgetChanged = !hasLayerState || previousWidget != currentWidget; + if (!globalChanged && !widgetChanged) + return Array.Empty(); + + var result = new List(2); + if (globalChanged) + result.Add(RetailCursorLayer.Global); + + if (widgetChanged) + { + RetailCursorLayer widgetResult = currentWidget.IsValid + ? RetailCursorLayer.Widget + : RetailCursorLayer.Global; + if (result.Count == 0 || result[^1] != widgetResult) + result.Add(widgetResult); + } + + return result; + } + + private static StandardCursor StandardCursorFor(RetailGlobalCursorKind kind) => kind switch { - CursorFeedbackKind.Text => StandardCursor.IBeam, - CursorFeedbackKind.WindowMove => StandardCursor.ResizeAll, - CursorFeedbackKind.ResizeHorizontal => StandardCursor.HResize, - CursorFeedbackKind.ResizeVertical => StandardCursor.VResize, - CursorFeedbackKind.ResizeDiagonalNwse => StandardCursor.NwseResize, - CursorFeedbackKind.ResizeDiagonalNesw => StandardCursor.NeswResize, - CursorFeedbackKind.Drag => StandardCursor.Hand, - CursorFeedbackKind.DragAccept => StandardCursor.ResizeAll, - CursorFeedbackKind.DragReject => StandardCursor.NotAllowed, - CursorFeedbackKind.TargetPending => StandardCursor.Crosshair, - CursorFeedbackKind.TargetValid => StandardCursor.ResizeAll, - CursorFeedbackKind.TargetInvalid => StandardCursor.NotAllowed, + RetailGlobalCursorKind.Use or RetailGlobalCursorKind.UseFound => StandardCursor.Hand, + RetailGlobalCursorKind.TargetPending => StandardCursor.Crosshair, + RetailGlobalCursorKind.TargetValid => StandardCursor.ResizeAll, + RetailGlobalCursorKind.TargetInvalid => StandardCursor.NotAllowed, _ => StandardCursor.Arrow, }; } + +internal enum RetailCursorLayer +{ + Global, + Widget, +} diff --git a/src/AcDream.App/Rendering/RetailCursorResolver.cs b/src/AcDream.App/Rendering/RetailCursorResolver.cs index 4cced48b..7ecaf011 100644 --- a/src/AcDream.App/Rendering/RetailCursorResolver.cs +++ b/src/AcDream.App/Rendering/RetailCursorResolver.cs @@ -18,7 +18,7 @@ internal sealed class RetailCursorResolver _datLock = datLock; } - public bool TryResolve(CursorFeedbackKind kind, out UiCursorMedia cursor) + public bool TryResolve(RetailGlobalCursorKind kind, out UiCursorMedia cursor) { cursor = default; if (!RetailCursorCatalog.TryGetGlobalCursor(kind, out var spec)) diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index 880fd851..31685f4d 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -1,3 +1,5 @@ +using AcDream.Core.Combat; + namespace AcDream.App.UI; /// @@ -9,6 +11,10 @@ namespace AcDream.App.UI; public enum CursorFeedbackKind { Default, + Combat, + Use, + Examine, + Busy, Text, WindowMove, ResizeHorizontal, @@ -23,7 +29,42 @@ public enum CursorFeedbackKind TargetInvalid, } -public readonly record struct CursorFeedback(CursorFeedbackKind Kind, UiCursorMedia Cursor = default) +/// Retail Target_Mode values consumed by UpdateCursorState. +public enum RetailCursorTargetMode +{ + None = 0, + Use = 1, + Examine = 2, + UseTarget = 3, +} + +/// +/// The global/default cursor selected by ClientUISystem. Widget-local cursor +/// media is a separate layer and may override this through CheckCursor. +/// +public enum RetailGlobalCursorKind +{ + Default, + DefaultFound, + MeleeOrMissile, + MeleeOrMissileFound, + Magic, + MagicFound, + Examine, + ExamineFound, + Use, + UseFound, + Busy, + BusyFound, + TargetPending, + TargetValid, + TargetInvalid, +} + +public readonly record struct CursorFeedback( + CursorFeedbackKind Kind, + UiCursorMedia Cursor = default, + RetailGlobalCursorKind GlobalKind = RetailGlobalCursorKind.Default) { public static CursorFeedback Default { get; } = new(CursorFeedbackKind.Default); } @@ -37,19 +78,26 @@ public readonly record struct CursorFeedbackSnapshot( bool HoverWindowMove = false, bool HoverUi = false, bool HoverTextEdit = false, - uint HoverTargetGuid = 0); + uint HoverTargetGuid = 0, + bool? HoverTargetCompatible = null, + int BusyCount = 0, + RetailCursorTargetMode TargetMode = RetailCursorTargetMode.None, + CombatMode CombatMode = CombatMode.NonCombat); public sealed class CursorFeedbackController { private readonly ItemInteractionController? _itemInteraction; private readonly Func? _worldTargetProvider; + private readonly Func _combatModeProvider; public CursorFeedbackController( ItemInteractionController? itemInteraction = null, - Func? worldTargetProvider = null) + Func? worldTargetProvider = null, + Func? combatModeProvider = null) { _itemInteraction = itemInteraction; _worldTargetProvider = worldTargetProvider; + _combatModeProvider = combatModeProvider ?? (() => CombatMode.NonCombat); } public CursorFeedback Current { get; private set; } = CursorFeedback.Default; @@ -65,13 +113,18 @@ public sealed class CursorFeedbackController // window occludes the world (no found object → pending). The one // UI-side source retail-style cells contribute is an occupied item // slot's own item. - uint hoverTarget = 0; - if (_itemInteraction?.IsTargetModeActive == true) - { - hoverTarget = hover is not null + RetailCursorTargetMode targetMode = _itemInteraction?.IsTargetModeActive == true + ? RetailCursorTargetMode.UseTarget + : RetailCursorTargetMode.None; + uint hoverTarget = hover is null + ? _worldTargetProvider?.Invoke() ?? 0u + : targetMode == RetailCursorTargetMode.UseTarget ? FindHoveredItemSlot(hover)?.ItemId ?? 0u - : _worldTargetProvider?.Invoke() ?? 0u; - } + : 0u; + bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget + && hoverTarget != 0 + ? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget) + : null; var snapshot = new CursorFeedbackSnapshot( DragPayload: root.DragPayload, @@ -82,10 +135,14 @@ public sealed class CursorFeedbackController HoverWindowMove: root.HoverWindowMove, HoverUi: hover is not null, HoverTextEdit: hover?.IsEditControl == true, - HoverTargetGuid: hoverTarget); + HoverTargetGuid: hoverTarget, + HoverTargetCompatible: hoverTargetCompatible, + BusyCount: _itemInteraction?.BusyCount ?? 0, + TargetMode: targetMode, + CombatMode: _combatModeProvider()); var kind = ResolveKind(snapshot); - Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind)); + Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover), ResolveGlobalKind(snapshot)); return Current; } @@ -96,7 +153,7 @@ public sealed class CursorFeedbackController } public CursorFeedback Resolve(CursorFeedbackSnapshot snapshot) - => new(ResolveKind(snapshot)); + => new(ResolveKind(snapshot), GlobalKind: ResolveGlobalKind(snapshot)); private CursorFeedbackKind ResolveKind(CursorFeedbackSnapshot snapshot) { @@ -119,11 +176,15 @@ public sealed class CursorFeedbackController if (snapshot.WindowMoveActive) return CursorFeedbackKind.WindowMove; - if (_itemInteraction?.IsTargetModeActive == true) + RetailCursorTargetMode targetMode = EffectiveTargetMode(snapshot); + if (targetMode == RetailCursorTargetMode.UseTarget) { if (snapshot.HoverTargetGuid != 0) { - return _itemInteraction.IsCurrentTargetCompatible(snapshot.HoverTargetGuid) + bool compatible = snapshot.HoverTargetCompatible + ?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid) + ?? false; + return compatible ? CursorFeedbackKind.TargetValid : CursorFeedbackKind.TargetInvalid; } @@ -136,15 +197,78 @@ public sealed class CursorFeedbackController return CursorFeedbackKind.TargetPending; } + if (snapshot.BusyCount > 0) + return CursorFeedbackKind.Busy; + + if (targetMode == RetailCursorTargetMode.Use) + return CursorFeedbackKind.Use; + + if (targetMode == RetailCursorTargetMode.Examine) + return CursorFeedbackKind.Examine; + if (snapshot.HoverWindowMove) return CursorFeedbackKind.WindowMove; if (snapshot.HoverTextEdit) return CursorFeedbackKind.Text; + if (snapshot.CombatMode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic) + return CursorFeedbackKind.Combat; + return CursorFeedbackKind.Default; } + /// + /// Faithful port of ClientUISystem::UpdateCursorState @ 0x00564630. + /// The found-object pair is selected first; busy then target mode then + /// combat mode replace it in exactly retail's order. + /// + internal RetailGlobalCursorKind ResolveGlobalKind(CursorFeedbackSnapshot snapshot) + { + bool found = snapshot.HoverTargetGuid != 0; + + if (snapshot.BusyCount > 0) + return found ? RetailGlobalCursorKind.BusyFound : RetailGlobalCursorKind.Busy; + + switch (EffectiveTargetMode(snapshot)) + { + case RetailCursorTargetMode.Use: + return found ? RetailGlobalCursorKind.UseFound : RetailGlobalCursorKind.Use; + case RetailCursorTargetMode.Examine: + return found ? RetailGlobalCursorKind.ExamineFound : RetailGlobalCursorKind.Examine; + case RetailCursorTargetMode.UseTarget: + if (!found) + return RetailGlobalCursorKind.TargetPending; + bool compatible = snapshot.HoverTargetCompatible + ?? _itemInteraction?.IsCurrentTargetCompatible(snapshot.HoverTargetGuid) + ?? false; + return compatible + ? RetailGlobalCursorKind.TargetValid + : RetailGlobalCursorKind.TargetInvalid; + case RetailCursorTargetMode.None: + break; + default: + throw new ArgumentOutOfRangeException(nameof(snapshot), snapshot.TargetMode, "Unknown retail target mode."); + } + + return snapshot.CombatMode switch + { + CombatMode.Melee or CombatMode.Missile => found + ? RetailGlobalCursorKind.MeleeOrMissileFound + : RetailGlobalCursorKind.MeleeOrMissile, + CombatMode.Magic => found + ? RetailGlobalCursorKind.MagicFound + : RetailGlobalCursorKind.Magic, + _ => found ? RetailGlobalCursorKind.DefaultFound : RetailGlobalCursorKind.Default, + }; + } + + private RetailCursorTargetMode EffectiveTargetMode(CursorFeedbackSnapshot snapshot) + => snapshot.TargetMode == RetailCursorTargetMode.None + && _itemInteraction?.IsTargetModeActive == true + ? RetailCursorTargetMode.UseTarget + : snapshot.TargetMode; + private static CursorFeedbackKind KindForResize(ResizeEdges edges) { bool horizontal = (edges & (ResizeEdges.Left | ResizeEdges.Right)) != 0; @@ -163,90 +287,22 @@ public sealed class CursorFeedbackController return CursorFeedbackKind.Default; } - private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover, CursorFeedbackKind kind) + private static UiCursorMedia ResolveCursor(UiElement? captured, UiElement? hover) { - foreach (var stateName in CursorStateNamesForKind(kind)) - { - var semantic = FindCursorForState(hover, stateName); - if (semantic.IsValid) - return semantic; - } - - var capturedCursor = FindActiveCursor(captured); + // UIElementManager::CheckCursor @ 0x0045ABF0: captured element first, + // then the last-entered element, then the ClientUISystem default. It + // does not search ancestors or synthesize a different UI state. + var capturedCursor = captured?.ActiveCursor() ?? default; if (capturedCursor.IsValid) return capturedCursor; - var hoverCursor = FindActiveCursor(hover); + var hoverCursor = hover?.ActiveCursor() ?? default; if (hoverCursor.IsValid) return hoverCursor; return default; } - private static IReadOnlyList CursorStateNamesForKind(CursorFeedbackKind kind) - => kind switch - { - CursorFeedbackKind.DragAccept => new[] - { - "Drag_rollover_accept", - "ItemSlot_DragOver_Accept", - "ItemSlot_DragOver_DropIn", - }, - CursorFeedbackKind.TargetValid => new[] - { - "Drag_rollover_accept", - "ItemSlot_DragOver_Accept", - "ItemSlot_DragOver_DropIn", - "Csm_highlight", - }, - CursorFeedbackKind.DragReject or CursorFeedbackKind.TargetInvalid => new[] - { - "Drag_rollover_reject", - "ItemSlot_DragOver_Reject", - "Csm_ghosted", - }, - CursorFeedbackKind.Drag => new[] - { - "ItemSlot_DragOver_Normal", - "Drag_rollover_accept", - }, - CursorFeedbackKind.TargetPending => new[] - { - "DDDMode", - "Csm_normal", - }, - CursorFeedbackKind.WindowMove => new[] - { - "Csm_highlight", - "Csm_normal", - }, - _ => Array.Empty(), - }; - - private static UiCursorMedia FindCursorForState(UiElement? element, string stateName) - { - while (element is not null) - { - var cursor = element.CursorForState(stateName, allowFallback: false); - if (cursor.IsValid) - return cursor; - element = element.Parent; - } - return default; - } - - private static UiCursorMedia FindActiveCursor(UiElement? element) - { - while (element is not null) - { - var cursor = element.ActiveCursor(); - if (cursor.IsValid) - return cursor; - element = element.Parent; - } - return default; - } - private static UiItemSlot? FindHoveredItemSlot(UiElement? element) { while (element is not null) diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index a7f29268..71350880 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -97,7 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint[]? emptyDigits, ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, - Action? sendRemoveShortcut = null) + Action? sendRemoveShortcut = null, + Action? toggleCombat = null) { _repo = repo; _combatState = combatState; @@ -128,8 +129,16 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont } // Cache the four mutually-exclusive combat-mode indicator elements. + // Retail gmToolbarUI::ListenToElementMessage @ 0x004BEE90 handles + // Click (message 1) from ANY id 0x10000192..0x10000195 by calling + // ClientCombatSystem::ToggleCombatMode. Exactly one is visible, but all + // four retain the same command binding as stance changes swap the art. for (int i = 0; i < CombatIndicatorIds.Length; i++) + { _combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]); + if (_combatIndicators[i] is UiButton button) + button.OnClick = toggleCombat; + } _characterButton = layout.FindElement(CharacterButtonId) as UiButton; _inventoryButton = layout.FindElement(InventoryButtonId) as UiButton; @@ -223,11 +232,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont uint[]? emptyDigits = null, ItemInteractionController? itemInteraction = null, Action? sendAddShortcut = null, - Action? sendRemoveShortcut = null) + Action? sendRemoveShortcut = null, + Action? toggleCombat = null) { var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, peaceDigits, warDigits, emptyDigits, itemInteraction, - sendAddShortcut, sendRemoveShortcut); + sendAddShortcut, sendRemoveShortcut, toggleCombat); c.Populate(); return c; } diff --git a/src/AcDream.App/UI/RetailCursorCatalog.cs b/src/AcDream.App/UI/RetailCursorCatalog.cs index 07bb474c..4d6b62d2 100644 --- a/src/AcDream.App/UI/RetailCursorCatalog.cs +++ b/src/AcDream.App/UI/RetailCursorCatalog.cs @@ -5,18 +5,28 @@ internal static class RetailCursorCatalog { public const uint CursorEnumTable = 6; - // ClientUISystem::UpdateCursorState (0x00564630): - // TARGET_MODE_USE_TARGET -> enum 0x27 when no SmartBox target is found, - // enum 0x28 when ItemHolder::IsTargetCompatibleWithTargetingObject returns true, - // enum 0x29 when it returns false. UIElementManager::SetCursor is called - // with hotspot (14,14) for all three. - public static bool TryGetGlobalCursor(CursorFeedbackKind kind, out RetailCursorSpec spec) + // Complete reachable ClientUISystem::UpdateCursorState (0x00564630) + // catalog. Enum values 0x07..0x09 exist in the DAT but are not selected by + // this method, so no semantics are invented for them here. + public static bool TryGetGlobalCursor(RetailGlobalCursorKind kind, out RetailCursorSpec spec) { spec = kind switch { - CursorFeedbackKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14), - CursorFeedbackKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14), - CursorFeedbackKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14), + RetailGlobalCursorKind.Default => new RetailCursorSpec(0x01u, 0, 0), + RetailGlobalCursorKind.DefaultFound => new RetailCursorSpec(0x02u, 0, 0), + RetailGlobalCursorKind.MeleeOrMissile => new RetailCursorSpec(0x03u, 0, 0), + RetailGlobalCursorKind.MeleeOrMissileFound => new RetailCursorSpec(0x04u, 0, 0), + RetailGlobalCursorKind.Magic => new RetailCursorSpec(0x05u, 0, 0), + RetailGlobalCursorKind.MagicFound => new RetailCursorSpec(0x06u, 0, 0), + RetailGlobalCursorKind.Examine => new RetailCursorSpec(0x0Au, 0, 0), + RetailGlobalCursorKind.ExamineFound => new RetailCursorSpec(0x0Bu, 0, 0), + RetailGlobalCursorKind.Use => new RetailCursorSpec(0x0Cu, 14, 14), + RetailGlobalCursorKind.UseFound => new RetailCursorSpec(0x0Du, 14, 14), + RetailGlobalCursorKind.Busy => new RetailCursorSpec(0x0Eu, 0, 0), + RetailGlobalCursorKind.BusyFound => new RetailCursorSpec(0x0Fu, 0, 0), + RetailGlobalCursorKind.TargetPending => new RetailCursorSpec(0x27u, 14, 14), + RetailGlobalCursorKind.TargetValid => new RetailCursorSpec(0x28u, 14, 14), + RetailGlobalCursorKind.TargetInvalid => new RetailCursorSpec(0x29u, 14, 14), _ => default, }; diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 7c4a5bb0..ada712bb 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, + Action ToggleCombat, ItemInteractionController ItemInteraction, Action? SendAddShortcut, Action? SendRemoveShortcut, @@ -358,7 +359,8 @@ public sealed class RetailUiRuntime : IDisposable ToolbarRuntimeBindings b = _bindings.Toolbar; ToolbarController = Layout.ToolbarController.Bind( layout, b.Objects, b.Shortcuts, b.ResolveIcon, b.UseItem, b.Combat, - peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut); + peace, war, empty, b.ItemInteraction, b.SendAddShortcut, b.SendRemoveShortcut, + toggleCombat: b.ToggleCombat); SelectedObjectController = Layout.SelectedObjectController.Bind( layout, b.Selection, diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 05a20bae..5993c96f 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -1,4 +1,5 @@ using AcDream.App.UI; +using AcDream.Core.Combat; using AcDream.Core.Items; namespace AcDream.App.Tests.UI; @@ -72,9 +73,15 @@ public sealed class CursorFeedbackControllerTests Assert.True(interaction.ActivateItem(Source)); Assert.Equal(CursorFeedbackKind.TargetValid, - c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind); + c.Resolve(new CursorFeedbackSnapshot( + HoverUi: true, + HoverTargetGuid: Player, + HoverTargetCompatible: true)).Kind); Assert.Equal(CursorFeedbackKind.TargetInvalid, - c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind); + c.Resolve(new CursorFeedbackSnapshot( + HoverUi: true, + HoverTargetGuid: 0x5000BADu, + HoverTargetCompatible: false)).Kind); // Retail UpdateCursorState: no found object → PENDING, including over // UI chrome (the HoverUi→Invalid arm was a non-retail invention). Assert.Equal(CursorFeedbackKind.TargetPending, @@ -84,7 +91,7 @@ public sealed class CursorFeedbackControllerTests } [Fact] - public void UpdateFromRoot_slotItemDrivesTargetCursor_withStateArt() + public void UpdateFromRoot_slotItemDrivesGlobalTargetCursor_withoutInventingWidgetState() { var objects = SeedTargetObjects(); objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items @@ -118,7 +125,8 @@ public sealed class CursorFeedbackControllerTests var feedback = c.Update(root); Assert.Equal(CursorFeedbackKind.TargetValid, feedback.Kind); - Assert.Equal(acceptCursor, feedback.Cursor); + Assert.Equal(RetailGlobalCursorKind.TargetValid, feedback.GlobalKind); + Assert.False(feedback.Cursor.IsValid); } [Fact] @@ -149,6 +157,79 @@ public sealed class CursorFeedbackControllerTests Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind); } + public static TheoryData GlobalCursorCases => new() + { + { new(), RetailGlobalCursorKind.Default }, + { new(HoverTargetGuid: Target), RetailGlobalCursorKind.DefaultFound }, + { new(CombatMode: CombatMode.Melee), RetailGlobalCursorKind.MeleeOrMissile }, + { new(HoverTargetGuid: Target, CombatMode: CombatMode.Melee), RetailGlobalCursorKind.MeleeOrMissileFound }, + { new(CombatMode: CombatMode.Missile), RetailGlobalCursorKind.MeleeOrMissile }, + { new(CombatMode: CombatMode.Magic), RetailGlobalCursorKind.Magic }, + { new(HoverTargetGuid: Target, CombatMode: CombatMode.Magic), RetailGlobalCursorKind.MagicFound }, + { new(TargetMode: RetailCursorTargetMode.Examine), RetailGlobalCursorKind.Examine }, + { new(HoverTargetGuid: Target, TargetMode: RetailCursorTargetMode.Examine), RetailGlobalCursorKind.ExamineFound }, + { new(TargetMode: RetailCursorTargetMode.Use), RetailGlobalCursorKind.Use }, + { new(HoverTargetGuid: Target, TargetMode: RetailCursorTargetMode.Use), RetailGlobalCursorKind.UseFound }, + { new(BusyCount: 1), RetailGlobalCursorKind.Busy }, + { new(HoverTargetGuid: Target, BusyCount: 1), RetailGlobalCursorKind.BusyFound }, + { new(TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetPending }, + { new(HoverTargetGuid: Target, HoverTargetCompatible: true, TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetValid }, + { new(HoverTargetGuid: Target, HoverTargetCompatible: false, TargetMode: RetailCursorTargetMode.UseTarget), RetailGlobalCursorKind.TargetInvalid }, + }; + + [Theory] + [MemberData(nameof(GlobalCursorCases))] + public void GlobalCursorDecisionTree_matchesUpdateCursorState( + CursorFeedbackSnapshot snapshot, + RetailGlobalCursorKind expected) + { + var c = new CursorFeedbackController(); + + Assert.Equal(expected, c.Resolve(snapshot).GlobalKind); + } + + [Fact] + public void BusyGlobalCursor_winsOverTargetAndCombat() + { + var c = new CursorFeedbackController(); + var snapshot = new CursorFeedbackSnapshot( + HoverTargetGuid: Target, + HoverTargetCompatible: true, + BusyCount: 1, + TargetMode: RetailCursorTargetMode.UseTarget, + CombatMode: CombatMode.Magic); + + Assert.Equal(RetailGlobalCursorKind.BusyFound, c.Resolve(snapshot).GlobalKind); + } + + [Fact] + public void WidgetCursorPrecedence_isCapturedThenHoveredThenGlobalDefault() + { + var root = new UiRoot { Width = 200, Height = 100 }; + var captured = new UiPanel { Left = 0, Top = 0, Width = 50, Height = 50 }; + var hovered = new UiPanel { Left = 60, Top = 0, Width = 50, Height = 50 }; + var capturedCursor = new UiCursorMedia(0x06001001u, 1, 2); + var hoveredCursor = new UiCursorMedia(0x06001002u, 3, 4); + captured.SetStateCursors(new Dictionary { [""] = capturedCursor }); + hovered.SetStateCursors(new Dictionary { [""] = hoveredCursor }); + root.AddChild(captured); + root.AddChild(hovered); + var c = new CursorFeedbackController(); + + root.OnMouseMove(10, 10); + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(70, 10); + Assert.Equal(capturedCursor, c.Update(root).Cursor); + + root.OnMouseUp(UiMouseButton.Left, 70, 10); + Assert.Equal(hoveredCursor, c.Update(root).Cursor); + + root.OnMouseMove(150, 75); + var fallback = c.Update(root); + Assert.False(fallback.Cursor.IsValid); + Assert.Equal(RetailGlobalCursorKind.Default, fallback.GlobalKind); + } + private static ClientObjectTable SeedTargetObjects() { var objects = new ClientObjectTable(); diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 6afb53e7..f9fbcd3a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -30,11 +30,12 @@ public class ToolbarControllerTests var root = new UiPanel(); foreach (var id in Row1) AddSlot(id); foreach (var id in Row2) AddSlot(id); - // Add combat indicator elements as plain UiPanels keyed by id. + // Combat indicators are retail UiButtons; one is visible at a time, + // and every stance-specific instance dispatches the same toggle command. foreach (var id in CombatIds) { - var e = new UiPanel { Visible = true }; - dict[id] = e; indicators[id] = e; root.AddChild(e); + AddButton(id); + indicators[id] = dict[id]; } AddButton(CharacterButtonId); AddButton(InventoryButtonId); @@ -232,6 +233,27 @@ public class ToolbarControllerTests // ── C1: combat-mode indicator tests ───────────────────────────────────── + [Fact] + public void CombatIndicators_allDispatchSharedRetailToggleCommand() + { + var (layout, _, indicators) = FakeToolbar(); + var repo = new ClientObjectTable(); + int toggles = 0; + + ToolbarController.Bind( + layout, + repo, + () => Array.Empty(), + iconIds: (_, _, _, _, _) => 0u, + useItem: _ => { }, + toggleCombat: () => toggles++); + + foreach (uint id in CombatIds) + ((UiButton)indicators[id]).OnClick?.Invoke(); + + Assert.Equal(CombatIds.Length, toggles); + } + /// /// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible; /// the melee/missile/magic indicators (0x10000193/4/5) are hidden. diff --git a/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs index 877b54e3..661bcc14 100644 --- a/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs @@ -9,11 +9,23 @@ namespace AcDream.App.Tests.UI; public sealed class RetailCursorCatalogTests { [Theory] - [InlineData(CursorFeedbackKind.TargetPending, 0x27u, 14, 14)] - [InlineData(CursorFeedbackKind.TargetValid, 0x28u, 14, 14)] - [InlineData(CursorFeedbackKind.TargetInvalid, 0x29u, 14, 14)] - public void TargetUseCursorSpecs_matchClientUISystemUpdateCursorState( - CursorFeedbackKind kind, + [InlineData(RetailGlobalCursorKind.Default, 0x01u, 0, 0)] + [InlineData(RetailGlobalCursorKind.DefaultFound, 0x02u, 0, 0)] + [InlineData(RetailGlobalCursorKind.MeleeOrMissile, 0x03u, 0, 0)] + [InlineData(RetailGlobalCursorKind.MeleeOrMissileFound, 0x04u, 0, 0)] + [InlineData(RetailGlobalCursorKind.Magic, 0x05u, 0, 0)] + [InlineData(RetailGlobalCursorKind.MagicFound, 0x06u, 0, 0)] + [InlineData(RetailGlobalCursorKind.Examine, 0x0Au, 0, 0)] + [InlineData(RetailGlobalCursorKind.ExamineFound, 0x0Bu, 0, 0)] + [InlineData(RetailGlobalCursorKind.Use, 0x0Cu, 14, 14)] + [InlineData(RetailGlobalCursorKind.UseFound, 0x0Du, 14, 14)] + [InlineData(RetailGlobalCursorKind.Busy, 0x0Eu, 0, 0)] + [InlineData(RetailGlobalCursorKind.BusyFound, 0x0Fu, 0, 0)] + [InlineData(RetailGlobalCursorKind.TargetPending, 0x27u, 14, 14)] + [InlineData(RetailGlobalCursorKind.TargetValid, 0x28u, 14, 14)] + [InlineData(RetailGlobalCursorKind.TargetInvalid, 0x29u, 14, 14)] + public void GlobalCursorSpecs_matchClientUISystemUpdateCursorState( + RetailGlobalCursorKind kind, uint expectedEnum, int expectedHotspotX, int expectedHotspotY) @@ -28,7 +40,7 @@ public sealed class RetailCursorCatalogTests } [Fact] - public void TargetUseCursorSpecs_resolveGoldenDatSurfaces() + public void ReachableGlobalCursorSpecs_resolveGoldenDatSurfaces() { string? datDir = ResolveDatDir(); if (datDir is null) @@ -37,13 +49,30 @@ public sealed class RetailCursorCatalogTests using var dats = new DatCollection(datDir, DatAccessType.Read); var resolver = new RetailCursorResolver(dats, new object()); - Assert.True(resolver.TryResolve(new RetailCursorSpec(0x27u, 14, 14), out var pending)); - Assert.True(resolver.TryResolve(new RetailCursorSpec(0x28u, 14, 14), out var valid)); - Assert.True(resolver.TryResolve(new RetailCursorSpec(0x29u, 14, 14), out var invalid)); + var expected = new Dictionary + { + [RetailGlobalCursorKind.Default] = new(0x06004D68u, 0, 0), + [RetailGlobalCursorKind.DefaultFound] = new(0x06004D69u, 0, 0), + [RetailGlobalCursorKind.MeleeOrMissile] = new(0x06004D6Au, 0, 0), + [RetailGlobalCursorKind.MeleeOrMissileFound] = new(0x06004D6Bu, 0, 0), + [RetailGlobalCursorKind.Magic] = new(0x06004D6Cu, 0, 0), + [RetailGlobalCursorKind.MagicFound] = new(0x06004D6Du, 0, 0), + [RetailGlobalCursorKind.Examine] = new(0x06004D71u, 0, 0), + [RetailGlobalCursorKind.ExamineFound] = new(0x06004D71u, 0, 0), + [RetailGlobalCursorKind.Use] = new(0x06004D72u, 14, 14), + [RetailGlobalCursorKind.UseFound] = new(0x06004D72u, 14, 14), + [RetailGlobalCursorKind.Busy] = new(0x06004D74u, 0, 0), + [RetailGlobalCursorKind.BusyFound] = new(0x06004D75u, 0, 0), + [RetailGlobalCursorKind.TargetPending] = new(0x06004D73u, 14, 14), + [RetailGlobalCursorKind.TargetValid] = new(0x06005E6Bu, 14, 14), + [RetailGlobalCursorKind.TargetInvalid] = new(0x06005E6Au, 14, 14), + }; - Assert.Equal(new UiCursorMedia(0x06004D73u, 14, 14), pending); - Assert.Equal(new UiCursorMedia(0x06005E6Bu, 14, 14), valid); - Assert.Equal(new UiCursorMedia(0x06005E6Au, 14, 14), invalid); + foreach (var (kind, cursor) in expected) + { + Assert.True(resolver.TryResolve(kind, out var actual), kind.ToString()); + Assert.Equal(cursor, actual); + } } private static string? ResolveDatDir() diff --git a/tests/AcDream.App.Tests/UI/RetailCursorManagerTests.cs b/tests/AcDream.App.Tests/UI/RetailCursorManagerTests.cs new file mode 100644 index 00000000..3dc0717a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/RetailCursorManagerTests.cs @@ -0,0 +1,62 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; + +namespace AcDream.App.Tests.UI; + +public sealed class RetailCursorManagerTests +{ + private static readonly UiCursorMedia WidgetA = new(0x06001001u, 1, 2); + private static readonly UiCursorMedia WidgetB = new(0x06001002u, 3, 4); + + [Fact] + public void InitialState_appliesGlobalDefaultThenWidgetOverride() + { + var plan = RetailCursorManager.PlanApplication( + hasLayerState: false, + previousGlobal: default, + previousWidget: default, + currentGlobal: RetailGlobalCursorKind.Default, + currentWidget: WidgetA); + + Assert.Equal(new[] { RetailCursorLayer.Global, RetailCursorLayer.Widget }, plan); + } + + [Fact] + public void GlobalChange_reassertsDefaultWithoutReapplyingUnchangedWidget() + { + var plan = RetailCursorManager.PlanApplication( + hasLayerState: true, + previousGlobal: RetailGlobalCursorKind.Default, + previousWidget: WidgetA, + currentGlobal: RetailGlobalCursorKind.TargetPending, + currentWidget: WidgetA); + + Assert.Equal(new[] { RetailCursorLayer.Global }, plan); + } + + [Fact] + public void LaterWidgetTransition_overridesUnchangedGlobalDefault() + { + var plan = RetailCursorManager.PlanApplication( + hasLayerState: true, + previousGlobal: RetailGlobalCursorKind.TargetPending, + previousWidget: WidgetA, + currentGlobal: RetailGlobalCursorKind.TargetPending, + currentWidget: WidgetB); + + Assert.Equal(new[] { RetailCursorLayer.Widget }, plan); + } + + [Fact] + public void ClearingWidgetCursor_restoresGlobalDefault() + { + var plan = RetailCursorManager.PlanApplication( + hasLayerState: true, + previousGlobal: RetailGlobalCursorKind.TargetPending, + previousWidget: WidgetA, + currentGlobal: RetailGlobalCursorKind.TargetPending, + currentWidget: default); + + Assert.Equal(new[] { RetailCursorLayer.Global }, plan); + } +}