From aed423174b1c12f85b7b5a45f40d31104cd8e3cf Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 16 Aug 2026 23:02:42 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20#411=20=E2=80=94=20pointer=20swaps?= =?UTF-8?q?=20over=20inventory=20items=20unconditionally,=20not=20only=20i?= =?UTF-8?q?n=20UseTarget=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects an incomplete reading from #411's original investigation. UIElement_SmartBoxWrapper::FindObject @0x004E5430 calls SmartBox::set_found_object(itemID, 0xFFFFFFFF) whenever the hovered UI element (m_pElementLastOver) casts to UIElement_UIItem (class 0x10000032) — UNCONDITIONALLY, not gated on target mode, and returns WITHOUT running the 3D raycast. ClientUISystem:: UpdateCursorState @0x00564630 computes its "found" flag ONCE at the top of the function (ebx = SmartBox::get_found_object_id() != 0, @0x00564642) and every later branch (default/melee-missile/magic/ use/examine/use-target/busy) reads that SAME flag — so hovering an occupied item cell shows the cursor's "...Found" variant in EVERY mode, not only during an active UseTarget selection. CursorFeedbackController.Update(UiRoot) already had the item-hover special case wired from an earlier round but incorrectly gated it to TargetMode.UseTarget only; that one-line gate is removed. ResolveGlobalKind needed no changes at all — it already read the snapshot's HoverTargetGuid unconditionally across every mode. Two new tests pin the widened behavior in ordinary peace mode and in combat mode. Live-DAT-independent (pure decomp + unit fixture). Co-Authored-By: Claude Fable 5 --- .../UI/CursorFeedbackController.cs | 31 +++++++++---- .../UI/CursorFeedbackControllerTests.cs | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs index 46b04511..1c98994b 100644 --- a/src/AcDream.App/UI/CursorFeedbackController.cs +++ b/src/AcDream.App/UI/CursorFeedbackController.cs @@ -109,17 +109,32 @@ public sealed class CursorFeedbackController UiElement? hover = root.Pick(root.MouseX, root.MouseY); - // Retail UpdateCursorState (0x00564630) keys the target-mode cursor off - // the SmartBox found object — the WORLD entity under the cursor. A UI - // 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. + // Retail UpdateCursorState (0x00564630) keys EVERY mode's cursor off + // the SAME SmartBox found-object flag, computed once at the top of + // the function (ebx = SmartBox::get_found_object_id() != 0, + // @0x00564642) and read verbatim by every later branch — target mode + // and combat mode only pick WHICH cursor variant (Default vs. + // DefaultFound, Use vs. UseFound, ...) to show for that SAME found + // state, never whether it is set. + // + // #411 correction (2026-08-16): the found object itself is not + // world-only. UIElement_SmartBoxWrapper::FindObject @0x004E5430 runs + // every frame regardless of input focus (Global_Loop @0x004E5620) + // and, when the currently-hovered UI element (m_pElementLastOver) + // casts to UIElement_UIItem (0x10000032), calls + // SmartBox::set_found_object(itemID) directly — UNCONDITIONALLY, not + // gated on target mode — and returns WITHOUT running the 3D raycast. + // So hovering an occupied item cell sets the SAME found flag in + // EVERY mode (peace, melee/missile, magic, busy, examine, use, + // use-target), which is why retail's cursor visibly changes there — + // the earlier reading here (item slots only contribute in + // UseTarget) covered only the Valid/Invalid sub-branch, not the + // found flag driving the Default/Combat/Use/Examine/Busy Found + // variants too. RetailCursorTargetMode targetMode = ModeFromInteraction(_itemInteraction); uint hoverTarget = hover is null ? _worldTargetProvider?.Invoke() ?? 0u - : targetMode == RetailCursorTargetMode.UseTarget - ? FindHoveredItemSlot(hover)?.ItemId ?? 0u - : 0u; + : FindHoveredItemSlot(hover)?.ItemId ?? 0u; bool? hoverTargetCompatible = targetMode == RetailCursorTargetMode.UseTarget && hoverTarget != 0 ? _itemInteraction?.IsCurrentTargetCompatible(hoverTarget) diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs index 3a18783f..8239b1ad 100644 --- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs @@ -308,6 +308,51 @@ public sealed class CursorFeedbackControllerTests Assert.False(feedback.Cursor.IsValid); } + /// + /// #411 fix (2026-08-16): UIElement_SmartBoxWrapper::FindObject + /// @0x004E5430 calls SmartBox::set_found_object(itemID) whenever the + /// hovered UI element casts to UIElement_UIItem, UNCONDITIONALLY — not + /// only in TARGET_MODE_USE_TARGET. ClientUISystem::UpdateCursorState + /// @0x00564630 then reads that SAME found flag from every mode branch + /// (Default/Combat/Use/Examine/Busy), so hovering an occupied item cell + /// in ordinary peace mode (no active interaction mode at all) must show + /// the Found cursor variant. This is the earlier-STOPped "does retail + /// swap the pointer over inventory items" question, resolved: yes, + /// unconditionally. + /// + [Fact] + public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InOrdinaryPeaceMode() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 }; + slot.SetItem(Target, iconTexture: 1u); + root.AddChild(slot); + root.OnMouseMove(20, 20); + var c = new CursorFeedbackController(); // no ItemInteractionController — TargetMode.None throughout + + var feedback = c.Update(root); + + Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind); + } + + /// Same #411 fix, combat mode: the found flag is mode- + /// independent, so an item hover under an active combat stance shows + /// MeleeOrMissileFound, not the plain (not-found) MeleeOrMissile. + [Fact] + public void UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_InCombatMode() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var slot = new UiItemSlot { Left = 10, Top = 10, Width = 32, Height = 32 }; + slot.SetItem(Target, iconTexture: 1u); + root.AddChild(slot); + root.OnMouseMove(20, 20); + var c = new CursorFeedbackController(combatModeProvider: () => CombatMode.Melee); + + var feedback = c.Update(root); + + Assert.Equal(RetailGlobalCursorKind.MeleeOrMissileFound, feedback.GlobalKind); + } + [Fact] public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered() {