From be3e617a2b8ca57249c8b40d14cf3cf9da9909fa Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 14 Aug 2026 12:45:10 +0200 Subject: [PATCH] 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 --- .../UI/Layout/SecureTradeUiController.cs | 10 +++++ src/AcDream.App/UI/UiItemSlot.cs | 22 +++++++++++ src/AcDream.Core/Chat/ClientTextRefusals.cs | 7 ++++ src/AcDream.Runtime/GameRuntime.cs | 6 ++- .../Gameplay/RuntimeTradeState.cs | 39 ++++++++++++++++++- .../Session/LiveSessionEventRouter.cs | 10 ++++- .../UI/Layout/PowerbarLayoutProbeTests.cs | 31 ++++++++++++++- 7 files changed, 119 insertions(+), 6 deletions(-) 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 = "";