diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index bfdd3505..99f0eb0f 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -150,7 +150,13 @@ public sealed class ItemInteractionController
if (source?.Useability is not { } useability)
return false;
- if (!TargetCompatible(source, targetGuid, useability))
+ bool compatible = TargetCompatible(source, targetGuid, useability);
+ var target = _objects.Get(targetGuid);
+ Console.WriteLine(
+ $"[use-target] src=0x{sourceGuid:X8} use=0x{useability:X8} ttypeMask=0x{source.TargetType ?? 0u:X8}"
+ + $" tgt=0x{targetGuid:X8} tgtKind={(target is null ? "none" : $"0x{(uint)target.Type:X8}")}"
+ + $" -> {(compatible ? "SEND UseWithTarget" : "refused")}");
+ if (!compatible)
return false;
_sendUseWithTarget?.Invoke(sourceGuid, targetGuid);
@@ -333,30 +339,48 @@ public sealed class ItemInteractionController
return false;
}
+ ///
+ /// Faithful port of ItemHolder::IsTargetCompatibleWithTargetingObject
+ /// (0x00588070) for a player-owned source (ActivateItem's SourceCompatible
+ /// already applied retail's GetLeastLimitedSourceUse arm when target mode was
+ /// armed). Retail shape: (1) a LOCATION constraint applies only when the
+ /// least-limited target-use bit (ItemUses::GetLeastLimitedTargetUse
+ /// 0x004fcd50) is Contained — target must be in our inventory, sole
+ /// exception self when the Self bit is present — or Wielded (refuse
+ /// non-owned); (2) the player as target additionally requires the Self bit
+ /// (ItemUses::IsUseable_SelfTarget 0x004fcd30); (3) EVERY target then passes
+ /// the KIND gate: source._targetType & target->InqType(). A
+ /// missing/zero TargetType mask therefore matches nothing, exactly like
+ /// retail. Retail's tradeState!=1 refusal is skipped — acdream has no trade
+ /// state yet. The previous per-bit arms (Remote ⇒ accept-anything, invented
+ /// Viewed/Contained accepts, self path skipping the kind gate) were the
+ /// 2026-07-03 "yellow bullseye over a coat" bug.
+ ///
private bool TargetCompatible(ClientObject source, uint targetGuid, uint useability)
{
uint flags = ItemUseability.TargetFlags(useability);
uint player = _playerGuid();
- if (targetGuid == source.ObjectId && (flags & ItemUseability.ObjSelf) != 0)
- return true;
-
- if (targetGuid == player)
- return (flags & ItemUseability.Self) != 0;
-
var target = _objects.Get(targetGuid);
if (target is null)
- return (flags & ItemUseability.Remote) != 0;
+ return false; // retail: GetWeenieObject(target) null → incompatible
- if (source.TargetType is { } targetType && targetType != 0
- && ((uint)target.Type & targetType) == 0)
+ if (!(IsCarriedByPlayer(target) || IsEquippedByPlayer(target)))
+ {
+ uint least = ItemUseability.LeastLimitedTargetUse(useability);
+ if ((least & ItemUseability.Contained) != 0)
+ {
+ if (!(targetGuid == player && (flags & ItemUseability.Self) != 0))
+ return false;
+ }
+ else if ((least & ItemUseability.Wielded) != 0)
+ return false;
+ }
+
+ if (targetGuid == player && (flags & ItemUseability.Self) == 0)
return false;
- if ((flags & ItemUseability.Wielded) != 0 && IsWieldedByPlayer(target)) return true;
- if ((flags & ItemUseability.Contained) != 0 && IsCarriedByPlayer(target)) return true;
- if ((flags & ItemUseability.Viewed) != 0 && IsCarriedByPlayer(target)) return true;
- if ((flags & ItemUseability.Remote) != 0) return true;
- return false;
+ return ((source.TargetType ?? 0u) & (uint)target.Type) != 0;
}
private bool IsWieldedByPlayer(ClientObject item)
diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs
index c29f6fff..4f7c035b 100644
--- a/src/AcDream.App/UI/Layout/PaperdollController.cs
+++ b/src/AcDream.App/UI/Layout/PaperdollController.cs
@@ -125,6 +125,15 @@ 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(); };
+ }
var slotsBtnEl = layout.FindElement(0x100005BEu);
if (slotsBtnEl is UiButton slotsBtn)
diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs
index 8a8c8ffa..d6636edc 100644
--- a/src/AcDream.App/UI/UiViewport.cs
+++ b/src/AcDream.App/UI/UiViewport.cs
@@ -9,6 +9,24 @@ public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
+ /// Optional click action (e.g. the paperdoll doll = "target self"
+ /// during item target-use). Non-null makes the viewport handle the press
+ /// itself instead of it being captured as a window move.
+ public System.Action? Clicked { get; set; }
+
+ public override bool HandlesClick => Clicked is not null;
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (Clicked is null) return base.OnEvent(e);
+ switch (e.Type)
+ {
+ case UiEventType.MouseDown: return true; // consume the press; act on Click
+ case UiEventType.Click: Clicked.Invoke(); return true;
+ }
+ return base.OnEvent(e);
+ }
+
/// Renderer that produces the off-screen texture. Set by GameWindow wiring (later task).
public IUiViewportRenderer? Renderer { get; set; }
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index 2b7f10f4..3e017e0b 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -254,6 +254,25 @@ public static class ItemUseability
public static uint TargetFlags(uint useability)
=> (useability & TargetMask) >> 16;
+ ///
+ /// Retail ItemUses::GetLeastLimitedTargetUse (0x004fcd50): the most
+ /// permissive target-use bit present, priority Remote > Viewed >
+ /// Contained > Wielded > Self > ObjSelf. The target-compat gate
+ /// (ItemHolder::IsTargetCompatibleWithTargetingObject 0x00588070)
+ /// only enforces a location constraint when the BEST available bit is
+ /// Contained or Wielded.
+ ///
+ public static uint LeastLimitedTargetUse(uint useability)
+ {
+ uint t = TargetFlags(useability);
+ if ((t & Remote) != 0) return Remote;
+ if ((t & Viewed) != 0) return Viewed;
+ if ((t & Contained) != 0) return Contained;
+ if ((t & Wielded) != 0) return Wielded;
+ if ((t & Self) != 0) return Self;
+ return t & ObjSelf;
+ }
+
public static bool IsDirectUseable(uint useability)
{
uint source = SourceFlags(useability);
diff --git a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
index b3bc6b85..77267eb9 100644
--- a/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs
@@ -9,7 +9,8 @@ public sealed class CursorFeedbackControllerTests
private const uint Pack = 0x50000010u;
private const uint Source = 0x50000020u;
private const uint Target = 0x50000021u;
- private const uint HealthKitUseability = 0x000A0008u;
+ // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
+ private const uint HealthKitUseability = 0x00220008u;
[Fact]
public void DragState_winsOverResizeAndUsesAcceptRejectCursors()
@@ -141,6 +142,7 @@ public sealed class CursorFeedbackControllerTests
Name = "Health Kit",
Type = ItemType.Misc,
Useability = HealthKitUseability,
+ TargetType = (uint)ItemType.Creature, // retail kind gate: kits target creatures/players
});
objects.MoveItem(Source, Pack, 0);
objects.AddOrUpdate(new ClientObject
diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
index 926ef73c..0a54512f 100644
--- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs
@@ -7,7 +7,9 @@ public sealed class ItemInteractionControllerTests
{
private const uint Player = 0x50000001u;
private const uint Pack = 0x50000010u;
- private const uint HealthKitUseability = 0x000A0008u;
+ // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF (acclient.h ITEM_USEABLE;
+ // ACE Usable.SourceContainedTargetRemoteOrSelf) — the classic healing-kit value.
+ private const uint HealthKitUseability = 0x00220008u;
private sealed class Harness
{
@@ -82,7 +84,11 @@ public sealed class ItemInteractionControllerTests
public void SelfTarget_sendsUseWithTargetAndClearsTargetMode()
{
var h = new Harness();
- h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability);
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = HealthKitUseability;
+ item.TargetType = (uint)ItemType.Creature; // kits target creatures/players
+ });
h.Controller.ActivateItem(0x50000A01u);
Assert.True(h.Controller.AcquireSelfTarget());
@@ -95,7 +101,11 @@ public sealed class ItemInteractionControllerTests
public void ActivateTargetItemDuringTargetMode_usesClickedItemAsTarget()
{
var h = new Harness();
- h.AddContained(0x50000A01u, item => item.Useability = 0x00080008u);
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = 0x00080008u; // TARGET_CONTAINED tool
+ item.TargetType = (uint)ItemType.Misc; // kind gate must pass for the send
+ });
h.AddContained(0x50000A02u);
h.Controller.ActivateItem(0x50000A01u);
@@ -105,6 +115,86 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.IsTargetModeActive);
}
+ // ── Retail target-compat gate (ItemHolder::IsTargetCompatibleWithTargetingObject 0x00588070) ──
+
+ [Fact]
+ public void TargetCompat_wrongKind_refusesAndClearsMode()
+ {
+ var h = new Harness();
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = HealthKitUseability;
+ item.TargetType = (uint)ItemType.Creature;
+ });
+ h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor); // a coat
+
+ h.Controller.ActivateItem(0x50000A01u);
+ Assert.False(h.Controller.ActivateItem(0x50000A02u)); // kind gate: Creature mask & Armor = 0
+
+ Assert.Empty(h.UseWithTarget);
+ Assert.False(h.Controller.IsTargetModeActive);
+ }
+
+ [Fact]
+ public void IsCurrentTargetCompatible_appliesKindGateForCursor()
+ {
+ var h = new Harness();
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = HealthKitUseability;
+ item.TargetType = (uint)ItemType.Creature;
+ });
+ h.AddContained(0x50000A02u, item => item.Type = ItemType.Armor);
+
+ h.Controller.ActivateItem(0x50000A01u);
+
+ Assert.True(h.Controller.IsCurrentTargetCompatible(Player)); // self: Self bit + Creature kind
+ Assert.False(h.Controller.IsCurrentTargetCompatible(0x50000A02u)); // coat: yellow-over-items bug pin
+ }
+
+ [Fact]
+ public void SelfTarget_requiresSelfBit()
+ {
+ var h = new Harness();
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = 0x00200008u; // TARGET_REMOTE only — no Self bit
+ item.TargetType = (uint)ItemType.Creature;
+ });
+
+ h.Controller.ActivateItem(0x50000A01u);
+ Assert.False(h.Controller.AcquireSelfTarget()); // IsUseable_SelfTarget 0x004fcd30
+ Assert.Empty(h.UseWithTarget);
+ }
+
+ [Fact]
+ public void MissingTargetTypeMask_matchesNothing()
+ {
+ var h = new Harness();
+ h.AddContained(0x50000A01u, item => item.Useability = HealthKitUseability); // no TargetType on wire
+
+ h.Controller.ActivateItem(0x50000A01u);
+ Assert.False(h.Controller.AcquireSelfTarget()); // retail: 0 mask & anything == 0
+ Assert.Empty(h.UseWithTarget);
+ }
+
+ [Fact]
+ public void ContainedTarget_refusesNonOwnedWorldObject()
+ {
+ var h = new Harness();
+ h.AddContained(0x50000A01u, item =>
+ {
+ item.Useability = 0x00080008u; // Contained is the least-limited target bit
+ item.TargetType = (uint)ItemType.Misc;
+ });
+ // right KIND, but out in the world — Contained requires our inventory:
+ h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x60000001u, Type = ItemType.Misc });
+
+ h.Controller.ActivateItem(0x50000A01u);
+ Assert.False(h.Controller.AcquireTarget(0x60000001u));
+ Assert.Empty(h.UseWithTarget);
+ }
+
[Fact]
public void DirectUseItem_sendsUse()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
index 53a14ef2..20e1b27f 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs
@@ -149,7 +149,12 @@ public class ToolbarControllerTests
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container, ItemsCapacity = 24 });
repo.MoveItem(pack, player, 0);
- repo.AddOrUpdate(new ClientObject { ObjectId = kit, Type = ItemType.Misc, Useability = 0x000A0008u });
+ repo.AddOrUpdate(new ClientObject
+ {
+ ObjectId = kit, Type = ItemType.Misc,
+ Useability = 0x00220008u, // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF
+ TargetType = (uint)ItemType.Creature, // retail kind gate
+ });
repo.MoveItem(kit, pack, 0);
var useWithTarget = new List<(uint Source, uint Target)>();
var interaction = new ItemInteractionController(
diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
index 6e27b3a9..9d2e8939 100644
--- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
+++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs
@@ -13,7 +13,8 @@ public sealed class RetailUiInteractionFlowTests
private const uint HealthKit = 0x50001002u;
private const uint SlotsButtonId = 0x100005BEu;
private const uint ChestArmorSlotId = 0x100005ACu;
- private const uint HealthKitUseability = 0x000A0008u;
+ // USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF — the classic healing-kit value.
+ private const uint HealthKitUseability = 0x00220008u;
private const EquipMask HauberkMask =
EquipMask.ChestArmor
@@ -194,6 +195,7 @@ public sealed class RetailUiInteractionFlowTests
Type = ItemType.Misc,
IconId = 0x06001235u,
Useability = HealthKitUseability,
+ TargetType = (uint)ItemType.Creature, // retail kind gate
});
Objects.MoveItem(HealthKit, Player, slot);
}