feat(ui): complete retail cursor state machine
Port every reachable ClientUISystem cursor branch through the production DAT enum map, preserve global-default versus widget-local event ordering, and surface the registered OS fallback. Route all four toolbar stance indicators through the same combat toggle command as the key binding, with golden DAT and transition conformance coverage. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
05f6222865
commit
6fcc510d5d
16 changed files with 535 additions and 164 deletions
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -15,8 +15,12 @@ public sealed class RetailCursorManager
|
|||
private readonly RetailCursorResolver _globalCursors;
|
||||
private readonly Dictionary<uint, RawImage> _imagesBySurface = new();
|
||||
private readonly HashSet<uint> _missingSurfaces = new();
|
||||
private CursorFeedbackKind _lastKind = (CursorFeedbackKind)(-1);
|
||||
private UiCursorMedia _lastCursor;
|
||||
private readonly HashSet<RetailGlobalCursorKind> _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<IMouse> 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))
|
||||
{
|
||||
if (layer == RetailCursorLayer.Widget
|
||||
&& feedback.Cursor.IsValid
|
||||
&& TryGetImage(feedback.Cursor.File, out var image))
|
||||
{
|
||||
ApplyCustom(mice, feedback.Cursor, image);
|
||||
_lastKind = feedback.Kind;
|
||||
_lastCursor = feedback.Cursor;
|
||||
return;
|
||||
}
|
||||
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<IMouse> 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<IMouse> 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<IMouse> 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<RetailCursorLayer> 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<RetailCursorLayer>();
|
||||
|
||||
var result = new List<RetailCursorLayer>(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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using AcDream.Core.Combat;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -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)
|
||||
/// <summary>Retail <c>Target_Mode</c> values consumed by UpdateCursorState.</summary>
|
||||
public enum RetailCursorTargetMode
|
||||
{
|
||||
None = 0,
|
||||
Use = 1,
|
||||
Examine = 2,
|
||||
UseTarget = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The global/default cursor selected by ClientUISystem. Widget-local cursor
|
||||
/// media is a separate layer and may override this through CheckCursor.
|
||||
/// </summary>
|
||||
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<uint>? _worldTargetProvider;
|
||||
private readonly Func<CombatMode> _combatModeProvider;
|
||||
|
||||
public CursorFeedbackController(
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Func<uint>? worldTargetProvider = null)
|
||||
Func<uint>? worldTargetProvider = null,
|
||||
Func<CombatMode>? 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> 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<string>(),
|
||||
};
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
uint[]? emptyDigits,
|
||||
ItemInteractionController? itemInteraction = null,
|
||||
Action<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
Action<uint>? 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<uint, uint>? sendAddShortcut = null,
|
||||
Action<uint>? sendRemoveShortcut = null)
|
||||
Action<uint>? 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public sealed record ToolbarRuntimeBindings(
|
|||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Action<uint> UseItem,
|
||||
CombatState Combat,
|
||||
Action ToggleCombat,
|
||||
ItemInteractionController ItemInteraction,
|
||||
Action<uint, uint>? SendAddShortcut,
|
||||
Action<uint>? 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,
|
||||
|
|
|
|||
|
|
@ -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<CursorFeedbackSnapshot, RetailGlobalCursorKind> 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<string, UiCursorMedia> { [""] = capturedCursor });
|
||||
hovered.SetStateCursors(new Dictionary<string, UiCursorMedia> { [""] = 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();
|
||||
|
|
|
|||
|
|
@ -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<PlayerDescriptionParser.ShortcutEntry>(),
|
||||
iconIds: (_, _, _, _, _) => 0u,
|
||||
useItem: _ => { },
|
||||
toggleCombat: () => toggles++);
|
||||
|
||||
foreach (uint id in CombatIds)
|
||||
((UiButton)indicators[id]).OnClick?.Invoke();
|
||||
|
||||
Assert.Equal(CombatIds.Length, toggles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible;
|
||||
/// the melee/missile/magic indicators (0x10000193/4/5) are hidden.
|
||||
|
|
|
|||
|
|
@ -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, UiCursorMedia>
|
||||
{
|
||||
[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()
|
||||
|
|
|
|||
62
tests/AcDream.App.Tests/UI/RetailCursorManagerTests.cs
Normal file
62
tests/AcDream.App.Tests/UI/RetailCursorManagerTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue