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()
{