refused-drop yellow notice Item 4 (confirmation dialogs missing text + names): the missing retail mechanism was StringTable template substitution - an entry is N+1 literal fragments interleaved with N named variables, composed by StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7). ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2, the exact compute_str_hash space; Chorizite stores the variable hashes directly): - Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 -> ID_Allegiance_AcceptSwearConfirmation, injected into GameplayConfirmationController; null resolve falls back to the bare wire message, never invented English. The 2/3/5/6 " Continue?" family never consults the composer. - Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed the dangling "Do you wish to swear to ") is replaced by click-time ResolveTemplate with the target's name. All five templates verified token-free in the installed DAT - this is NOT a StringTableMetaLanguage port (AD-81's engine caveat stands). Item 5 (refused drop shows nothing; retail shows yellow top-center text): the prevRequest latch was ALREADY ported (InventoryTransactionState); what was missing was the consumer. InventoryTransactionState now raises RequestFailed(request, weenieError) when a 0x00A0 clears the latch; ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's "The <item> can't be <verb>" (verb table + suffix map ported verbatim in Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow top-center line. The dispatcher's second leg (@0x0055B342) also runs: outside the 7-code exclusion set, WeenieErrorMessages resolves per-code text/destination; 0x426 AttunedItem has no row in either place beyond the verb line - faithful single-line output. Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the token-free interleave is now ported; meta-token engine + FormatName remain), AD-93 filed (wire-guid-match vs retail's latched-guid preference; no Move/Wield latch kinds). Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate (constructed StringTable fixtures), +1 composer injection, +1 end-to-end refused-drop line. Core 4,697/1 skip, App 4,983/3 skips. Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
445 lines
13 KiB
C#
445 lines
13 KiB
C#
namespace AcDream.Core.Items;
|
|
|
|
public enum InventoryRequestKind
|
|
{
|
|
Pickup,
|
|
PutInContainer,
|
|
SplitToContainer,
|
|
Merge,
|
|
DropToWorld,
|
|
SplitToWorld,
|
|
Give,
|
|
}
|
|
|
|
public readonly record struct PendingInventoryRequest(
|
|
ulong Token,
|
|
InventoryRequestKind Kind,
|
|
uint ItemId,
|
|
ClientObject? ItemIdentity,
|
|
bool Dispatched);
|
|
|
|
/// <summary>
|
|
/// Owns one retail UI busy reference while an accepted Use request crosses the
|
|
/// local approach boundary. Dispatch transfers that reference to the matching
|
|
/// authoritative UseDone; cancellation before dispatch releases it locally.
|
|
/// </summary>
|
|
public sealed class ItemUseRequestReservation
|
|
{
|
|
private readonly Action<bool> _resolve;
|
|
private int _resolved;
|
|
|
|
internal ItemUseRequestReservation(Action<bool> resolve)
|
|
=> _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
|
|
|
public void MarkDispatched()
|
|
{
|
|
if (Interlocked.Exchange(ref _resolved, 1) == 0)
|
|
_resolve(true);
|
|
}
|
|
|
|
public void CancelBeforeDispatch()
|
|
{
|
|
if (Interlocked.Exchange(ref _resolved, 1) == 0)
|
|
_resolve(false);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Presentation-independent owner for retail's single
|
|
/// <c>ACCWeenieObject::prevRequest</c> inventory gate and
|
|
/// <c>ClientUISystem</c> busy-reference count.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Request recording and response clearing follow
|
|
/// <c>ACCWeenieObject::RecordRequest @ 0x0058C220</c> and
|
|
/// <c>ACCWeenieObject::RecordResponse @ 0x0058CAB0</c>. The owner borrows the
|
|
/// canonical object table and never creates a second inventory collection.
|
|
/// </remarks>
|
|
public sealed class InventoryTransactionState : IDisposable
|
|
{
|
|
private readonly ClientObjectTable _objects;
|
|
private ulong _nextRequestToken;
|
|
private ulong _useReservationGeneration;
|
|
private PendingInventoryRequest? _pendingRequest;
|
|
private int _busyCount;
|
|
private long _dispatchFailureCount;
|
|
private bool _disposed;
|
|
|
|
public InventoryTransactionState(ClientObjectTable objects)
|
|
{
|
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
|
_objects.ObjectMoved += OnObjectMoved;
|
|
_objects.MoveRequestFailed += OnMoveFailed;
|
|
_objects.ObjectRemoved += OnObjectRemoved;
|
|
_objects.StackSizeUpdated += OnStackSizeUpdated;
|
|
_objects.Cleared += OnObjectsCleared;
|
|
}
|
|
|
|
public event Action? StateChanged;
|
|
public event Action<PendingInventoryRequest>? RequestCompleted;
|
|
|
|
/// <summary>
|
|
/// Fires when the pending request is cleared by an
|
|
/// InventoryServerSaveFailed (0x00A0) response, carrying the request plus
|
|
/// the wire WeenieError. This is the seam retail's
|
|
/// <c>ACCWeenieObject::ServerSaysAttemptFailed @ 0x0058EAE0</c> consumes:
|
|
/// it reads <c>prevRequest</c> to pick the "can't be <verb>" text
|
|
/// before <c>RecordResponse</c> clears the latch. Fires after
|
|
/// <see cref="RequestCompleted"/> for the same request.
|
|
/// </summary>
|
|
public event Action<PendingInventoryRequest, uint>? RequestFailed;
|
|
|
|
public event Action? ObjectTableCleared;
|
|
|
|
public ClientObjectTable Objects => _objects;
|
|
public int BusyCount => _busyCount;
|
|
public bool HasPendingRequest => _pendingRequest is not null;
|
|
public bool CanBeginRequest => _busyCount == 0 && _pendingRequest is null;
|
|
public bool IsDisposed => _disposed;
|
|
public long DispatchFailureCount =>
|
|
Interlocked.Read(ref _dispatchFailureCount);
|
|
public Exception? LastDispatchFailure { get; private set; }
|
|
|
|
public bool TryReserve(
|
|
InventoryRequestKind kind,
|
|
uint itemId,
|
|
out PendingInventoryRequest pending,
|
|
Action<PendingInventoryRequest>? beforeStateChanged = null)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (itemId == 0u || !CanBeginRequest)
|
|
{
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
ulong token = NextToken();
|
|
pending = new PendingInventoryRequest(
|
|
token,
|
|
kind,
|
|
itemId,
|
|
_objects.Get(itemId),
|
|
Dispatched: false);
|
|
_pendingRequest = pending;
|
|
|
|
if (beforeStateChanged is not null)
|
|
{
|
|
try
|
|
{
|
|
beforeStateChanged(pending);
|
|
}
|
|
catch
|
|
{
|
|
if (IsCurrent(pending))
|
|
_pendingRequest = null;
|
|
throw;
|
|
}
|
|
}
|
|
|
|
if (!IsCurrent(pending))
|
|
return false;
|
|
|
|
DispatchStateChanged();
|
|
return IsCurrent(pending);
|
|
}
|
|
|
|
public bool TryDispatch(
|
|
InventoryRequestKind kind,
|
|
uint itemId,
|
|
Func<bool> dispatch,
|
|
ulong reservationToken = 0u)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dispatch);
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (itemId == 0u)
|
|
return false;
|
|
|
|
PendingInventoryRequest reserved;
|
|
if (reservationToken != 0u)
|
|
{
|
|
if (_pendingRequest is not { } current
|
|
|| current.Token != reservationToken
|
|
|| current.ItemId != itemId
|
|
|| current.Kind != kind
|
|
|| current.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
reserved = current;
|
|
}
|
|
else if (!TryReserve(kind, itemId, out reserved))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
bool dispatched;
|
|
try
|
|
{
|
|
dispatched = dispatch();
|
|
}
|
|
catch
|
|
{
|
|
ClearPending(reserved.Token);
|
|
throw;
|
|
}
|
|
|
|
if (!dispatched)
|
|
{
|
|
ClearPending(reserved.Token);
|
|
return false;
|
|
}
|
|
|
|
// A synchronous transport or re-entrant table callback may already
|
|
// have completed this exact request. Never resurrect it or overwrite a
|
|
// newer transaction acquired from that response.
|
|
if (_pendingRequest is { } currentRequest
|
|
&& currentRequest.Token == reserved.Token)
|
|
{
|
|
_pendingRequest = reserved with { Dispatched = true };
|
|
DispatchStateChanged();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetPending(out PendingInventoryRequest pending)
|
|
{
|
|
if (_pendingRequest is { } current)
|
|
{
|
|
pending = current;
|
|
return true;
|
|
}
|
|
pending = default;
|
|
return false;
|
|
}
|
|
|
|
public bool CancelBeforeDispatch(ulong token)
|
|
{
|
|
if (_pendingRequest is not { } pending
|
|
|| pending.Token != token
|
|
|| pending.Dispatched)
|
|
{
|
|
return false;
|
|
}
|
|
_pendingRequest = null;
|
|
DispatchStateChanged();
|
|
return true;
|
|
}
|
|
|
|
public void IncrementBusyCount()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
_busyCount++;
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
public ItemUseRequestReservation BeginUseRequestReservation()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ulong generation = _useReservationGeneration;
|
|
_busyCount++;
|
|
DispatchStateChanged();
|
|
return new ItemUseRequestReservation(dispatched =>
|
|
{
|
|
if (generation != _useReservationGeneration || dispatched)
|
|
return;
|
|
if (_busyCount > 0)
|
|
_busyCount--;
|
|
DispatchStateChanged();
|
|
});
|
|
}
|
|
|
|
public void CompleteUse(uint _)
|
|
{
|
|
if (_busyCount == 0)
|
|
return;
|
|
_busyCount--;
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
public void ClearBusy()
|
|
{
|
|
if (_busyCount == 0)
|
|
return;
|
|
_useReservationGeneration++;
|
|
_busyCount = 0;
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
bool changed = _pendingRequest is not null || _busyCount != 0;
|
|
_pendingRequest = null;
|
|
_useReservationGeneration++;
|
|
_busyCount = 0;
|
|
if (changed)
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
_objects.Cleared -= OnObjectsCleared;
|
|
_objects.StackSizeUpdated -= OnStackSizeUpdated;
|
|
_objects.ObjectRemoved -= OnObjectRemoved;
|
|
_objects.MoveRequestFailed -= OnMoveFailed;
|
|
_objects.ObjectMoved -= OnObjectMoved;
|
|
_pendingRequest = null;
|
|
_useReservationGeneration++;
|
|
_busyCount = 0;
|
|
StateChanged = null;
|
|
RequestCompleted = null;
|
|
RequestFailed = null;
|
|
ObjectTableCleared = null;
|
|
}
|
|
|
|
private ulong NextToken()
|
|
{
|
|
ulong token = ++_nextRequestToken;
|
|
if (token == 0u)
|
|
token = ++_nextRequestToken;
|
|
return token;
|
|
}
|
|
|
|
private bool IsCurrent(PendingInventoryRequest expected) =>
|
|
_pendingRequest is { } current
|
|
&& current.Token == expected.Token
|
|
&& current.ItemId == expected.ItemId
|
|
&& current.Kind == expected.Kind
|
|
&& !current.Dispatched;
|
|
|
|
private void ClearPending(ulong token)
|
|
{
|
|
if (_pendingRequest is not { } pending || pending.Token != token)
|
|
return;
|
|
_pendingRequest = null;
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
private void OnObjectMoved(ClientObjectMove move)
|
|
{
|
|
if (move.Origin == ClientObjectMoveOrigin.AuthoritativeResponse)
|
|
CompleteInventoryResponse(move.ItemId, move.Item);
|
|
}
|
|
|
|
private void OnMoveFailed(MoveRequestFailure failure)
|
|
{
|
|
// Match by the wire guid. Retail's dispatcher (case 0xA0 @ 0x0055B342)
|
|
// PREFERS the latched prevRequestObjectID over the wire guid, but ACE
|
|
// always sends the item guid, so requiring the match is equivalent —
|
|
// and it protects a stale latch from mislabeling an unrelated failure.
|
|
if (CompleteInventoryResponse(failure.ItemId, _objects.Get(failure.ItemId))
|
|
is { } failed)
|
|
{
|
|
Dispatch(RequestFailed, failed, failure.WeenieError);
|
|
}
|
|
}
|
|
|
|
private void OnObjectRemoved(ClientObject item) =>
|
|
CompleteInventoryResponse(item.ObjectId, item);
|
|
|
|
private void OnStackSizeUpdated(ClientObject item) =>
|
|
CompleteInventoryResponse(item.ObjectId, item);
|
|
|
|
private PendingInventoryRequest? CompleteInventoryResponse(
|
|
uint itemId,
|
|
ClientObject? identity)
|
|
{
|
|
if (_pendingRequest is not { } request
|
|
|| request.ItemId != itemId
|
|
|| !MatchesIdentity(request.ItemIdentity, itemId, identity))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// RecordResponse clears prevRequest before ItemList receives the
|
|
// response notice, so a completion observer may synchronously acquire
|
|
// the next request.
|
|
_pendingRequest = null;
|
|
Dispatch(RequestCompleted, request);
|
|
DispatchStateChanged();
|
|
return request;
|
|
}
|
|
|
|
private bool MatchesIdentity(
|
|
ClientObject? expected,
|
|
uint itemId,
|
|
ClientObject? actual)
|
|
{
|
|
if (expected is null)
|
|
return true;
|
|
if (actual is not null)
|
|
return ReferenceEquals(expected, actual);
|
|
return _objects.Get(itemId) is not { } current
|
|
|| ReferenceEquals(expected, current);
|
|
}
|
|
|
|
private void OnObjectsCleared()
|
|
{
|
|
bool changed = _pendingRequest is not null;
|
|
_pendingRequest = null;
|
|
Dispatch(ObjectTableCleared);
|
|
if (changed)
|
|
DispatchStateChanged();
|
|
}
|
|
|
|
private void DispatchStateChanged() => Dispatch(StateChanged);
|
|
|
|
private void Dispatch(Action? listeners)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action listener in listeners.GetInvocationList())
|
|
{
|
|
try
|
|
{
|
|
listener();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
RecordDispatchFailure(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Dispatch<T>(Action<T>? listeners, T value)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action<T> listener in listeners.GetInvocationList())
|
|
{
|
|
try
|
|
{
|
|
listener(value);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
RecordDispatchFailure(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Dispatch<T1, T2>(Action<T1, T2>? listeners, T1 first, T2 second)
|
|
{
|
|
if (listeners is null)
|
|
return;
|
|
foreach (Action<T1, T2> listener in listeners.GetInvocationList())
|
|
{
|
|
try
|
|
{
|
|
listener(first, second);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
RecordDispatchFailure(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RecordDispatchFailure(Exception error)
|
|
{
|
|
Interlocked.Increment(ref _dispatchFailureCount);
|
|
LastDispatchFailure = error;
|
|
}
|
|
}
|