fix: trade gate round 4 - "The trade has been cancelled." + retail's

staged-item trading marker

- Cancel text: ClientTradeSystem::Handle_Trade__Recv_CloseTrade
  @0x0056DE30 shows "The trade has been cancelled." UNCONDITIONALLY
  (every close reason) as 0x1A ClientLocal - the yellow top-center
  SpewBox line. Wired at the router's onTradeClose beside ApplyClose;
  the string lives in ClientTextRefusals with its citation.
- Staged-item marker: retail's mechanism decoded end-to-end - the
  UIItem prototype (catalog 0x21000037) authors overlay child
  0x10000438 (sprite 0x06001DAE, the green frame + corner trade icon),
  bound @0x004E18FC and SetVisible(tradeState != 0) @0x004E2420;
  gmSecureTradeUI::AddItem @0x004CA801 sets
  ACCWeenieObject::SetTradeState(1) on YOUR staged items. Ported as:
  UiItemSlot.ShowTradeOverlay + TradeOverlaySprite (drawn over the
  icon), set on the trade window's self-grid cells; and
  RuntimeTradeState now borrows the canonical object table and
  maintains ClientObject.TradeState (1 at stage, 0 at remove/failure/
  reset/close/clear) - which also brings the ALREADY-PORTED placement
  policy's "You cannot move an item while it is being traded" refusal
  to life (its input field previously had no live producer).

Runtime 1,626, App 4,992/3 - green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 12:45:10 +02:00
parent ffc73f80bf
commit be3e617a2b
7 changed files with 119 additions and 6 deletions

View file

@ -48,6 +48,13 @@ public sealed class SecureTradeUiController : IRetainedPanelController
/// 0x1000007F states '', 'Highlight', 'Ghosted').</summary> /// 0x1000007F states '', 'Highlight', 'Ghosted').</summary>
private const string AcceptedState = "Highlight"; private const string AcceptedState = "Highlight";
/// <summary>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.</summary>
private const uint TradeOverlaySpriteId = 0x06001DAEu;
public sealed record Bindings( public sealed record Bindings(
IRuntimeTradeView Trade, IRuntimeTradeView Trade,
ClientObjectTable Objects, ClientObjectTable Objects,
@ -289,6 +296,9 @@ public sealed class SecureTradeUiController : IRetainedPanelController
// Staged rows are not drag sources — ACE has no // Staged rows are not drag sources — ACE has no
// per-item removal (only Clear All / reset). // per-item removal (only Clear All / reset).
AllowDragSource = false, AllowDragSource = false,
// Your staged items carry retail's trading marker.
ShowTradeOverlay = side == RuntimeTradeSide.Self,
TradeOverlaySprite = TradeOverlaySpriteId,
}; };
cell.SetItem(guid, icon); cell.SetItem(guid, icon);
list.AddItem(cell); list.AddItem(cell);

View file

@ -114,6 +114,15 @@ public class UiItemSlot : UiElement
/// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary> /// <summary>RenderSurface id -> (GL texture, w, h). Set by the factory/controller.</summary>
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; } public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>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
/// (<c>@ 0x004E2420</c>).</summary>
public bool ShowTradeOverlay { get; set; }
/// <summary>The authored overlay sprite id (0x06001DAE); 0 = no draw.</summary>
public uint TradeOverlaySprite { get; set; }
/// <summary> /// <summary>
/// DAT-authored 10%..100% radial cooldown overlays from element ids /// DAT-authored 10%..100% radial cooldown overlays from element ids
/// <c>0x1000054F..0x10000558</c>. /// <c>0x1000054F..0x10000558</c>.
@ -391,6 +400,19 @@ public class UiItemSlot : UiElement
ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); 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). // Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465).
// Occupancy branch (decomp 229481): // Occupancy branch (decomp 229481):
// occupied (ItemId != 0) → regular/ghosted digit set 0x10000042/43 // occupied (ItemId != 0) → regular/ghosted digit set 0x10000042/43

View file

@ -65,6 +65,13 @@ public static class ClientTextRefusals
/// </summary> /// </summary>
public const string CantJumpRecent = "You've jumped too recently!"; public const string CantJumpRecent = "You've jumped too recently!";
/// <summary>
/// <c>ClientTradeSystem::Handle_Trade__Recv_CloseTrade @ 0x0056DE30</c>
/// — shown UNCONDITIONALLY (every close reason) as LogTextType 0x1A
/// ClientLocal, the yellow top-center SpewBox line.
/// </summary>
public const string TradeCancelled = "The trade has been cancelled.";
/// <summary> /// <summary>
/// <c>WeenieError.YouAreTooTiredToDoThat (0x003E)</c> — /// <c>WeenieError.YouAreTooTiredToDoThat (0x003E)</c> —
/// <c>CommandInterpreter::MovePlayer</c>'s stamina-exhausted refusal /// <c>CommandInterpreter::MovePlayer</c>'s stamina-exhausted refusal

View file

@ -255,8 +255,10 @@ public sealed class GameRuntime
// Secure trade (2026-08-14): third sibling J-owner — // Secure trade (2026-08-14): third sibling J-owner —
// session-scoped like fellowship (a disconnect closes the trade // session-scoped like fellowship (a disconnect closes the trade
// server-side), clears at every generation reset. // server-side), clears at every generation reset. Borrows the
context.Trade = new RuntimeTradeState(); // 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); construction.Own(context.Trade);
Fault( Fault(
GameRuntimeConstructionPoint.TradeCreated, GameRuntimeConstructionPoint.TradeCreated,

View file

@ -60,6 +60,7 @@ public interface IRuntimeTradeView
public sealed class RuntimeTradeState : IDisposable public sealed class RuntimeTradeState : IDisposable
{ {
private readonly object _gate = new(); private readonly object _gate = new();
private readonly AcDream.Core.Items.ClientObjectTable? _objects;
private readonly List<uint> _selfItems = []; private readonly List<uint> _selfItems = [];
private readonly List<uint> _partnerItems = []; private readonly List<uint> _partnerItems = [];
private bool _isOpen; private bool _isOpen;
@ -71,7 +72,17 @@ public sealed class RuntimeTradeState : IDisposable
private long _revision; private long _revision;
private bool _disposed; private bool _disposed;
public RuntimeTradeState() => View = new TradeView(this); /// <summary>Borrows the canonical object table (optional for bare
/// fixtures) so YOUR staged items carry retail's client-side trade
/// state — <c>ACCWeenieObject::SetTradeState(1) @ 0x004CA801</c> 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.</summary>
public RuntimeTradeState(AcDream.Core.Items.ClientObjectTable? objects = null)
{
_objects = objects;
View = new TradeView(this);
}
public IRuntimeTradeView View { get; } public IRuntimeTradeView View { get; }
@ -133,6 +144,8 @@ public sealed class RuntimeTradeState : IDisposable
: _selfItems; : _selfItems;
if (!items.Contains(update.ItemGuid)) if (!items.Contains(update.ItemGuid))
items.Add(update.ItemGuid); items.Add(update.ItemGuid);
if (update.Side != (uint)RuntimeTradeSide.Partner)
SetTradeState(update.ItemGuid, 1);
// Staging changes invalidate prior acceptance server-side (ACE // Staging changes invalidate prior acceptance server-side (ACE
// re-arms via ClearTradeAcceptance; mirrored defensively here so // re-arms via ClearTradeAcceptance; mirrored defensively here so
// a dropped 0x0208 cannot leave a stale green check). // a dropped 0x0208 cannot leave a stale green check).
@ -153,7 +166,11 @@ public sealed class RuntimeTradeState : IDisposable
if (!_isOpen) return; if (!_isOpen) return;
bool removed = _selfItems.Remove(update.ItemGuid); bool removed = _selfItems.Remove(update.ItemGuid);
removed |= _partnerItems.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); ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isOpen) return; if (!_isOpen) return;
ClearSelfTradeStates();
_selfItems.Clear(); _selfItems.Clear();
_partnerItems.Clear(); _partnerItems.Clear();
_selfAccepted = false; _selfAccepted = false;
@ -212,6 +230,7 @@ public sealed class RuntimeTradeState : IDisposable
if (!_isOpen) return; if (!_isOpen) return;
_selfItems.Remove(failure.ItemGuid); _selfItems.Remove(failure.ItemGuid);
_partnerItems.Remove(failure.ItemGuid); _partnerItems.Remove(failure.ItemGuid);
SetTradeState(failure.ItemGuid, 0);
_lastFailureItemGuid = failure.ItemGuid; _lastFailureItemGuid = failure.ItemGuid;
_lastFailureReason = failure.Reason; _lastFailureReason = failure.Reason;
Bump(); Bump();
@ -260,6 +279,21 @@ public sealed class RuntimeTradeState : IDisposable
} }
} }
/// <summary>Retail clears the client-side trade state on every staged
/// item at reset/close (<c>Trade::Reset</c> tail — the same lifecycle
/// gmSalvageUI::ClearAllTradeStates mirrors for its lists).</summary>
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() private void ClearLocked()
{ {
bool changed = _isOpen bool changed = _isOpen
@ -267,6 +301,7 @@ public sealed class RuntimeTradeState : IDisposable
|| _partnerItems.Count != 0 || _partnerItems.Count != 0
|| _selfAccepted || _selfAccepted
|| _partnerAccepted; || _partnerAccepted;
ClearSelfTradeStates();
_isOpen = false; _isOpen = false;
_partnerGuid = 0u; _partnerGuid = 0u;
_selfItems.Clear(); _selfItems.Clear();

View file

@ -292,7 +292,15 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid()) ? update => tradeRegister.ApplyRegister(update, inventory.PlayerGuid())
: null, : null,
onTradeClose: social.Trade is { } tradeClose 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, : null,
onTradeAdd: social.Trade is { } tradeAdd onTradeAdd: social.Trade is { } tradeAdd
? tradeAdd.ApplyAdd ? tradeAdd.ApplyAdd

View file

@ -141,6 +141,29 @@ public sealed class PowerbarLayoutProbeTests
} }
} }
/// <summary>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).</summary>
[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) private static ElementInfo? FindById(ElementInfo element, uint id)
{ {
if (element.Id == id) return element; if (element.Id == id) return element;
@ -169,10 +192,16 @@ public sealed class PowerbarLayoutProbeTests
private static void DumpElement(DatStringResolver strings, ElementInfo e, int depth) private static void DumpElement(DatStringResolver strings, ElementInfo e, int depth)
{ {
string indent = new(' ', depth * 2); 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( Console.WriteLine(
$"[pbprobe] {indent}0x{e.Id:X8} type={e.Type} ({e.X},{e.Y} {e.Width}x{e.Height}) " $"[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} " + $"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) foreach (var (stateId, state) in e.States)
{ {
string text = ""; string text = "";