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:
parent
d3cab1ab10
commit
047410ccc9
8 changed files with 190 additions and 21 deletions
|
|
@ -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 & target->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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -254,6 +254,25 @@ public static class ItemUseability
|
|||
public static uint TargetFlags(uint useability)
|
||||
=> (useability & TargetMask) >> 16;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ItemUses::GetLeastLimitedTargetUse</c> (0x004fcd50): the most
|
||||
/// permissive target-use bit present, priority Remote > Viewed >
|
||||
/// Contained > Wielded > Self > ObjSelf. The target-compat gate
|
||||
/// (<c>ItemHolder::IsTargetCompatibleWithTargetingObject</c> 0x00588070)
|
||||
/// only enforces a location constraint when the BEST available bit is
|
||||
/// Contained or Wielded.
|
||||
/// </summary>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue