diff --git a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
index 0a3168ed..793e0741 100644
--- a/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
+++ b/src/AcDream.App/UI/Layout/SecureTradeUiController.cs
@@ -48,6 +48,13 @@ public sealed class SecureTradeUiController : IRetainedPanelController
/// 0x1000007F states '', 'Highlight', 'Ghosted').
private const string AcceptedState = "Highlight";
+ /// The UIItem prototype's authored trade-state overlay sprite
+ /// (catalog 0x21000037 element 0x10000438 — the green frame + corner
+ /// trade icon; bound @ 0x004E18FC, shown when tradeState != 0
+ /// @ 0x004E2420). Retail sets tradeState=1 on YOUR staged items
+ /// (gmSecureTradeUI::AddItem @ 0x004CA801), so the self grid marks.
+ private const uint TradeOverlaySpriteId = 0x06001DAEu;
+
public sealed record Bindings(
IRuntimeTradeView Trade,
ClientObjectTable Objects,
@@ -289,6 +296,9 @@ public sealed class SecureTradeUiController : IRetainedPanelController
// Staged rows are not drag sources — ACE has no
// per-item removal (only Clear All / reset).
AllowDragSource = false,
+ // Your staged items carry retail's trading marker.
+ ShowTradeOverlay = side == RuntimeTradeSide.Self,
+ TradeOverlaySprite = TradeOverlaySpriteId,
};
cell.SetItem(guid, icon);
list.AddItem(cell);
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index e6a67c74..614ec3b6 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -114,6 +114,15 @@ public class UiItemSlot : UiElement
/// RenderSurface id -> (GL texture, w, h). Set by the factory/controller.
public Func? SpriteResolve { get; set; }
+ /// Draw the retail trading marker over the icon — the UIItem
+ /// prototype's authored overlay child 0x10000438 (sprite 0x06001DAE),
+ /// shown when the underlying object's trade state is set
+ /// (@ 0x004E2420).
+ public bool ShowTradeOverlay { get; set; }
+
+ /// The authored overlay sprite id (0x06001DAE); 0 = no draw.
+ public uint TradeOverlaySprite { get; set; }
+
///
/// DAT-authored 10%..100% radial cooldown overlays from element ids
/// 0x1000054F..0x10000558.
@@ -391,6 +400,19 @@ public class UiItemSlot : UiElement
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
+ // Trade-state overlay: UIElement_UIItem's authored child 0x10000438
+ // (sprite 0x06001DAE — the green frame + corner trade icon), bound at
+ // @ 0x004E18FC and SetVisible(tradeState != 0) at @ 0x004E2420.
+ if (ShowTradeOverlay
+ && ItemId != 0
+ && SpriteResolve is not null
+ && TradeOverlaySprite != 0)
+ {
+ var (overlayTex, _, _) = SpriteResolve(TradeOverlaySprite);
+ if (overlayTex != 0)
+ ctx.DrawSprite(overlayTex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
// Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Occupancy branch (decomp 229481):
// occupied (ItemId != 0) → regular/ghosted digit set 0x10000042/43
diff --git a/src/AcDream.Core/Chat/ClientTextRefusals.cs b/src/AcDream.Core/Chat/ClientTextRefusals.cs
index 9736286f..a1851827 100644
--- a/src/AcDream.Core/Chat/ClientTextRefusals.cs
+++ b/src/AcDream.Core/Chat/ClientTextRefusals.cs
@@ -65,6 +65,13 @@ public static class ClientTextRefusals
///
public const string CantJumpRecent = "You've jumped too recently!";
+ ///
+ /// ClientTradeSystem::Handle_Trade__Recv_CloseTrade @ 0x0056DE30
+ /// — shown UNCONDITIONALLY (every close reason) as LogTextType 0x1A
+ /// ClientLocal, the yellow top-center SpewBox line.
+ ///
+ public const string TradeCancelled = "The trade has been cancelled.";
+
///
/// WeenieError.YouAreTooTiredToDoThat (0x003E) —
/// CommandInterpreter::MovePlayer's stamina-exhausted refusal
diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs
index 0f452896..90ae8e84 100644
--- a/src/AcDream.Runtime/GameRuntime.cs
+++ b/src/AcDream.Runtime/GameRuntime.cs
@@ -255,8 +255,10 @@ public sealed class GameRuntime
// Secure trade (2026-08-14): third sibling J-owner —
// session-scoped like fellowship (a disconnect closes the trade
- // server-side), clears at every generation reset.
- context.Trade = new RuntimeTradeState();
+ // server-side), clears at every generation reset. Borrows the
+ // canonical object table so staged items carry retail's
+ // client-side trade state (SetTradeState @ 0x004CA801).
+ context.Trade = new RuntimeTradeState(context.EntityObjects!.Objects);
construction.Own(context.Trade);
Fault(
GameRuntimeConstructionPoint.TradeCreated,
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs b/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
index fee1a7b6..9164e03f 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeTradeState.cs
@@ -60,6 +60,7 @@ public interface IRuntimeTradeView
public sealed class RuntimeTradeState : IDisposable
{
private readonly object _gate = new();
+ private readonly AcDream.Core.Items.ClientObjectTable? _objects;
private readonly List _selfItems = [];
private readonly List _partnerItems = [];
private bool _isOpen;
@@ -71,7 +72,17 @@ public sealed class RuntimeTradeState : IDisposable
private long _revision;
private bool _disposed;
- public RuntimeTradeState() => View = new TradeView(this);
+ /// Borrows the canonical object table (optional for bare
+ /// fixtures) so YOUR staged items carry retail's client-side trade
+ /// state — ACCWeenieObject::SetTradeState(1) @ 0x004CA801 at
+ /// stage, cleared on remove/failure/reset/close. The existing placement
+ /// policy's "You cannot move an item while it is being traded" reads
+ /// this exact field.
+ public RuntimeTradeState(AcDream.Core.Items.ClientObjectTable? objects = null)
+ {
+ _objects = objects;
+ View = new TradeView(this);
+ }
public IRuntimeTradeView View { get; }
@@ -133,6 +144,8 @@ public sealed class RuntimeTradeState : IDisposable
: _selfItems;
if (!items.Contains(update.ItemGuid))
items.Add(update.ItemGuid);
+ if (update.Side != (uint)RuntimeTradeSide.Partner)
+ SetTradeState(update.ItemGuid, 1);
// Staging changes invalidate prior acceptance server-side (ACE
// re-arms via ClearTradeAcceptance; mirrored defensively here so
// a dropped 0x0208 cannot leave a stale green check).
@@ -153,7 +166,11 @@ public sealed class RuntimeTradeState : IDisposable
if (!_isOpen) return;
bool removed = _selfItems.Remove(update.ItemGuid);
removed |= _partnerItems.Remove(update.ItemGuid);
- if (removed) Bump();
+ if (removed)
+ {
+ SetTradeState(update.ItemGuid, 0);
+ Bump();
+ }
}
}
@@ -193,6 +210,7 @@ public sealed class RuntimeTradeState : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return;
+ ClearSelfTradeStates();
_selfItems.Clear();
_partnerItems.Clear();
_selfAccepted = false;
@@ -212,6 +230,7 @@ public sealed class RuntimeTradeState : IDisposable
if (!_isOpen) return;
_selfItems.Remove(failure.ItemGuid);
_partnerItems.Remove(failure.ItemGuid);
+ SetTradeState(failure.ItemGuid, 0);
_lastFailureItemGuid = failure.ItemGuid;
_lastFailureReason = failure.Reason;
Bump();
@@ -260,6 +279,21 @@ public sealed class RuntimeTradeState : IDisposable
}
}
+ /// Retail clears the client-side trade state on every staged
+ /// item at reset/close (Trade::Reset tail — the same lifecycle
+ /// gmSalvageUI::ClearAllTradeStates mirrors for its lists).
+ private void ClearSelfTradeStates()
+ {
+ foreach (uint guid in _selfItems)
+ SetTradeState(guid, 0);
+ }
+
+ private void SetTradeState(uint itemGuid, int state)
+ {
+ if (_objects?.Get(itemGuid) is { } item)
+ item.TradeState = state;
+ }
+
private void ClearLocked()
{
bool changed = _isOpen
@@ -267,6 +301,7 @@ public sealed class RuntimeTradeState : IDisposable
|| _partnerItems.Count != 0
|| _selfAccepted
|| _partnerAccepted;
+ ClearSelfTradeStates();
_isOpen = false;
_partnerGuid = 0u;
_selfItems.Clear();
diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
index 91605c7e..7cd25774 100644
--- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
+++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs
@@ -292,7 +292,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
: null,
onTradeClose: social.Trade is { } tradeClose
- ? _ => tradeClose.ApplyClose()
+ ? _ =>
+ {
+ tradeClose.ApplyClose();
+ // Handle_Trade__Recv_CloseTrade @ 0x0056DE30 shows
+ // this on EVERY close reason, 0x1A ClientLocal.
+ social.AddText?.Invoke(
+ AcDream.Core.Chat.ClientTextRefusals.TradeCancelled,
+ RetailLogTextType.ClientLocal);
+ }
: null,
onTradeAdd: social.Trade is { } tradeAdd
? tradeAdd.ApplyAdd
diff --git a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
index 6f68b2eb..ba063304 100644
--- a/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/PowerbarLayoutProbeTests.cs
@@ -141,6 +141,29 @@ public sealed class PowerbarLayoutProbeTests
}
}
+ /// 2026-08-14 trade gate round 4: dump the shared UIItem
+ /// catalog 0x21000037 tree — find the trading-overlay art (retail marks
+ /// staged cells via ACCWeenieObject::SetTradeState @ 0x004CA801; the
+ /// UIItem draw reads it).
+ [Fact]
+ public void ProbeUiItemCatalog()
+ {
+ if (Environment.GetEnvironmentVariable("ACDREAM_PROBE_POWERBAR") != "1")
+ return;
+
+ var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
+ ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ "Documents",
+ "Asheron's Call");
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var strings = new DatStringResolver(dats);
+
+ ElementInfo? root = LayoutImporter.ImportInfos(dats, 0x21000037u);
+ Assert.NotNull(root);
+ DumpElement(strings, root!, 0);
+ }
+
private static ElementInfo? FindById(ElementInfo element, uint id)
{
if (element.Id == id) return element;
@@ -169,10 +192,16 @@ public sealed class PowerbarLayoutProbeTests
private static void DumpElement(DatStringResolver strings, ElementInfo e, int depth)
{
string indent = new(' ', depth * 2);
+ string media = string.Join(
+ " ",
+ e.StateMedia
+ .OrderBy(static pair => pair.Key, StringComparer.Ordinal)
+ .Select(static pair => $"'{pair.Key}'=0x{pair.Value.File:X8}"));
Console.WriteLine(
$"[pbprobe] {indent}0x{e.Id:X8} type={e.Type} ({e.X},{e.Y} {e.Width}x{e.Height}) "
+ $"defaultState=0x{e.DefaultStateId:X8}('{e.DefaultStateName}') states={e.States.Count} "
- + $"hjustify={e.HJustify} fontColor={(e.FontColor is { } fc ? fc.ToString() : "none")}");
+ + $"hjustify={e.HJustify} fontColor={(e.FontColor is { } fc ? fc.ToString() : "none")} "
+ + $"media[{media}]");
foreach (var (stateId, state) in e.States)
{
string text = "";