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); }