diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs
index 930bac2b..4988a28a 100644
--- a/src/AcDream.App/Interaction/SelectionInteractionController.cs
+++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs
@@ -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);
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index 1cb81c22..e9fd614a 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -1033,6 +1033,41 @@ public sealed class ItemInteractionController : IDisposable
ClearTargetMode();
}
+ ///
+ /// Gate fix (2026-08-14 trade round 1): the world Use path
+ /// (SelectionInteractionController.RequestUse) dispatches
+ /// 0x0036 Use directly and never consults
+ /// DetermineUseResult — retail's CPlayerSystem::UsingItem
+ /// @ 0x00562F70 DOES, and routes result 5 (target is another
+ /// PLAYER) to ClientTradeSystem::AttemptToOpenTradeNegotiations
+ /// @ 0x0056DEE0, 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).
+ ///
+ 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;
+ }
+
+ /// TEMPORARY (2026-08-14 trade gate round 1): [trade] seam
+ /// probe lines, stripped once the two-client gate passes.
+ 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:
diff --git a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
index 34235ef9..2cedd243 100644
--- a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
+++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
@@ -58,7 +58,12 @@ public sealed class SecureTradeUiController : IRetainedPanelController
Action AcceptTrade,
Action DeclineTrade,
Action ResetTrade,
- Action SetWindowVisible);
+ Action 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)
{
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 0d81bb39..a4d2c167 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -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.");