fix: trade gate round 1 - the Use-on-player route, authored grid cells,

and [trade] seam probes

- Use on a selected player did nothing: the world Use path
  (SelectionInteractionController.RequestUse) dispatches 0x0036 directly
  and NEVER consults DetermineUseResult - lane C's "both paths already
  classified" claim described the POLICY's capability, not a live
  caller (the verify-subagent-claims lesson, again). Retail's
  CPlayerSystem::UsingItem @0x00562F70 consults it and routes result 5
  (another PLAYER) to AttemptToOpenTradeNegotiations @0x0056DEE0, peace
  mode only, with no Use send. Ported as
  ItemInteractionController.TryOpenSecureTradeWithPlayer, called at
  RequestUse entry.
- The broken window: both grids rendered their raw authored strip art
  with no cell layout (the gold-ring tile was the naked track). They now
  get the vendor strips' exact single-row 32px config + the authored
  empty-slot art resolved through ItemListCellTemplate (cell-template
  attr 0x1000000E -> 0x1000033A).
- Drag-onto-player "nothing happens": the policy chain reads correct
  end-to-end in code (pick -> PlaceIn3D -> StartSecureTrade -> event ->
  cmd), so TEMPORARY [trade] probe lines now bracket every seam
  (drag-release pick, policy arm, controller request, use-on-player) -
  the next gate log pinpoints the break if it persists. Stripped once
  the two-client gate passes.

App suite 4,990/3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 12:03:22 +02:00
parent 067cbea8a5
commit 22f30f291d
4 changed files with 103 additions and 2 deletions

View file

@ -99,6 +99,9 @@ internal sealed class SelectionInteractionController
{
ArgumentNullException.ThrowIfNull(payload);
uint target = _query.PickAt(mouseX, mouseY, includeSelf: true) ?? 0u;
if (ItemInteractionController.PhysicsDiagnosticsTradeProbe)
Console.WriteLine(
$"[trade] drag-release item=0x{payload.ObjId:X8} pick=0x{target:X8}");
if (target != 0u)
_query.BeginLightingPulse(target);
_items.PlaceIn3D(payload, target);
@ -278,6 +281,17 @@ internal sealed class SelectionInteractionController
{
CancelPendingApproach();
// Gate fix (2026-08-14 trade round 1): retail's use dispatch
// (CPlayerSystem::UsingItem @ 0x00562F70 → DetermineUseResult
// @ 0x00588460 result 5) routes "Use on another PLAYER" to secure
// trade and never sends 0x0036 for it — this path previously
// dispatched a plain Use the server ignores.
if (_items.TryOpenSecureTradeWithPlayer(serverGuid))
{
reservation?.CancelBeforeDispatch();
return;
}
bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid);
bool useable = ownedByPlayer || _query.IsUseable(serverGuid);

View file

@ -1033,6 +1033,41 @@ public sealed class ItemInteractionController : IDisposable
ClearTargetMode();
}
/// <summary>
/// Gate fix (2026-08-14 trade round 1): the world Use path
/// (<c>SelectionInteractionController.RequestUse</c>) dispatches
/// <c>0x0036 Use</c> directly and never consults
/// <c>DetermineUseResult</c> — retail's <c>CPlayerSystem::UsingItem
/// @ 0x00562F70</c> DOES, and routes result 5 (target is another
/// PLAYER) to <c>ClientTradeSystem::AttemptToOpenTradeNegotiations
/// @ 0x0056DEE0</c>, gated on peace mode, with no Use send at all.
/// Returns true when the target is another player (the use is consumed
/// by the trade path either way; in combat mode retail's open attempt
/// early-outs silently).
/// </summary>
public bool TryOpenSecureTradeWithPlayer(uint targetGuid)
{
if (targetGuid == 0u || targetGuid == _playerGuid())
return false;
ClientObject? target = _objects.Get(targetGuid);
if (target is null
|| ((PublicWeenieFlags)(target.PublicWeenieBitfield ?? 0u)
& PublicWeenieFlags.Player) == 0)
return false;
if (PhysicsDiagnosticsTradeProbe)
Console.WriteLine(
$"[trade] use-on-player guid=0x{targetGuid:X8} "
+ $"nonCombat={_inNonCombatMode()} subscribers={SecureTradeRequested is not null}");
if (_inNonCombatMode())
SecureTradeRequested?.Invoke(targetGuid, 0u);
return true;
}
/// <summary>TEMPORARY (2026-08-14 trade gate round 1): [trade] seam
/// probe lines, stripped once the two-client gate passes.</summary>
internal static bool PhysicsDiagnosticsTradeProbe = true;
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
@ -1184,6 +1219,10 @@ public sealed class ItemInteractionController : IDisposable
// (ItemHolder::AttemptPlaceIn3D @ 0x00588600's option branch
// → ClientTradeSystem::AttemptToTradeItem @ 0x0056DF80).
// ObjectId = the dragged item, TargetId = the player.
if (PhysicsDiagnosticsTradeProbe)
Console.WriteLine(
$"[trade] drag-on-player item=0x{action.ObjectId:X8} "
+ $"target=0x{action.TargetId:X8} subscribers={SecureTradeRequested is not null}");
SecureTradeRequested?.Invoke(action.TargetId, action.ObjectId);
break;
case ItemPolicyActionKind.DropToWorld:

View file

@ -58,7 +58,12 @@ public sealed class SecureTradeUiController : IRetainedPanelController
Action<bool /*selfAccepted*/, bool /*partnerAccepted*/, uint /*partner*/> AcceptTrade,
Action DeclineTrade,
Action ResetTrade,
Action<bool> SetWindowVisible);
Action<bool> SetWindowVisible,
// The authored empty-slot background for each grid, resolved via
// ItemListCellTemplate from the lists' own 0x1000000E cell-template
// attribute (0x1000033A) — same recipe as the vendor strips.
uint SelfEmptySlotSprite = 0u,
uint PartnerEmptySlotSprite = 0u);
private readonly Bindings _bindings;
private readonly UiText? _partnerName;
@ -122,9 +127,34 @@ public sealed class SecureTradeUiController : IRetainedPanelController
// dropped on the grid stages it.
_selfList?.RegisterDragHandler(new SelfGridDropHandler(this));
// Gate fix (2026-08-14 round 1: "broken trade window"): the grids
// rendered their raw authored strip art with no cell layout at all —
// the same single-row 32px config + authored empty-slot fill the
// vendor/external-container strips use.
ConfigureGrid(_selfList, bindings.SelfEmptySlotSprite);
ConfigureGrid(_partnerList, bindings.PartnerEmptySlotSprite);
_bindings.SetWindowVisible(false);
}
private static void ConfigureGrid(UiItemList? list, uint emptySlotSprite)
{
if (list is null) return;
list.Columns = 1;
list.SingleRow = true;
list.HorizontalScroll = true;
list.CellWidth = 32f;
list.CellHeight = 32f;
list.FillVisibleEmptySlots = true;
if (emptySlotSprite != 0u)
list.CellEmptySprite = emptySlotSprite;
list.EmptySlotFactory = () => new UiItemSlot
{
SpriteResolve = list.SpriteResolve,
AllowDragSource = false,
};
}
public static SecureTradeUiController? Bind(
ImportedLayout layout, Bindings bindings)
{
@ -148,6 +178,10 @@ public sealed class SecureTradeUiController : IRetainedPanelController
public void RequestSecureTrade(uint partnerGuid, uint itemGuid)
{
if (_disposed || partnerGuid == 0u) return;
if (ItemInteractionController.PhysicsDiagnosticsTradeProbe)
Console.WriteLine(
$"[trade] request partner=0x{partnerGuid:X8} item=0x{itemGuid:X8} "
+ $"open={_bindings.Trade.Snapshot.IsOpen}");
RuntimeTradeSnapshot snapshot = _bindings.Trade.Snapshot;
if (snapshot.IsOpen && snapshot.PartnerGuid == partnerGuid)
{

View file

@ -3489,6 +3489,8 @@ public sealed class RetailUiRuntime : IDisposable
}
ImportedLayout? layout;
uint selfEmptySlotSprite;
uint partnerEmptySlotSprite;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@ -3498,6 +3500,16 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
// The authored empty-slot art for each grid (cell-template attr
// 0x1000000E → 0x1000033A) — same resolution the vendor uses.
selfEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.SelfListId);
partnerEmptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
_bindings.Assets.Dats,
Layout.SecureTradeUiController.LayoutId,
Layout.SecureTradeUiController.PartnerListId);
}
if (layout is null)
{
@ -3529,7 +3541,9 @@ public sealed class RetailUiRuntime : IDisposable
{
if (visible) Host.ShowWindow(WindowNames.SecureTrade);
else Host.HideWindow(WindowNames.SecureTrade);
}));
},
SelfEmptySlotSprite: selfEmptySlotSprite,
PartnerEmptySlotSprite: partnerEmptySlotSprite));
if (controller is null)
{
Console.WriteLine("[M4] secure trade: required authored grids are missing.");