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

@ -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;
}
/// <summary>
/// Faithful port of <c>ItemHolder::IsTargetCompatibleWithTargetingObject</c>
/// (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: <c>source._targetType &amp; target-&gt;InqType()</c>. 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.
/// </summary>
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)

View file

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

View file

@ -9,6 +9,24 @@ public sealed class UiViewport : UiElement
{
public override bool ConsumesDatChildren => true;
/// <summary>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.</summary>
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);
}
/// <summary>Renderer that produces the off-screen texture. Set by GameWindow wiring (later task).</summary>
public IUiViewportRenderer? Renderer { get; set; }