fix(ui): retail-faithful use-target compat gate + paperdoll doll self-target

Visual gate on the item-interaction slice found two target-use bugs:
the valid-target bullseye showed over inventory ITEMS (a coat is not a
valid healing-kit target) while the paperdoll doll showed blocked (it
should mean SELF).

- TargetCompatible is now a faithful port of
  ItemHolder::IsTargetCompatibleWithTargetingObject (0x00588070):
  a location constraint applies only when the least-limited target-use
  bit (ItemUses::GetLeastLimitedTargetUse 0x004fcd50, new
  ItemUseability.LeastLimitedTargetUse) is Contained/Wielded; the
  player as target requires the Self bit (IsUseable_SelfTarget
  0x004fcd30); and EVERY target passes retail's kind gate
  source._targetType & target->InqType(). The previous hand-rolled
  arms (Remote => accept anything, invented Viewed/Contained accepts,
  self path skipping the kind gate) were the yellow-over-items bug.
  Retail's tradeState refusal is skipped (no trade state yet).
- The paperdoll doll viewport now carries UseTargetGuidProvider =
  player and Clicked -> AcquireSelfTarget (UiViewport gains an opt-in
  Clicked handler), so hovering the doll resolves the self cursor and
  clicking it applies the armed kit to yourself.
- AcquireTarget logs one [use-target] line (wire useability, TargetType
  mask, target kind, decision) so live refusals are diagnosable from
  the launch log.
- Test fixtures updated from the guessed kit useability 0x000A0008 to
  the real USEABLE_SOURCE_CONTAINED_TARGET_REMOTE_OR_SELF (0x00220008,
  ACE Usable.SourceContainedTargetRemoteOrSelf) + TargetType masks;
  new coverage pins the kind gate, the Self-bit requirement, the
  missing-mask-matches-nothing shape, and the Contained location rule.

Full suite green (3,294).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 10:09:13 +02:00
parent d3cab1ab10
commit 047410ccc9
8 changed files with 190 additions and 21 deletions

View file

@ -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

View file

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

View file

@ -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(

View file

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