diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d48be1a8..4e7e0cde 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,6 +46,21 @@ Copy this block when adding a new issue:
---
+## #166 — Port the portal String-table lookup for WeenieError / UseDone text
+
+**Status:** OPEN
+**Severity:** LOW
+**Filed:** 2026-07-03
+**Component:** ui / net
+
+**Description:** UseDone (0x01C7) refusals now surface in chat, but the text comes from `WeenieErrorText.For` — a hardcoded subset map (0x001D/0x04EB/0x04FC/0x04FE) with a generic fallback. Retail resolves the WeenieError code through the client String tables into the canonical sentence. Port that lookup (portal String-table dat objects) and delete the map. Register row AP-74.
+
+**Files:** `src/AcDream.Core.Net/Messages/WeenieErrorText.cs`, `src/AcDream.Core.Net/GameEventWiring.cs` (UseDone registration).
+
+**Acceptance:** every WeenieError code prints retail's exact string; AP-74 retired.
+
+---
+
## #159 — Retail chat ChatVM lacks Fps/Position providers — /framerate and /loc degrade
**Status:** OPEN
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 2d88228a..ec3d5cfc 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -98,7 +98,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 70 rows
+## 3. Documented approximation (AP) — 71 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -172,6 +172,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
+| AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now VISIBLE (the silent-refusal class is closed); only the wording of unmapped codes deviates, and those print the raw code. Retire by porting the String-table lookup (#166). | An unmapped WeenieError shows "You can't do that. (error 0xNNNN)" instead of retail's exact sentence — cosmetic only; check #166 before adding more hardcoded rows. | retail String-table error lookup (unported); ACE `WeenieError.cs` values |
| AP-73 | **Character raises apply optimistically with no pending/rollback ledger** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits unassigned XP / skill credits locally (via eventful store APIs). Retail's local-predict behavior for raises is unverified; item moves in acdream reconcile via RecordPending/Confirm/Rollback but raises do not (**#163**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ApplyLocalRaise`) | The next server property echo (PlayerDescription / private stat updates) is authoritative and overwrites every optimistic value; ACE confirms affordable raises, and the affordability gate runs client-side first (`Cost <= 0` refuses). | A server-rejected raise (XP race, validation) leaves ranks/XP/credits wrong on the sheet until the next full property refresh — retire by porting the ledger (#163) or cdb-tracing retail's raise flow. | unverified — candidate cdb trace: retail raise-button send path |
---
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e2b2dea8..b615c796 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1844,7 +1844,12 @@ public sealed class GameWindow : IDisposable
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
toast: text => _debugVm?.AddToast(text));
- _cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(_itemInteractionController);
+ _cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
+ _itemInteractionController,
+ // 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);
_retailCursorManager = new RetailCursorManager(_dats!, _datLock);
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
@@ -12375,26 +12380,27 @@ public sealed class GameWindow : IDisposable
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
// ============================================================
- private void PickAndStoreSelection(bool useImmediately)
+ ///
+ /// Shared world pick at the current cursor — the 2026-05-16
+ /// retail-faithful screen-rect picker (hit area = the target
+ /// indicator's rect via the shared ScreenProjection helper; the old
+ /// per-type radius/offset heuristics are retired). Used by the click
+ /// select path AND the target-mode cursor hover (retail
+ /// SmartBox::get_found_object_id analogue). :
+ /// item target-use may pick the LOCAL PLAYER (retail lets you kit-heal
+ /// yourself by clicking your own toon); plain selection never does.
+ ///
+ private uint? PickWorldGuidAtCursor(bool includeSelf)
{
- if (_cameraController is null || _window is null) return;
+ if (_cameraController is null || _window is null) return null;
- // 2026-05-16 — retail-faithful screen-rect picker. The hit area
- // is the same screen-space rect the target indicator draws
- // (computed via the shared AcDream.Core.Selection.ScreenProjection
- // helper). Per user feedback 2026-05-16: clicking the indicator
- // brackets — including the rect corners — must select the entity.
- // The per-type radius/offset heuristics retired here (1.0/1.6/2.0
- // m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
- // existed to make a 3D ray-sphere picker approximate the visible
- // rect; the new picker doesn't need them.
var camera = _cameraController.Active;
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
// cached EnvCell physics so the picker can occlude entities behind
- // walls. Snapshot is per-pick (one click), iteration is bounded
- // by the streaming radius (~80 cells at radius 4).
+ // walls. Snapshot is per-pick (one click / one hover frame),
+ // iteration is bounded by the streaming radius (~80 cells at radius 4).
var loadedCellPhysics = new List();
foreach (var cellId in _physicsDataCache.CellStructIds)
{
@@ -12402,12 +12408,12 @@ public sealed class GameWindow : IDisposable
if (cp is not null) loadedCellPhysics.Add(cp);
}
- var picked = AcDream.Core.Selection.WorldPicker.Pick(
+ return AcDream.Core.Selection.WorldPicker.Pick(
mouseX: _lastMouseX, mouseY: _lastMouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,
- skipServerGuid: _playerServerGuid,
+ skipServerGuid: includeSelf ? 0u : _playerServerGuid,
// Resolver: Setup's SelectionSphere is the ONLY input. If the
// entity's Setup didn't bake a SelectionSphere, return null —
// the picker skips it, which matches retail behaviour
@@ -12429,6 +12435,16 @@ public sealed class GameWindow : IDisposable
? (origin, direction) =>
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
: null);
+ }
+
+ private void PickAndStoreSelection(bool useImmediately)
+ {
+ if (_cameraController is null || _window is null) return;
+
+ // Target-use mode picks WITH self (retail: kit-heal yourself by
+ // clicking your own toon); plain selection keeps the self-exclusion.
+ var picked = PickWorldGuidAtCursor(
+ includeSelf: _itemInteractionController?.IsTargetModeActive == true);
if (picked is uint guid)
{
diff --git a/src/AcDream.App/UI/CursorFeedbackController.cs b/src/AcDream.App/UI/CursorFeedbackController.cs
index 71ae6231..880fd851 100644
--- a/src/AcDream.App/UI/CursorFeedbackController.cs
+++ b/src/AcDream.App/UI/CursorFeedbackController.cs
@@ -42,10 +42,14 @@ public readonly record struct CursorFeedbackSnapshot(
public sealed class CursorFeedbackController
{
private readonly ItemInteractionController? _itemInteraction;
+ private readonly Func? _worldTargetProvider;
- public CursorFeedbackController(ItemInteractionController? itemInteraction = null)
+ public CursorFeedbackController(
+ ItemInteractionController? itemInteraction = null,
+ Func? worldTargetProvider = null)
{
_itemInteraction = itemInteraction;
+ _worldTargetProvider = worldTargetProvider;
}
public CursorFeedback Current { get; private set; } = CursorFeedback.Default;
@@ -55,6 +59,20 @@ public sealed class CursorFeedbackController
ArgumentNullException.ThrowIfNull(root);
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.
+ uint hoverTarget = 0;
+ if (_itemInteraction?.IsTargetModeActive == true)
+ {
+ hoverTarget = hover is not null
+ ? FindHoveredItemSlot(hover)?.ItemId ?? 0u
+ : _worldTargetProvider?.Invoke() ?? 0u;
+ }
+
var snapshot = new CursorFeedbackSnapshot(
DragPayload: root.DragPayload,
DragAccept: FindHoveredItemSlot(hover)?.DragAcceptVisual ?? UiItemSlot.DragAcceptState.None,
@@ -64,7 +82,7 @@ public sealed class CursorFeedbackController
HoverWindowMove: root.HoverWindowMove,
HoverUi: hover is not null,
HoverTextEdit: hover?.IsEditControl == true,
- HoverTargetGuid: ResolveUseTargetGuid(hover));
+ HoverTargetGuid: hoverTarget);
var kind = ResolveKind(snapshot);
Current = new CursorFeedback(kind, ResolveCursor(root.Captured, hover, kind));
@@ -110,9 +128,12 @@ public sealed class CursorFeedbackController
: CursorFeedbackKind.TargetInvalid;
}
- return snapshot.HoverUi
- ? CursorFeedbackKind.TargetInvalid
- : CursorFeedbackKind.TargetPending;
+ // Retail UpdateCursorState (0x00564630), TARGET_MODE 3: no found
+ // object → the 0x27 four-arrows pending cursor — INCLUDING over
+ // UI chrome. Valid/invalid exist only with a target under the
+ // cursor. (The earlier HoverUi → Invalid arm was a non-retail
+ // invention — 2026-07-03 visual gate.)
+ return CursorFeedbackKind.TargetPending;
}
if (snapshot.HoverWindowMove)
@@ -236,20 +257,4 @@ public sealed class CursorFeedbackController
}
return null;
}
-
- private static uint ResolveUseTargetGuid(UiElement? element)
- {
- while (element is not null)
- {
- uint provided = element.UseTargetGuidProvider?.Invoke() ?? 0u;
- if (provided != 0)
- return provided;
-
- if (element is UiItemSlot { ItemId: not 0 } slot)
- return slot.ItemId;
-
- element = element.Parent;
- }
- return 0u;
- }
}
diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs
index 4f7c035b..c967f9a1 100644
--- a/src/AcDream.App/UI/Layout/PaperdollController.cs
+++ b/src/AcDream.App/UI/Layout/PaperdollController.cs
@@ -99,7 +99,6 @@ public sealed class PaperdollController : IItemListDragHandler
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
- list.Cell.UseTargetGuidProvider = _playerGuid;
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
list.Cell.DoubleClicked = () =>
{
@@ -125,15 +124,12 @@ public sealed class PaperdollController : IItemListDragHandler
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
_dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3)
- if (_dollViewport is not null)
- {
- // The doll IS the player: hovering it during target-use resolves the
- // valid/invalid cursor for SELF (retail: the SmartBox target is your
- // own creature), and clicking it self-targets like the equip cells.
- _dollViewport.UseTargetGuidProvider = _playerGuid;
- if (_dollViewport is UiViewport doll)
- doll.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
- }
+ // The doll IS the player: clicking it during target-use self-targets
+ // like the equip cells do. (The CURSOR over it stays the pending
+ // four-arrows — retail resolves target cursors off the SmartBox world
+ // object only; UI hover is always pending. 2026-07-03 visual gate.)
+ if (_dollViewport is UiViewport doll)
+ doll.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
var slotsBtnEl = layout.FindElement(0x100005BEu);
if (slotsBtnEl is UiButton slotsBtn)
diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs
index 26a8029b..fc86bc99 100644
--- a/src/AcDream.App/UI/Layout/ToolbarController.cs
+++ b/src/AcDream.App/UI/Layout/ToolbarController.cs
@@ -230,8 +230,6 @@ public sealed class ToolbarController : IItemListDragHandler
{
if (_inventoryButton is not null)
{
- if (_itemInteraction is not null)
- _inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
_inventoryButton.OnClick = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs
index 643518bd..eb0e9e3d 100644
--- a/src/AcDream.App/UI/UiElement.cs
+++ b/src/AcDream.App/UI/UiElement.cs
@@ -183,14 +183,6 @@ public abstract class UiElement
/// self-driven interior drag such as text selection). Default false.
public virtual bool HandlesClick => false;
- ///
- /// Optional game-object target used by item-use cursor/target mode when this UI
- /// element represents a target that is not its visible item icon. Example:
- /// the status-bar backpack button and paperdoll both mean "self" for a
- /// health-kit-style use-with-target action.
- ///
- public Func? UseTargetGuidProvider { get; set; }
-
/// Minimum size enforced while resizing.
public float MinWidth { get; set; } = 40f;
public float MinHeight { get; set; } = 40f;
diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs
index 12de64fd..b9e82d6f 100644
--- a/src/AcDream.Core.Net/GameEventWiring.cs
+++ b/src/AcDream.Core.Net/GameEventWiring.cs
@@ -306,6 +306,20 @@ public static class GameEventWiring
Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} rolledBack={rolledBack} item={itemInfo}");
});
+ // UseDone (0x01C7) — the Use/UseWithTarget completion signal. A non-zero
+ // code is a WeenieError refusal ("You are not trained in healing!" etc.);
+ // retail surfaces the string-table text as a chat line. Interim text map:
+ // WeenieErrorText (#166 / register AP-74). Without this line a refused
+ // kit-heal looks like "nothing happened" — the 2026-07-03 session bug.
+ dispatcher.Register(GameEventType.UseDone, e =>
+ {
+ uint? err = GameEvents.ParseUseDone(e.Payload.Span);
+ if (err is null) return;
+ Console.WriteLine($"[use-done] err=0x{err.Value:X4}");
+ if (err.Value != 0)
+ chat.OnSystemMessage(WeenieErrorText.For(err.Value), chatType: 0);
+ });
+
// B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container
// view. No table change (the view is UI-only, wired in container-open); log.
dispatcher.Register(GameEventType.CloseGroundContainer, e =>
diff --git a/src/AcDream.Core.Net/Messages/WeenieErrorText.cs b/src/AcDream.Core.Net/Messages/WeenieErrorText.cs
new file mode 100644
index 00000000..3656c7b5
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/WeenieErrorText.cs
@@ -0,0 +1,23 @@
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Interim WeenieError → display text. Retail resolves error codes through
+/// the portal String tables into lines like "You are not trained in
+/// healing!"; porting that lookup is #166 — until then this map (codes from
+/// ACE's WeenieError enum, texts phrased after the retail messages the
+/// enum names encode) covers the errors our current use/heal flows can
+/// produce. Register row AP-74. Unknown codes fall back to a generic line
+/// carrying the code so nothing is ever silently dropped again (the
+/// 2026-07-03 "kit did nothing" session was an unsurfaced 0x04FC).
+///
+public static class WeenieErrorText
+{
+ public static string For(uint error) => error switch
+ {
+ 0x001Du => "You're too busy!", // YoureTooBusy
+ 0x04EBu => "You can't do that while in the air!", // YouCantDoThatWhileInTheAir
+ 0x04FCu => "You are not trained in healing!", // YouArentTrainedInHealing
+ 0x04FEu => "You can't heal that!", // YouCantHealThat
+ _ => $"You can't do that. (error 0x{error:X4})",
+ };
+}
diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
index 77267eb9..05a20bae 100644
--- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
@@ -75,16 +75,19 @@ public sealed class CursorFeedbackControllerTests
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: Player)).Kind);
Assert.Equal(CursorFeedbackKind.TargetInvalid,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true, HoverTargetGuid: 0x5000BADu)).Kind);
- Assert.Equal(CursorFeedbackKind.TargetInvalid,
+ // Retail UpdateCursorState: no found object → PENDING, including over
+ // UI chrome (the HoverUi→Invalid arm was a non-retail invention).
+ Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot(HoverUi: true)).Kind);
Assert.Equal(CursorFeedbackKind.TargetPending,
c.Resolve(new CursorFeedbackSnapshot()).Kind);
}
[Fact]
- public void UpdateFromRoot_usesUseTargetGuidProviderBeforeSlotItem()
+ public void UpdateFromRoot_slotItemDrivesTargetCursor_withStateArt()
{
var objects = SeedTargetObjects();
+ objects.Get(Source)!.TargetType = (uint)ItemType.Misc; // a tool that targets items
var interaction = new ItemInteractionController(
objects,
playerGuid: () => Player,
@@ -100,7 +103,6 @@ public sealed class CursorFeedbackControllerTests
Top = 10,
Width = 32,
Height = 32,
- UseTargetGuidProvider = () => Player,
};
var acceptCursor = new UiCursorMedia(0x06009999u, 11, 12);
slot.SetStateCursors(new Dictionary
@@ -119,6 +121,34 @@ public sealed class CursorFeedbackControllerTests
Assert.Equal(acceptCursor, feedback.Cursor);
}
+ [Fact]
+ public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
+ {
+ var objects = SeedTargetObjects();
+ var interaction = new ItemInteractionController(
+ objects,
+ playerGuid: () => Player,
+ sendUse: null,
+ sendUseWithTarget: null,
+ sendWield: null,
+ sendDrop: null,
+ nowMs: () => 1_000);
+ var root = new UiRoot { Width = 800, Height = 600 }; // nothing under the mouse
+ root.OnMouseMove(400, 300);
+ var c = new CursorFeedbackController(interaction, worldTargetProvider: () => Player);
+
+ Assert.True(interaction.ActivateItem(Source));
+
+ // World hover (retail SmartBox found object) drives valid/invalid…
+ Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
+
+ // …but UI occludes the world: hovering a plain panel → PENDING even
+ // though the world provider would return a valid target.
+ var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 };
+ root.AddChild(panel);
+ Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
+ }
+
private static ClientObjectTable SeedTargetObjects()
{
var objects = new ClientObjectTable();